mirror of
https://github.com/borgmatic-collective/borgmatic.git
synced 2026-07-22 18:13:02 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e9e06afe6 | ||
|
|
2bc91ac3d2 | ||
|
|
5b615d51a4 | ||
|
|
c7f5d5fd0b | ||
|
|
6ef7538eb0 | ||
|
|
8fa90053cf | ||
|
|
b3682b61d1 | ||
|
|
ad0e2e0d7c | ||
|
|
6629f40cab | ||
|
|
e76bfa555f | ||
|
|
8ddb7268eb | ||
|
|
cb5fe02ebd | ||
|
|
77b84f8a48 | ||
|
|
691ec96909 | ||
|
|
29b4666205 | ||
|
|
316a22701f | ||
|
|
be59a3e574 | ||
|
|
37327379bc | ||
|
|
22c2f13611 | ||
|
|
8708ca07f4 | ||
|
|
634d9e4946 | ||
|
|
54933ebef5 | ||
|
|
157e59ac88 | ||
|
|
666f0dd751 | ||
|
|
8b179e4647 | ||
|
|
865eff7d98 | ||
|
|
b9741f4d0b | ||
|
|
02781662f8 | ||
|
|
32a1043468 | ||
|
|
3e4aeec649 | ||
|
|
b98b827594 | ||
|
|
255cc6ec23 | ||
|
|
51fc37d57a | ||
|
|
1921f55a9d | ||
|
|
fbd381fcc1 | ||
|
|
cd88f9f2ea | ||
|
|
788281cfb9 | ||
|
|
cd234b689d | ||
|
|
92354a77ee | ||
|
|
48ff3e70d1 | ||
|
|
7e9adfb899 | ||
|
|
e238e256f7 | ||
|
|
3ecb92a8d2 | ||
|
|
d58d450628 | ||
|
|
dee9c6e293 | ||
|
|
897c4487de | ||
|
|
48b50b5209 | ||
|
|
13bae8c23b | ||
|
|
4a48e6aa04 | ||
|
|
525266ede6 | ||
|
|
d045eb55ac | ||
|
|
0e6b425ac5 | ||
|
|
bdc26f2117 | ||
|
|
ed7fe5c6d0 | ||
|
|
cbce6707f4 | ||
|
|
e40e726687 | ||
|
|
0c027a3050 | ||
|
|
9f44bbad65 | ||
|
|
413a079f51 | ||
|
|
6f3accf691 | ||
|
|
5b3cfc542d | ||
|
|
c838c1d11b | ||
|
|
4d1d8d7409 | ||
|
|
db7499db82 | ||
|
|
6b500c2a8b | ||
|
|
95c518e59b | ||
|
|
976516d0e1 | ||
|
|
574eb91921 | ||
|
|
28fef3264b | ||
|
|
9161dbcb7d | ||
|
|
4b3027e4fc | ||
|
|
0eb2634f9b | ||
|
|
7c5b68c98f | ||
|
|
9317cbaaf0 | ||
|
|
1b5f04b79f | ||
|
|
948c86f62c | ||
|
|
7e7209322a | ||
|
|
00a57fd947 | ||
|
|
6bf6ac310b | ||
|
|
4b5af2770d | ||
|
|
b525e70e1c | ||
|
|
4498671233 | ||
|
|
9997aa9a92 | ||
|
|
cbf7284f64 | ||
|
|
ee466f870d | ||
|
|
e3f4bf0293 | ||
|
|
46688f10b1 | ||
|
|
48f44d2f3d | ||
|
|
bff1347ba3 | ||
|
|
9582324c88 | ||
|
|
bb0716421d | ||
|
|
bec73245e9 | ||
|
|
dcead12e86 | ||
|
|
0119514c11 | ||
|
|
b39f08694d | ||
|
|
80bdf1430b | ||
|
|
2ee75546f5 | ||
|
|
07d7ae60d5 | ||
|
|
87001337b4 | ||
|
|
2e9964c200 | ||
|
|
3ec3d8d045 | ||
|
|
96384d5ee1 | ||
|
|
8ed5467435 | ||
|
|
7c6ce9399c | ||
|
|
6b7653484b | ||
|
|
85e0334826 | ||
|
|
2a80e48a92 | ||
|
|
5821c6782e | ||
|
|
f15498f6d9 | ||
|
|
a1673d1fa1 | ||
|
|
2e99a1898c | ||
|
|
7a086d8430 | ||
|
|
0e8e9ced64 | ||
|
|
f34951c088 | ||
|
|
c6f47d4d56 | ||
|
|
c3e76585fc | ||
|
|
0014b149f8 | ||
|
|
091c07bbe2 | ||
|
|
240547102f | ||
|
|
2bbd53e25a | ||
|
|
58f2f63977 | ||
|
|
7df6a78c30 | ||
|
|
c646edf2c7 | ||
|
|
bcc820d646 | ||
|
|
3729ba5ca3 | ||
|
|
9c19591768 | ||
|
|
38ebfd2969 | ||
|
|
180018fd81 | ||
|
|
794ae94ac4 | ||
|
|
4eb6359ed3 | ||
|
|
976a877a25 | ||
|
|
b4117916b8 | ||
|
|
19cad89978 | ||
|
|
6b182c9d2d | ||
|
|
4d6ed27f73 | ||
|
|
745a8f9b8a | ||
|
|
6299d8115d | ||
|
|
717cfd2d37 | ||
|
|
7881327004 | ||
|
|
549aa9a25f | ||
|
|
1c6890492b | ||
|
|
a7c8e7c823 | ||
|
|
c8fcf6b336 | ||
|
|
449896f661 | ||
|
|
1004500d65 | ||
|
|
0a8d4e5dfb | ||
|
|
38e35bdb12 | ||
|
|
65503e38b6 | ||
|
|
d0c5bf6f6f | ||
|
|
f129e4c301 | ||
|
|
fbbb096cec | ||
|
|
77980511c6 | ||
|
|
4ba206f8f4 | ||
|
|
ecc849dd07 | ||
|
|
7ff6066d47 | ||
|
|
2bb1fc9826 | ||
|
|
6df6176f3a | ||
|
|
acb2ca79d9 | ||
|
|
c9211320e1 | ||
|
|
760286abe1 | ||
|
|
5890a1cb48 | ||
|
|
b3f5a9d18f | ||
|
|
80b33fbf8a | ||
|
|
5389ff6160 | ||
|
|
e8b8d86592 | ||
|
|
92d729a9dd | ||
|
|
c63219936e | ||
|
|
0aff497430 | ||
|
|
1f3907a6a5 | ||
|
|
2a8692c64f | ||
|
|
1709f57ff0 | ||
|
|
89baf757cf | ||
|
|
4f36fe2b9f | ||
|
|
510449ce65 | ||
|
|
4cc4b8d484 | ||
|
|
9c972cb0e5 | ||
|
|
9b1779065e | ||
|
|
057ec3e59b | ||
|
|
bc2e611a74 | ||
|
|
b6d3a1e02f | ||
|
|
54d57e1349 | ||
|
|
af0b3da8ed | ||
|
|
27d37b606b | ||
|
|
77a860cc62 | ||
|
|
7bd6374751 | ||
|
|
cf8882f2bc | ||
|
|
b37dd1a79e | ||
|
|
fd59776f91 | ||
|
|
9fd28d2eed | ||
|
|
f5c61c8013 | ||
|
|
88cb49dcc4 | ||
|
|
73235e59be | ||
|
|
7076a7ff86 | ||
|
|
d6e376d32d | ||
|
|
9016f4be43 | ||
|
|
d1c403999f | ||
|
|
d543109ef4 | ||
|
|
7085a45649 | ||
|
|
cf4c603f1d | ||
|
|
d2533313bc | ||
|
|
c43b50b6e6 | ||
|
|
c072678936 | ||
|
|
631da1465e | ||
|
|
f29519a5cd | ||
|
|
5d82b42ab8 | ||
|
|
4897a78fd3 | ||
|
|
a1d986d952 | ||
|
|
717c90a7d0 | ||
|
|
8fde19a7dc | ||
|
|
ad7198ba66 | ||
|
|
eb4b4cc92b | ||
|
|
41bf520585 | ||
|
|
c0ae01f5d5 | ||
|
|
8b8f92d717 | ||
|
|
ccd1627175 | ||
|
|
b8a7e23f46 | ||
|
|
1f4f28b4dc | ||
|
|
ea6cd53067 | ||
|
|
267138776d | ||
|
|
604b3d5e17 | ||
|
|
667e1e5b15 | ||
|
|
9b819f32f8 | ||
|
|
b619bde037 | ||
|
|
97af16bd86 | ||
|
|
fa75f89acc | ||
|
|
222b61b577 | ||
|
|
e77757f0fd | ||
|
|
ebac02f118 | ||
|
|
1c9ae81987 | ||
|
|
7b1fb68c18 | ||
|
|
8aa7830f0d | ||
|
|
79bee755ee | ||
|
|
cde0ee96ff | ||
|
|
1ea04aedf0 | ||
|
|
446a2bc15a | ||
|
|
2d10e758e0 | ||
|
|
0e978299cf | ||
|
|
d06c1f2943 | ||
|
|
d768b50b97 | ||
|
|
034ade48f2 | ||
|
|
d1e9f74087 | ||
|
|
f262f77dbd | ||
|
|
a3387953a9 | ||
|
|
7cad5a8608 | ||
|
|
9b83fcbf06 | ||
|
|
32a93ce8a2 | ||
|
|
e428329c03 | ||
|
|
e844bbee15 | ||
|
|
631c3068a9 | ||
|
|
79d4888e22 | ||
|
|
de61fdef48 | ||
|
|
93caeba200 | ||
|
|
3c723e8d99 | ||
|
|
c5776447b9 | ||
|
|
5356f487a5 | ||
|
|
72bd96c656 | ||
|
|
f611fe7be3 | ||
|
|
dd6ea40a36 | ||
|
|
ea1274d1c6 | ||
|
|
8526468975 | ||
|
|
95c415f416 | ||
|
|
06dc336481 | ||
|
|
893fca2816 | ||
|
|
99590cb6b6 | ||
|
|
b3fd1be5f6 | ||
|
|
a23083f737 | ||
|
|
8306b758e8 | ||
|
|
218cbd5289 | ||
|
|
2ac58670d5 | ||
|
|
6f82c9979b | ||
|
|
0a659a397f | ||
|
|
2781873faf | ||
|
|
3aaa89fb08 | ||
|
|
35d542a676 | ||
|
|
d0b9c436b1 | ||
|
|
37cc229749 | ||
|
|
17c2d109e5 | ||
|
|
c8d5de2179 | ||
|
|
32e15dc905 | ||
|
|
f5ebca4907 | ||
|
|
01db676d68 | ||
|
|
d2d92b1f1a | ||
|
|
27cbe9dfc0 | ||
|
|
8fb830099f | ||
|
|
463a133a63 | ||
|
|
a16fed8887 | ||
|
|
33113890f5 | ||
|
|
abd47fc14e | ||
|
|
7fb4061759 | ||
|
|
b320e74ad5 | ||
|
|
0ed8f67b9d | ||
|
|
a12a1121b6 | ||
|
|
795e18773b | ||
|
|
aa14449857 | ||
|
|
ed7b1cd3d7 | ||
|
|
a155eefa23 | ||
|
|
398665be9e | ||
|
|
6db232d4ac | ||
|
|
d7277893fb | ||
|
|
00033bf0a8 |
+23
-96
@@ -1,110 +1,29 @@
|
||||
---
|
||||
kind: pipeline
|
||||
name: python-3-5-alpine-3-10
|
||||
name: python-3-8-alpine-3-13
|
||||
|
||||
services:
|
||||
- name: postgresql
|
||||
image: postgres:11.6-alpine
|
||||
image: postgres:13.1-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: test
|
||||
- name: mysql
|
||||
image: mariadb:10.3
|
||||
image: mariadb:10.5
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: test
|
||||
MYSQL_DATABASE: test
|
||||
- name: mongodb
|
||||
image: mongo:5.0.5
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: test
|
||||
|
||||
clone:
|
||||
skip_verify: true
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: python:3.5-alpine3.10
|
||||
pull: always
|
||||
commands:
|
||||
- scripts/run-full-tests
|
||||
---
|
||||
kind: pipeline
|
||||
name: python-3-6-alpine-3-10
|
||||
|
||||
services:
|
||||
- name: postgresql
|
||||
image: postgres:11.6-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: test
|
||||
- name: mysql
|
||||
image: mariadb:10.3
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: test
|
||||
MYSQL_DATABASE: test
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: python:3.6-alpine3.10
|
||||
pull: always
|
||||
commands:
|
||||
- scripts/run-full-tests
|
||||
---
|
||||
kind: pipeline
|
||||
name: python-3-7-alpine-3-10
|
||||
|
||||
services:
|
||||
- name: postgresql
|
||||
image: postgres:11.6-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: test
|
||||
- name: mysql
|
||||
image: mariadb:10.3
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: test
|
||||
MYSQL_DATABASE: test
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: python:3.7-alpine3.10
|
||||
pull: always
|
||||
commands:
|
||||
- scripts/run-full-tests
|
||||
---
|
||||
kind: pipeline
|
||||
name: python-3-7-alpine-3-7
|
||||
|
||||
services:
|
||||
- name: postgresql
|
||||
image: postgres:10.11-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: test
|
||||
- name: mysql
|
||||
image: mariadb:10.1
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: test
|
||||
MYSQL_DATABASE: test
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: python:3.7-alpine3.7
|
||||
pull: always
|
||||
commands:
|
||||
- scripts/run-full-tests
|
||||
---
|
||||
kind: pipeline
|
||||
name: python-3-8-alpine-3-10
|
||||
|
||||
services:
|
||||
- name: postgresql
|
||||
image: postgres:11.6-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: test
|
||||
- name: mysql
|
||||
image: mariadb:10.3
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: test
|
||||
MYSQL_DATABASE: test
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: python:3.8-alpine3.10
|
||||
image: alpine:3.13
|
||||
pull: always
|
||||
commands:
|
||||
- scripts/run-full-tests
|
||||
@@ -112,6 +31,9 @@ steps:
|
||||
kind: pipeline
|
||||
name: documentation
|
||||
|
||||
clone:
|
||||
skip_verify: true
|
||||
|
||||
steps:
|
||||
- name: build
|
||||
image: plugins/docker
|
||||
@@ -122,6 +44,11 @@ steps:
|
||||
from_secret: docker_password
|
||||
repo: witten/borgmatic-docs
|
||||
dockerfile: docs/Dockerfile
|
||||
when:
|
||||
branch:
|
||||
- master
|
||||
|
||||
trigger:
|
||||
repo:
|
||||
- borgmatic-collective/borgmatic
|
||||
branch:
|
||||
- master
|
||||
event:
|
||||
- push
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const pluginSyntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight");
|
||||
const inclusiveLangPlugin = require("@11ty/eleventy-plugin-inclusive-language");
|
||||
const navigationPlugin = require("@11ty/eleventy-navigation");
|
||||
|
||||
module.exports = function(eleventyConfig) {
|
||||
eleventyConfig.addPlugin(pluginSyntaxHighlight);
|
||||
eleventyConfig.addPlugin(inclusiveLangPlugin);
|
||||
eleventyConfig.addPlugin(navigationPlugin);
|
||||
|
||||
let markdownIt = require("markdown-it");
|
||||
let markdownItAnchor = require("markdown-it-anchor");
|
||||
@@ -34,6 +36,8 @@ module.exports = function(eleventyConfig) {
|
||||
|
||||
eleventyConfig.addPassthroughCopy({"docs/static": "static"});
|
||||
|
||||
eleventyConfig.setLiquidOptions({dynamicPartials: false});
|
||||
|
||||
return {
|
||||
templateFormats: [
|
||||
"md",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
*.pyc
|
||||
*.swp
|
||||
.cache
|
||||
.coverage
|
||||
.coverage*
|
||||
.pytest_cache
|
||||
.tox
|
||||
__pycache__
|
||||
|
||||
@@ -1,3 +1,213 @@
|
||||
1.6.2
|
||||
* #523: Reduce the default consistency check frequency and support configuring the frequency
|
||||
independently for each check. Also add "borgmatic check --force" flag to ignore configured
|
||||
frequencies. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#check-frequency
|
||||
* #536: Fix generate-borgmatic-config to support more complex schema changes like the new
|
||||
Healthchecks configuration options when the "--source" flag is used.
|
||||
* #538: Add support for "borgmatic borg debug" command.
|
||||
* #539: Add "generate-borgmatic-config --overwrite" flag to replace an existing destination file.
|
||||
* Add Bash completion script so you can tab-complete the borgmatic command-line. See the
|
||||
documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#shell-completion
|
||||
|
||||
1.6.1
|
||||
* #294: Add Healthchecks monitoring hook "ping_body_limit" option to configure how many bytes of
|
||||
logs to send to the Healthchecks server.
|
||||
* #402: Remove the error when "archive_name_format" is specified but a retention prefix isn't.
|
||||
* #420: Warn when an unsupported variable is used in a hook command.
|
||||
* #439: Change connection failures for monitoring hooks (Healthchecks, Cronitor, PagerDuty, and
|
||||
Cronhub) to be warnings instead of errors. This way, the monitoring system failing does not block
|
||||
backups.
|
||||
* #460: Add Healthchecks monitoring hook "send_logs" option to enable/disable sending borgmatic
|
||||
logs to the Healthchecks server.
|
||||
* #525: Add Healthchecks monitoring hook "states" option to only enable pinging for particular
|
||||
monitoring states (start, finish, fail).
|
||||
* #528: Improve the error message when a configuration override contains an invalid value.
|
||||
* #531: BREAKING: When deep merging common configuration, merge colliding list values by appending
|
||||
them. Previously, one list replaced the other.
|
||||
* #532: When a configuration include is a relative path, load it from either the current working
|
||||
directory or from the directory containing the file doing the including. Previously, only the
|
||||
working directory was used.
|
||||
* Add a randomized delay to the sample systemd timer to spread out the load on a server.
|
||||
* Change the configuration format for borgmatic monitoring hooks (Healthchecks, Cronitor,
|
||||
PagerDuty, and Cronhub) to specify the ping URL / integration key as a named option. The intent
|
||||
is to support additional options (some in this release). This change is backwards-compatible.
|
||||
* Add emojis to documentation table of contents to make it easier to find particular how-to and
|
||||
reference guides at a glance.
|
||||
|
||||
1.6.0
|
||||
* #381: BREAKING: Greatly simplify configuration file reuse by deep merging when including common
|
||||
configuration. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#include-merging
|
||||
* #473: BREAKING: Instead of executing "before" command hooks before all borgmatic actions run (and
|
||||
"after" hooks after), execute these hooks right before/after the corresponding action. E.g.,
|
||||
"before_check" now runs immediately before the "check" action. This better supports running
|
||||
timing-sensitive tasks like pausing containers. Side effect: before/after command hooks now run
|
||||
once for each configured repository instead of once per configuration file. Additionally, the
|
||||
"repositories" interpolated variable has been changed to "repository", containing the path to the
|
||||
current repository for the hook. See the documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/
|
||||
* #513: Add mention of sudo's "secure_path" option to borgmatic installation documentation.
|
||||
* #515: Fix "borgmatic borg key ..." to pass parameters to Borg in the correct order.
|
||||
* #516: Fix handling of TERM signal to exit borgmatic, not just forward the signal to Borg.
|
||||
* #517: Fix borgmatic exit code (so it's zero) when initial Borg calls fail but later retries
|
||||
succeed.
|
||||
* Change Healthchecks logs truncation size from 10k bytes to 100k bytes, corresponding to that
|
||||
same change on Healthchecks.io.
|
||||
|
||||
1.5.24
|
||||
* #431: Add "working_directory" option to support source directories with relative paths.
|
||||
* #444: When loading a configuration file that is unreadable due to file permissions, warn instead
|
||||
of erroring. This supports running borgmatic as a non-root user with configuration in ~/.config
|
||||
even if there is an unreadable global configuration file in /etc.
|
||||
* #469: Add "repositories" context to "before_*" and "after_*" command action hooks. See the
|
||||
documentation for more information:
|
||||
https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/
|
||||
* #486: Fix handling of "patterns_from" and "exclude_from" options to error instead of warning when
|
||||
referencing unreadable files and "create" action is run.
|
||||
* #507: Fix Borg usage error in the "compact" action when running "borgmatic --dry-run". Now, skip
|
||||
"compact" entirely during a dry run.
|
||||
|
||||
1.5.23
|
||||
* #394: Compact repository segments and free space with new "borgmatic compact" action. Borg 1.2+
|
||||
only. Also run "compact" by default when no actions are specified, as "prune" in Borg 1.2 no
|
||||
longer frees up space unless "compact" is run.
|
||||
* #394: When using the "atime", "bsd_flags", "numeric_owner", or "remote_rate_limit" options,
|
||||
tailor the flags passed to Borg depending on the Borg version.
|
||||
* #480, #482: Fix traceback when a YAML validation error occurs.
|
||||
|
||||
1.5.22
|
||||
* #288: Add database dump hook for MongoDB.
|
||||
* #470: Move mysqldump options to the beginning of the command due to MySQL bug 30994.
|
||||
* #471: When command-line configuration override produces a parse error, error cleanly instead of
|
||||
tracebacking.
|
||||
* #476: Fix unicode error when restoring particular MySQL databases.
|
||||
* Drop support for Python 3.6, which has been end-of-lifed.
|
||||
* Add support for Python 3.10.
|
||||
|
||||
1.5.21
|
||||
* #28: Optionally retry failing backups via "retries" and "retry_wait" configuration options.
|
||||
* #306: Add "list_options" MySQL configuration option for passing additional arguments to MySQL
|
||||
list command.
|
||||
* #459: Add support for old version (2.x) of jsonschema library.
|
||||
|
||||
1.5.20
|
||||
* Re-release with correct version without dev0 tag.
|
||||
|
||||
1.5.19
|
||||
* #387: Fix error when configured source directories are not present on the filesystem at the time
|
||||
of backup. Now, Borg will complain, but the backup will still continue.
|
||||
* #455: Mention changing borgmatic path in cron documentation.
|
||||
* Update sample systemd service file with more granular read-only filesystem settings.
|
||||
* Move Gitea and GitHub hosting from a personal namespace to an organization for better
|
||||
collaboration with related projects.
|
||||
* 1k ★s on GitHub!
|
||||
|
||||
1.5.18
|
||||
* #389: Fix "message too long" error when logging to rsyslog.
|
||||
* #440: Fix traceback that can occur when dumping a database.
|
||||
|
||||
1.5.17
|
||||
* #437: Fix error when configuration file contains "umask" option.
|
||||
* Remove test dependency on vim and /dev/urandom.
|
||||
|
||||
1.5.16
|
||||
* #379: Suppress console output in sample crontab and systemd service files.
|
||||
* #407: Fix syslog logging on FreeBSD.
|
||||
* #430: Fix hang when restoring a PostgreSQL "tar" format database dump.
|
||||
* Better error messages! Switch the library used for validating configuration files (from pykwalify
|
||||
to jsonschema).
|
||||
* Link borgmatic Ansible role from installation documentation:
|
||||
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#other-ways-to-install
|
||||
|
||||
1.5.15
|
||||
* #419: Document use case of running backups conditionally based on laptop power level:
|
||||
https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/
|
||||
* #425: Run arbitrary Borg commands with new "borgmatic borg" action. See the documentation for
|
||||
more information: https://torsion.org/borgmatic/docs/how-to/run-arbitrary-borg-commands/
|
||||
|
||||
1.5.14
|
||||
* #390: Add link to Hetzner storage offering from the documentation.
|
||||
* #398: Clarify canonical home of borgmatic in documentation.
|
||||
* #406: Clarify that spaces in path names should not be backslashed in path names.
|
||||
* #423: Fix error handling to error loudly when Borg gets killed due to running out of memory!
|
||||
* Fix build so as not to attempt to build and push documentation for a non-master branch.
|
||||
* "Fix" build failure with Alpine Edge by switching from Edge to Alpine 3.13.
|
||||
* Move #borgmatic IRC channel from Freenode to Libera Chat due to Freenode takeover drama.
|
||||
IRC connection info: https://torsion.org/borgmatic/#issues
|
||||
|
||||
1.5.13
|
||||
* #373: Document that passphrase is used for Borg keyfile encryption, not just repokey encryption.
|
||||
* #404: Add support for ruamel.yaml 0.17.x YAML parsing library.
|
||||
* Update systemd service example to return a permission error when a system call isn't permitted
|
||||
(instead of terminating borgmatic outright).
|
||||
* Drop support for Python 3.5, which has been end-of-lifed.
|
||||
* Add support for Python 3.9.
|
||||
* Update versions of test dependencies (test_requirements.txt and test containers).
|
||||
* Only support black code formatter on Python 3.8+. New black dependencies make installation
|
||||
difficult on older versions of Python.
|
||||
* Replace "improve this documentation" form with link to support and ticket tracker.
|
||||
|
||||
1.5.12
|
||||
* Fix for previous release with incorrect version suffix in setup.py. No other changes.
|
||||
|
||||
1.5.11
|
||||
* #341: Add "temporary_directory" option for changing Borg's temporary directory.
|
||||
* #352: Lock down systemd security settings in sample systemd service file.
|
||||
* #355: Fix traceback when a database hook value is null in a configuration file.
|
||||
* #361: Merge override values when specifying the "--override" flag multiple times. The previous
|
||||
behavior was to take the value of the last "--override" flag only.
|
||||
* #367: Fix traceback when upgrading old INI-style configuration with upgrade-borgmatic-config.
|
||||
* #368: Fix signal forwarding from borgmatic to Borg resulting in recursion traceback.
|
||||
* #369: Document support for Borg placeholders in repository names.
|
||||
|
||||
1.5.10
|
||||
* #347: Add hooks that run for the "extract" action: "before_extract" and "after_extract".
|
||||
* #350: Fix traceback when a configuration directory is non-readable due to directory permissions.
|
||||
* Add documentation navigation links on left side of all documentation pages.
|
||||
* Clarify documentation on configuration overrides, specifically the portion about list syntax:
|
||||
http://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides
|
||||
* Clarify documentation overview of monitoring options:
|
||||
http://torsion.org/borgmatic/docs/how-to/monitor-your-backups/
|
||||
|
||||
1.5.9
|
||||
* #300: Add "borgmatic export-tar" action to export an archive to a tar-formatted file or stream.
|
||||
* #339: Fix for intermittent timing-related test failure of logging function.
|
||||
* Clarify database documentation about excluding named pipes and character/block devices to prevent
|
||||
hangs.
|
||||
* Add documentation on how to make backups redundant with multiple repositories:
|
||||
https://torsion.org/borgmatic/docs/how-to/make-backups-redundant/
|
||||
|
||||
1.5.8
|
||||
* #336: Fix for traceback when running Cronitor, Cronhub, and PagerDuty monitor hooks.
|
||||
|
||||
1.5.7
|
||||
* #327: Fix broken pass-through of BORG_* environment variables to Borg.
|
||||
* #328: Fix duplicate logging to Healthchecks and send "after_*" hooks output to Healthchecks.
|
||||
* #331: Add SSL support to PostgreSQL database configuration.
|
||||
* #333: Fix for potential data loss (data not getting backed up) when borgmatic omitted configured
|
||||
source directories in certain situations. Specifically, this occurred when two source directories
|
||||
on different filesystems were related by parentage (e.g. "/foo" and "/foo/bar/baz") and the
|
||||
one_file_system option was enabled.
|
||||
* Update documentation code fragments theme to better match the rest of the page.
|
||||
* Improve configuration reference documentation readability via more aggressive word-wrapping in
|
||||
configuration schema descriptions.
|
||||
|
||||
1.5.6
|
||||
* #292: Allow before_backup and similiar hooks to exit with a soft failure without altering the
|
||||
monitoring status on Healthchecks or other providers. Support this by waiting to ping monitoring
|
||||
services with a "start" status until after before_* hooks finish. Failures in before_* hooks
|
||||
still trigger a monitoring "fail" status.
|
||||
* #316: Fix hang when a stale database dump named pipe from an aborted borgmatic run remains on
|
||||
disk.
|
||||
* #323: Fix for certain configuration options like ssh_command impacting Borg invocations for
|
||||
separate configuration files.
|
||||
* #324: Add "borgmatic extract --strip-components" flag to remove leading path components when
|
||||
extracting an archive.
|
||||
* Tweak comment indentation in generated configuration file for clarity.
|
||||
* Link to Borgmacator GNOME AppIndicator from monitoring documentation.
|
||||
|
||||
1.5.5
|
||||
* #314: Fix regression in support for PostgreSQL's "directory" dump format. Unlike other dump
|
||||
formats, the "directory" dump format does not stream directly to/from Borg.
|
||||
@@ -464,7 +674,7 @@
|
||||
* #49: Support for Borg experimental --patterns-from and --patterns options for specifying mixed
|
||||
includes/excludes.
|
||||
* Moved issue tracker from Taiga to integrated Gitea tracker at
|
||||
https://projects.torsion.org/witten/borgmatic/issues
|
||||
https://projects.torsion.org/borgmatic-collective/borgmatic/issues
|
||||
|
||||
1.1.12
|
||||
* #46: Declare dependency on pykwalify 1.6 or above, as older versions yield "Unknown key: version"
|
||||
|
||||
@@ -11,6 +11,8 @@ borgmatic is simple, configuration-driven backup software for servers and
|
||||
workstations. Protect your files with client-side encryption. Backup your
|
||||
databases too. Monitor it all with integrated third-party services.
|
||||
|
||||
The canonical home of borgmatic is at <a href="https://torsion.org/borgmatic">https://torsion.org/borgmatic</a>.
|
||||
|
||||
Here's an example configuration file:
|
||||
|
||||
```yaml
|
||||
@@ -35,8 +37,9 @@ retention:
|
||||
consistency:
|
||||
# List of checks to run to validate your backups.
|
||||
checks:
|
||||
- repository
|
||||
- archives
|
||||
- name: repository
|
||||
- name: archives
|
||||
frequency: 2 weeks
|
||||
|
||||
hooks:
|
||||
# Custom preparation scripts to run.
|
||||
@@ -52,9 +55,9 @@ hooks:
|
||||
```
|
||||
|
||||
Want to see borgmatic in action? Check out the <a
|
||||
href="https://asciinema.org/a/203761" target="_blank">screencast</a>.
|
||||
href="https://asciinema.org/a/203761?autoplay=1" target="_blank">screencast</a>.
|
||||
|
||||
<script src="https://asciinema.org/a/203761.js" id="asciicast-203761" async></script>
|
||||
<a href="https://asciinema.org/a/203761?autoplay=1" target="_blank"><img src="https://asciinema.org/a/203761.png" width="480"></a>
|
||||
|
||||
borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
|
||||
|
||||
@@ -63,61 +66,55 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
|
||||
<a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px;"></a>
|
||||
<a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px;"></a>
|
||||
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px;"></a>
|
||||
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px;"></a>
|
||||
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px;"></a>
|
||||
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px;"></a>
|
||||
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px;"></a>
|
||||
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px;"></a>
|
||||
<a href="https://www.rsync.net/cgi-bin/borg.cgi?campaign=borg&adgroup=borgmatic"><img src="docs/static/rsyncnet.png" alt="rsync.net" height="60px" style="margin-bottom: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;"></a>
|
||||
|
||||
|
||||
## How-to guides
|
||||
## Getting started
|
||||
|
||||
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/) ⬅ *Start here!*
|
||||
* [Make per-application backups](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/)
|
||||
* [Deal with very large backups](https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/)
|
||||
* [Inspect your backups](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/)
|
||||
* [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
|
||||
* [Extract a backup](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/)
|
||||
* [Backup your databases](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/)
|
||||
* [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/)
|
||||
* [Backup to a removable drive or an intermittent server](https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/)
|
||||
* [Upgrade borgmatic](https://torsion.org/borgmatic/docs/how-to/upgrade/)
|
||||
* [Develop on borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/)
|
||||
Your first step is to [install and configure
|
||||
borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/).
|
||||
|
||||
|
||||
## Reference guides
|
||||
|
||||
* [borgmatic configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/)
|
||||
* [borgmatic command-line reference](https://torsion.org/borgmatic/docs/reference/command-line/)
|
||||
For additional documentation, check out the links above for <a
|
||||
href="https://torsion.org/borgmatic/#documentation">borgmatic how-to and
|
||||
reference guides</a>.
|
||||
|
||||
|
||||
## Hosting providers
|
||||
|
||||
Need somewhere to store your encrypted offsite backups? The following hosting
|
||||
providers include specific support for Borg/borgmatic. Using these links and
|
||||
services helps support borgmatic development and hosting. (These are referral
|
||||
links, but without any tracking scripts or cookies.)
|
||||
Need somewhere to store your encrypted off-site backups? The following hosting
|
||||
providers include specific support for Borg/borgmatic—and fund borgmatic
|
||||
development and hosting when you use these links to sign up. (These are
|
||||
referral links, but without any tracking scripts or cookies.)
|
||||
|
||||
<ul>
|
||||
<li class="referral"><a href="https://www.rsync.net/cgi-bin/borg.cgi?campaign=borg&adgroup=borgmatic">rsync.net</a>: Cloud Storage provider with full support for borg and any other SSH/SFTP tool</li>
|
||||
<li class="referral"><a href="https://www.borgbase.com/?utm_source=borgmatic">BorgBase</a>: Borg hosting service with support for monitoring, 2FA, and append-only repos</li>
|
||||
</ul>
|
||||
|
||||
Additionally, [rsync.net](https://www.rsync.net/products/borg.html) and
|
||||
[Hetzner](https://www.hetzner.com/storage/storage-box) have compatible storage
|
||||
offerings, but do not currently fund borgmatic development or hosting.
|
||||
|
||||
## Support and contributing
|
||||
|
||||
### Issues
|
||||
|
||||
You've got issues? Or an idea for a feature enhancement? We've got an [issue
|
||||
tracker](https://projects.torsion.org/witten/borgmatic/issues). In order to
|
||||
tracker](https://projects.torsion.org/borgmatic-collective/borgmatic/issues). In order to
|
||||
create a new issue or comment on an issue, you'll need to [login
|
||||
first](https://projects.torsion.org/user/login). Note that you can login with
|
||||
an existing GitHub account if you prefer.
|
||||
|
||||
If you'd like to chat with borgmatic developers or users, head on over to the
|
||||
`#borgmatic` IRC channel on Freenode, either via <a
|
||||
href="https://webchat.freenode.net/?channels=borgmatic">web chat</a> or a
|
||||
native <a href="irc://chat.freenode.net:6697">IRC client</a>.
|
||||
`#borgmatic` IRC channel on Libera Chat, either via <a
|
||||
href="https://web.libera.chat/#borgmatic">web chat</a> or a
|
||||
native <a href="ircs://irc.libera.chat:6697">IRC client</a>. If you
|
||||
don't get a response right away, please hang around a while—or file a ticket
|
||||
instead.
|
||||
|
||||
Also see the [security
|
||||
policy](https://torsion.org/borgmatic/docs/security-policy/) for any security
|
||||
@@ -129,16 +126,16 @@ Other questions or comments? Contact
|
||||
|
||||
### Contributing
|
||||
|
||||
borgmatic is hosted at <https://torsion.org/borgmatic> with [source code
|
||||
available](https://projects.torsion.org/witten/borgmatic), and is also
|
||||
mirrored on [GitHub](https://github.com/witten/borgmatic) for convenience.
|
||||
borgmatic [source code is
|
||||
available](https://projects.torsion.org/borgmatic-collective/borgmatic) and is also mirrored
|
||||
on [GitHub](https://github.com/borgmatic-collective/borgmatic) for convenience.
|
||||
|
||||
borgmatic is licensed under the GNU General Public License version 3 or any
|
||||
later version.
|
||||
|
||||
If you'd like to contribute to borgmatic development, please feel free to
|
||||
submit a [Pull Request](https://projects.torsion.org/witten/borgmatic/pulls)
|
||||
or open an [issue](https://projects.torsion.org/witten/borgmatic/issues) first
|
||||
submit a [Pull Request](https://projects.torsion.org/borgmatic-collective/borgmatic/pulls)
|
||||
or open an [issue](https://projects.torsion.org/borgmatic-collective/borgmatic/issues) first
|
||||
to discuss your idea. We also accept Pull Requests on GitHub, if that's more
|
||||
your thing. In general, contributions are very welcome. We don't bite!
|
||||
|
||||
@@ -146,5 +143,5 @@ 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/witten/borgmatic" alt="build status"></a>
|
||||
<a href="https://build.torsion.org/borgmatic-collective/borgmatic" alt="build status"></a>
|
||||
|
||||
|
||||
+4
-5
@@ -6,14 +6,13 @@ permalink: security-policy/index.html
|
||||
## Supported versions
|
||||
|
||||
While we want to hear about security vulnerabilities in all versions of
|
||||
borgmatic, security fixes will only be made to the most recently released
|
||||
version. It's not practical for our small volunteer effort to maintain
|
||||
multiple different release branches and put out separate security patches for
|
||||
each.
|
||||
borgmatic, security fixes are only made to the most recently released version.
|
||||
It's simply not practical for our small volunteer effort to maintain multiple
|
||||
release branches and put out separate security patches for each.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
If you find a security vulnerability, please [file a
|
||||
ticket](https://torsion.org/borgmatic/#issues) or [send email
|
||||
directly](mailto:witten@torsion.org) as appropriate. You should expect to hear
|
||||
back within a few days at most, and generally sooner.
|
||||
back within a few days at most and generally sooner.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import logging
|
||||
|
||||
from borgmatic.borg.flags import make_flags
|
||||
from borgmatic.execute import execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
REPOSITORYLESS_BORG_COMMANDS = {'serve', None}
|
||||
BORG_COMMANDS_WITH_SUBCOMMANDS = {'key', 'debug'}
|
||||
BORG_SUBCOMMANDS_WITHOUT_REPOSITORY = (('debug', 'info'), ('debug', 'convert-profile'))
|
||||
|
||||
|
||||
def run_arbitrary_borg(
|
||||
repository, storage_config, options, archive=None, local_path='borg', remote_path=None
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a storage config dict, a sequence of arbitrary
|
||||
command-line Borg options, and an optional archive name, run an arbitrary Borg command on the
|
||||
given repository/archive.
|
||||
'''
|
||||
lock_wait = storage_config.get('lock_wait', None)
|
||||
|
||||
try:
|
||||
options = options[1:] if options[0] == '--' else options
|
||||
|
||||
# Borg commands like "key" have a sub-command ("export", etc.) that must follow it.
|
||||
command_options_start_index = 2 if options[0] in BORG_COMMANDS_WITH_SUBCOMMANDS else 1
|
||||
borg_command = tuple(options[:command_options_start_index])
|
||||
command_options = tuple(options[command_options_start_index:])
|
||||
except IndexError:
|
||||
borg_command = ()
|
||||
command_options = ()
|
||||
|
||||
if borg_command in BORG_SUBCOMMANDS_WITHOUT_REPOSITORY:
|
||||
repository_archive = None
|
||||
else:
|
||||
repository_archive = (
|
||||
'::'.join((repository, archive)) if repository and archive else repository
|
||||
)
|
||||
|
||||
full_command = (
|
||||
(local_path,)
|
||||
+ borg_command
|
||||
+ ((repository_archive,) if borg_command and repository_archive else ())
|
||||
+ command_options
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ make_flags('remote-path', remote_path)
|
||||
+ make_flags('lock-wait', lock_wait)
|
||||
)
|
||||
|
||||
return execute_command(
|
||||
full_command, output_log_level=logging.WARNING, borg_local_path=local_path,
|
||||
)
|
||||
+204
-27
@@ -1,46 +1,157 @@
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from borgmatic.borg import extract
|
||||
from borgmatic.borg import extract, info, state
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
DEFAULT_CHECKS = ('repository', 'archives')
|
||||
DEFAULT_CHECKS = (
|
||||
{'name': 'repository', 'frequency': '1 month'},
|
||||
{'name': 'archives', 'frequency': '1 month'},
|
||||
)
|
||||
DEFAULT_PREFIX = '{hostname}-'
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_checks(consistency_config, only_checks=None):
|
||||
def parse_checks(consistency_config, only_checks=None):
|
||||
'''
|
||||
Given a consistency config with a "checks" list, and an optional list of override checks,
|
||||
transform them a tuple of named checks to run.
|
||||
Given a consistency config 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 retention config of:
|
||||
|
||||
{'checks': ['repository', 'archives']}
|
||||
{'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 the checks value
|
||||
is the string "disabled", return an empty tuple, meaning that no checks should be run.
|
||||
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 the "data" option is present, then make sure the "archives" option is included as well.
|
||||
If the "data" check is present, then make sure the "archives" check is included as well.
|
||||
'''
|
||||
checks = [
|
||||
check.lower() for check in (only_checks or consistency_config.get('checks', []) or [])
|
||||
]
|
||||
if checks == ['disabled']:
|
||||
checks = only_checks or tuple(
|
||||
check_config['name']
|
||||
for check_config in (consistency_config.get('checks', None) or DEFAULT_CHECKS)
|
||||
)
|
||||
checks = tuple(check.lower() for check in checks)
|
||||
if 'disabled' in checks:
|
||||
if len(checks) > 1:
|
||||
logger.warning(
|
||||
'Multiple checks are configured, but one of them is "disabled"; not running any checks'
|
||||
)
|
||||
return ()
|
||||
|
||||
if 'data' in checks and 'archives' not in checks:
|
||||
checks.append('archives')
|
||||
return checks + ('archives',)
|
||||
|
||||
return tuple(check for check in checks if check not in ('disabled', '')) or DEFAULT_CHECKS
|
||||
return checks
|
||||
|
||||
|
||||
def _make_check_flags(checks, check_last=None, prefix=None):
|
||||
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(
|
||||
location_config, consistency_config, borg_repository_id, checks, force
|
||||
):
|
||||
'''
|
||||
Given a location config, a consistency config with a "checks" sequence of dicts, a Borg
|
||||
repository ID, a sequence of checks, and whether to force checks to run, 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:
|
||||
|
||||
consistency_config = {
|
||||
'checks': [
|
||||
{
|
||||
'name': 'archives',
|
||||
'frequency': '2 weeks',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
When this function is called with that consistency_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.
|
||||
'''
|
||||
filtered_checks = list(checks)
|
||||
|
||||
if force:
|
||||
return tuple(filtered_checks)
|
||||
|
||||
for check_config in consistency_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 = read_check_time(
|
||||
make_check_time_path(location_config, borg_repository_id, check)
|
||||
)
|
||||
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"
|
||||
)
|
||||
filtered_checks.remove(check)
|
||||
|
||||
return tuple(filtered_checks)
|
||||
|
||||
|
||||
def make_check_flags(checks, check_last=None, prefix=None):
|
||||
'''
|
||||
Given a parsed sequence of checks, transform it into tuple of command-line flags.
|
||||
|
||||
@@ -66,27 +177,67 @@ def _make_check_flags(checks, check_last=None, prefix=None):
|
||||
last_flags = ()
|
||||
prefix_flags = ()
|
||||
if check_last:
|
||||
logger.warning(
|
||||
'Ignoring check_last option, as "archives" is not in consistency checks.'
|
||||
)
|
||||
logger.info('Ignoring check_last option, as "archives" is not in consistency checks')
|
||||
if prefix:
|
||||
logger.warning(
|
||||
'Ignoring consistency prefix option, as "archives" is not in consistency checks.'
|
||||
logger.info(
|
||||
'Ignoring consistency prefix option, as "archives" is not in consistency checks'
|
||||
)
|
||||
|
||||
common_flags = last_flags + prefix_flags + (('--verify-data',) if 'data' in checks else ())
|
||||
|
||||
if set(DEFAULT_CHECKS).issubset(set(checks)):
|
||||
if {'repository', 'archives'}.issubset(set(checks)):
|
||||
return common_flags
|
||||
|
||||
return (
|
||||
tuple('--{}-only'.format(check) for check in checks if check in DEFAULT_CHECKS)
|
||||
tuple('--{}-only'.format(check) for check in checks if check in ('repository', 'archives'))
|
||||
+ common_flags
|
||||
)
|
||||
|
||||
|
||||
def make_check_time_path(location_config, borg_repository_id, check_type):
|
||||
'''
|
||||
Given a location configuration dict, a Borg repository ID, and the name of a check type
|
||||
("repository", "archives", etc.), return a path for recording that check's time (the time of
|
||||
that check last occurring).
|
||||
'''
|
||||
return os.path.join(
|
||||
os.path.expanduser(
|
||||
location_config.get(
|
||||
'borgmatic_source_directory', state.DEFAULT_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 check_archives(
|
||||
repository,
|
||||
location_config,
|
||||
storage_config,
|
||||
consistency_config,
|
||||
local_path='borg',
|
||||
@@ -94,6 +245,7 @@ def check_archives(
|
||||
progress=None,
|
||||
repair=None,
|
||||
only_checks=None,
|
||||
force=None,
|
||||
):
|
||||
'''
|
||||
Given a local or remote repository path, a storage config dict, a consistency config dict,
|
||||
@@ -102,13 +254,34 @@ def check_archives(
|
||||
Borg archives for consistency.
|
||||
|
||||
If there are no consistency checks to run, skip running them.
|
||||
|
||||
Raises ValueError if the Borg repository ID cannot be determined.
|
||||
'''
|
||||
checks = _parse_checks(consistency_config, only_checks)
|
||||
try:
|
||||
borg_repository_id = json.loads(
|
||||
info.display_archives_info(
|
||||
repository,
|
||||
storage_config,
|
||||
argparse.Namespace(json=True, archive=None),
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
)['repository']['id']
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
raise ValueError(f'Cannot determine Borg repository ID for {repository}')
|
||||
|
||||
checks = filter_checks_on_frequency(
|
||||
location_config,
|
||||
consistency_config,
|
||||
borg_repository_id,
|
||||
parse_checks(consistency_config, only_checks),
|
||||
force,
|
||||
)
|
||||
check_last = consistency_config.get('check_last', None)
|
||||
lock_wait = None
|
||||
extra_borg_options = storage_config.get('extra_borg_options', {}).get('check', '')
|
||||
|
||||
if set(checks).intersection(set(DEFAULT_CHECKS + ('data',))):
|
||||
if set(checks).intersection({'repository', 'archives', 'data'}):
|
||||
lock_wait = storage_config.get('lock_wait', None)
|
||||
|
||||
verbosity_flags = ()
|
||||
@@ -122,7 +295,7 @@ def check_archives(
|
||||
full_command = (
|
||||
(local_path, 'check')
|
||||
+ (('--repair',) if repair else ())
|
||||
+ _make_check_flags(checks, check_last, prefix)
|
||||
+ make_check_flags(checks, check_last, prefix)
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ verbosity_flags
|
||||
@@ -131,12 +304,16 @@ def check_archives(
|
||||
+ (repository,)
|
||||
)
|
||||
|
||||
# The Borg repair option trigger an interactive prompt, which won't work when output is
|
||||
# The Borg repair option triggers an interactive prompt, which won't work when output is
|
||||
# captured. And progress messes with the terminal directly.
|
||||
if repair or progress:
|
||||
execute_command(full_command, output_file=DO_NOT_CAPTURE)
|
||||
else:
|
||||
execute_command(full_command)
|
||||
|
||||
for check in checks:
|
||||
write_check_time(make_check_time_path(location_config, borg_repository_id, check))
|
||||
|
||||
if 'extract' in checks:
|
||||
extract.extract_last_archive_dry_run(repository, lock_wait, local_path, remote_path)
|
||||
write_check_time(make_check_time_path(location_config, borg_repository_id, 'extract'))
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import logging
|
||||
|
||||
from borgmatic.execute import execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def compact_segments(
|
||||
dry_run,
|
||||
repository,
|
||||
storage_config,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
progress=False,
|
||||
cleanup_commits=False,
|
||||
threshold=None,
|
||||
):
|
||||
'''
|
||||
Given dry-run flag, a local or remote repository path, and a storage config dict, compact Borg
|
||||
segments in a repository.
|
||||
'''
|
||||
umask = storage_config.get('umask', None)
|
||||
lock_wait = storage_config.get('lock_wait', None)
|
||||
extra_borg_options = storage_config.get('extra_borg_options', {}).get('compact', '')
|
||||
|
||||
full_command = (
|
||||
(local_path, 'compact')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--progress',) if progress else ())
|
||||
+ (('--cleanup-commits',) if cleanup_commits else ())
|
||||
+ (('--threshold', str(threshold)) if threshold else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
|
||||
+ (repository,)
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
execute_command(full_command, output_log_level=logging.INFO, borg_local_path=local_path)
|
||||
+115
-40
@@ -5,12 +5,13 @@ import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
|
||||
from borgmatic.borg import feature, state
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command, execute_command_with_processes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _expand_directory(directory):
|
||||
def expand_directory(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.
|
||||
@@ -20,7 +21,7 @@ def _expand_directory(directory):
|
||||
return glob.glob(expanded_directory) or [expanded_directory]
|
||||
|
||||
|
||||
def _expand_directories(directories):
|
||||
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.
|
||||
@@ -29,11 +30,11 @@ def _expand_directories(directories):
|
||||
return ()
|
||||
|
||||
return tuple(
|
||||
itertools.chain.from_iterable(_expand_directory(directory) for directory in directories)
|
||||
itertools.chain.from_iterable(expand_directory(directory) for directory in directories)
|
||||
)
|
||||
|
||||
|
||||
def _expand_home_directories(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.
|
||||
@@ -44,10 +45,29 @@ def _expand_home_directories(directories):
|
||||
return tuple(os.path.expanduser(directory) for directory in directories)
|
||||
|
||||
|
||||
def deduplicate_directories(directories):
|
||||
def map_directories_to_devices(directories):
|
||||
'''
|
||||
Given a sequence of directories, return them as a sorted tuple with all duplicate child
|
||||
directories removed. For instance, if paths is ('/foo', '/foo/bar'), return just: ('/foo',)
|
||||
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):
|
||||
'''
|
||||
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
|
||||
@@ -56,24 +76,29 @@ def deduplicate_directories(directories):
|
||||
Borg.
|
||||
'''
|
||||
deduplicated = set()
|
||||
directories = sorted(directory_devices.keys())
|
||||
|
||||
for directory in sorted(directories):
|
||||
# If the directory is "/", that contains all child directories, so we can early out.
|
||||
if directory == os.path.sep:
|
||||
return (os.path.sep,)
|
||||
for directory in directories:
|
||||
deduplicated.add(directory)
|
||||
parents = pathlib.PurePath(directory).parents
|
||||
|
||||
# If no other directories are parents of current directory (even n levels up), then the
|
||||
# current directory isn't a duplicate.
|
||||
if not any(
|
||||
pathlib.PurePath(other_directory) in pathlib.PurePath(directory).parents
|
||||
for other_directory in directories
|
||||
):
|
||||
deduplicated.add(directory)
|
||||
# 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_directory in directories:
|
||||
for parent in parents:
|
||||
if (
|
||||
pathlib.PurePath(other_directory) == parent
|
||||
and directory_devices[directory] is not None
|
||||
and directory_devices[other_directory] == directory_devices[directory]
|
||||
):
|
||||
if directory in deduplicated:
|
||||
deduplicated.remove(directory)
|
||||
break
|
||||
|
||||
return tuple(sorted(deduplicated))
|
||||
|
||||
|
||||
def _write_pattern_file(patterns=None):
|
||||
def write_pattern_file(patterns=None):
|
||||
'''
|
||||
Given a sequence of patterns, write them to a named temporary file and return it. Return None
|
||||
if no patterns are provided.
|
||||
@@ -88,7 +113,19 @@ def _write_pattern_file(patterns=None):
|
||||
return pattern_file
|
||||
|
||||
|
||||
def _make_pattern_flags(location_config, pattern_filename=None):
|
||||
def ensure_files_readable(*filename_lists):
|
||||
'''
|
||||
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.
|
||||
'''
|
||||
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(location_config, pattern_filename=None):
|
||||
'''
|
||||
Given a location config 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.
|
||||
@@ -104,7 +141,7 @@ def _make_pattern_flags(location_config, pattern_filename=None):
|
||||
)
|
||||
|
||||
|
||||
def _make_exclude_flags(location_config, exclude_filename=None):
|
||||
def make_exclude_flags(location_config, exclude_filename=None):
|
||||
'''
|
||||
Given a location config dict with various exclude options, and a filename containing any exclude
|
||||
patterns, return the corresponding Borg flags as a tuple.
|
||||
@@ -138,7 +175,7 @@ def _make_exclude_flags(location_config, exclude_filename=None):
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic'
|
||||
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
|
||||
|
||||
|
||||
def borgmatic_source_directories(borgmatic_source_directory):
|
||||
@@ -146,7 +183,7 @@ def 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 = DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
borgmatic_source_directory = state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
|
||||
return (
|
||||
[borgmatic_source_directory]
|
||||
@@ -155,14 +192,12 @@ def borgmatic_source_directories(borgmatic_source_directory):
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
|
||||
|
||||
|
||||
def create_archive(
|
||||
dry_run,
|
||||
repository,
|
||||
location_config,
|
||||
storage_config,
|
||||
local_borg_version,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
progress=False,
|
||||
@@ -179,15 +214,21 @@ def create_archive(
|
||||
create command while also triggering the given processes to produce output.
|
||||
'''
|
||||
sources = deduplicate_directories(
|
||||
_expand_directories(
|
||||
location_config['source_directories']
|
||||
+ borgmatic_source_directories(location_config.get('borgmatic_source_directory'))
|
||||
map_directories_to_devices(
|
||||
expand_directories(
|
||||
location_config['source_directories']
|
||||
+ borgmatic_source_directories(location_config.get('borgmatic_source_directory'))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
pattern_file = _write_pattern_file(location_config.get('patterns'))
|
||||
exclude_file = _write_pattern_file(
|
||||
_expand_home_directories(location_config.get('exclude_patterns'))
|
||||
try:
|
||||
working_directory = os.path.expanduser(location_config.get('working_directory'))
|
||||
except TypeError:
|
||||
working_directory = None
|
||||
pattern_file = write_pattern_file(location_config.get('patterns'))
|
||||
exclude_file = write_pattern_file(
|
||||
expand_home_directories(location_config.get('exclude_patterns'))
|
||||
)
|
||||
checkpoint_interval = storage_config.get('checkpoint_interval', None)
|
||||
chunker_params = storage_config.get('chunker_params', None)
|
||||
@@ -199,25 +240,52 @@ def create_archive(
|
||||
archive_name_format = storage_config.get('archive_name_format', DEFAULT_ARCHIVE_NAME_FORMAT)
|
||||
extra_borg_options = storage_config.get('extra_borg_options', {}).get('create', '')
|
||||
|
||||
if feature.available(feature.Feature.ATIME, local_borg_version):
|
||||
atime_flags = ('--atime',) if location_config.get('atime') is True else ()
|
||||
else:
|
||||
atime_flags = ('--noatime',) if location_config.get('atime') is False else ()
|
||||
|
||||
if feature.available(feature.Feature.NOFLAGS, local_borg_version):
|
||||
noflags_flags = ('--noflags',) if location_config.get('bsd_flags') is False else ()
|
||||
else:
|
||||
noflags_flags = ('--nobsdflags',) if location_config.get('bsd_flags') is False else ()
|
||||
|
||||
if feature.available(feature.Feature.NUMERIC_IDS, local_borg_version):
|
||||
numeric_ids_flags = ('--numeric-ids',) if location_config.get('numeric_owner') else ()
|
||||
else:
|
||||
numeric_ids_flags = ('--numeric-owner',) if location_config.get('numeric_owner') else ()
|
||||
|
||||
if feature.available(feature.Feature.UPLOAD_RATELIMIT, local_borg_version):
|
||||
upload_ratelimit_flags = (
|
||||
('--upload-ratelimit', str(remote_rate_limit)) if remote_rate_limit else ()
|
||||
)
|
||||
else:
|
||||
upload_ratelimit_flags = (
|
||||
('--remote-ratelimit', str(remote_rate_limit)) if remote_rate_limit else ()
|
||||
)
|
||||
|
||||
ensure_files_readable(location_config.get('patterns_from'), location_config.get('exclude_from'))
|
||||
|
||||
full_command = (
|
||||
(local_path, 'create')
|
||||
+ _make_pattern_flags(location_config, pattern_file.name if pattern_file else None)
|
||||
+ _make_exclude_flags(location_config, exclude_file.name if exclude_file else None)
|
||||
tuple(local_path.split(' '))
|
||||
+ ('create',)
|
||||
+ make_pattern_flags(location_config, pattern_file.name if pattern_file else None)
|
||||
+ make_exclude_flags(location_config, exclude_file.name if exclude_file else None)
|
||||
+ (('--checkpoint-interval', str(checkpoint_interval)) if checkpoint_interval else ())
|
||||
+ (('--chunker-params', chunker_params) if chunker_params else ())
|
||||
+ (('--compression', compression) if compression else ())
|
||||
+ (('--remote-ratelimit', str(remote_rate_limit)) if remote_rate_limit else ())
|
||||
+ upload_ratelimit_flags
|
||||
+ (
|
||||
('--one-file-system',)
|
||||
if location_config.get('one_file_system') or stream_processes
|
||||
else ()
|
||||
)
|
||||
+ (('--numeric-owner',) if location_config.get('numeric_owner') else ())
|
||||
+ (('--noatime',) if location_config.get('atime') is False else ())
|
||||
+ numeric_ids_flags
|
||||
+ atime_flags
|
||||
+ (('--noctime',) if location_config.get('ctime') is False else ())
|
||||
+ (('--nobirthtime',) if location_config.get('birthtime') is False else ())
|
||||
+ (('--read-special',) if (location_config.get('read_special') or stream_processes) else ())
|
||||
+ (('--nobsdflags',) if location_config.get('bsd_flags') is False else ())
|
||||
+ noflags_flags
|
||||
+ (('--files-cache', files_cache) if files_cache else ())
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
@@ -256,6 +324,13 @@ def create_archive(
|
||||
output_log_level,
|
||||
output_file,
|
||||
borg_local_path=local_path,
|
||||
working_directory=working_directory,
|
||||
)
|
||||
|
||||
return execute_command(full_command, output_log_level, output_file, borg_local_path=local_path)
|
||||
return execute_command(
|
||||
full_command,
|
||||
output_log_level,
|
||||
output_file,
|
||||
borg_local_path=local_path,
|
||||
working_directory=working_directory,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
'encryption_passcommand': 'BORG_PASSCOMMAND',
|
||||
'encryption_passphrase': 'BORG_PASSPHRASE',
|
||||
'ssh_command': 'BORG_RSH',
|
||||
'temporary_directory': 'TMPDIR',
|
||||
}
|
||||
|
||||
DEFAULT_BOOL_OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
@@ -19,9 +20,15 @@ DEFAULT_BOOL_OPTION_TO_ENVIRONMENT_VARIABLE = {
|
||||
|
||||
def initialize(storage_config):
|
||||
for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items():
|
||||
value = storage_config.get(option_name)
|
||||
|
||||
# Options from borgmatic configuration take precedence over already set BORG_* environment
|
||||
# variables.
|
||||
value = storage_config.get(option_name) or os.environ.get(environment_variable_name)
|
||||
|
||||
if value:
|
||||
os.environ[environment_variable_name] = value
|
||||
else:
|
||||
os.environ.pop(environment_variable_name, None)
|
||||
|
||||
for (
|
||||
option_name,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def export_tar_archive(
|
||||
dry_run,
|
||||
repository,
|
||||
archive,
|
||||
paths,
|
||||
destination_path,
|
||||
storage_config,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
tar_filter=None,
|
||||
files=False,
|
||||
strip_components=None,
|
||||
):
|
||||
'''
|
||||
Given a dry-run flag, a local or remote repository path, an archive name, zero or more paths to
|
||||
export from the archive, a destination path to export to, a storage configuration dict, optional
|
||||
local and remote Borg paths, an optional filter program, whether to include per-file details,
|
||||
and an optional number of path components to strip, export the archive into the given
|
||||
destination path as a tar-formatted file.
|
||||
|
||||
If the destination path is "-", then stream the output to stdout instead of to a file.
|
||||
'''
|
||||
umask = storage_config.get('umask', None)
|
||||
lock_wait = storage_config.get('lock_wait', None)
|
||||
|
||||
full_command = (
|
||||
(local_path, 'export-tar')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--list',) if files else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ (('--dry-run',) if dry_run else ())
|
||||
+ (('--tar-filter', tar_filter) if tar_filter else ())
|
||||
+ (('--strip-components', str(strip_components)) if strip_components else ())
|
||||
+ ('::'.join((repository if ':' in repository else os.path.abspath(repository), archive)),)
|
||||
+ (destination_path,)
|
||||
+ (tuple(paths) if paths else ())
|
||||
)
|
||||
|
||||
if files and logger.getEffectiveLevel() == logging.WARNING:
|
||||
output_log_level = logging.WARNING
|
||||
else:
|
||||
output_log_level = logging.INFO
|
||||
|
||||
if dry_run:
|
||||
logging.info('{}: Skipping export to tar file (dry run)'.format(repository))
|
||||
return
|
||||
|
||||
execute_command(
|
||||
full_command,
|
||||
output_file=DO_NOT_CAPTURE if destination_path == '-' else None,
|
||||
output_log_level=output_log_level,
|
||||
borg_local_path=local_path,
|
||||
)
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from borgmatic.borg import feature
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,17 +62,19 @@ def extract_archive(
|
||||
paths,
|
||||
location_config,
|
||||
storage_config,
|
||||
local_borg_version,
|
||||
local_path='borg',
|
||||
remote_path=None,
|
||||
destination_path=None,
|
||||
strip_components=None,
|
||||
progress=False,
|
||||
extract_to_stdout=False,
|
||||
):
|
||||
'''
|
||||
Given a dry-run flag, a local or remote repository path, an archive name, zero or more paths to
|
||||
restore from the archive, location/storage configuration dicts, optional local and remote Borg
|
||||
paths, and an optional destination path to extract to, extract the archive into the current
|
||||
directory.
|
||||
restore from the archive, the local Borg version string, location/storage configuration dicts,
|
||||
optional local and remote Borg paths, and an optional destination path to extract to, extract
|
||||
the archive into the current directory.
|
||||
|
||||
If extract to stdout is True, then start the extraction streaming to stdout, and return that
|
||||
extract process as an instance of subprocess.Popen.
|
||||
@@ -82,15 +85,21 @@ def extract_archive(
|
||||
if progress and extract_to_stdout:
|
||||
raise ValueError('progress and extract_to_stdout cannot both be set')
|
||||
|
||||
if feature.available(feature.Feature.NUMERIC_IDS, local_borg_version):
|
||||
numeric_ids_flags = ('--numeric-ids',) if location_config.get('numeric_owner') else ()
|
||||
else:
|
||||
numeric_ids_flags = ('--numeric-owner',) if location_config.get('numeric_owner') else ()
|
||||
|
||||
full_command = (
|
||||
(local_path, 'extract')
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (('--numeric-owner',) if location_config.get('numeric_owner') else ())
|
||||
+ numeric_ids_flags
|
||||
+ (('--umask', str(umask)) if umask else ())
|
||||
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ (('--dry-run',) if dry_run else ())
|
||||
+ (('--strip-components', str(strip_components)) if strip_components else ())
|
||||
+ (('--progress',) if progress else ())
|
||||
+ (('--stdout',) if extract_to_stdout else ())
|
||||
+ ('::'.join((repository if ':' in repository else os.path.abspath(repository), archive)),)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from enum import Enum
|
||||
|
||||
from pkg_resources import parse_version
|
||||
|
||||
|
||||
class Feature(Enum):
|
||||
COMPACT = 1
|
||||
ATIME = 2
|
||||
NOFLAGS = 3
|
||||
NUMERIC_IDS = 4
|
||||
UPLOAD_RATELIMIT = 5
|
||||
|
||||
|
||||
FEATURE_TO_MINIMUM_BORG_VERSION = {
|
||||
Feature.COMPACT: parse_version('1.2.0a2'), # borg compact
|
||||
Feature.ATIME: parse_version('1.2.0a7'), # borg create --atime
|
||||
Feature.NOFLAGS: parse_version('1.2.0a8'), # borg create --noflags
|
||||
Feature.NUMERIC_IDS: parse_version('1.2.0b3'), # borg create/extract/mount --numeric-ids
|
||||
Feature.UPLOAD_RATELIMIT: parse_version('1.2.0b3'), # borg create --upload-ratelimit
|
||||
}
|
||||
|
||||
|
||||
def available(feature, borg_version):
|
||||
'''
|
||||
Given a Borg Feature constant and a Borg version string, return whether that feature is
|
||||
available in that version of Borg.
|
||||
'''
|
||||
return FEATURE_TO_MINIMUM_BORG_VERSION[feature] <= parse_version(borg_version)
|
||||
+9
-10
@@ -1,6 +1,8 @@
|
||||
import argparse
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from borgmatic.borg import info
|
||||
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,17 +25,14 @@ def initialize_repository(
|
||||
whether the repository should be append-only, and the storage quota to use, initialize the
|
||||
repository. If the repository already exists, then log and skip initialization.
|
||||
'''
|
||||
info_command = (
|
||||
(local_path, 'info')
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug',) if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
+ (('--remote-path', remote_path) if remote_path else ())
|
||||
+ (repository,)
|
||||
)
|
||||
logger.debug(' '.join(info_command))
|
||||
|
||||
try:
|
||||
execute_command(info_command, output_log_level=None)
|
||||
info.display_archives_info(
|
||||
repository,
|
||||
storage_config,
|
||||
argparse.Namespace(json=True, archive=None),
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
logger.info('Repository already exists. Skipping initialization.')
|
||||
return
|
||||
except subprocess.CalledProcessError as error:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic'
|
||||
@@ -0,0 +1,25 @@
|
||||
import logging
|
||||
|
||||
from borgmatic.execute import execute_command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def local_borg_version(local_path='borg'):
|
||||
'''
|
||||
Given a local Borg binary 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.
|
||||
'''
|
||||
full_command = (
|
||||
(local_path, '--version')
|
||||
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
|
||||
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
|
||||
)
|
||||
output = execute_command(full_command, output_log_level=None, borg_local_path=local_path)
|
||||
|
||||
try:
|
||||
return output.split(' ')[1].strip()
|
||||
except IndexError:
|
||||
raise ValueError('Could not parse Borg version string')
|
||||
+192
-53
@@ -1,30 +1,33 @@
|
||||
import collections
|
||||
from argparse import ArgumentParser
|
||||
from argparse import Action, ArgumentParser
|
||||
|
||||
from borgmatic.config import collect
|
||||
|
||||
SUBPARSER_ALIASES = {
|
||||
'init': ['--init', '-I'],
|
||||
'prune': ['--prune', '-p'],
|
||||
'compact': [],
|
||||
'create': ['--create', '-C'],
|
||||
'check': ['--check', '-k'],
|
||||
'extract': ['--extract', '-x'],
|
||||
'export-tar': ['--export-tar'],
|
||||
'mount': ['--mount', '-m'],
|
||||
'umount': ['--umount', '-u'],
|
||||
'restore': ['--restore', '-r'],
|
||||
'list': ['--list', '-l'],
|
||||
'info': ['--info', '-i'],
|
||||
'borg': [],
|
||||
}
|
||||
|
||||
|
||||
def parse_subparser_arguments(unparsed_arguments, subparsers):
|
||||
'''
|
||||
Given a sequence of arguments, and a subparsers object as returned by
|
||||
argparse.ArgumentParser().add_subparsers(), give each requested action's subparser a shot at
|
||||
parsing all arguments. This allows common arguments like "--repository" to be shared across
|
||||
multiple subparsers.
|
||||
Given a sequence of arguments and a dict from subparser name to argparse.ArgumentParser
|
||||
instance, give each requested action's subparser a shot at parsing all arguments. This allows
|
||||
common arguments like "--repository" to be shared across multiple subparsers.
|
||||
|
||||
Return the result as a dict mapping from subparser name to a parsed namespace of arguments.
|
||||
Return the result as a tuple of (a dict mapping from subparser name to a parsed namespace of
|
||||
arguments, a list of remaining arguments not claimed by any subparser).
|
||||
'''
|
||||
arguments = collections.OrderedDict()
|
||||
remaining_arguments = list(unparsed_arguments)
|
||||
@@ -34,7 +37,12 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
|
||||
for alias in aliases
|
||||
}
|
||||
|
||||
for subparser_name, subparser in subparsers.choices.items():
|
||||
# If the "borg" action is used, skip all other subparsers. This avoids confusion like
|
||||
# "borg list" triggering borgmatic's own list action.
|
||||
if 'borg' in unparsed_arguments:
|
||||
subparsers = {'borg': subparsers['borg']}
|
||||
|
||||
for subparser_name, subparser in subparsers.items():
|
||||
if subparser_name not in remaining_arguments:
|
||||
continue
|
||||
|
||||
@@ -46,70 +54,70 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
|
||||
parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments)
|
||||
for value in vars(parsed).values():
|
||||
if isinstance(value, str):
|
||||
if value in subparsers.choices:
|
||||
if value in subparsers:
|
||||
remaining_arguments.remove(value)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
if item in subparsers.choices:
|
||||
if item in subparsers:
|
||||
remaining_arguments.remove(item)
|
||||
|
||||
arguments[canonical_name] = parsed
|
||||
|
||||
# If no actions are explicitly requested, assume defaults: prune, create, and check.
|
||||
# If no actions are explicitly requested, assume defaults: prune, compact, create, and check.
|
||||
if not arguments and '--help' not in unparsed_arguments and '-h' not in unparsed_arguments:
|
||||
for subparser_name in ('prune', 'create', 'check'):
|
||||
subparser = subparsers.choices[subparser_name]
|
||||
for subparser_name in ('prune', 'compact', 'create', 'check'):
|
||||
subparser = subparsers[subparser_name]
|
||||
parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments)
|
||||
arguments[subparser_name] = parsed
|
||||
|
||||
return arguments
|
||||
|
||||
|
||||
def parse_global_arguments(unparsed_arguments, top_level_parser, subparsers):
|
||||
'''
|
||||
Given a sequence of arguments, a top-level parser (containing subparsers), and a subparsers
|
||||
object as returned by argparse.ArgumentParser().add_subparsers(), parse and return any global
|
||||
arguments as a parsed argparse.Namespace instance.
|
||||
'''
|
||||
# Ask each subparser, one by one, to greedily consume arguments. Any arguments that remain
|
||||
# are global arguments.
|
||||
remaining_arguments = list(unparsed_arguments)
|
||||
present_subparser_names = set()
|
||||
|
||||
for subparser_name, subparser in subparsers.choices.items():
|
||||
if subparser_name not in remaining_arguments:
|
||||
# Now ask each subparser, one by one, to greedily consume arguments.
|
||||
for subparser_name, subparser in subparsers.items():
|
||||
if subparser_name not in arguments.keys():
|
||||
continue
|
||||
|
||||
present_subparser_names.add(subparser_name)
|
||||
subparser = subparsers[subparser_name]
|
||||
unused_parsed, remaining_arguments = subparser.parse_known_args(remaining_arguments)
|
||||
|
||||
# If no actions are explicitly requested, assume defaults: prune, create, and check.
|
||||
if (
|
||||
not present_subparser_names
|
||||
and '--help' not in unparsed_arguments
|
||||
and '-h' not in unparsed_arguments
|
||||
):
|
||||
for subparser_name in ('prune', 'create', 'check'):
|
||||
subparser = subparsers.choices[subparser_name]
|
||||
unused_parsed, remaining_arguments = subparser.parse_known_args(remaining_arguments)
|
||||
# Special case: If "borg" is present in the arguments, consume all arguments after (+1) the
|
||||
# "borg" action.
|
||||
if 'borg' in arguments:
|
||||
borg_options_index = remaining_arguments.index('borg') + 1
|
||||
arguments['borg'].options = remaining_arguments[borg_options_index:]
|
||||
remaining_arguments = remaining_arguments[:borg_options_index]
|
||||
|
||||
# Remove the subparser names themselves.
|
||||
for subparser_name in present_subparser_names:
|
||||
for subparser_name, subparser in subparsers.items():
|
||||
if subparser_name in remaining_arguments:
|
||||
remaining_arguments.remove(subparser_name)
|
||||
|
||||
return top_level_parser.parse_args(remaining_arguments)
|
||||
return (arguments, remaining_arguments)
|
||||
|
||||
|
||||
def parse_arguments(*unparsed_arguments):
|
||||
class Extend_action(Action):
|
||||
'''
|
||||
Given command-line arguments with which this script was invoked, parse the arguments and return
|
||||
them as a dict mapping from subparser name (or "global") to an argparse.Namespace instance.
|
||||
An argparse action to support Python 3.8's "extend" action in older versions of Python.
|
||||
'''
|
||||
|
||||
def __call__(self, parser, namespace, values, option_string=None):
|
||||
items = getattr(namespace, self.dest, None)
|
||||
|
||||
if items:
|
||||
items.extend(values)
|
||||
else:
|
||||
setattr(namespace, self.dest, list(values))
|
||||
|
||||
|
||||
def make_parsers():
|
||||
'''
|
||||
Build a top-level parser and its subparsers and return them as a tuple.
|
||||
'''
|
||||
config_paths = collect.get_default_config_paths(expand_home=True)
|
||||
unexpanded_config_paths = collect.get_default_config_paths(expand_home=False)
|
||||
|
||||
global_parser = ArgumentParser(add_help=False)
|
||||
global_parser.register('action', 'extend', Extend_action)
|
||||
global_group = global_parser.add_argument_group('global arguments')
|
||||
|
||||
global_group.add_argument(
|
||||
@@ -177,8 +185,15 @@ def parse_arguments(*unparsed_arguments):
|
||||
metavar='SECTION.OPTION=VALUE',
|
||||
nargs='+',
|
||||
dest='overrides',
|
||||
action='extend',
|
||||
help='One or more configuration file options to override with specified values',
|
||||
)
|
||||
global_group.add_argument(
|
||||
'--bash-completion',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='Show bash completion script and exit',
|
||||
)
|
||||
global_group.add_argument(
|
||||
'--version',
|
||||
dest='version',
|
||||
@@ -190,8 +205,8 @@ def parse_arguments(*unparsed_arguments):
|
||||
top_level_parser = ArgumentParser(
|
||||
description='''
|
||||
Simple, configuration-driven backup software for servers and workstations. If none of
|
||||
the action options are given, then borgmatic defaults to: prune, create, and check
|
||||
archives.
|
||||
the action options are given, then borgmatic defaults to: prune, compact, create, and
|
||||
check.
|
||||
''',
|
||||
parents=[global_parser],
|
||||
)
|
||||
@@ -199,7 +214,7 @@ def parse_arguments(*unparsed_arguments):
|
||||
subparsers = top_level_parser.add_subparsers(
|
||||
title='actions',
|
||||
metavar='',
|
||||
help='Specify zero or more actions. Defaults to prune, create, and check. Use --help with action for details:',
|
||||
help='Specify zero or more actions. Defaults to prune, compact, create, and check. Use --help with action for details:',
|
||||
)
|
||||
init_parser = subparsers.add_parser(
|
||||
'init',
|
||||
@@ -232,8 +247,8 @@ def parse_arguments(*unparsed_arguments):
|
||||
prune_parser = subparsers.add_parser(
|
||||
'prune',
|
||||
aliases=SUBPARSER_ALIASES['prune'],
|
||||
help='Prune archives according to the retention policy',
|
||||
description='Prune archives according to the retention policy',
|
||||
help='Prune archives according to the retention policy (with Borg 1.2+, run compact afterwards to actually free space)',
|
||||
description='Prune archives according to the retention policy (with Borg 1.2+, run compact afterwards to actually free space)',
|
||||
add_help=False,
|
||||
)
|
||||
prune_group = prune_parser.add_argument_group('prune arguments')
|
||||
@@ -249,6 +264,38 @@ def parse_arguments(*unparsed_arguments):
|
||||
)
|
||||
prune_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
compact_parser = subparsers.add_parser(
|
||||
'compact',
|
||||
aliases=SUBPARSER_ALIASES['compact'],
|
||||
help='Compact segments to free space (Borg 1.2+ only)',
|
||||
description='Compact segments to free space (Borg 1.2+ only)',
|
||||
add_help=False,
|
||||
)
|
||||
compact_group = compact_parser.add_argument_group('compact arguments')
|
||||
compact_group.add_argument(
|
||||
'--progress',
|
||||
dest='progress',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='Display progress as each segment is compacted',
|
||||
)
|
||||
compact_group.add_argument(
|
||||
'--cleanup-commits',
|
||||
dest='cleanup_commits',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='Cleanup commit-only 17-byte segment files left behind by Borg 1.1',
|
||||
)
|
||||
compact_group.add_argument(
|
||||
'--threshold',
|
||||
type=int,
|
||||
dest='threshold',
|
||||
help='Minimum saved space percentage threshold for compacting a segment, defaults to 10',
|
||||
)
|
||||
compact_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
create_parser = subparsers.add_parser(
|
||||
'create',
|
||||
aliases=SUBPARSER_ALIASES['create'],
|
||||
@@ -299,7 +346,7 @@ def parse_arguments(*unparsed_arguments):
|
||||
dest='repair',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='Attempt to repair any inconsistencies found (experimental and only for interactive use)',
|
||||
help='Attempt to repair any inconsistencies found (for interactive use)',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'--only',
|
||||
@@ -307,7 +354,13 @@ def parse_arguments(*unparsed_arguments):
|
||||
choices=('repository', 'archives', 'data', 'extract'),
|
||||
dest='only',
|
||||
action='append',
|
||||
help='Run a particular consistency check (repository, archives, data, or extract) instead of configured checks; can specify flag multiple times',
|
||||
help='Run a particular consistency check (repository, archives, data, or extract) instead of configured checks (subject to configured frequency, can specify flag multiple times)',
|
||||
)
|
||||
check_group.add_argument(
|
||||
'--force',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='Ignore configured check frequencies and run checks unconditionally',
|
||||
)
|
||||
check_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
@@ -340,6 +393,13 @@ def parse_arguments(*unparsed_arguments):
|
||||
dest='destination',
|
||||
help='Directory to extract files into, defaults to the current directory',
|
||||
)
|
||||
extract_group.add_argument(
|
||||
'--strip-components',
|
||||
type=int,
|
||||
metavar='NUMBER',
|
||||
dest='strip_components',
|
||||
help='Number of leading path components to remove from each extracted path. Skip paths with fewer elements',
|
||||
)
|
||||
extract_group.add_argument(
|
||||
'--progress',
|
||||
dest='progress',
|
||||
@@ -351,6 +411,52 @@ def parse_arguments(*unparsed_arguments):
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
export_tar_parser = subparsers.add_parser(
|
||||
'export-tar',
|
||||
aliases=SUBPARSER_ALIASES['export-tar'],
|
||||
help='Export an archive to a tar-formatted file or stream',
|
||||
description='Export an archive to a tar-formatted file or stream',
|
||||
add_help=False,
|
||||
)
|
||||
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',
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'--archive', help='Name of archive to export (or "latest")', required=True
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'--path',
|
||||
metavar='PATH',
|
||||
nargs='+',
|
||||
dest='paths',
|
||||
help='Paths to export from archive, defaults to the entire archive',
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'--destination',
|
||||
metavar='PATH',
|
||||
dest='destination',
|
||||
help='Path to destination export tar file, or "-" for stdout (but be careful about dirtying output with --verbosity or --files)',
|
||||
required=True,
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'--tar-filter', help='Name of filter program to pipe data through'
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'--files', default=False, action='store_true', help='Show per-file details'
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'--strip-components',
|
||||
type=int,
|
||||
metavar='NUMBER',
|
||||
dest='strip_components',
|
||||
help='Number of leading path components to remove from each exported path. Skip paths with fewer elements',
|
||||
)
|
||||
export_tar_group.add_argument(
|
||||
'-h', '--help', action='help', help='Show this help message and exit'
|
||||
)
|
||||
|
||||
mount_parser = subparsers.add_parser(
|
||||
'mount',
|
||||
aliases=SUBPARSER_ALIASES['mount'],
|
||||
@@ -440,8 +546,7 @@ def parse_arguments(*unparsed_arguments):
|
||||
)
|
||||
list_group = list_parser.add_argument_group('list arguments')
|
||||
list_group.add_argument(
|
||||
'--repository',
|
||||
help='Path of repository to list, defaults to the configured repository if there is only one',
|
||||
'--repository', help='Path of repository to list, defaults to the configured repositories',
|
||||
)
|
||||
list_group.add_argument('--archive', help='Name of archive to list (or "latest")')
|
||||
list_group.add_argument(
|
||||
@@ -527,12 +632,46 @@ def parse_arguments(*unparsed_arguments):
|
||||
help='Show info for first N archives after other filters are applied',
|
||||
)
|
||||
info_group.add_argument(
|
||||
'--last', metavar='N', help='Show info for first N archives after other filters are applied'
|
||||
'--last', metavar='N', help='Show info for last N archives after other filters are applied'
|
||||
)
|
||||
info_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
arguments = parse_subparser_arguments(unparsed_arguments, subparsers)
|
||||
arguments['global'] = parse_global_arguments(unparsed_arguments, top_level_parser, subparsers)
|
||||
borg_parser = subparsers.add_parser(
|
||||
'borg',
|
||||
aliases=SUBPARSER_ALIASES['borg'],
|
||||
help='Run an arbitrary Borg command',
|
||||
description='Run an arbitrary Borg command based on borgmatic\'s configuration',
|
||||
add_help=False,
|
||||
)
|
||||
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',
|
||||
)
|
||||
borg_group.add_argument('--archive', help='Name of archive to pass to Borg (or "latest")')
|
||||
borg_group.add_argument(
|
||||
'--',
|
||||
metavar='OPTION',
|
||||
dest='options',
|
||||
nargs='+',
|
||||
help='Options to pass to Borg, command first ("create", "list", etc). "--" is optional. To specify the repository or the archive, you must use --repository or --archive instead of providing them here.',
|
||||
)
|
||||
borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
|
||||
|
||||
return top_level_parser, subparsers
|
||||
|
||||
|
||||
def parse_arguments(*unparsed_arguments):
|
||||
'''
|
||||
Given command-line arguments with which this script was invoked, parse the arguments and return
|
||||
them as a dict mapping from subparser name (or "global") to an argparse.Namespace instance.
|
||||
'''
|
||||
top_level_parser, subparsers = make_parsers()
|
||||
|
||||
arguments, remaining_arguments = parse_subparser_arguments(
|
||||
unparsed_arguments, subparsers.choices
|
||||
)
|
||||
arguments['global'] = top_level_parser.parse_args(remaining_arguments)
|
||||
|
||||
if arguments['global'].excludes_filename:
|
||||
raise ValueError(
|
||||
|
||||
+304
-102
@@ -4,21 +4,29 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from queue import Queue
|
||||
from subprocess import CalledProcessError
|
||||
|
||||
import colorama
|
||||
import pkg_resources
|
||||
|
||||
import borgmatic.commands.completion
|
||||
from borgmatic.borg import borg as borg_borg
|
||||
from borgmatic.borg import check as borg_check
|
||||
from borgmatic.borg import compact as borg_compact
|
||||
from borgmatic.borg import create as borg_create
|
||||
from borgmatic.borg import environment as borg_environment
|
||||
from borgmatic.borg import export_tar as borg_export_tar
|
||||
from borgmatic.borg import extract as borg_extract
|
||||
from borgmatic.borg import feature as borg_feature
|
||||
from borgmatic.borg import info as borg_info
|
||||
from borgmatic.borg import init as borg_init
|
||||
from borgmatic.borg import list as borg_list
|
||||
from borgmatic.borg import mount as borg_mount
|
||||
from borgmatic.borg import prune as borg_prune
|
||||
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, convert, validate
|
||||
from borgmatic.hooks import command, dispatch, dump, monitor
|
||||
@@ -34,8 +42,8 @@ LEGACY_CONFIG_PATH = '/etc/borgmatic/config'
|
||||
def run_configuration(config_filename, config, arguments):
|
||||
'''
|
||||
Given a config filename, the corresponding parsed config dict, and command-line arguments as a
|
||||
dict from subparser name to a namespace of parsed arguments, execute its defined pruning,
|
||||
backups, consistency checks, and/or other actions.
|
||||
dict from subparser name to a namespace of parsed arguments, execute the defined prune, compact,
|
||||
create, check, and/or other actions.
|
||||
|
||||
Yield a combination of:
|
||||
|
||||
@@ -50,14 +58,33 @@ def run_configuration(config_filename, config, arguments):
|
||||
|
||||
local_path = location.get('local_path', 'borg')
|
||||
remote_path = location.get('remote_path')
|
||||
retries = storage.get('retries', 0)
|
||||
retry_wait = storage.get('retry_wait', 0)
|
||||
borg_environment.initialize(storage)
|
||||
encountered_error = None
|
||||
error_repository = ''
|
||||
prune_create_or_check = {'prune', 'create', 'check'}.intersection(arguments)
|
||||
using_primary_action = {'prune', 'compact', 'create', 'check'}.intersection(arguments)
|
||||
monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
|
||||
|
||||
try:
|
||||
if prune_create_or_check:
|
||||
local_borg_version = borg_version.local_borg_version(local_path)
|
||||
except (OSError, CalledProcessError, ValueError) as error:
|
||||
yield from log_error_records(
|
||||
'{}: Error getting local Borg version'.format(config_filename), error
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
if using_primary_action:
|
||||
dispatch.call_hooks(
|
||||
'initialize_monitor',
|
||||
hooks,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if using_primary_action:
|
||||
dispatch.call_hooks(
|
||||
'ping_monitor',
|
||||
hooks,
|
||||
@@ -67,44 +94,28 @@ def run_configuration(config_filename, config, arguments):
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if 'prune' in arguments:
|
||||
command.execute_hook(
|
||||
hooks.get('before_prune'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'pre-prune',
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if 'create' in arguments:
|
||||
command.execute_hook(
|
||||
hooks.get('before_backup'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'pre-backup',
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if 'check' in arguments:
|
||||
command.execute_hook(
|
||||
hooks.get('before_check'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'pre-check',
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
except (OSError, CalledProcessError) as error:
|
||||
if command.considered_soft_failure(config_filename, error):
|
||||
return
|
||||
|
||||
encountered_error = error
|
||||
yield from make_error_log_records(
|
||||
'{}: Error running pre hook'.format(config_filename), error
|
||||
)
|
||||
yield from log_error_records('{}: Error pinging monitor'.format(config_filename), error)
|
||||
|
||||
if not encountered_error:
|
||||
for repository_path in location['repositories']:
|
||||
repo_queue = Queue()
|
||||
for repo in location['repositories']:
|
||||
repo_queue.put((repo, 0),)
|
||||
|
||||
while not repo_queue.empty():
|
||||
repository_path, retry_num = repo_queue.get()
|
||||
timeout = retry_num * retry_wait
|
||||
if timeout:
|
||||
logger.warning(f'{config_filename}: Sleeping {timeout}s before next retry')
|
||||
time.sleep(timeout)
|
||||
try:
|
||||
yield from run_actions(
|
||||
arguments=arguments,
|
||||
config_filename=config_filename,
|
||||
location=location,
|
||||
storage=storage,
|
||||
retention=retention,
|
||||
@@ -112,50 +123,37 @@ def run_configuration(config_filename, config, arguments):
|
||||
hooks=hooks,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
local_borg_version=local_borg_version,
|
||||
repository_path=repository_path,
|
||||
)
|
||||
except (OSError, CalledProcessError, ValueError) as error:
|
||||
encountered_error = error
|
||||
error_repository = repository_path
|
||||
yield from make_error_log_records(
|
||||
if retry_num < retries:
|
||||
repo_queue.put((repository_path, retry_num + 1),)
|
||||
tuple( # Consume the generator so as to trigger logging.
|
||||
log_error_records(
|
||||
'{}: Error running actions for repository'.format(repository_path),
|
||||
error,
|
||||
levelno=logging.WARNING,
|
||||
log_command_error_output=True,
|
||||
)
|
||||
)
|
||||
logger.warning(
|
||||
f'{config_filename}: Retrying... attempt {retry_num + 1}/{retries}'
|
||||
)
|
||||
continue
|
||||
|
||||
if command.considered_soft_failure(config_filename, error):
|
||||
return
|
||||
|
||||
yield from log_error_records(
|
||||
'{}: Error running actions for repository'.format(repository_path), error
|
||||
)
|
||||
encountered_error = error
|
||||
error_repository = repository_path
|
||||
|
||||
if not encountered_error:
|
||||
try:
|
||||
if 'prune' in arguments:
|
||||
command.execute_hook(
|
||||
hooks.get('after_prune'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'post-prune',
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if 'create' in arguments:
|
||||
dispatch.call_hooks(
|
||||
'remove_database_dumps',
|
||||
hooks,
|
||||
config_filename,
|
||||
dump.DATABASE_HOOK_NAMES,
|
||||
location,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
command.execute_hook(
|
||||
hooks.get('after_backup'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'post-backup',
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if 'check' in arguments:
|
||||
command.execute_hook(
|
||||
hooks.get('after_check'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'post-check',
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if {'prune', 'create', 'check'}.intersection(arguments):
|
||||
if using_primary_action:
|
||||
dispatch.call_hooks(
|
||||
'ping_monitor',
|
||||
hooks,
|
||||
@@ -165,16 +163,22 @@ def run_configuration(config_filename, config, arguments):
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
dispatch.call_hooks(
|
||||
'destroy_monitor',
|
||||
hooks,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
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 make_error_log_records(
|
||||
'{}: Error running post hook'.format(config_filename), error
|
||||
)
|
||||
yield from log_error_records('{}: Error pinging monitor'.format(config_filename), error)
|
||||
|
||||
if encountered_error and prune_create_or_check:
|
||||
if encountered_error and using_primary_action:
|
||||
try:
|
||||
command.execute_hook(
|
||||
hooks.get('on_error'),
|
||||
@@ -195,11 +199,19 @@ def run_configuration(config_filename, config, arguments):
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
dispatch.call_hooks(
|
||||
'destroy_monitor',
|
||||
hooks,
|
||||
config_filename,
|
||||
monitor.MONITOR_HOOK_NAMES,
|
||||
monitoring_log_level,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
except (OSError, CalledProcessError) as error:
|
||||
if command.considered_soft_failure(config_filename, error):
|
||||
return
|
||||
|
||||
yield from make_error_log_records(
|
||||
yield from log_error_records(
|
||||
'{}: Error running on-error hook'.format(config_filename), error
|
||||
)
|
||||
|
||||
@@ -207,6 +219,7 @@ def run_configuration(config_filename, config, arguments):
|
||||
def run_actions(
|
||||
*,
|
||||
arguments,
|
||||
config_filename,
|
||||
location,
|
||||
storage,
|
||||
retention,
|
||||
@@ -214,21 +227,30 @@ def run_actions(
|
||||
hooks,
|
||||
local_path,
|
||||
remote_path,
|
||||
repository_path
|
||||
): # pragma: no cover
|
||||
local_borg_version,
|
||||
repository_path,
|
||||
):
|
||||
'''
|
||||
Given parsed command-line arguments as an argparse.ArgumentParser instance, several different
|
||||
configuration dicts, local and remote paths to Borg, and a repository name, run all actions
|
||||
from the command-line arguments on the given repository.
|
||||
Given parsed command-line arguments as an argparse.ArgumentParser instance, the configuration
|
||||
filename, several different configuration dicts, local and remote paths to Borg, a local Borg
|
||||
version string, and a repository name, run all actions from the command-line arguments on the
|
||||
given repository.
|
||||
|
||||
Yield JSON output strings from executing any actions that produce JSON.
|
||||
|
||||
Raise OSError or subprocess.CalledProcessError if an error occurs running a command for an
|
||||
action. Raise ValueError if the arguments or configuration passed to action are invalid.
|
||||
action or a hook. Raise ValueError if the arguments or configuration passed to action are
|
||||
invalid.
|
||||
'''
|
||||
repository = os.path.expanduser(repository_path)
|
||||
global_arguments = arguments['global']
|
||||
dry_run_label = ' (dry run; not making any changes)' if global_arguments.dry_run else ''
|
||||
hook_context = {
|
||||
'repository': repository_path,
|
||||
# Deprecated: For backwards compatibility with borgmatic < 1.6.0.
|
||||
'repositories': ','.join(location['repositories']),
|
||||
}
|
||||
|
||||
if 'init' in arguments:
|
||||
logger.info('{}: Initializing repository'.format(repository))
|
||||
borg_init.initialize_repository(
|
||||
@@ -241,6 +263,14 @@ def run_actions(
|
||||
remote_path=remote_path,
|
||||
)
|
||||
if 'prune' in arguments:
|
||||
command.execute_hook(
|
||||
hooks.get('before_prune'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'pre-prune',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
|
||||
borg_prune.prune_archives(
|
||||
global_arguments.dry_run,
|
||||
@@ -252,7 +282,54 @@ def run_actions(
|
||||
stats=arguments['prune'].stats,
|
||||
files=arguments['prune'].files,
|
||||
)
|
||||
command.execute_hook(
|
||||
hooks.get('after_prune'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'post-prune',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
if 'compact' in arguments:
|
||||
command.execute_hook(
|
||||
hooks.get('before_compact'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'pre-compact',
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if borg_feature.available(borg_feature.Feature.COMPACT, local_borg_version):
|
||||
logger.info('{}: Compacting segments{}'.format(repository, dry_run_label))
|
||||
borg_compact.compact_segments(
|
||||
global_arguments.dry_run,
|
||||
repository,
|
||||
storage,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
progress=arguments['compact'].progress,
|
||||
cleanup_commits=arguments['compact'].cleanup_commits,
|
||||
threshold=arguments['compact'].threshold,
|
||||
)
|
||||
else: # pragma: nocover
|
||||
logger.info(
|
||||
'{}: Skipping compact (only available/needed in Borg 1.2+)'.format(repository)
|
||||
)
|
||||
command.execute_hook(
|
||||
hooks.get('after_compact'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'post-compact',
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
if 'create' in arguments:
|
||||
command.execute_hook(
|
||||
hooks.get('before_backup'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'pre-backup',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
|
||||
dispatch.call_hooks(
|
||||
'remove_database_dumps',
|
||||
@@ -277,6 +354,7 @@ def run_actions(
|
||||
repository,
|
||||
location,
|
||||
storage,
|
||||
local_borg_version,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
progress=arguments['create'].progress,
|
||||
@@ -285,13 +363,39 @@ def run_actions(
|
||||
files=arguments['create'].files,
|
||||
stream_processes=stream_processes,
|
||||
)
|
||||
if json_output:
|
||||
if json_output: # pragma: nocover
|
||||
yield json.loads(json_output)
|
||||
|
||||
dispatch.call_hooks(
|
||||
'remove_database_dumps',
|
||||
hooks,
|
||||
config_filename,
|
||||
dump.DATABASE_HOOK_NAMES,
|
||||
location,
|
||||
global_arguments.dry_run,
|
||||
)
|
||||
command.execute_hook(
|
||||
hooks.get('after_backup'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'post-backup',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
|
||||
if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency):
|
||||
command.execute_hook(
|
||||
hooks.get('before_check'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'pre-check',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
logger.info('{}: Running consistency checks'.format(repository))
|
||||
borg_check.check_archives(
|
||||
repository,
|
||||
location,
|
||||
storage,
|
||||
consistency,
|
||||
local_path=local_path,
|
||||
@@ -299,8 +403,25 @@ def run_actions(
|
||||
progress=arguments['check'].progress,
|
||||
repair=arguments['check'].repair,
|
||||
only_checks=arguments['check'].only,
|
||||
force=arguments['check'].force,
|
||||
)
|
||||
command.execute_hook(
|
||||
hooks.get('after_check'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'post-check',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
if 'extract' in arguments:
|
||||
command.execute_hook(
|
||||
hooks.get('before_extract'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'pre-extract',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
if arguments['extract'].repository is None or validate.repositories_match(
|
||||
repository, arguments['extract'].repository
|
||||
):
|
||||
@@ -316,11 +437,45 @@ def run_actions(
|
||||
arguments['extract'].paths,
|
||||
location,
|
||||
storage,
|
||||
local_borg_version,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
destination_path=arguments['extract'].destination,
|
||||
strip_components=arguments['extract'].strip_components,
|
||||
progress=arguments['extract'].progress,
|
||||
)
|
||||
command.execute_hook(
|
||||
hooks.get('after_extract'),
|
||||
hooks.get('umask'),
|
||||
config_filename,
|
||||
'post-extract',
|
||||
global_arguments.dry_run,
|
||||
**hook_context,
|
||||
)
|
||||
if 'export-tar' in arguments:
|
||||
if arguments['export-tar'].repository is None or validate.repositories_match(
|
||||
repository, arguments['export-tar'].repository
|
||||
):
|
||||
logger.info(
|
||||
'{}: Exporting archive {} as tar file'.format(
|
||||
repository, arguments['export-tar'].archive
|
||||
)
|
||||
)
|
||||
borg_export_tar.export_tar_archive(
|
||||
global_arguments.dry_run,
|
||||
repository,
|
||||
borg_list.resolve_archive_name(
|
||||
repository, arguments['export-tar'].archive, storage, local_path, remote_path
|
||||
),
|
||||
arguments['export-tar'].paths,
|
||||
arguments['export-tar'].destination,
|
||||
storage,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
tar_filter=arguments['export-tar'].tar_filter,
|
||||
files=arguments['export-tar'].files,
|
||||
strip_components=arguments['export-tar'].strip_components,
|
||||
)
|
||||
if 'mount' in arguments:
|
||||
if arguments['mount'].repository is None or validate.repositories_match(
|
||||
repository, arguments['mount'].repository
|
||||
@@ -329,7 +484,7 @@ def run_actions(
|
||||
logger.info(
|
||||
'{}: Mounting archive {}'.format(repository, arguments['mount'].archive)
|
||||
)
|
||||
else:
|
||||
else: # pragma: nocover
|
||||
logger.info('{}: Mounting repository'.format(repository))
|
||||
|
||||
borg_mount.mount_archive(
|
||||
@@ -345,7 +500,7 @@ def run_actions(
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
if 'restore' in arguments:
|
||||
if 'restore' in arguments: # pragma: nocover
|
||||
if arguments['restore'].repository is None or validate.repositories_match(
|
||||
repository, arguments['restore'].repository
|
||||
):
|
||||
@@ -399,6 +554,7 @@ def run_actions(
|
||||
paths=dump.convert_glob_patterns_to_borg_patterns([dump_pattern]),
|
||||
location_config=location,
|
||||
storage_config=storage,
|
||||
local_borg_version=local_borg_version,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
destination_path='/',
|
||||
@@ -443,7 +599,7 @@ def run_actions(
|
||||
repository, arguments['list'].repository
|
||||
):
|
||||
list_arguments = copy.copy(arguments['list'])
|
||||
if not list_arguments.json:
|
||||
if not list_arguments.json: # pragma: nocover
|
||||
logger.warning('{}: Listing archives'.format(repository))
|
||||
list_arguments.archive = borg_list.resolve_archive_name(
|
||||
repository, list_arguments.archive, storage, local_path, remote_path
|
||||
@@ -455,14 +611,14 @@ def run_actions(
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
if json_output:
|
||||
if json_output: # pragma: nocover
|
||||
yield json.loads(json_output)
|
||||
if 'info' in arguments:
|
||||
if arguments['info'].repository is None or validate.repositories_match(
|
||||
repository, arguments['info'].repository
|
||||
):
|
||||
info_arguments = copy.copy(arguments['info'])
|
||||
if not info_arguments.json:
|
||||
if not info_arguments.json: # pragma: nocover
|
||||
logger.warning('{}: Displaying summary info for archives'.format(repository))
|
||||
info_arguments.archive = borg_list.resolve_archive_name(
|
||||
repository, info_arguments.archive, storage, local_path, remote_path
|
||||
@@ -474,8 +630,24 @@ def run_actions(
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
if json_output:
|
||||
if json_output: # pragma: nocover
|
||||
yield json.loads(json_output)
|
||||
if 'borg' in arguments:
|
||||
if arguments['borg'].repository is None or validate.repositories_match(
|
||||
repository, arguments['borg'].repository
|
||||
):
|
||||
logger.warning('{}: Running arbitrary Borg command'.format(repository))
|
||||
archive_name = borg_list.resolve_archive_name(
|
||||
repository, arguments['borg'].archive, storage, local_path, remote_path
|
||||
)
|
||||
borg_borg.run_arbitrary_borg(
|
||||
repository,
|
||||
storage,
|
||||
options=arguments['borg'].options,
|
||||
archive=archive_name,
|
||||
local_path=local_path,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
|
||||
|
||||
def load_configurations(config_filenames, overrides=None):
|
||||
@@ -494,6 +666,20 @@ def load_configurations(config_filenames, overrides=None):
|
||||
configs[config_filename] = validate.parse_configuration(
|
||||
config_filename, validate.schema_filename(), overrides
|
||||
)
|
||||
except PermissionError:
|
||||
logs.extend(
|
||||
[
|
||||
logging.makeLogRecord(
|
||||
dict(
|
||||
levelno=logging.WARNING,
|
||||
levelname='WARNING',
|
||||
msg='{}: Insufficient permissions to read configuration file'.format(
|
||||
config_filename
|
||||
),
|
||||
)
|
||||
),
|
||||
]
|
||||
)
|
||||
except (ValueError, OSError, validate.Validation_error) as error:
|
||||
logs.extend(
|
||||
[
|
||||
@@ -526,28 +712,39 @@ def log_record(suppress_log=False, **kwargs):
|
||||
return record
|
||||
|
||||
|
||||
def make_error_log_records(message, error=None):
|
||||
def log_error_records(
|
||||
message, error=None, levelno=logging.CRITICAL, log_command_error_output=False
|
||||
):
|
||||
'''
|
||||
Given error message text and an optional exception object, yield a series of logging.LogRecord
|
||||
instances with error summary information. As a side effect, log each record.
|
||||
Given error message text, an optional exception object, an optional log level, and whether to
|
||||
log the error output of a CalledProcessError (if any), log error summary information and also
|
||||
yield it as a series of logging.LogRecord instances.
|
||||
|
||||
Note that because the logs are yielded as a generator, logs won't get logged unless you consume
|
||||
the generator output.
|
||||
'''
|
||||
level_name = logging._levelToName[levelno]
|
||||
|
||||
if not error:
|
||||
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=message)
|
||||
return
|
||||
|
||||
try:
|
||||
raise error
|
||||
except CalledProcessError as error:
|
||||
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=message)
|
||||
if error.output:
|
||||
# Suppress these logs for now and save full error output for the log summary at the end.
|
||||
yield log_record(
|
||||
levelno=logging.CRITICAL, levelname='CRITICAL', msg=error.output, suppress_log=True
|
||||
levelno=levelno,
|
||||
levelname=level_name,
|
||||
msg=error.output,
|
||||
suppress_log=not log_command_error_output,
|
||||
)
|
||||
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=error)
|
||||
except (ValueError, OSError) as error:
|
||||
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
|
||||
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=message)
|
||||
yield log_record(levelno=levelno, levelname=level_name, msg=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.
|
||||
@@ -586,12 +783,14 @@ def collect_configuration_run_summary_logs(configs, arguments):
|
||||
try:
|
||||
validate.guard_configuration_contains_repository(repository, configs)
|
||||
except ValueError as error:
|
||||
yield from make_error_log_records(str(error))
|
||||
yield from log_error_records(str(error))
|
||||
return
|
||||
|
||||
if not configs:
|
||||
yield from make_error_log_records(
|
||||
'{}: No configuration files found'.format(' '.join(arguments['global'].config_paths))
|
||||
yield from log_error_records(
|
||||
'{}: No valid configuration files found'.format(
|
||||
' '.join(arguments['global'].config_paths)
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
@@ -607,7 +806,7 @@ def collect_configuration_run_summary_logs(configs, arguments):
|
||||
arguments['global'].dry_run,
|
||||
)
|
||||
except (CalledProcessError, ValueError, OSError) as error:
|
||||
yield from make_error_log_records('Error running pre-everything hook', error)
|
||||
yield from log_error_records('Error running pre-everything hook', error)
|
||||
return
|
||||
|
||||
# Execute the actions corresponding to each configuration file.
|
||||
@@ -617,7 +816,7 @@ def collect_configuration_run_summary_logs(configs, arguments):
|
||||
error_logs = tuple(result for result in results if isinstance(result, logging.LogRecord))
|
||||
|
||||
if error_logs:
|
||||
yield from make_error_log_records(
|
||||
yield from log_error_records(
|
||||
'{}: Error running configuration file'.format(config_filename)
|
||||
)
|
||||
yield from error_logs
|
||||
@@ -639,7 +838,7 @@ def collect_configuration_run_summary_logs(configs, arguments):
|
||||
mount_point=arguments['umount'].mount_point, local_path=get_local_path(configs)
|
||||
)
|
||||
except (CalledProcessError, OSError) as error:
|
||||
yield from make_error_log_records('Error unmounting mount point', error)
|
||||
yield from log_error_records('Error unmounting mount point', error)
|
||||
|
||||
if json_results:
|
||||
sys.stdout.write(json.dumps(json_results))
|
||||
@@ -656,7 +855,7 @@ def collect_configuration_run_summary_logs(configs, arguments):
|
||||
arguments['global'].dry_run,
|
||||
)
|
||||
except (CalledProcessError, ValueError, OSError) as error:
|
||||
yield from make_error_log_records('Error running post-everything hook', error)
|
||||
yield from log_error_records('Error running post-everything hook', error)
|
||||
|
||||
|
||||
def exit_with_help_link(): # pragma: no cover
|
||||
@@ -688,6 +887,9 @@ def main(): # pragma: no cover
|
||||
if global_arguments.version:
|
||||
print(pkg_resources.require('borgmatic')[0].version)
|
||||
sys.exit(0)
|
||||
if global_arguments.bash_completion:
|
||||
print(borgmatic.commands.completion.bash_completion())
|
||||
sys.exit(0)
|
||||
|
||||
config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
|
||||
configs, parse_logs = load_configurations(config_filenames, global_arguments.overrides)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from borgmatic.commands import arguments
|
||||
|
||||
UPGRADE_MESSAGE = '''
|
||||
Your bash completions script is from a different version of borgmatic than is
|
||||
currently installed. Please upgrade your script so your completions match the
|
||||
command-line flags in your installed borgmatic! Try this to upgrade:
|
||||
|
||||
sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"
|
||||
source $BASH_SOURCE
|
||||
'''
|
||||
|
||||
|
||||
def parser_flags(parser):
|
||||
'''
|
||||
Given an argparse.ArgumentParser instance, return its argument flags in a space-separated
|
||||
string.
|
||||
'''
|
||||
return ' '.join(option for action in parser._actions for option in action.option_strings)
|
||||
|
||||
|
||||
def bash_completion():
|
||||
'''
|
||||
Return a bash completion script for the borgmatic command. Produce this by introspecting
|
||||
borgmatic's command-line argument parsers.
|
||||
'''
|
||||
top_level_parser, subparsers = arguments.make_parsers()
|
||||
global_flags = parser_flags(top_level_parser)
|
||||
actions = ' '.join(subparsers.choices.keys())
|
||||
|
||||
# Avert your eyes.
|
||||
return '\n'.join(
|
||||
(
|
||||
'set -uo pipefail',
|
||||
'check_version() {',
|
||||
' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"',
|
||||
' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"',
|
||||
' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];'
|
||||
' then cat << EOF\n%s\nEOF' % UPGRADE_MESSAGE,
|
||||
' fi',
|
||||
'}',
|
||||
'complete_borgmatic() {',
|
||||
)
|
||||
+ tuple(
|
||||
''' if [[ " ${COMP_WORDS[*]} " =~ " %s " ]]; then
|
||||
COMPREPLY=($(compgen -W "%s %s %s" -- "${COMP_WORDS[COMP_CWORD]}"))
|
||||
return 0
|
||||
fi'''
|
||||
% (action, parser_flags(subparser), actions, global_flags)
|
||||
for action, subparser in subparsers.choices.items()
|
||||
)
|
||||
+ (
|
||||
' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))'
|
||||
% (actions, global_flags),
|
||||
' (check_version &)',
|
||||
'}',
|
||||
'\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic',
|
||||
)
|
||||
)
|
||||
@@ -99,7 +99,9 @@ def main(): # pragma: no cover
|
||||
)
|
||||
|
||||
generate.write_configuration(
|
||||
args.destination_config_filename, destination_config, mode=source_config_file_mode
|
||||
args.destination_config_filename,
|
||||
generate.render_configuration(destination_config),
|
||||
mode=source_config_file_mode,
|
||||
)
|
||||
|
||||
display_result(args)
|
||||
|
||||
@@ -23,10 +23,16 @@ def parse_arguments(*arguments):
|
||||
'--destination',
|
||||
dest='destination_filename',
|
||||
default=DEFAULT_DESTINATION_CONFIG_FILENAME,
|
||||
help='Destination YAML configuration file. Default: {}'.format(
|
||||
help='Destination YAML configuration file, default: {}'.format(
|
||||
DEFAULT_DESTINATION_CONFIG_FILENAME
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
'--overwrite',
|
||||
default=False,
|
||||
action='store_true',
|
||||
help='Whether to overwrite any existing destination file, defaults to false',
|
||||
)
|
||||
|
||||
return parser.parse_args(arguments)
|
||||
|
||||
@@ -36,7 +42,10 @@ def main(): # pragma: no cover
|
||||
args = parse_arguments(*sys.argv[1:])
|
||||
|
||||
generate.generate_sample_configuration(
|
||||
args.source_filename, args.destination_filename, validate.schema_filename()
|
||||
args.source_filename,
|
||||
args.destination_filename,
|
||||
validate.schema_filename(),
|
||||
overwrite=args.overwrite,
|
||||
)
|
||||
|
||||
print('Generated a sample configuration file at {}.'.format(args.destination_filename))
|
||||
|
||||
@@ -44,6 +44,9 @@ def collect_config_filenames(config_paths):
|
||||
yield path
|
||||
continue
|
||||
|
||||
if not os.access(path, os.R_OK):
|
||||
continue
|
||||
|
||||
for filename in sorted(os.listdir(path)):
|
||||
full_filename = os.path.join(path, filename)
|
||||
matching_filetype = full_filename.endswith('.yaml') or full_filename.endswith('.yml')
|
||||
|
||||
@@ -17,7 +17,7 @@ def _convert_section(source_section_config, section_schema):
|
||||
(
|
||||
option_name,
|
||||
int(option_value)
|
||||
if section_schema['map'].get(option_name, {}).get('type') == 'int'
|
||||
if section_schema['properties'].get(option_name, {}).get('type') == 'integer'
|
||||
else option_value,
|
||||
)
|
||||
for option_name, option_value in source_section_config.items()
|
||||
@@ -38,7 +38,7 @@ def convert_legacy_parsed_config(source_config, source_excludes, schema):
|
||||
'''
|
||||
destination_config = yaml.comments.CommentedMap(
|
||||
[
|
||||
(section_name, _convert_section(section_config, schema['map'][section_name]))
|
||||
(section_name, _convert_section(section_config, schema['properties'][section_name]))
|
||||
for section_name, section_config in source_config._asdict().items()
|
||||
]
|
||||
)
|
||||
@@ -54,11 +54,11 @@ def convert_legacy_parsed_config(source_config, source_excludes, schema):
|
||||
destination_config['consistency']['checks'] = source_config.consistency['checks'].split(' ')
|
||||
|
||||
# Add comments to each section, and then add comments to the fields in each section.
|
||||
generate.add_comments_to_configuration_map(destination_config, schema)
|
||||
generate.add_comments_to_configuration_object(destination_config, schema)
|
||||
|
||||
for section_name, section_config in destination_config.items():
|
||||
generate.add_comments_to_configuration_map(
|
||||
section_config, schema['map'][section_name], indent=generate.INDENT
|
||||
generate.add_comments_to_configuration_object(
|
||||
section_config, schema['properties'][section_name], indent=generate.INDENT
|
||||
)
|
||||
|
||||
return destination_config
|
||||
|
||||
@@ -5,7 +5,7 @@ import re
|
||||
|
||||
from ruamel import yaml
|
||||
|
||||
from borgmatic.config import load
|
||||
from borgmatic.config import load, normalize
|
||||
|
||||
INDENT = 4
|
||||
SEQUENCE_INDENT = 2
|
||||
@@ -24,31 +24,27 @@ def _insert_newline_before_comment(config, field_name):
|
||||
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
|
||||
for each section based on the schema "desc" description.
|
||||
for each section based on the schema "description".
|
||||
'''
|
||||
schema_type = schema.get('type')
|
||||
example = schema.get('example')
|
||||
if example is not None:
|
||||
return example
|
||||
|
||||
if 'seq' in schema:
|
||||
if schema_type == 'array':
|
||||
config = yaml.comments.CommentedSeq(
|
||||
[
|
||||
_schema_to_sample_configuration(item_schema, level, parent_is_sequence=True)
|
||||
for item_schema in schema['seq']
|
||||
]
|
||||
[_schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
|
||||
)
|
||||
add_comments_to_configuration_sequence(
|
||||
config, schema, indent=(level * INDENT) + SEQUENCE_INDENT
|
||||
)
|
||||
elif 'map' in schema:
|
||||
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
|
||||
elif schema_type == 'object':
|
||||
config = yaml.comments.CommentedMap(
|
||||
[
|
||||
(field_name, _schema_to_sample_configuration(sub_schema, level + 1))
|
||||
for field_name, sub_schema in schema['map'].items()
|
||||
for field_name, sub_schema in schema['properties'].items()
|
||||
]
|
||||
)
|
||||
indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
|
||||
add_comments_to_configuration_map(
|
||||
add_comments_to_configuration_object(
|
||||
config, schema, indent=indent, skip_first=parent_is_sequence
|
||||
)
|
||||
else:
|
||||
@@ -86,8 +82,8 @@ def _comment_out_optional_configuration(rendered_config):
|
||||
optional = False
|
||||
|
||||
for line in rendered_config.split('\n'):
|
||||
# Upon encountering an optional configuration option, commenting out lines until the next
|
||||
# blank line.
|
||||
# Upon encountering an optional configuration option, comment out lines until the next blank
|
||||
# line.
|
||||
if line.strip().startswith('# {}'.format(COMMENTED_OUT_SENTINEL)):
|
||||
optional = True
|
||||
continue
|
||||
@@ -101,7 +97,7 @@ def _comment_out_optional_configuration(rendered_config):
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _render_configuration(config):
|
||||
def render_configuration(config):
|
||||
'''
|
||||
Given a config data structure of nested OrderedDicts, render the config as YAML and return it.
|
||||
'''
|
||||
@@ -113,13 +109,18 @@ def _render_configuration(config):
|
||||
return rendered.getvalue()
|
||||
|
||||
|
||||
def write_configuration(config_filename, rendered_config, mode=0o600):
|
||||
def write_configuration(config_filename, rendered_config, mode=0o600, overwrite=False):
|
||||
'''
|
||||
Given a target config filename and rendered config YAML, write it out to file. Create any
|
||||
containing directories as needed.
|
||||
containing directories as needed. But if the file already exists and overwrite is False,
|
||||
abort before writing anything.
|
||||
'''
|
||||
if os.path.exists(config_filename):
|
||||
raise FileExistsError('{} already exists. Aborting.'.format(config_filename))
|
||||
if not overwrite and os.path.exists(config_filename):
|
||||
raise FileExistsError(
|
||||
'{} already exists. Aborting. Use --overwrite to replace the file.'.format(
|
||||
config_filename
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(config_filename), mode=0o700)
|
||||
@@ -134,26 +135,26 @@ def write_configuration(config_filename, rendered_config, mode=0o600):
|
||||
|
||||
def add_comments_to_configuration_sequence(config, schema, indent=0):
|
||||
'''
|
||||
If the given config sequence's items are maps, then mine the schema for the description of the
|
||||
map's first item, and slap that atop the sequence. Indent the comment the given number of
|
||||
If the given config sequence's items are object, then mine the schema for the description of the
|
||||
object's first item, and slap that atop the sequence. Indent the comment the given number of
|
||||
characters.
|
||||
|
||||
Doing this for sequences of maps results in nice comments that look like:
|
||||
|
||||
```
|
||||
things:
|
||||
# First key description. Added by this function.
|
||||
# First key description. Added by this function.
|
||||
- key: foo
|
||||
# Second key description. Added by add_comments_to_configuration_map().
|
||||
# Second key description. Added by add_comments_to_configuration_object().
|
||||
other: bar
|
||||
```
|
||||
'''
|
||||
if 'map' not in schema['seq'][0]:
|
||||
if schema['items'].get('type') != 'object':
|
||||
return
|
||||
|
||||
for field_name in config[0].keys():
|
||||
field_schema = schema['seq'][0]['map'].get(field_name, {})
|
||||
description = field_schema.get('desc')
|
||||
field_schema = schema['items']['properties'].get(field_name, {})
|
||||
description = field_schema.get('description')
|
||||
|
||||
# No description to use? Skip it.
|
||||
if not field_schema or not description:
|
||||
@@ -162,7 +163,7 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
|
||||
config[0].yaml_set_start_comment(description, indent=indent)
|
||||
|
||||
# We only want the first key's description here, as the rest of the keys get commented by
|
||||
# add_comments_to_configuration_map().
|
||||
# add_comments_to_configuration_object().
|
||||
return
|
||||
|
||||
|
||||
@@ -171,7 +172,7 @@ REQUIRED_KEYS = {'source_directories', 'repositories', 'keep_daily'}
|
||||
COMMENTED_OUT_SENTINEL = 'COMMENT_OUT'
|
||||
|
||||
|
||||
def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False):
|
||||
def add_comments_to_configuration_object(config, schema, indent=0, skip_first=False):
|
||||
'''
|
||||
Using descriptions from a schema as a source, add those descriptions as comments to the given
|
||||
config mapping, before each field. Indent the comment the given number of characters.
|
||||
@@ -180,8 +181,8 @@ def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False
|
||||
if skip_first and index == 0:
|
||||
continue
|
||||
|
||||
field_schema = schema['map'].get(field_name, {})
|
||||
description = field_schema.get('desc', '').strip()
|
||||
field_schema = schema['properties'].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
|
||||
# out from the sample configuration. This sentinel is consumed by downstream processing that
|
||||
@@ -267,18 +268,22 @@ def merge_source_configuration_into_destination(destination_config, source_confi
|
||||
return destination_config
|
||||
|
||||
|
||||
def generate_sample_configuration(source_filename, destination_filename, schema_filename):
|
||||
def generate_sample_configuration(
|
||||
source_filename, destination_filename, schema_filename, overwrite=False
|
||||
):
|
||||
'''
|
||||
Given an optional source configuration filename, and a required destination configuration
|
||||
filename, and the path to a schema filename in pykwalify YAML schema format, write out a
|
||||
sample configuration file based on that schema. If a source filename is provided, merge the
|
||||
parsed contents of that configuration into the generated configuration.
|
||||
filename, the path to a schema filename in a YAML rendition of the JSON Schema format, and
|
||||
whether to overwrite a destination file, write out a sample configuration file based on that
|
||||
schema. If a source filename is provided, merge the parsed contents of that configuration into
|
||||
the generated configuration.
|
||||
'''
|
||||
schema = yaml.round_trip_load(open(schema_filename))
|
||||
source_config = None
|
||||
|
||||
if source_filename:
|
||||
source_config = load.load_configuration(source_filename)
|
||||
normalize.normalize(source_config)
|
||||
|
||||
destination_config = merge_source_configuration_into_destination(
|
||||
_schema_to_sample_configuration(schema), source_config
|
||||
@@ -286,5 +291,6 @@ def generate_sample_configuration(source_filename, destination_filename, schema_
|
||||
|
||||
write_configuration(
|
||||
destination_filename,
|
||||
_comment_out_optional_configuration(_render_configuration(destination_config)),
|
||||
_comment_out_optional_configuration(render_configuration(destination_config)),
|
||||
overwrite=overwrite,
|
||||
)
|
||||
|
||||
+162
-6
@@ -6,6 +6,19 @@ import ruamel.yaml
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Yaml_with_loader_stream(ruamel.yaml.YAML):
|
||||
'''
|
||||
A derived class of ruamel.yaml.YAML that simply tacks the loaded stream (file object) onto the
|
||||
loader class so that it's available anywhere that's passed a loader (in this case,
|
||||
include_configuration() below).
|
||||
'''
|
||||
|
||||
def get_constructor_parser(self, stream):
|
||||
constructor, parser = super(Yaml_with_loader_stream, self).get_constructor_parser(stream)
|
||||
constructor.loader.stream = stream
|
||||
return constructor, parser
|
||||
|
||||
|
||||
def load_configuration(filename):
|
||||
'''
|
||||
Load the given configuration file and return its contents as a data structure of nested dicts
|
||||
@@ -14,7 +27,7 @@ def load_configuration(filename):
|
||||
Raise ruamel.yaml.error.YAMLError if something goes wrong parsing the YAML, or RecursionError
|
||||
if there are too many recursive includes.
|
||||
'''
|
||||
yaml = ruamel.yaml.YAML(typ='safe')
|
||||
yaml = Yaml_with_loader_stream(typ='safe')
|
||||
yaml.Constructor = Include_constructor
|
||||
|
||||
return yaml.load(open(filename))
|
||||
@@ -22,10 +35,146 @@ def load_configuration(filename):
|
||||
|
||||
def include_configuration(loader, filename_node):
|
||||
'''
|
||||
Load the given YAML filename (ignoring the given loader so we can use our own), and return its
|
||||
contents as a data structure of nested dicts and lists.
|
||||
Load the given YAML filename (ignoring the given loader so we can use our own) and return its
|
||||
contents as a data structure of nested dicts and lists. If the filename is relative, probe for
|
||||
it within 1. the current working directory and 2. the directory containing the YAML file doing
|
||||
the including.
|
||||
|
||||
Raise FileNotFoundError if an included file was not found.
|
||||
'''
|
||||
return load_configuration(os.path.expanduser(filename_node.value))
|
||||
include_directories = [os.getcwd(), os.path.abspath(os.path.dirname(loader.stream.name))]
|
||||
include_filename = os.path.expanduser(filename_node.value)
|
||||
|
||||
if not os.path.isabs(include_filename):
|
||||
candidate_filenames = [
|
||||
os.path.join(directory, include_filename) for directory in include_directories
|
||||
]
|
||||
|
||||
for candidate_filename in candidate_filenames:
|
||||
if os.path.exists(candidate_filename):
|
||||
include_filename = candidate_filename
|
||||
break
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f'Could not find include {filename_node.value} at {" or ".join(candidate_filenames)}'
|
||||
)
|
||||
|
||||
return load_configuration(include_filename)
|
||||
|
||||
|
||||
DELETED_NODE = object()
|
||||
|
||||
|
||||
def deep_merge_nodes(nodes):
|
||||
'''
|
||||
Given a nested borgmatic configuration data structure as a list of tuples in the form of:
|
||||
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode as a key,
|
||||
ruamel.yaml.nodes.MappingNode or other Node as a value,
|
||||
),
|
||||
|
||||
... deep merge any node values corresponding to duplicate keys and return the result. If
|
||||
there are colliding keys with non-MappingNode values (e.g., integers or strings), the last
|
||||
of the values wins.
|
||||
|
||||
For instance, given node values of:
|
||||
|
||||
[
|
||||
(
|
||||
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
|
||||
MappingNode(tag='tag:yaml.org,2002:map', value=[
|
||||
(
|
||||
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_hourly'),
|
||||
ScalarNode(tag='tag:yaml.org,2002:int', value='24')
|
||||
),
|
||||
(
|
||||
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
|
||||
ScalarNode(tag='tag:yaml.org,2002:int', value='7')
|
||||
),
|
||||
]),
|
||||
),
|
||||
(
|
||||
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
|
||||
MappingNode(tag='tag:yaml.org,2002:map', value=[
|
||||
(
|
||||
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
|
||||
ScalarNode(tag='tag:yaml.org,2002:int', value='5')
|
||||
),
|
||||
]),
|
||||
),
|
||||
]
|
||||
|
||||
... the returned result would be:
|
||||
|
||||
[
|
||||
(
|
||||
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
|
||||
MappingNode(tag='tag:yaml.org,2002:map', value=[
|
||||
(
|
||||
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_hourly'),
|
||||
ScalarNode(tag='tag:yaml.org,2002:int', value='24')
|
||||
),
|
||||
(
|
||||
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
|
||||
ScalarNode(tag='tag:yaml.org,2002:int', value='5')
|
||||
),
|
||||
]),
|
||||
),
|
||||
]
|
||||
|
||||
The purpose of deep merging like this is to support, for instance, merging one borgmatic
|
||||
configuration file into another for reuse, such that a configuration section ("retention",
|
||||
etc.) does not completely replace the corresponding section in a merged file.
|
||||
'''
|
||||
# Map from original node key/value to the replacement merged node. DELETED_NODE as a replacement
|
||||
# node indications deletion.
|
||||
replaced_nodes = {}
|
||||
|
||||
# To find nodes that require merging, compare each node with each other node.
|
||||
for a_key, a_value in nodes:
|
||||
for b_key, b_value in nodes:
|
||||
# If we've already considered one of the nodes for merging, skip it.
|
||||
if (a_key, a_value) in replaced_nodes or (b_key, b_value) in replaced_nodes:
|
||||
continue
|
||||
|
||||
# If the keys match and the values are different, we need to merge these two A and B nodes.
|
||||
if a_key.tag == b_key.tag and a_key.value == b_key.value and a_value != b_value:
|
||||
# Since we're merging into the B node, consider the A node a duplicate and remove it.
|
||||
replaced_nodes[(a_key, a_value)] = DELETED_NODE
|
||||
|
||||
# If we're dealing with MappingNodes, recurse and merge its values as well.
|
||||
if isinstance(b_value, ruamel.yaml.nodes.MappingNode):
|
||||
replaced_nodes[(b_key, b_value)] = (
|
||||
b_key,
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag=b_value.tag,
|
||||
value=deep_merge_nodes(a_value.value + b_value.value),
|
||||
start_mark=b_value.start_mark,
|
||||
end_mark=b_value.end_mark,
|
||||
flow_style=b_value.flow_style,
|
||||
comment=b_value.comment,
|
||||
anchor=b_value.anchor,
|
||||
),
|
||||
)
|
||||
# If we're dealing with SequenceNodes, merge by appending one sequence to the other.
|
||||
elif isinstance(b_value, ruamel.yaml.nodes.SequenceNode):
|
||||
replaced_nodes[(b_key, b_value)] = (
|
||||
b_key,
|
||||
ruamel.yaml.nodes.SequenceNode(
|
||||
tag=b_value.tag,
|
||||
value=a_value.value + b_value.value,
|
||||
start_mark=b_value.start_mark,
|
||||
end_mark=b_value.end_mark,
|
||||
flow_style=b_value.flow_style,
|
||||
comment=b_value.comment,
|
||||
anchor=b_value.anchor,
|
||||
),
|
||||
)
|
||||
|
||||
return [
|
||||
replaced_nodes.get(node, node) for node in nodes if replaced_nodes.get(node) != DELETED_NODE
|
||||
]
|
||||
|
||||
|
||||
class Include_constructor(ruamel.yaml.SafeConstructor):
|
||||
@@ -40,14 +189,19 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
|
||||
|
||||
def flatten_mapping(self, node):
|
||||
'''
|
||||
Support the special case of shallow merging included configuration into an existing mapping
|
||||
Support the special case of deep merging included configuration into an existing mapping
|
||||
using the YAML '<<' merge key. Example syntax:
|
||||
|
||||
```
|
||||
retention:
|
||||
keep_daily: 1
|
||||
<<: !include common.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
```
|
||||
|
||||
These includes are deep merged into the current configuration file. For instance, in this
|
||||
example, any "retention" options in common.yaml will get merged into the "retention" section
|
||||
in the example configuration file.
|
||||
'''
|
||||
representer = ruamel.yaml.representer.SafeRepresenter()
|
||||
|
||||
@@ -57,3 +211,5 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
|
||||
node.value[index] = (key_node, included_value)
|
||||
|
||||
super(Include_constructor, self).flatten_mapping(node)
|
||||
|
||||
node.value = deep_merge_nodes(node.value)
|
||||
|
||||
@@ -3,8 +3,29 @@ def normalize(config):
|
||||
Given a configuration dict, apply particular hard-coded rules to normalize its contents to
|
||||
adhere to the configuration schema.
|
||||
'''
|
||||
# Upgrade exclude_if_present from a string to a list.
|
||||
exclude_if_present = config.get('location', {}).get('exclude_if_present')
|
||||
|
||||
# "Upgrade" exclude_if_present from a string to a list.
|
||||
if isinstance(exclude_if_present, str):
|
||||
config['location']['exclude_if_present'] = [exclude_if_present]
|
||||
|
||||
# Upgrade various monitoring hooks from a string to a dict.
|
||||
healthchecks = config.get('hooks', {}).get('healthchecks')
|
||||
if isinstance(healthchecks, str):
|
||||
config['hooks']['healthchecks'] = {'ping_url': healthchecks}
|
||||
|
||||
cronitor = config.get('hooks', {}).get('cronitor')
|
||||
if isinstance(cronitor, str):
|
||||
config['hooks']['cronitor'] = {'ping_url': cronitor}
|
||||
|
||||
pagerduty = config.get('hooks', {}).get('pagerduty')
|
||||
if isinstance(pagerduty, str):
|
||||
config['hooks']['pagerduty'] = {'integration_key': pagerduty}
|
||||
|
||||
cronhub = config.get('hooks', {}).get('cronhub')
|
||||
if isinstance(cronhub, str):
|
||||
config['hooks']['cronhub'] = {'ping_url': cronhub}
|
||||
|
||||
# Upgrade consistency checks from a list of strings to a list of dicts.
|
||||
checks = config.get('consistency', {}).get('checks')
|
||||
if isinstance(checks, list) and len(checks) and isinstance(checks[0], str):
|
||||
config['consistency']['checks'] = [{'name': check_type} for check_type in checks]
|
||||
|
||||
@@ -26,6 +26,8 @@ def convert_value_type(value):
|
||||
'''
|
||||
Given a string value, determine its logical type (string, boolean, integer, etc.), and return it
|
||||
converted to that type.
|
||||
|
||||
Raise ruamel.yaml.error.YAMLError if there's a parse issue with the YAML.
|
||||
'''
|
||||
return ruamel.yaml.YAML(typ='safe').load(io.StringIO(value))
|
||||
|
||||
@@ -50,14 +52,20 @@ def parse_overrides(raw_overrides):
|
||||
if not raw_overrides:
|
||||
return ()
|
||||
|
||||
try:
|
||||
return tuple(
|
||||
(tuple(raw_keys.split('.')), convert_value_type(value))
|
||||
for raw_override in raw_overrides
|
||||
for raw_keys, value in (raw_override.split('=', 1),)
|
||||
)
|
||||
except ValueError:
|
||||
raise ValueError('Invalid override. Make sure you use the form: SECTION.OPTION=VALUE')
|
||||
parsed_overrides = []
|
||||
|
||||
for raw_override in raw_overrides:
|
||||
try:
|
||||
raw_keys, value = raw_override.split('=', 1)
|
||||
parsed_overrides.append((tuple(raw_keys.split('.')), convert_value_type(value),))
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid override '{raw_override}'. Make sure you use the form: SECTION.OPTION=VALUE"
|
||||
)
|
||||
except ruamel.yaml.error.YAMLError as error:
|
||||
raise ValueError(f"Invalid override '{raw_override}': {error.problem}")
|
||||
|
||||
return tuple(parsed_overrides)
|
||||
|
||||
|
||||
def apply_overrides(config, raw_overrides):
|
||||
|
||||
+835
-451
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
import jsonschema
|
||||
import pkg_resources
|
||||
import pykwalify.core
|
||||
import pykwalify.errors
|
||||
import ruamel.yaml
|
||||
|
||||
from borgmatic.config import load, normalize, override
|
||||
@@ -17,15 +15,40 @@ def schema_filename():
|
||||
return pkg_resources.resource_filename('borgmatic', 'config/schema.yaml')
|
||||
|
||||
|
||||
def format_json_error_path_element(path_element):
|
||||
'''
|
||||
Given a path element into a JSON data structure, format it for display as a string.
|
||||
'''
|
||||
if isinstance(path_element, int):
|
||||
return str('[{}]'.format(path_element))
|
||||
|
||||
return str('.{}'.format(path_element))
|
||||
|
||||
|
||||
def format_json_error(error):
|
||||
'''
|
||||
Given an instance of jsonschema.exceptions.ValidationError, format it for display as a string.
|
||||
'''
|
||||
if not error.path:
|
||||
return 'At the top level: {}'.format(error.message)
|
||||
|
||||
formatted_path = ''.join(format_json_error_path_element(element) for element in error.path)
|
||||
return "At '{}': {}".format(formatted_path.lstrip('.'), error.message)
|
||||
|
||||
|
||||
class Validation_error(ValueError):
|
||||
'''
|
||||
A collection of error message strings generated when attempting to validate a particular
|
||||
configurartion file.
|
||||
A collection of error messages generated when attempting to validate a particular
|
||||
configuration file.
|
||||
'''
|
||||
|
||||
def __init__(self, config_filename, error_messages):
|
||||
def __init__(self, config_filename, errors):
|
||||
'''
|
||||
Given a configuration filename path and a sequence of string error messages, create a
|
||||
Validation_error.
|
||||
'''
|
||||
self.config_filename = config_filename
|
||||
self.error_messages = error_messages
|
||||
self.errors = errors
|
||||
|
||||
def __str__(self):
|
||||
'''
|
||||
@@ -33,7 +56,7 @@ class Validation_error(ValueError):
|
||||
'''
|
||||
return 'An error occurred while parsing a configuration file at {}:\n'.format(
|
||||
self.config_filename
|
||||
) + '\n'.join(self.error_messages)
|
||||
) + '\n'.join(error for error in self.errors)
|
||||
|
||||
|
||||
def apply_logical_validation(config_filename, parsed_configuration):
|
||||
@@ -42,15 +65,6 @@ def apply_logical_validation(config_filename, parsed_configuration):
|
||||
below), run through any additional logical validation checks. If there are any such validation
|
||||
problems, raise a Validation_error.
|
||||
'''
|
||||
archive_name_format = parsed_configuration.get('storage', {}).get('archive_name_format')
|
||||
prefix = parsed_configuration.get('retention', {}).get('prefix')
|
||||
|
||||
if archive_name_format and not prefix:
|
||||
raise Validation_error(
|
||||
config_filename,
|
||||
('If you provide an archive_name_format, you must also specify a retention prefix.',),
|
||||
)
|
||||
|
||||
location_repositories = parsed_configuration.get('location', {}).get('repositories')
|
||||
check_repositories = parsed_configuration.get('consistency', {}).get('check_repositories', [])
|
||||
for repository in check_repositories:
|
||||
@@ -65,29 +79,12 @@ def apply_logical_validation(config_filename, parsed_configuration):
|
||||
)
|
||||
|
||||
|
||||
def remove_examples(schema):
|
||||
'''
|
||||
pykwalify gets angry if the example field is not a string. So rather than bend to its will,
|
||||
remove all examples from the given schema before passing the schema to pykwalify.
|
||||
'''
|
||||
if 'map' in schema:
|
||||
for item_name, item_schema in schema['map'].items():
|
||||
item_schema.pop('example', None)
|
||||
remove_examples(item_schema)
|
||||
elif 'seq' in schema:
|
||||
for item_schema in schema['seq']:
|
||||
item_schema.pop('example', None)
|
||||
remove_examples(item_schema)
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def parse_configuration(config_filename, schema_filename, overrides=None):
|
||||
'''
|
||||
Given the path to a config filename in YAML format, the path to a schema filename in pykwalify
|
||||
YAML schema format, a sequence of configuration file override strings in the form of
|
||||
"section.option=value", return the parsed configuration as a data structure of nested dicts and
|
||||
lists corresponding to the schema. Example return value:
|
||||
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 "section.option=value", return the parsed configuration as a data structure of nested dicts
|
||||
and lists corresponding to the schema. Example return value:
|
||||
|
||||
{'location': {'source_directories': ['/home', '/etc'], 'repository': 'hostname.borg'},
|
||||
'retention': {'keep_daily': 7}, 'consistency': {'checks': ['repository', 'archives']}}
|
||||
@@ -95,8 +92,6 @@ def parse_configuration(config_filename, schema_filename, overrides=None):
|
||||
Raise FileNotFoundError if the file does not exist, PermissionError if the user does not
|
||||
have permissions to read the file, or Validation_error if the config does not match the schema.
|
||||
'''
|
||||
logging.getLogger('pykwalify').setLevel(logging.ERROR)
|
||||
|
||||
try:
|
||||
config = load.load_configuration(config_filename)
|
||||
schema = load.load_configuration(schema_filename)
|
||||
@@ -106,15 +101,20 @@ def parse_configuration(config_filename, schema_filename, overrides=None):
|
||||
override.apply_overrides(config, overrides)
|
||||
normalize.normalize(config)
|
||||
|
||||
validator = pykwalify.core.Core(source_data=config, schema_data=remove_examples(schema))
|
||||
parsed_result = validator.validate(raise_exception=False)
|
||||
try:
|
||||
validator = jsonschema.Draft7Validator(schema)
|
||||
except AttributeError: # pragma: no cover
|
||||
validator = jsonschema.Draft4Validator(schema)
|
||||
validation_errors = tuple(validator.iter_errors(config))
|
||||
|
||||
if validator.validation_errors:
|
||||
raise Validation_error(config_filename, validator.validation_errors)
|
||||
if validation_errors:
|
||||
raise Validation_error(
|
||||
config_filename, tuple(format_json_error(error) for error in validation_errors)
|
||||
)
|
||||
|
||||
apply_logical_validation(config_filename, parsed_result)
|
||||
apply_logical_validation(config_filename, config)
|
||||
|
||||
return parsed_result
|
||||
return config
|
||||
|
||||
|
||||
def normalize_repository_path(repository):
|
||||
|
||||
+32
-8
@@ -23,7 +23,7 @@ def exit_code_indicates_error(process, exit_code, borg_local_path=None):
|
||||
command = process.args.split(' ') if isinstance(process.args, str) else process.args
|
||||
|
||||
if borg_local_path and command[0] == borg_local_path:
|
||||
return bool(exit_code >= BORG_ERROR_EXIT_CODE)
|
||||
return bool(exit_code < 0 or exit_code >= BORG_ERROR_EXIT_CODE)
|
||||
|
||||
return bool(exit_code != 0)
|
||||
|
||||
@@ -59,11 +59,12 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
|
||||
'''
|
||||
# Map from output buffer to sequence of last lines.
|
||||
buffer_last_lines = collections.defaultdict(list)
|
||||
output_buffers = [
|
||||
output_buffer_for_process(process, exclude_stdouts)
|
||||
process_for_output_buffer = {
|
||||
output_buffer_for_process(process, exclude_stdouts): process
|
||||
for process in processes
|
||||
if process.stdout or process.stderr
|
||||
]
|
||||
}
|
||||
output_buffers = list(process_for_output_buffer.keys())
|
||||
|
||||
# Log output for each process until they all exit.
|
||||
while True:
|
||||
@@ -71,8 +72,23 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
|
||||
(ready_buffers, _, _) = select.select(output_buffers, [], [])
|
||||
|
||||
for ready_buffer in ready_buffers:
|
||||
ready_process = process_for_output_buffer.get(ready_buffer)
|
||||
|
||||
# The "ready" process has exited, but it might be a pipe destination with other
|
||||
# processes (pipe sources) waiting to be read from. So as a measure to prevent
|
||||
# hangs, vent all processes when one exits.
|
||||
if ready_process and ready_process.poll() is not None:
|
||||
for other_process in processes:
|
||||
if (
|
||||
other_process.poll() is None
|
||||
and other_process.stdout
|
||||
and other_process.stdout not in output_buffers
|
||||
):
|
||||
# Add the process's output to output_buffers to ensure it'll get read.
|
||||
output_buffers.append(other_process.stdout)
|
||||
|
||||
line = ready_buffer.readline().rstrip().decode()
|
||||
if not line:
|
||||
if not line or not ready_process:
|
||||
continue
|
||||
|
||||
# Keep the last few lines of output in case the process errors, and we need the output for
|
||||
@@ -84,9 +100,14 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
|
||||
|
||||
logger.log(output_log_level, line)
|
||||
|
||||
still_running = False
|
||||
|
||||
for process in processes:
|
||||
exit_code = process.poll() if output_buffers else process.wait()
|
||||
|
||||
if exit_code is None:
|
||||
still_running = True
|
||||
|
||||
# If any process errors, then raise accordingly.
|
||||
if exit_code_indicates_error(process, exit_code, borg_local_path):
|
||||
# If an error occurs, include its output in the raised exception so that we don't
|
||||
@@ -108,7 +129,7 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
|
||||
exit_code, command_for_process(process), '\n'.join(last_lines)
|
||||
)
|
||||
|
||||
if all(process.poll() is not None for process in processes):
|
||||
if not still_running:
|
||||
break
|
||||
|
||||
# Consume any remaining output that we missed (if any).
|
||||
@@ -118,9 +139,12 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
|
||||
if not output_buffer:
|
||||
continue
|
||||
|
||||
remaining_output = output_buffer.read().rstrip().decode()
|
||||
while True: # pragma: no cover
|
||||
remaining_output = output_buffer.readline().rstrip().decode()
|
||||
|
||||
if not remaining_output:
|
||||
break
|
||||
|
||||
if remaining_output: # pragma: no cover
|
||||
logger.log(output_log_level, remaining_output)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from borgmatic import execute
|
||||
|
||||
@@ -9,14 +10,19 @@ logger = logging.getLogger(__name__)
|
||||
SOFT_FAIL_EXIT_CODE = 75
|
||||
|
||||
|
||||
def interpolate_context(command, context):
|
||||
def interpolate_context(config_filename, hook_description, command, context):
|
||||
'''
|
||||
Given a single hook command and a dict of context names/values, interpolate the values by
|
||||
"{name}" into the command and return the result.
|
||||
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.
|
||||
'''
|
||||
for name, value in context.items():
|
||||
command = command.replace('{%s}' % name, str(value))
|
||||
|
||||
for unsupported_variable in re.findall(r'{\w+}', command):
|
||||
logger.warning(
|
||||
f"{config_filename}: Variable '{unsupported_variable}' is not supported in {hook_description} hook"
|
||||
)
|
||||
|
||||
return command
|
||||
|
||||
|
||||
@@ -26,8 +32,7 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
|
||||
a hook description, and whether this is a dry run, run the given commands. Or, don't run them
|
||||
if this is a dry run.
|
||||
|
||||
The context contains optional values interpolated by name into the hook commands. Currently,
|
||||
this only applies to the on_error hook.
|
||||
The context contains optional values interpolated by name into the hook commands.
|
||||
|
||||
Raise ValueError if the umask cannot be parsed.
|
||||
Raise subprocesses.CalledProcessError if an error occurs in a hook.
|
||||
@@ -39,7 +44,9 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
|
||||
dry_run_label = ' (dry run; not actually running hooks)' if dry_run else ''
|
||||
|
||||
context['configuration_filename'] = config_filename
|
||||
commands = [interpolate_context(command, context) for command in commands]
|
||||
commands = [
|
||||
interpolate_context(config_filename, description, command, context) for command in commands
|
||||
]
|
||||
|
||||
if len(commands) == 1:
|
||||
logger.info(
|
||||
|
||||
@@ -13,14 +13,27 @@ MONITOR_STATE_TO_CRONHUB = {
|
||||
}
|
||||
|
||||
|
||||
def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run):
|
||||
def initialize_monitor(
|
||||
ping_url, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Ping the given Cronhub URL, modified with the monitor.State. Use the given configuration
|
||||
No initialization is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Ping the configured Cronhub URL, modified with the monitor.State. Use the given configuration
|
||||
filename in any log entries. If this is a dry run, then don't actually ping anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
formatted_state = '/{}/'.format(MONITOR_STATE_TO_CRONHUB[state])
|
||||
ping_url = ping_url.replace('/start/', formatted_state).replace('/ping/', formatted_state)
|
||||
ping_url = (
|
||||
hook_config['ping_url']
|
||||
.replace('/start/', formatted_state)
|
||||
.replace('/ping/', formatted_state)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'{}: Pinging Cronhub {}{}'.format(config_filename, state.name.lower(), dry_run_label)
|
||||
@@ -29,4 +42,16 @@ def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
requests.get(ping_url)
|
||||
try:
|
||||
requests.get(ping_url)
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Cronhub error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
@@ -13,13 +13,22 @@ MONITOR_STATE_TO_CRONITOR = {
|
||||
}
|
||||
|
||||
|
||||
def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run):
|
||||
def initialize_monitor(
|
||||
ping_url, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Ping the given Cronitor URL, modified with the monitor.State. Use the given configuration
|
||||
No initialization is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Ping the configured Cronitor URL, modified with the monitor.State. Use the given configuration
|
||||
filename in any log entries. If this is a dry run, then don't actually ping anything.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
|
||||
ping_url = '{}/{}'.format(ping_url, MONITOR_STATE_TO_CRONITOR[state])
|
||||
ping_url = '{}/{}'.format(hook_config['ping_url'], MONITOR_STATE_TO_CRONITOR[state])
|
||||
|
||||
logger.info(
|
||||
'{}: Pinging Cronitor {}{}'.format(config_filename, state.name.lower(), dry_run_label)
|
||||
@@ -28,4 +37,16 @@ def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
requests.get(ping_url)
|
||||
try:
|
||||
requests.get(ping_url)
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Cronitor error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from borgmatic.hooks import cronhub, cronitor, healthchecks, mysql, pagerduty, postgresql
|
||||
from borgmatic.hooks import cronhub, cronitor, healthchecks, mongodb, mysql, pagerduty, postgresql
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -11,6 +11,7 @@ HOOK_NAME_TO_MODULE = {
|
||||
'pagerduty': pagerduty,
|
||||
'postgresql_databases': postgresql,
|
||||
'mysql_databases': mysql,
|
||||
'mongodb_databases': mongodb,
|
||||
}
|
||||
|
||||
|
||||
@@ -58,5 +59,5 @@ def call_hooks(function_name, hooks, log_prefix, hook_names, *args, **kwargs):
|
||||
return {
|
||||
hook_name: call_hook(function_name, hooks, log_prefix, hook_name, *args, **kwargs)
|
||||
for hook_name in hook_names
|
||||
if hook_name in hooks
|
||||
if hooks.get(hook_name)
|
||||
}
|
||||
|
||||
+10
-32
@@ -2,11 +2,11 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from borgmatic.borg.create import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
from borgmatic.borg.state import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATABASE_HOOK_NAMES = ('postgresql_databases', 'mysql_databases')
|
||||
DATABASE_HOOK_NAMES = ('postgresql_databases', 'mysql_databases', 'mongodb_databases')
|
||||
|
||||
|
||||
def make_database_dump_path(borgmatic_source_directory, database_hook_name):
|
||||
@@ -48,46 +48,24 @@ def create_named_pipe_for_dump(dump_path):
|
||||
os.mkfifo(dump_path, mode=0o600)
|
||||
|
||||
|
||||
def remove_database_dumps(dump_path, databases, database_type_name, log_prefix, dry_run):
|
||||
def remove_database_dumps(dump_path, database_type_name, log_prefix, dry_run):
|
||||
'''
|
||||
Remove the database dumps for the given databases in the dump directory path. The databases are
|
||||
supplied as a sequence of dicts, one dict describing each database as per the configuration
|
||||
schema. Use the name of the database type and the log prefix in any log entries. If this is a
|
||||
dry run, then don't actually remove anything.
|
||||
Remove all database dumps in the given dump directory path (including the directory itself). If
|
||||
this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
if not databases:
|
||||
logger.debug('{}: No {} databases configured'.format(log_prefix, database_type_name))
|
||||
return
|
||||
|
||||
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
|
||||
|
||||
logger.info(
|
||||
'{}: Removing {} database dumps{}'.format(log_prefix, database_type_name, dry_run_label)
|
||||
)
|
||||
|
||||
for database in databases:
|
||||
dump_filename = make_database_dump_filename(
|
||||
dump_path, database['name'], database.get('hostname')
|
||||
)
|
||||
expanded_path = os.path.expanduser(dump_path)
|
||||
|
||||
logger.debug(
|
||||
'{}: Removing {} database dump {} from {}{}'.format(
|
||||
log_prefix, database_type_name, database['name'], dump_filename, dry_run_label
|
||||
)
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
if os.path.exists(dump_filename):
|
||||
if os.path.isdir(dump_filename):
|
||||
shutil.rmtree(dump_filename)
|
||||
else:
|
||||
os.remove(dump_filename)
|
||||
|
||||
dump_file_dir = os.path.dirname(dump_filename)
|
||||
|
||||
if os.path.exists(dump_file_dir) and len(os.listdir(dump_file_dir)) == 0:
|
||||
os.rmdir(dump_file_dir)
|
||||
if os.path.exists(expanded_path):
|
||||
shutil.rmtree(expanded_path)
|
||||
|
||||
|
||||
def convert_glob_patterns_to_borg_patterns(patterns):
|
||||
|
||||
@@ -13,13 +13,14 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
|
||||
}
|
||||
|
||||
PAYLOAD_TRUNCATION_INDICATOR = '...\n'
|
||||
PAYLOAD_LIMIT_BYTES = 10 * 1024 - len(PAYLOAD_TRUNCATION_INDICATOR)
|
||||
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.
|
||||
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):
|
||||
@@ -36,6 +37,9 @@ class Forgetful_buffering_handler(logging.Handler):
|
||||
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)
|
||||
@@ -65,27 +69,45 @@ def format_buffered_logs_for_payload():
|
||||
return payload
|
||||
|
||||
|
||||
def ping_monitor(ping_url_or_uuid, config_filename, state, monitoring_log_level, dry_run):
|
||||
def initialize_monitor(hook_config, config_filename, monitoring_log_level, dry_run):
|
||||
'''
|
||||
Ping the given Healthchecks URL or UUID, modified with the monitor.State. Use the given
|
||||
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_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.
|
||||
'''
|
||||
if state is monitor.State.START:
|
||||
# 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.
|
||||
logging.getLogger().addHandler(
|
||||
Forgetful_buffering_handler(PAYLOAD_LIMIT_BYTES, monitoring_log_level)
|
||||
)
|
||||
payload = ''
|
||||
|
||||
ping_url = (
|
||||
ping_url_or_uuid
|
||||
if ping_url_or_uuid.startswith('http')
|
||||
else 'https://hc-ping.com/{}'.format(ping_url_or_uuid)
|
||||
hook_config['ping_url']
|
||||
if hook_config['ping_url'].startswith('http')
|
||||
else 'https://hc-ping.com/{}'.format(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 = '{}/{}'.format(ping_url, healthchecks_state)
|
||||
@@ -97,7 +119,24 @@ def ping_monitor(ping_url_or_uuid, config_filename, state, monitoring_log_level,
|
||||
|
||||
if state in (monitor.State.FINISH, monitor.State.FAIL):
|
||||
payload = format_buffered_logs_for_payload()
|
||||
else:
|
||||
payload = ''
|
||||
|
||||
if not dry_run:
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
requests.post(ping_url, data=payload.encode('utf-8'))
|
||||
try:
|
||||
requests.post(ping_url, data=payload.encode('utf-8'))
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: Healthchecks error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(hook_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)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import logging
|
||||
|
||||
from borgmatic.execute import execute_command, execute_command_with_processes
|
||||
from borgmatic.hooks import dump
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_dump_path(location_config): # pragma: no cover
|
||||
'''
|
||||
Make the dump path from the given location configuration and the name of this hook.
|
||||
'''
|
||||
return dump.make_database_dump_path(
|
||||
location_config.get('borgmatic_source_directory'), 'mongodb_databases'
|
||||
)
|
||||
|
||||
|
||||
def dump_databases(databases, log_prefix, location_config, 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 given log
|
||||
prefix in any log entries. Use the given location configuration dict 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.
|
||||
'''
|
||||
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
|
||||
|
||||
logger.info('{}: Dumping MongoDB databases{}'.format(log_prefix, dry_run_label))
|
||||
|
||||
processes = []
|
||||
for database in databases:
|
||||
name = database['name']
|
||||
dump_filename = dump.make_database_dump_filename(
|
||||
make_dump_path(location_config), name, database.get('hostname')
|
||||
)
|
||||
dump_format = database.get('format', 'archive')
|
||||
|
||||
logger.debug(
|
||||
'{}: Dumping MongoDB database {} to {}{}'.format(
|
||||
log_prefix, name, dump_filename, dry_run_label
|
||||
)
|
||||
)
|
||||
if dry_run:
|
||||
continue
|
||||
|
||||
if dump_format == 'directory':
|
||||
dump.create_parent_directory_for_dump(dump_filename)
|
||||
else:
|
||||
dump.create_named_pipe_for_dump(dump_filename)
|
||||
|
||||
command = build_dump_command(database, dump_filename, dump_format)
|
||||
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'
|
||||
command = ['mongodump', '--archive']
|
||||
if dump_format == 'directory':
|
||||
command.append(dump_filename)
|
||||
if 'hostname' in database:
|
||||
command.extend(('--host', database['hostname']))
|
||||
if 'port' in database:
|
||||
command.extend(('--port', str(database['port'])))
|
||||
if 'username' in database:
|
||||
command.extend(('--username', database['username']))
|
||||
if 'password' in database:
|
||||
command.extend(('--password', database['password']))
|
||||
if 'authentication_database' in database:
|
||||
command.extend(('--authenticationDatabase', database['authentication_database']))
|
||||
if not all_databases:
|
||||
command.extend(('--db', database['name']))
|
||||
if 'options' in database:
|
||||
command.extend(database['options'].split(' '))
|
||||
if dump_format != 'directory':
|
||||
command.extend(('>', dump_filename))
|
||||
return command
|
||||
|
||||
|
||||
def remove_database_dumps(databases, log_prefix, location_config, 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 location configuration dict to construct the
|
||||
destination path. If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dump.remove_database_dumps(make_dump_path(location_config), 'MongoDB', log_prefix, dry_run)
|
||||
|
||||
|
||||
def make_database_dump_pattern(
|
||||
databases, log_prefix, location_config, name=None
|
||||
): # pragma: no cover
|
||||
'''
|
||||
Given a sequence of configurations dicts, a prefix to log with, a location configuration dict,
|
||||
and a database name to match, return the corresponding glob patterns to match the database dump
|
||||
in an archive.
|
||||
'''
|
||||
return dump.make_database_dump_filename(make_dump_path(location_config), name, hostname='*')
|
||||
|
||||
|
||||
def restore_database_dump(database_config, log_prefix, location_config, dry_run, extract_process):
|
||||
'''
|
||||
Restore the given MongoDB database from an extract stream. The database is supplied as a
|
||||
one-element sequence containing a dict describing the database, as per the configuration schema.
|
||||
Use the given log prefix in 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 ''
|
||||
|
||||
if len(database_config) != 1:
|
||||
raise ValueError('The database configuration value is invalid')
|
||||
|
||||
database = database_config[0]
|
||||
dump_filename = dump.make_database_dump_filename(
|
||||
make_dump_path(location_config), database['name'], database.get('hostname')
|
||||
)
|
||||
restore_command = build_restore_command(extract_process, database, dump_filename)
|
||||
|
||||
logger.debug(
|
||||
'{}: Restoring MongoDB database {}{}'.format(log_prefix, database['name'], dry_run_label)
|
||||
)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
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,
|
||||
borg_local_path=location_config.get('local_path', 'borg'),
|
||||
)
|
||||
|
||||
|
||||
def build_restore_command(extract_process, database, dump_filename):
|
||||
'''
|
||||
Return the mongorestore command from a single database configuration.
|
||||
'''
|
||||
command = ['mongorestore', '--archive']
|
||||
if not extract_process:
|
||||
command.append(dump_filename)
|
||||
if database['name'] != 'all':
|
||||
command.extend(('--drop', '--db', database['name']))
|
||||
if 'hostname' in database:
|
||||
command.extend(('--host', database['hostname']))
|
||||
if 'port' in database:
|
||||
command.extend(('--port', str(database['port'])))
|
||||
if 'username' in database:
|
||||
command.extend(('--username', database['username']))
|
||||
if 'password' in database:
|
||||
command.extend(('--password', database['password']))
|
||||
if 'authentication_database' in database:
|
||||
command.extend(('--authenticationDatabase', database['authentication_database']))
|
||||
return command
|
||||
@@ -31,6 +31,7 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run_labe
|
||||
|
||||
show_command = (
|
||||
('mysql',)
|
||||
+ (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 ())
|
||||
@@ -81,12 +82,12 @@ def dump_databases(databases, log_prefix, location_config, dry_run):
|
||||
|
||||
dump_command = (
|
||||
('mysqldump',)
|
||||
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
|
||||
+ ('--add-drop-database',)
|
||||
+ (('--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 ())
|
||||
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
|
||||
+ ('--databases',)
|
||||
+ dump_database_names
|
||||
# Use shell redirection rather than execute_command(output_file=open(...)) to prevent
|
||||
@@ -118,14 +119,11 @@ def dump_databases(databases, log_prefix, location_config, dry_run):
|
||||
|
||||
def remove_database_dumps(databases, log_prefix, location_config, dry_run): # pragma: no cover
|
||||
'''
|
||||
Remove the database dumps for the given databases. The databases are supplied as a sequence of
|
||||
dicts, one dict describing each database as per the configuration schema. Use the log prefix in
|
||||
any log entries. Use the given location configuration dict to construct the destination path. If
|
||||
this is a dry run, then don't actually remove anything.
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the log
|
||||
prefix in any log entries. Use the given location configuration dict to construct the
|
||||
destination path. If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dump.remove_database_dumps(
|
||||
make_dump_path(location_config), databases, 'MySQL', log_prefix, dry_run
|
||||
)
|
||||
dump.remove_database_dumps(make_dump_path(location_config), 'MySQL', log_prefix, dry_run)
|
||||
|
||||
|
||||
def make_database_dump_pattern(
|
||||
@@ -154,7 +152,7 @@ def restore_database_dump(database_config, log_prefix, location_config, dry_run,
|
||||
|
||||
database = database_config[0]
|
||||
restore_command = (
|
||||
('mysql', '--batch', '--verbose')
|
||||
('mysql', '--batch')
|
||||
+ (('--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 ())
|
||||
|
||||
@@ -12,10 +12,19 @@ logger = logging.getLogger(__name__)
|
||||
EVENTS_API_URL = 'https://events.pagerduty.com/v2/enqueue'
|
||||
|
||||
|
||||
def ping_monitor(integration_key, config_filename, state, monitoring_log_level, dry_run):
|
||||
def initialize_monitor(
|
||||
integration_key, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
If this is an error state, create a PagerDuty event with the given integration key. Use the
|
||||
given configuration filename in any log entries. If this is a dry run, then don't actually
|
||||
No initialization is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
|
||||
def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
|
||||
'''
|
||||
If this is an error state, create a PagerDuty event with the configured integration key. Use
|
||||
the given configuration filename in any log entries. If this is a dry run, then don't actually
|
||||
create an event.
|
||||
'''
|
||||
if state != monitor.State.FAIL:
|
||||
@@ -38,7 +47,7 @@ def ping_monitor(integration_key, config_filename, state, monitoring_log_level,
|
||||
)
|
||||
payload = json.dumps(
|
||||
{
|
||||
'routing_key': integration_key,
|
||||
'routing_key': hook_config['integration_key'],
|
||||
'event_action': 'trigger',
|
||||
'payload': {
|
||||
'summary': 'backup failed on {}'.format(hostname),
|
||||
@@ -59,4 +68,16 @@ def ping_monitor(integration_key, config_filename, state, monitoring_log_level,
|
||||
logger.debug('{}: Using PagerDuty payload: {}'.format(config_filename, payload))
|
||||
|
||||
logging.getLogger('urllib3').setLevel(logging.ERROR)
|
||||
requests.post(EVENTS_API_URL, data=payload.encode('utf-8'))
|
||||
try:
|
||||
requests.post(EVENTS_API_URL, data=payload.encode('utf-8'))
|
||||
except requests.exceptions.RequestException as error:
|
||||
logger.warning(f'{config_filename}: PagerDuty error: {error}')
|
||||
|
||||
|
||||
def destroy_monitor(
|
||||
ping_url_or_uuid, config_filename, monitoring_log_level, dry_run
|
||||
): # pragma: no cover
|
||||
'''
|
||||
No destruction is necessary for this monitor.
|
||||
'''
|
||||
pass
|
||||
|
||||
@@ -15,6 +15,25 @@ def make_dump_path(location_config): # pragma: no cover
|
||||
)
|
||||
|
||||
|
||||
def make_extra_environment(database):
|
||||
'''
|
||||
Make the extra_environment dict from the given database configuration.
|
||||
'''
|
||||
extra = dict()
|
||||
if 'password' in database:
|
||||
extra['PGPASSWORD'] = database['password']
|
||||
extra['PGSSLMODE'] = database.get('ssl_mode', 'disable')
|
||||
if 'ssl_cert' in database:
|
||||
extra['PGSSLCERT'] = database['ssl_cert']
|
||||
if 'ssl_key' in database:
|
||||
extra['PGSSLKEY'] = database['ssl_key']
|
||||
if 'ssl_root_cert' in database:
|
||||
extra['PGSSLROOTCERT'] = database['ssl_root_cert']
|
||||
if 'ssl_crl' in database:
|
||||
extra['PGSSLCRL'] = database['ssl_crl']
|
||||
return extra
|
||||
|
||||
|
||||
def dump_databases(databases, log_prefix, location_config, dry_run):
|
||||
'''
|
||||
Dump the given PostgreSQL databases to a named pipe. The databases are supplied as a sequence of
|
||||
@@ -56,7 +75,7 @@ def dump_databases(databases, log_prefix, location_config, dry_run):
|
||||
# format in a particular, a named destination is required, and redirection doesn't work.
|
||||
+ (('>', dump_filename) if dump_format != 'directory' else ())
|
||||
)
|
||||
extra_environment = {'PGPASSWORD': database['password']} if 'password' in database else None
|
||||
extra_environment = make_extra_environment(database)
|
||||
|
||||
logger.debug(
|
||||
'{}: Dumping PostgreSQL database {} to {}{}'.format(
|
||||
@@ -82,14 +101,11 @@ def dump_databases(databases, log_prefix, location_config, dry_run):
|
||||
|
||||
def remove_database_dumps(databases, log_prefix, location_config, dry_run): # pragma: no cover
|
||||
'''
|
||||
Remove the database dumps for the given databases. The databases are supplied as a sequence of
|
||||
dicts, one dict describing each database as per the configuration schema. Use the log prefix in
|
||||
any log entries. Use the given location configuration dict to construct the destination path. If
|
||||
this is a dry run, then don't actually remove anything.
|
||||
Remove all database dump files for this hook regardless of the given databases. Use the log
|
||||
prefix in any log entries. Use the given location configuration dict to construct the
|
||||
destination path. If this is a dry run, then don't actually remove anything.
|
||||
'''
|
||||
dump.remove_database_dumps(
|
||||
make_dump_path(location_config), databases, 'PostgreSQL', log_prefix, dry_run
|
||||
)
|
||||
dump.remove_database_dumps(make_dump_path(location_config), 'PostgreSQL', log_prefix, dry_run)
|
||||
|
||||
|
||||
def make_database_dump_pattern(
|
||||
@@ -144,7 +160,7 @@ def restore_database_dump(database_config, log_prefix, location_config, dry_run,
|
||||
+ (('--username', database['username']) if 'username' in database else ())
|
||||
+ (() if extract_process else (dump_filename,))
|
||||
)
|
||||
extra_environment = {'PGPASSWORD': database['password']} if 'password' in database else None
|
||||
extra_environment = make_extra_environment(database)
|
||||
|
||||
logger.debug(
|
||||
'{}: Restoring PostgreSQL database {}{}'.format(log_prefix, database['name'], dry_run_label)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
|
||||
@@ -151,6 +152,8 @@ def configure_logging(
|
||||
syslog_path = '/dev/log'
|
||||
elif os.path.exists('/var/run/syslog'):
|
||||
syslog_path = '/var/run/syslog'
|
||||
elif os.path.exists('/var/run/log'):
|
||||
syslog_path = '/var/run/log'
|
||||
|
||||
if syslog_path and not interactive_console():
|
||||
syslog_handler = logging.handlers.SysLogHandler(address=syslog_path)
|
||||
|
||||
+20
-4
@@ -1,18 +1,34 @@
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _handle_signal(signal_number, frame): # pragma: no cover
|
||||
EXIT_CODE_FROM_SIGNAL = 128
|
||||
|
||||
|
||||
def handle_signal(signal_number, frame):
|
||||
'''
|
||||
Send the signal to all processes in borgmatic's process group, which includes child process.
|
||||
Send the signal to all processes in borgmatic's process group, which includes child processes.
|
||||
'''
|
||||
# Prevent infinite signal handler recursion. If the parent frame is this very same handler
|
||||
# function, we know we're recursing.
|
||||
if frame.f_back.f_code.co_name == handle_signal.__name__:
|
||||
return
|
||||
|
||||
os.killpg(os.getpgrp(), signal_number)
|
||||
|
||||
if signal_number == signal.SIGTERM:
|
||||
logger.critical('Exiting due to TERM signal')
|
||||
sys.exit(EXIT_CODE_FROM_SIGNAL + signal.SIGTERM)
|
||||
|
||||
def configure_signals(): # pragma: no cover
|
||||
|
||||
def configure_signals():
|
||||
'''
|
||||
Configure borgmatic's signal handlers to pass relevant signals through to any child processes
|
||||
like Borg. Note that SIGINT gets passed through even without these changes.
|
||||
'''
|
||||
for signal_number in (signal.SIGHUP, signal.SIGTERM, signal.SIGUSR1, signal.SIGUSR2):
|
||||
signal.signal(signal_number, _handle_signal)
|
||||
signal.signal(signal_number, handle_signal)
|
||||
|
||||
+6
-4
@@ -1,13 +1,14 @@
|
||||
FROM python:3.8.1-alpine3.11 as borgmatic
|
||||
FROM python:3.8-alpine3.13 as borgmatic
|
||||
|
||||
COPY . /app
|
||||
RUN apk add --no-cache py3-ruamel.yaml py3-ruamel.yaml.clib
|
||||
RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
|
||||
RUN borgmatic --help > /command-line.txt \
|
||||
&& for action in init prune create check extract mount umount restore list info; do \
|
||||
&& for action in init prune compact create check extract export-tar mount umount restore list info borg; do \
|
||||
echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
|
||||
&& borgmatic "$action" --help >> /command-line.txt; done
|
||||
|
||||
FROM node:13.7.0-alpine as html
|
||||
FROM node:15.2.1-alpine as html
|
||||
|
||||
ARG ENVIRONMENT=production
|
||||
|
||||
@@ -16,6 +17,7 @@ WORKDIR /source
|
||||
RUN npm install @11ty/eleventy \
|
||||
@11ty/eleventy-plugin-syntaxhighlight \
|
||||
@11ty/eleventy-plugin-inclusive-language \
|
||||
@11ty/eleventy-navigation \
|
||||
markdown-it \
|
||||
markdown-it-anchor \
|
||||
markdown-it-replace-link
|
||||
@@ -25,7 +27,7 @@ COPY . /source
|
||||
RUN NODE_ENV=${ENVIRONMENT} npx eleventy --input=/source/docs --output=/output/docs \
|
||||
&& mv /output/docs/index.html /output/index.html
|
||||
|
||||
FROM nginx:1.16.1-alpine
|
||||
FROM nginx:1.19.4-alpine
|
||||
|
||||
COPY --from=html /output /usr/share/nginx/html
|
||||
COPY --from=borgmatic /etc/borgmatic/config.yaml /usr/share/nginx/html/docs/reference/config.yaml
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* Buzzwords */
|
||||
@keyframes rainbow {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
}
|
||||
.buzzword-list,
|
||||
.inlinelist {
|
||||
@@ -25,6 +24,7 @@
|
||||
margin: 4px 4px 4px 0;
|
||||
transition: .15s linear outline;
|
||||
}
|
||||
|
||||
.inlinelist .inlinelist-item.active {
|
||||
background-color: #222;
|
||||
color: #fff;
|
||||
@@ -36,6 +36,38 @@
|
||||
}
|
||||
.inlinelist .inlinelist-item code {
|
||||
background-color: transparent;
|
||||
font-size: 80%;
|
||||
margin-left: 6px;
|
||||
padding-left: 6px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
@media (max-width: 26.8125em) { /* 429px */
|
||||
.inlinelist .inlinelist-item {
|
||||
overflow: hidden;
|
||||
}
|
||||
.inlinelist .inlinelist-item code {
|
||||
float: right;
|
||||
line-height: 1.75;
|
||||
}
|
||||
}
|
||||
@media (min-width: 26.875em) { /* 430px */
|
||||
.inlinelist .inlinelist-item code {
|
||||
float: none;
|
||||
}
|
||||
.inlinelist .inlinelist-item code:before {
|
||||
content: " ";
|
||||
border-left: 1px solid rgba(255,255,255,.8);
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: -2px;
|
||||
bottom: 2px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.inlinelist .inlinelist-item code:before {
|
||||
border-left-color: rgba(0,0,0,.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
a.buzzword {
|
||||
text-decoration: underline;
|
||||
@@ -59,44 +91,74 @@ a.buzzword {
|
||||
.buzzword {
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.buzzword-list li,
|
||||
.buzzword {
|
||||
background-color: #080808;
|
||||
}
|
||||
}
|
||||
.inlinelist .inlinelist-item {
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.inlinelist .inlinelist-item {
|
||||
background-color: #000;
|
||||
}
|
||||
.inlinelist .inlinelist-item a {
|
||||
color: #fff;
|
||||
}
|
||||
.inlinelist .inlinelist-item code {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
.inlinelist .inlinelist-item:hover,
|
||||
.inlinelist .inlinelist-item:focus,
|
||||
.buzzword-list li:hover,
|
||||
.buzzword-list li:focus,
|
||||
.buzzword:hover,
|
||||
.buzzword:focus {
|
||||
.buzzword:focus,
|
||||
.rainbow-active:hover,
|
||||
.rainbow-active:focus {
|
||||
position: relative;
|
||||
background-image: linear-gradient(238deg, #ff0000, #ff8000, #ffff00, #80ff00, #00ff00, #00ff80, #00ffff, #0080ff, #0000ff, #8000ff, #ff0080);
|
||||
background-size: 1200% 1200%;
|
||||
background-position: 2% 80%;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 2px rgba(0,0,0,.9);
|
||||
animation: rainbow 1.6s infinite;
|
||||
animation: rainbow 4s ease-out alternate infinite;
|
||||
}
|
||||
.rainbow-active-noanim {
|
||||
animation: none !important;
|
||||
}
|
||||
.inlinelist .inlinelist-item:hover a,
|
||||
.inlinelist .inlinelist-item:focus a,
|
||||
.buzzword-list li:hover a,
|
||||
.buzzword-list li:focus a,
|
||||
a.buzzword:hover,
|
||||
a.buzzword:focus {
|
||||
a.buzzword:focus,
|
||||
a.rainbow-active:hover,
|
||||
a.rainbow-active:focus {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
/*
|
||||
I wish there were a PE friendly way to do this but media queries don’t work work with @supports
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.inlinelist .inlinelist-item:hover,
|
||||
.inlinelist .inlinelist-item:focus,
|
||||
.buzzword-list li:hover,
|
||||
.buzzword-list li:focus,
|
||||
.buzzword:hover,
|
||||
.buzzword:focus {
|
||||
animation: rainbow 1s infinite;
|
||||
.buzzword:focus,
|
||||
.rainbow-active:hover,
|
||||
.rainbow-active:focus {
|
||||
animation: none;
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
.buzzword-list li:hover:after,
|
||||
.buzzword-list li:focus:after,
|
||||
.buzzword:hover:after,
|
||||
.buzzword:focus:after {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
content: "Buzzword alert!!!";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -123,4 +185,94 @@ main h2 a.buzzword,
|
||||
main h3 a.buzzword,
|
||||
main p a.buzzword {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small viewport */
|
||||
@media (max-width: 26.8125em) { /* 429px */
|
||||
.inlinelist .inlinelist-item {
|
||||
display: block;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.inlinelist .inlinelist-item > a {
|
||||
display: block;
|
||||
padding: .2em .5em;
|
||||
}
|
||||
}
|
||||
@media (min-width: 26.875em) { /* 430px */
|
||||
.inlinelist .inlinelist-item > a {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.numberflag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #dff7ff;
|
||||
border-radius: 50%;
|
||||
width: 1.75em;
|
||||
height: 1.75em;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.numberflag {
|
||||
background-color: #00bcd4;
|
||||
color: #222;
|
||||
}
|
||||
}
|
||||
h1 .numberflag,
|
||||
h2 .numberflag,
|
||||
h3 .numberflag,
|
||||
h4 .numberflag,
|
||||
h5 .numberflag {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
}
|
||||
h2 .numberflag {
|
||||
position: relative;
|
||||
margin-right: 0.25em; /* 10px /40 */
|
||||
}
|
||||
h2 .numberflag:after {
|
||||
content: " ";
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
background-color: #fff;
|
||||
width: calc(100% + 0.4em); /* 16px /40 */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
h2 .numberflag:after {
|
||||
background-color: #222;
|
||||
}
|
||||
}
|
||||
|
||||
/* Super featured list on home page */
|
||||
.list-superfeatured .avatar {
|
||||
width: calc(30px + 5vw);
|
||||
height: calc(30px + 5vw);
|
||||
max-width: 60px;
|
||||
max-height: 60px;
|
||||
margin-left: 0;
|
||||
}
|
||||
@media (max-width: 26.8125em) { /* 429px */
|
||||
.list-superfeatured .inlinelist-item > a {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
@media (min-width: 26.875em) { /* 430px */
|
||||
.list-superfeatured .inlinelist-item {
|
||||
font-size: 110%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Only top level */
|
||||
.inlinelist-no-nest ul,
|
||||
.inlinelist-no-nest ol {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,20 @@
|
||||
font-weight: 500;
|
||||
margin: 0 0.4285714285714em 0.07142857142857em 0; /* 0 6px 1px 0 /14 */
|
||||
line-height: 1.285714285714; /* 18px /14 */
|
||||
font-family: system-ui, sans-serif;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.minilink {
|
||||
background-color: #222;
|
||||
/*
|
||||
!important to override .elv-callout a
|
||||
see _includes/components/callout.css
|
||||
*/
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
table .minilink {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.minilink[href] {
|
||||
box-shadow: 0 1px 1px 0 rgba(0,0,0,.5);
|
||||
@@ -19,6 +32,12 @@
|
||||
.minilink[href]:focus {
|
||||
background-color: #bbb;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.minilink[href]:hover,
|
||||
.minilink[href]:focus {
|
||||
background-color: #444;
|
||||
}
|
||||
}
|
||||
pre + .minilink {
|
||||
color: #fff;
|
||||
border-radius: 0 0 0.2857142857143em 0.2857142857143em; /* 4px /14 */
|
||||
@@ -35,6 +54,54 @@ p.minilink {
|
||||
margin-left: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
h1 .minilink,
|
||||
h2 .minilink,
|
||||
h3 .minilink,
|
||||
h4 .minilink {
|
||||
font-size: 0.9375rem; /* 15px /16 */
|
||||
vertical-align: middle;
|
||||
margin-left: 1em;
|
||||
}
|
||||
h3 .minilink,
|
||||
h4 .minilink {
|
||||
font-size: 0.8125rem; /* 13px /16 */
|
||||
}
|
||||
.minilink + pre[class*=language-] {
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
.minilink-addedin {
|
||||
text-transform: none;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.3);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.minilink-addedin {
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,0.3);
|
||||
}
|
||||
}
|
||||
.minilink-addedin:not(:first-child) {
|
||||
margin-left: .5em;
|
||||
}
|
||||
.minilink-addedin.minilink-inline {
|
||||
margin: 0 4px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.minilink-lower {
|
||||
text-transform: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
.minilink-lower[href] {
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.5);
|
||||
}
|
||||
.minilink-lower[href]:hover,
|
||||
.minilink-lower[href]:focus {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.minilink > .minilink {
|
||||
margin: -.125em .375em -.125em -.375em;
|
||||
box-shadow: none;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
#suggestion-form textarea {
|
||||
font-family: sans-serif;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#suggestion-form label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#suggestion-form input[type=email] {
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#suggestion-form .form-error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<h2>Improve this documentation</h2>
|
||||
|
||||
<p>Have an idea on how to make this documentation even better? Send your
|
||||
feedback below! But if you need help with borgmatic, or have an idea for a
|
||||
borgmatic feature, please use our <a href="https://torsion.org/borgmatic/#issues">issue
|
||||
tracker</a> instead.</p>
|
||||
|
||||
<form id="suggestion-form">
|
||||
<div><label for="suggestion">Documentation suggestion</label></div>
|
||||
<textarea id="suggestion" rows="8" cols="60" name="suggestion"></textarea>
|
||||
<div data-sk-error="suggestion" class="form-error"></div>
|
||||
<input id="_page" type="hidden" name="_page">
|
||||
<input id="_subject" type="hidden" name="_subject" value="borgmatic documentation suggestion">
|
||||
<br />
|
||||
<label for="email">Email address</label>
|
||||
<div><input id="email" type="email" name="email" placeholder="Only required if you want a response!"></div>
|
||||
<div data-sk-error="email" class="form-error"></div>
|
||||
<br />
|
||||
<div><button type="submit">Send</button></div>
|
||||
<br />
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.getElementById('_page').value = window.location.href;
|
||||
window.sk=window.sk||function(){(sk.q=sk.q||[]).push(arguments)};
|
||||
|
||||
sk('form', 'init', {
|
||||
id: '1d536680ab96',
|
||||
element: '#suggestion-form'
|
||||
});
|
||||
</script>
|
||||
|
||||
<script defer src="https://js.statickit.com/statickit.js"></script>
|
||||
@@ -0,0 +1,5 @@
|
||||
<h2>Improve this documentation</h2>
|
||||
|
||||
<p>Have an idea on how to make this documentation even better? Use our <a
|
||||
href="https://projects.torsion.org/borgmatic-collective/borgmatic/issues">issue tracker</a> to send your
|
||||
feedback!</p>
|
||||
@@ -1,63 +1,111 @@
|
||||
.elv-toc {
|
||||
font-size: 1rem; /* Reset */
|
||||
}
|
||||
.elv-toc details {
|
||||
--details-force-closed: (max-width: 63.9375em); /* 1023px */
|
||||
}
|
||||
.elv-toc details > summary {
|
||||
font-size: 1.375rem; /* 22px /16 */
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
@media (min-width: 64em) { /* 1024px */
|
||||
.elv-toc {
|
||||
position: absolute;
|
||||
left: -17rem;
|
||||
left: 3rem;
|
||||
width: 16rem;
|
||||
z-index: 1;
|
||||
}
|
||||
.elv-toc details > summary {
|
||||
margin-top: 0;
|
||||
}
|
||||
.js .elv-toc details > summary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.elv-toc-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
margin: 0 0 2.5em;
|
||||
list-style: none;
|
||||
}
|
||||
.elv-toc-list li {
|
||||
font-size: 0.9375em; /* 15px /16 */
|
||||
line-height: 1.466666666667; /* 22px /15 */
|
||||
}
|
||||
/* Nested lists */
|
||||
.elv-toc-list ul {
|
||||
padding: 0;
|
||||
display: none;
|
||||
margin-bottom: 1.5em;
|
||||
padding: 0 0 .75em 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.elv-toc-list ul li {
|
||||
padding-left: 0.875em; /* 14px /16 */
|
||||
|
||||
/* Menus nested 2 or more deep */
|
||||
.elv-toc-list ul ul {
|
||||
padding-bottom: 0;
|
||||
padding-left: 0.625rem; /* 10px /16 */
|
||||
}
|
||||
@media (min-width: 64em) and (min-height: 48em) { /* 1024 x 768px */
|
||||
.elv-toc-list ul {
|
||||
display: block;
|
||||
}
|
||||
/* Hide inactive menus 3 or more deep */
|
||||
.elv-toc-list ul ul > li:not(.elv-toc-active) > ul > li:not(.elv-toc-active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* List items */
|
||||
.elv-toc summary,
|
||||
.elv-toc-list a {
|
||||
padding: .15em .25em;
|
||||
}
|
||||
.elv-toc-list a {
|
||||
display: block;
|
||||
}
|
||||
.elv-toc-list a:not(:hover) {
|
||||
text-decoration: none;
|
||||
}
|
||||
.elv-toc-list li {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin: .1em 0 .5em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.elv-toc-list > li {
|
||||
flex-grow: 1;
|
||||
flex-basis: 14.375rem; /* 230px /16 */
|
||||
}
|
||||
/* Top level links */
|
||||
.elv-toc-list > li > a {
|
||||
font-weight: 400;
|
||||
font-size: 1.0625em; /* 17px /16 */
|
||||
color: #222;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #ddd;
|
||||
margin-bottom: 0.25em; /* 4px /16 */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.elv-toc-list > li > a {
|
||||
color: #fff;
|
||||
border-color: #444;
|
||||
}
|
||||
}
|
||||
|
||||
/* Active links */
|
||||
.elv-toc-list li.elv-toc-active > a {
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
background-color: #dff7ff;
|
||||
}
|
||||
.elv-toc-active > a:after {
|
||||
content: " ⬅";
|
||||
line-height: .5;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.elv-toc-list li.elv-toc-active > a {
|
||||
background-color: #353535;
|
||||
}
|
||||
}
|
||||
.elv-toc-list ul .elv-toc-active > a:after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Show only active nested lists */
|
||||
.elv-toc-list ul.elv-toc-active,
|
||||
.elv-toc-list li.elv-toc-active > ul {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer catgory navigation */
|
||||
.elv-cat-list-active {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
+80
-34
@@ -181,7 +181,7 @@ pre {
|
||||
padding: .5em;
|
||||
margin: 1em -.5em 2em -.5em;
|
||||
overflow-x: auto;
|
||||
background-color: #eee;
|
||||
background-color: #fafafa;
|
||||
font-size: 0.75em; /* 12px /16 */
|
||||
}
|
||||
pre,
|
||||
@@ -194,7 +194,7 @@ code {
|
||||
-webkit-hyphens: manual;
|
||||
-moz-hyphens: manual;
|
||||
hyphens: manual;
|
||||
background-color: #efefef;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
pre + pre[class*="language-"] {
|
||||
margin-top: 1em;
|
||||
@@ -234,6 +234,9 @@ pre + .note {
|
||||
max-width: 42rem;
|
||||
clear: both;
|
||||
}
|
||||
header.elv-layout {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
footer.elv-layout {
|
||||
margin-bottom: 5em;
|
||||
}
|
||||
@@ -242,7 +245,7 @@ footer.elv-layout {
|
||||
}
|
||||
@media (min-width: 64em) { /* 1024px */
|
||||
.elv-layout-toc {
|
||||
margin-left: 18rem;
|
||||
padding-left: 15rem;
|
||||
max-width: 60rem;
|
||||
margin-right: 1rem;
|
||||
position: relative;
|
||||
@@ -254,14 +257,21 @@ footer.elv-layout {
|
||||
|
||||
/* Header */
|
||||
.elv-header {
|
||||
color: #222;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
.elv-header-default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 0;
|
||||
}
|
||||
.elv-header-c {
|
||||
width: 100%;
|
||||
}
|
||||
.elv-header-docs .elv-header-c {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.elv-header-docs:before,
|
||||
.elv-header-docs:after {
|
||||
@@ -272,53 +282,89 @@ footer.elv-layout {
|
||||
clear: both;
|
||||
}
|
||||
/* Header Hero */
|
||||
.elv-hero img {
|
||||
max-width: 80vw;
|
||||
max-height: 60vh;
|
||||
.elv-hero {
|
||||
background-color: #222;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.elv-hero {
|
||||
background-color: #292929;
|
||||
}
|
||||
}
|
||||
.elv-hero img,
|
||||
.elv-hero svg {
|
||||
width: 42.95774646vh;
|
||||
height: 60vh;
|
||||
}
|
||||
.elv-hero:hover img,
|
||||
.elv-hero:hover svg {
|
||||
background-color: inherit;
|
||||
}
|
||||
.elv-header-default .elv-hero {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: calc(100% + 2rem);
|
||||
margin-left: -1rem;
|
||||
margin-right: -1rem;
|
||||
}
|
||||
.elv-hero:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.elv-header-docs .elv-hero {
|
||||
float: left;
|
||||
margin-right: 1.5em;
|
||||
margin-right: .5em;
|
||||
}
|
||||
.elv-header-docs .elv-hero img {
|
||||
.elv-header-default .elv-hero img,
|
||||
.elv-header-default .elv-hero svg {
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
z-index: 1;
|
||||
}
|
||||
.elv-header-docs .elv-hero img,
|
||||
.elv-header-docs .elv-hero svg {
|
||||
width: auto;
|
||||
height: 3em;
|
||||
}
|
||||
@media (min-width: 37.5em) { /* 600px */
|
||||
.elv-header-docs .elv-hero img {
|
||||
@media (min-width: 43.75em) { /* 700px */
|
||||
.elv-header-docs .elv-hero {
|
||||
margin-right: 1em;
|
||||
}
|
||||
.elv-header-docs .elv-hero img,
|
||||
.elv-header-docs .elv-hero svg {
|
||||
width: 4.303125em; /* 68.85px /16 */
|
||||
height: 6em;
|
||||
}
|
||||
}
|
||||
/* Header Possum */
|
||||
.elv-possum-anchor {
|
||||
display: block;
|
||||
}
|
||||
.elv-possum {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 1em;
|
||||
top: 1em;
|
||||
width: 16vmin;
|
||||
right: .5rem;
|
||||
top: 1rem;
|
||||
transition: .3s opacity ease-out;
|
||||
}
|
||||
@media (min-width: 31.25em) { /* 500px */
|
||||
.elv-possum {
|
||||
display: block;
|
||||
}
|
||||
.elv-header-docs .elv-possum {
|
||||
width: 15vw;
|
||||
max-width: 6.25rem; /* 100px /16 */
|
||||
}
|
||||
|
||||
/* Header Heading */
|
||||
.elv-hed {
|
||||
font-size: 3em;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: .25em;
|
||||
text-align: center;
|
||||
text-transform: none;
|
||||
.elv-header-default {
|
||||
overflow: hidden;
|
||||
}
|
||||
.elv-header-docs .elv-hed {
|
||||
font-size: 2.3em;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
.elv-header-default .elv-possum {
|
||||
pointer-events: none;
|
||||
width: auto;
|
||||
height: calc((60vh - 2rem) / 1.6);
|
||||
top: 36%;
|
||||
left: 1vw;
|
||||
right: auto;
|
||||
animation-duration: 180s;
|
||||
animation-name: balloonFloat;
|
||||
}
|
||||
@media (min-width: 37.5em) { /* 600px */
|
||||
.elv-header-docs .elv-hed {
|
||||
font-size: 3em;
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.elv-header-default .elv-possum {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
{% include 'components/minilink.css' %}
|
||||
{% include 'components/toc.css' %}
|
||||
{% include 'components/info-blocks.css' %}
|
||||
{% include 'components/suggestion-form.css' %}
|
||||
{% include 'prism-theme.css' %}
|
||||
{% include 'asciinema.css' %}
|
||||
{% endset %}
|
||||
|
||||
@@ -6,9 +6,27 @@ headerClass: elv-header-default
|
||||
{% include "header.njk" %}
|
||||
|
||||
<main class="elv-layout{% if layoutClass %} {{ layoutClass }}{% endif %}">
|
||||
<article>
|
||||
{{ content | safe }}
|
||||
<div id="documentation" class="elv-toc">
|
||||
<div>
|
||||
{% set navPages = collections.all | eleventyNavigation %}
|
||||
{% macro renderNavListItem(entry) -%}
|
||||
<li{% if entry.url == page.url %} class="elv-toc-active"{% endif %}>
|
||||
<a {% if entry.url %}href="https://torsion.org/borgmatic/docs{{ entry.url | url }}"{% endif %}>{{ entry.title }}</a>
|
||||
{%- if entry.children.length -%}
|
||||
<ul>
|
||||
{%- for child in entry.children %}{{ renderNavListItem(child) }}{% endfor -%}
|
||||
</ul>
|
||||
{%- endif -%}
|
||||
</li>
|
||||
{%- endmacro %}
|
||||
|
||||
{% include 'components/suggestion-form.html' %}
|
||||
</article>
|
||||
<ul class="elv-toc-list">
|
||||
{%- for entry in navPages %}{{ renderNavListItem(entry) }}{%- endfor -%}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ content | safe }}
|
||||
|
||||
{% include 'components/suggestion-link.html' %}
|
||||
</main>
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
* Based on dabblet (http://dabblet.com)
|
||||
* @author Lea Verou
|
||||
*/
|
||||
/*
|
||||
* Modified with an approximation of the One Light syntax highlighting theme.
|
||||
*/
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
color: #ABB2BF;
|
||||
color: #494b53;
|
||||
background: none;
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
text-align: left;
|
||||
@@ -26,13 +29,15 @@ pre[class*="language-"] {
|
||||
pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
|
||||
code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
|
||||
text-shadow: none;
|
||||
background: #383e49;
|
||||
color: #232324;
|
||||
background: #dbdbdc;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
|
||||
code[class*="language-"]::selection, code[class*="language-"] ::selection {
|
||||
text-shadow: none;
|
||||
background: #9aa2b1;
|
||||
color: #232324;
|
||||
background: #dbdbdc;
|
||||
}
|
||||
|
||||
@media print {
|
||||
@@ -50,7 +55,7 @@ pre[class*="language-"] {
|
||||
|
||||
:not(pre) > code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
background: #282c34;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
@@ -64,16 +69,16 @@ pre[class*="language-"] {
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #5C6370;
|
||||
color: #505157;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #abb2bf;
|
||||
color: #526fff;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.tag {
|
||||
color: #e06c75;
|
||||
color: none;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
@@ -83,7 +88,7 @@ pre[class*="language-"] {
|
||||
.token.symbol,
|
||||
.token.attr-name,
|
||||
.token.deleted {
|
||||
color: #d19a66;
|
||||
color: #986801;
|
||||
}
|
||||
|
||||
.token.string,
|
||||
@@ -91,7 +96,7 @@ pre[class*="language-"] {
|
||||
.token.attr-value,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #98c379;
|
||||
color: #50a14f;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
@@ -99,22 +104,22 @@ pre[class*="language-"] {
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #56b6c2;
|
||||
color: #526fff;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.keyword {
|
||||
color: #e06c75;
|
||||
color: #e45649;
|
||||
}
|
||||
|
||||
.token.function {
|
||||
color: #61afef;
|
||||
color: #4078f2;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #c678dd;
|
||||
color: #e45649;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
---
|
||||
title: How to add preparation and cleanup steps to backups
|
||||
eleventyNavigation:
|
||||
key: 🧹 Add preparation and cleanup steps
|
||||
parent: How-to guides
|
||||
order: 8
|
||||
---
|
||||
## Preparation and cleanup hooks
|
||||
|
||||
If you find yourself performing prepraration tasks before your backup runs, or
|
||||
If you find yourself performing preparation tasks before your backup runs, or
|
||||
cleanup work afterwards, borgmatic hooks may be of interest. Hooks are shell
|
||||
commands that borgmatic executes for you at various points, and they're
|
||||
configured in the `hooks` section of your configuration file. But if you're
|
||||
looking to backup a database, it's probably easier to use the [database backup
|
||||
commands that borgmatic executes for you at various points as it runs, and
|
||||
they're configured in the `hooks` section of your configuration file. But if
|
||||
you're looking to backup a database, it's probably easier to use the [database
|
||||
backup
|
||||
feature](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/)
|
||||
instead.
|
||||
|
||||
@@ -23,15 +28,40 @@ hooks:
|
||||
- umount /some/filesystem
|
||||
```
|
||||
|
||||
The `before_backup` and `after_backup` hooks each run once per configuration
|
||||
file. `before_backup` hooks run prior to backups of all repositories in a
|
||||
configuration file, right before the `create` action. `after_backup` hooks run
|
||||
afterwards, but not if an error occurs in a previous hook or in the backups
|
||||
themselves.
|
||||
The `before_backup` and `after_backup` hooks each run once per repository in a
|
||||
configuration file. `before_backup` hooks runs right before the `create`
|
||||
action for a particular repository, and `after_backup` hooks run afterwards,
|
||||
but not if an error occurs in a previous hook or in the backups themselves.
|
||||
(Prior to borgmatic 1.6.0, these hooks instead ran once per configuration file
|
||||
rather than once per repository.)
|
||||
|
||||
There are additional hooks for the `prune` and `check` actions as well.
|
||||
`before_prune` and `after_prune` run if there are any `prune` actions, while
|
||||
`before_check` and `after_check` run if there are any `check` actions.
|
||||
There are additional hooks that run before/after other actions as well. For
|
||||
instance, `before_prune` runs before a `prune` action for a repository, while
|
||||
`after_prune` runs after it.
|
||||
|
||||
## Variable interpolation
|
||||
|
||||
The before and after action hooks support interpolating particular runtime
|
||||
variables into the hook command. Here's an example that assumes you provide a
|
||||
separate shell script:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
after_prune:
|
||||
- record-prune.sh "{configuration_filename}" "{repository}"
|
||||
```
|
||||
|
||||
In this example, when the hook is triggered, borgmatic interpolates runtime
|
||||
values into the hook command: the borgmatic configuration filename and the
|
||||
paths of the current Borg repository. Here's the full set of supported
|
||||
variables you can use here:
|
||||
|
||||
* `configuration_filename`: borgmatic configuration filename in which the
|
||||
hook was defined
|
||||
* `repository`: path of the current repository as configured in the current
|
||||
borgmatic configuration file
|
||||
|
||||
## Global hooks
|
||||
|
||||
You can also use `before_everything` and `after_everything` hooks to perform
|
||||
global setup or cleanup:
|
||||
@@ -54,6 +84,8 @@ but only if there is a `create` action. It runs even if an error occurs during
|
||||
a backup or a backup hook, but not if an error occurs during a
|
||||
`before_everything` hook.
|
||||
|
||||
## Error hooks
|
||||
|
||||
borgmatic also runs `on_error` hooks if an error occurs, either when creating
|
||||
a backup or running a backup hook. See the [monitoring and alerting
|
||||
documentation](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
|
||||
@@ -73,11 +105,3 @@ with the user permissions of borgmatic itself. So to prevent potential shell
|
||||
injection or privilege escalation, do not forget to set secure permissions
|
||||
on borgmatic configuration files (`chmod 0600`) and scripts (`chmod 0700`)
|
||||
invoked by hooks.
|
||||
|
||||
|
||||
## Related documentation
|
||||
|
||||
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
|
||||
* [Backup your databases](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/)
|
||||
* [Inspect your backups](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/)
|
||||
* [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: How to backup to a removable drive or an intermittent server
|
||||
eleventyNavigation:
|
||||
key: 💾 Backup to a removable drive/server
|
||||
parent: How-to guides
|
||||
order: 9
|
||||
---
|
||||
## Occasional backups
|
||||
|
||||
@@ -12,9 +16,14 @@ But if you run borgmatic and your hard drive isn't plugged in, or your buddy's
|
||||
server is offline, then you'll get an annoying error message and the overall
|
||||
borgmatic run will fail (even if individual repositories still complete).
|
||||
|
||||
Another variant is when the source machine is only sometimes available for
|
||||
backups, e.g. a laptop where you want to skip backups when the battery falls
|
||||
below a certain level.
|
||||
|
||||
So what if you want borgmatic to swallow the error of a missing drive
|
||||
or an offline server, and continue trucking along? That's where the concept of
|
||||
"soft failure" come in.
|
||||
or an offline server or a low battery—and exit gracefully? That's where the
|
||||
concept of "soft failure" come in.
|
||||
|
||||
|
||||
## Soft failure command hooks
|
||||
|
||||
@@ -74,6 +83,17 @@ hooks:
|
||||
- ping -q -c 1 buddys-server.org > /dev/null || exit 75
|
||||
```
|
||||
|
||||
Or to only run backups if the battery level is high enough:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
before_backup:
|
||||
- is_battery_percent_at_least.sh 25
|
||||
```
|
||||
|
||||
(Writing the battery script is left as an exercise to the reader.)
|
||||
|
||||
|
||||
## Caveats and details
|
||||
|
||||
There are some caveats you should be aware of with this feature.
|
||||
@@ -95,13 +115,6 @@ There are some caveats you should be aware of with this feature.
|
||||
* The soft failure doesn't have to apply to a repository. You can even perform
|
||||
a test to make sure that individual source directories are mounted and
|
||||
available. Use your imagination!
|
||||
* The soft failure feature also works for `before_prune`, `after_prune`,
|
||||
`before_check`, and `after_check` hooks. But it is not implemented for
|
||||
`before_everything` or `after_everything`.
|
||||
|
||||
## Related documentation
|
||||
|
||||
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
|
||||
* [Make per-application backups](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/)
|
||||
* [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/)
|
||||
* [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
|
||||
* The soft failure feature also works for before/after hooks for other
|
||||
actions as well. But it is not implemented for `before_everything` or
|
||||
`after_everything`.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: How to backup your databases
|
||||
eleventyNavigation:
|
||||
key: 🗄️ Backup your databases
|
||||
parent: How-to guides
|
||||
order: 7
|
||||
---
|
||||
## Database dump hooks
|
||||
|
||||
@@ -11,7 +15,8 @@ consistent snapshot that is more suited for backups.
|
||||
|
||||
Fortunately, borgmatic includes built-in support for creating database dumps
|
||||
prior to running backups. For example, here is everything you need to dump and
|
||||
backup a couple of local PostgreSQL databases and a MySQL/MariaDB database:
|
||||
backup a couple of local PostgreSQL databases, a MySQL/MariaDB database, and a
|
||||
MongoDB database:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
@@ -20,12 +25,16 @@ hooks:
|
||||
- name: orders
|
||||
mysql_databases:
|
||||
- name: posts
|
||||
mongodb_databases:
|
||||
- name: messages
|
||||
```
|
||||
|
||||
As part of each backup, borgmatic streams a database dump for each configured
|
||||
database directly to Borg, so it's included in the backup without consuming
|
||||
additional disk space. (The one exception is PostgreSQL's "directory" dump
|
||||
format, which can't stream and therefore does consume temporary disk space.)
|
||||
additional disk space. (The exceptions are the PostgreSQL/MongoDB "directory"
|
||||
dump formats, which can't stream and therefore do consume temporary disk
|
||||
space. Additionally, prior to borgmatic 1.5.3, all database dumps consumed
|
||||
temporary disk space.)
|
||||
|
||||
To support this, borgmatic creates temporary named pipes in `~/.borgmatic` by
|
||||
default. To customize this path, set the `borgmatic_source_directory` option
|
||||
@@ -34,6 +43,7 @@ in the `location` section of borgmatic's configuration.
|
||||
Also note that using a database hook implicitly enables both the
|
||||
`read_special` and `one_file_system` configuration settings (even if they're
|
||||
disabled in your configuration) to support this dump and restore streaming.
|
||||
See Limitations below for more on this.
|
||||
|
||||
Here's a more involved example that connects to remote databases:
|
||||
|
||||
@@ -54,6 +64,14 @@ hooks:
|
||||
username: root
|
||||
password: trustsome1
|
||||
options: "--skip-comments"
|
||||
mongodb_databases:
|
||||
- name: messages
|
||||
hostname: database3.example.org
|
||||
port: 27018
|
||||
username: dbuser
|
||||
password: trustsome1
|
||||
authentication_database: mongousers
|
||||
options: "--ssl"
|
||||
```
|
||||
|
||||
If you want to dump all databases on a host, use `all` for the database name:
|
||||
@@ -64,11 +82,24 @@ hooks:
|
||||
- name: all
|
||||
mysql_databases:
|
||||
- name: all
|
||||
mongodb_databases:
|
||||
- name: all
|
||||
```
|
||||
|
||||
Note that you may need to use a `username` of the `postgres` superuser for
|
||||
this to work with PostgreSQL.
|
||||
|
||||
If you would like to backup databases only and not source directories, you can
|
||||
specify an empty `source_directories` value (as it is a mandatory field):
|
||||
|
||||
```yaml
|
||||
location:
|
||||
source_directories: []
|
||||
hooks:
|
||||
mysql_databases:
|
||||
- name: all
|
||||
```
|
||||
|
||||
|
||||
### Configuration backups
|
||||
|
||||
@@ -81,7 +112,7 @@ bring back any missing configuration files in order to restore a database.
|
||||
|
||||
## Supported databases
|
||||
|
||||
As of now, borgmatic supports PostgreSQL and MySQL/MariaDB databases
|
||||
As of now, borgmatic supports PostgreSQL, MySQL/MariaDB, and MongoDB databases
|
||||
directly. But see below about general-purpose preparation and cleanup hooks as
|
||||
a work-around with other database systems. Also, please [file a
|
||||
ticket](https://torsion.org/borgmatic/#issues) for additional database systems
|
||||
@@ -167,15 +198,28 @@ borgmatic's own configuration file. So include your configuration file in
|
||||
backups to avoid getting caught without a way to restore a database.
|
||||
3. borgmatic does not currently support backing up or restoring multiple
|
||||
databases that share the exact same name on different hosts.
|
||||
4. Because database hooks implicitly enable the `read_special` configuration
|
||||
setting to support dump and restore streaming, you'll need to ensure that any
|
||||
special files are excluded from backups (named pipes, block devices,
|
||||
character devices, and sockets) to prevent hanging. Try a command like
|
||||
`find /your/source/path -type c,b,p,s` to find such files. Common directories
|
||||
to exclude are `/dev` and `/run`, but that may not be exhaustive.
|
||||
|
||||
|
||||
### Manual restoration
|
||||
|
||||
If you prefer to restore a database without the help of borgmatic, first
|
||||
[extract](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) an
|
||||
archive containing a database dump, and then manually restore the dump file
|
||||
found within the extracted `~/.borgmatic/` path (e.g. with `pg_restore` or
|
||||
`mysql` commands).
|
||||
archive containing a database dump.
|
||||
|
||||
borgmatic extracts the dump file into the *`username`*`/.borgmatic/` directory
|
||||
within the extraction destination path, where *`username`* is the user that
|
||||
created the backup. For example, if you created the backup with the `root`
|
||||
user and you're extracting to `/tmp`, then the dump will be in
|
||||
`/tmp/root/.borgmatic`.
|
||||
|
||||
After extraction, you can manually restore the dump file using native database
|
||||
commands like `pg_restore`, `mysql`, `mongorestore` or similar.
|
||||
|
||||
|
||||
## Preparation and cleanup hooks
|
||||
@@ -205,10 +249,13 @@ hooks:
|
||||
options: "--single-transaction --quick"
|
||||
```
|
||||
|
||||
### borgmatic hangs during backup
|
||||
|
||||
## Related documentation
|
||||
See Limitations above about `read_special`. You may need to exclude certain
|
||||
paths with named pipes, block devices, character devices, or sockets on which
|
||||
borgmatic is hanging.
|
||||
|
||||
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
|
||||
* [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/)
|
||||
* [Inspect your backups](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/)
|
||||
* [Extract a backup](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/)
|
||||
Alternatively, if excluding special files is too onerous, you can create two
|
||||
separate borgmatic configuration files—one for your source files and a
|
||||
separate one for backing up databases. That way, the database `read_special`
|
||||
option will not be active when backing up special files.
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
---
|
||||
title: How to deal with very large backups
|
||||
eleventyNavigation:
|
||||
key: 📏 Deal with very large backups
|
||||
parent: How-to guides
|
||||
order: 3
|
||||
---
|
||||
## Biggish data
|
||||
|
||||
Borg itself is great for efficiently de-duplicating data across successive
|
||||
backup archives, even when dealing with very large repositories. But you may
|
||||
find that while borgmatic's default mode of "prune, create, and check" works
|
||||
well on small repositories, it's not so great on larger ones. That's because
|
||||
running the default pruning and consistency checks take a long time on large
|
||||
repositories.
|
||||
find that while borgmatic's default mode of `prune`, `compact`, `create`, and
|
||||
`check` works well on small repositories, it's not so great on larger ones.
|
||||
That's because running the default pruning, compact, and consistency checks
|
||||
take a long time on large repositories.
|
||||
|
||||
### A la carte actions
|
||||
|
||||
If you find yourself in this situation, you have some options. First, you can
|
||||
run borgmatic's pruning, creating, or checking actions separately. For
|
||||
instance, the the following optional actions are available:
|
||||
run borgmatic's `prune`, `compact`, `create`, or `check` actions separately.
|
||||
For instance, the following optional actions are available:
|
||||
|
||||
```bash
|
||||
borgmatic prune
|
||||
borgmatic compact
|
||||
borgmatic create
|
||||
borgmatic check
|
||||
```
|
||||
@@ -28,7 +33,7 @@ borgmatic check
|
||||
You can run with only one of these actions provided, or you can mix and match
|
||||
any number of them in a single borgmatic run. This supports approaches like
|
||||
skipping certain actions while running others. For instance, this skips
|
||||
`prune` and only runs `create` and `check`:
|
||||
`prune` and `compact` and only runs `create` and `check`:
|
||||
|
||||
```bash
|
||||
borgmatic create check
|
||||
@@ -44,7 +49,7 @@ consistency checks with `check` on a much less frequent basis (e.g. with
|
||||
|
||||
Another option is to customize your consistency checks. The default
|
||||
consistency checks run both full-repository checks and per-archive checks
|
||||
within each repository.
|
||||
within each repository no more than once a month.
|
||||
|
||||
But if you find that archive checks are too slow, for example, you can
|
||||
configure borgmatic to run repository checks only. Configure this in the
|
||||
@@ -53,9 +58,11 @@ configure borgmatic to run repository checks only. Configure this in the
|
||||
```yaml
|
||||
consistency:
|
||||
checks:
|
||||
- repository
|
||||
- name: repository
|
||||
```
|
||||
|
||||
(Prior to borgmatic 1.6.2, `checks` was a plain list of strings without the `name:` part.)
|
||||
|
||||
Here are the available checks from fastest to slowest:
|
||||
|
||||
* `repository`: Checks the consistency of the repository itself.
|
||||
@@ -65,6 +72,36 @@ Here are the available checks from fastest to slowest:
|
||||
|
||||
See [Borg's check documentation](https://borgbackup.readthedocs.io/en/stable/usage/check.html) for more information.
|
||||
|
||||
### Check frequency
|
||||
|
||||
As of borgmatic 1.6.2, you can optionally configure checks to run on a
|
||||
periodic basis rather than every time borgmatic runs checks. For instance:
|
||||
|
||||
```yaml
|
||||
consistency:
|
||||
checks:
|
||||
- name: repository
|
||||
frequency: 2 weeks
|
||||
```
|
||||
|
||||
This tells borgmatic to run this consistency check at most once every two
|
||||
weeks for a given repository. The `frequency` value is a number followed by a
|
||||
unit of time, e.g. "3 days", "1 week", "2 months", etc. The `frequency`
|
||||
defaults to "always", which means run this check every time checks run.
|
||||
|
||||
Unlike a real scheduler like cron, borgmatic only makes a best effort to run
|
||||
checks on the configured frequency. It compares that frequency with how long
|
||||
it's been since the last check for a given repository (as recorded in a file
|
||||
within `~/.borgmatic/checks`). If it hasn't been long enough, the check is
|
||||
skipped. And you still have to run `borgmatic check` (or just `borgmatic`) in
|
||||
order for checks to run, even when a `frequency` is configured!
|
||||
|
||||
If you want to temporarily ignore your configured frequencies, you can invoke
|
||||
`borgmatic check --force` to run checks unconditionally.
|
||||
|
||||
|
||||
### Disabling checks
|
||||
|
||||
If that's still too slow, you can disable consistency checks entirely,
|
||||
either for a single repository or for all repositories.
|
||||
|
||||
@@ -73,7 +110,7 @@ Disabling all consistency checks looks like this:
|
||||
```yaml
|
||||
consistency:
|
||||
checks:
|
||||
- disabled
|
||||
- name: disabled
|
||||
```
|
||||
|
||||
Or, if you have multiple repositories in your borgmatic configuration file,
|
||||
@@ -94,7 +131,8 @@ borgmatic check --only data --only extract
|
||||
```
|
||||
|
||||
This is useful for running slow consistency checks on an infrequent basis,
|
||||
separate from your regular checks.
|
||||
separate from your regular checks. It is still subject to any configured
|
||||
check frequencies unless the `--force` flag is used.
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
@@ -120,8 +158,3 @@ the following to the `~/.ssh/config` file on the client:
|
||||
|
||||
This should make the client keep the connection alive while validating
|
||||
backups.
|
||||
|
||||
|
||||
## Related documentation
|
||||
|
||||
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
---
|
||||
title: How to develop on borgmatic
|
||||
eleventyNavigation:
|
||||
key: 🏗️ Develop on borgmatic
|
||||
parent: How-to guides
|
||||
order: 12
|
||||
---
|
||||
## Source code
|
||||
|
||||
To get set up to hack on borgmatic, first clone master via HTTPS or SSH:
|
||||
|
||||
```bash
|
||||
git clone https://projects.torsion.org/witten/borgmatic.git
|
||||
git clone https://projects.torsion.org/borgmatic-collective/borgmatic.git
|
||||
```
|
||||
|
||||
Or:
|
||||
|
||||
```bash
|
||||
git clone ssh://git@projects.torsion.org:3022/witten/borgmatic.git
|
||||
git clone ssh://git@projects.torsion.org:3022/borgmatic-collective/borgmatic.git
|
||||
```
|
||||
|
||||
Then, install borgmatic
|
||||
"[editable](https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs)"
|
||||
"[editable](https://pip.pypa.io/en/stable/cli/pip_install/#editable-installs)"
|
||||
so that you can run borgmatic commands while you're hacking on them to
|
||||
make sure your changes work.
|
||||
|
||||
@@ -62,8 +66,6 @@ following:
|
||||
tox -e black
|
||||
```
|
||||
|
||||
Note that Black requires at minimum Python 3.6.
|
||||
|
||||
And if you get a complaint from the
|
||||
[isort](https://github.com/timothycrosley/isort) Python import orderer, you
|
||||
can ask isort to order your imports for you:
|
||||
@@ -114,7 +116,7 @@ See the Black, Flake8, and isort documentation for more information.
|
||||
|
||||
Each pull request triggers a continuous integration build which runs the test
|
||||
suite. You can view these builds on
|
||||
[build.torsion.org](https://build.torsion.org/witten/borgmatic), and they're
|
||||
[build.torsion.org](https://build.torsion.org/borgmatic-collective/borgmatic), and they're
|
||||
also linked from the commits list on each pull request.
|
||||
|
||||
## Documentation development
|
||||
@@ -139,7 +141,3 @@ http://localhost:8080 to view the documentation with your changes.
|
||||
To close the documentation server, ctrl-C the script. Note that it does not
|
||||
currently auto-reload, so you'll need to stop it and re-run it for any
|
||||
additional documentation changes to take effect.
|
||||
|
||||
## Related documentation
|
||||
|
||||
* [Inspect your backups](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: How to extract a backup
|
||||
eleventyNavigation:
|
||||
key: 📤 Extract a backup
|
||||
parent: How-to guides
|
||||
order: 6
|
||||
---
|
||||
## Extract
|
||||
|
||||
@@ -132,11 +136,3 @@ When you're all done exploring your files, unmount your mount point. No
|
||||
```bash
|
||||
borgmatic umount --mount-point /mnt
|
||||
```
|
||||
|
||||
|
||||
## Related documentation
|
||||
|
||||
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
|
||||
* [Inspect your backups](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/)
|
||||
* [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
|
||||
* [Backup your databases](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
eleventyNavigation:
|
||||
key: How-to guides
|
||||
permalink: false
|
||||
---
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: How to inspect your backups
|
||||
eleventyNavigation:
|
||||
key: 🔎 Inspect your backups
|
||||
parent: How-to guides
|
||||
order: 4
|
||||
---
|
||||
## Backup progress
|
||||
|
||||
@@ -98,11 +102,3 @@ Note that if you use the `--log-file` flag, you are responsible for rotating
|
||||
the log file so it doesn't grow too large, for example with
|
||||
[logrotate](https://wiki.archlinux.org/index.php/Logrotate). Also, there is a
|
||||
`--log-file-verbosity` flag to customize the log file's log level.
|
||||
|
||||
|
||||
## Related documentation
|
||||
|
||||
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
|
||||
* [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
|
||||
* [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/)
|
||||
* [Develop on borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
title: How to make backups redundant
|
||||
eleventyNavigation:
|
||||
key: ☁️ Make backups redundant
|
||||
parent: How-to guides
|
||||
order: 2
|
||||
---
|
||||
## Multiple repositories
|
||||
|
||||
If you really care about your data, you probably want more than one backup of
|
||||
it. borgmatic supports this in its configuration by specifying multiple backup
|
||||
repositories. Here's an example:
|
||||
|
||||
```yaml
|
||||
location:
|
||||
# List of source directories to backup.
|
||||
source_directories:
|
||||
- /home
|
||||
- /etc
|
||||
|
||||
# Paths of local or remote repositories to backup to.
|
||||
repositories:
|
||||
- 1234@usw-s001.rsync.net:backups.borg
|
||||
- k8pDxu32@k8pDxu32.repo.borgbase.com:repo
|
||||
- /var/lib/backups/local.borg
|
||||
```
|
||||
|
||||
When you run borgmatic with this configuration, it invokes Borg once for each
|
||||
configured repository in sequence. (So, not in parallel.) That means—in each
|
||||
repository—borgmatic creates a single new backup archive containing all of
|
||||
your source directories.
|
||||
|
||||
Here's a way of visualizing what borgmatic does with the above configuration:
|
||||
|
||||
1. Backup `/home` and `/etc` to `1234@usw-s001.rsync.net:backups.borg`
|
||||
2. Backup `/home` and `/etc` to `k8pDxu32@k8pDxu32.repo.borgbase.com:repo`
|
||||
3. Backup `/home` and `/etc` to `/var/lib/backups/local.borg`
|
||||
|
||||
This gives you redundancy of your data across repositories and even
|
||||
potentially across providers.
|
||||
|
||||
See [Borg repository URLs
|
||||
documentation](https://borgbackup.readthedocs.io/en/stable/usage/general.html#repository-urls)
|
||||
for more information on how to specify local and remote repository paths.
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: How to make per-application backups
|
||||
eleventyNavigation:
|
||||
key: 🔀 Make per-application backups
|
||||
parent: How-to guides
|
||||
order: 1
|
||||
---
|
||||
## Multiple backup configurations
|
||||
|
||||
@@ -28,10 +32,16 @@ perform any merging of configuration files by default. If you'd like borgmatic
|
||||
to merge your configuration files, see below about configuration includes.
|
||||
|
||||
Additionally, the `~/.config/borgmatic.d/` directory works the same way as
|
||||
`/etc/borgmatic.d`. If you need even more customizability, you can specify
|
||||
alternate configuration paths on the command-line with borgmatic's `--config`
|
||||
flag. See `borgmatic --help` for more information.
|
||||
`/etc/borgmatic.d`.
|
||||
|
||||
If you need even more customizability, you can specify alternate configuration
|
||||
paths on the command-line with borgmatic's `--config` flag. (See `borgmatic
|
||||
--help` for more information.) For instance, if you want to schedule your
|
||||
various borgmatic backups to run at different times, you'll need multiple
|
||||
entries in your [scheduling software of
|
||||
choice](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#autopilot),
|
||||
each entry using borgmatic's `--config` flag instead of relying on
|
||||
`/etc/borgmatic.d`.
|
||||
|
||||
## Configuration includes
|
||||
|
||||
@@ -65,6 +75,10 @@ themselves and complaining that they are not valid configuration files, you
|
||||
should put them in a directory other than `/etc/borgmatic.d/`. (A subdirectory
|
||||
is fine.)
|
||||
|
||||
When a configuration include is a relative path, borgmatic loads it from either
|
||||
the current working directory or from the directory containing the file doing
|
||||
the including.
|
||||
|
||||
Note that this form of include must be a YAML value rather than a key. For
|
||||
example, this will not work:
|
||||
|
||||
@@ -76,7 +90,7 @@ location:
|
||||
!include /etc/borgmatic/common_retention.yaml
|
||||
```
|
||||
|
||||
But if you do want to merge in a YAML key and its values, keep reading!
|
||||
But if you do want to merge in a YAML key *and* its values, keep reading!
|
||||
|
||||
|
||||
## Include merging
|
||||
@@ -85,35 +99,44 @@ If you need to get even fancier and pull in common configuration options while
|
||||
potentially overriding individual options, you can perform a YAML merge of
|
||||
included configuration using the YAML `<<` key. For instance, here's an
|
||||
example of a main configuration file that pulls in two retention options via
|
||||
an include, and then overrides one of them locally:
|
||||
an include and then overrides one of them locally:
|
||||
|
||||
```yaml
|
||||
<<: !include /etc/borgmatic/common.yaml
|
||||
|
||||
location:
|
||||
...
|
||||
|
||||
retention:
|
||||
keep_daily: 5
|
||||
<<: !include /etc/borgmatic/common_retention.yaml
|
||||
```
|
||||
|
||||
This is what `common_retention.yaml` might look like:
|
||||
This is what `common.yaml` might look like:
|
||||
|
||||
```yaml
|
||||
keep_hourly: 24
|
||||
keep_daily: 7
|
||||
retention:
|
||||
keep_hourly: 24
|
||||
keep_daily: 7
|
||||
```
|
||||
|
||||
Once this include gets merged in, the resulting configuration would have a
|
||||
`keep_hourly` value of `24` and an overridden `keep_daily` value of `5`.
|
||||
|
||||
When there is a collision of an option between the local file and the merged
|
||||
include, the local file's option takes precedent. And note that this is a
|
||||
shallow merge rather than a deep merge, so the merging does not descend into
|
||||
nested values.
|
||||
When there's an option collision between the local file and the merged
|
||||
include, the local file's option takes precedence. And as of borgmatic 1.6.0,
|
||||
this feature performs a deep merge, meaning that values are merged at all
|
||||
levels in the two configuration files. Colliding list values are appended
|
||||
together. This allows you to include common configuration—up to full borgmatic
|
||||
configuration files—while overriding only the parts you want to customize.
|
||||
|
||||
Note that this `<<` include merging syntax is only for merging in mappings
|
||||
(keys/values). If you'd like to include other types like scalars or lists
|
||||
directly, please see the section above about standard includes.
|
||||
(configuration options and their values). But if you'd like to include a
|
||||
single value directly, please see the section above about standard includes.
|
||||
|
||||
Additionally, there is a limitation preventing multiple `<<` include merges
|
||||
per section. So for instance, that means you can do one `<<` merge at the
|
||||
global level, another `<<` within each configuration section, etc. (This is a
|
||||
YAML limitation.)
|
||||
|
||||
|
||||
## Configuration overrides
|
||||
@@ -129,27 +152,54 @@ Whatever the reason, you can override borgmatic configuration options at the
|
||||
command-line via the `--override` flag. Here's an example:
|
||||
|
||||
```bash
|
||||
borgmatic create --override location.remote_path=borg1
|
||||
borgmatic create --override location.remote_path=/usr/local/bin/borg1
|
||||
```
|
||||
|
||||
What this does is load your configuration files, and for each one, disregard
|
||||
the configured value for the `remote_path` option in the `location` section,
|
||||
and use the value of `borg1` instead.
|
||||
and use the value of `/usr/local/bin/borg1` instead.
|
||||
|
||||
Note that the value is parsed as an actual YAML string, so you can even set
|
||||
You can even override multiple values at once. For instance:
|
||||
|
||||
```bash
|
||||
borgmatic create --override section.option1=value1 section.option2=value2
|
||||
```
|
||||
|
||||
This will accomplish the same thing:
|
||||
|
||||
```bash
|
||||
borgmatic create --override section.option1=value1 --override section.option2=value2
|
||||
```
|
||||
|
||||
Note that each value is parsed as an actual YAML string, so you can even set
|
||||
list values by using brackets. For instance:
|
||||
|
||||
```bash
|
||||
borgmatic create --override location.repositories=[test1.borg,test2.borg]
|
||||
```
|
||||
|
||||
Or even a single list element:
|
||||
|
||||
```bash
|
||||
borgmatic create --override location.repositories=[/root/test.borg]
|
||||
```
|
||||
|
||||
If your override value contains special YAML characters like colons, then
|
||||
you'll need quotes for it to parse correctly:
|
||||
|
||||
```bash
|
||||
borgmatic create --override location.repositories="['user@server:test.borg']"
|
||||
```
|
||||
|
||||
There is not currently a way to override a single element of a list without
|
||||
replacing the whole list.
|
||||
|
||||
Note that if you override an option of the list type (like
|
||||
`location.repositories`), you do need to use the `[ ]` list syntax. See the
|
||||
[configuration
|
||||
reference](https://torsion.org/borgmatic/docs/reference/configuration/) for
|
||||
which options are list types. (YAML list values look like `- this` with an
|
||||
indentation and a leading dash.)
|
||||
|
||||
Be sure to quote your overrides if they contain spaces or other characters
|
||||
that your shell may interpret.
|
||||
|
||||
|
||||
## Related documentation
|
||||
|
||||
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: How to monitor your backups
|
||||
eleventyNavigation:
|
||||
key: 🚨 Monitor your backups
|
||||
parent: How-to guides
|
||||
order: 5
|
||||
---
|
||||
|
||||
## Monitoring and alerting
|
||||
@@ -10,47 +14,68 @@ and alerting comes in.
|
||||
|
||||
There are several different ways you can monitor your backups and find out
|
||||
whether they're succeeding. Which of these you choose to do is up to you and
|
||||
your particular infrastructure:
|
||||
your particular infrastructure.
|
||||
|
||||
1. **Job runner alerts**: The easiest place to start is with failure alerts
|
||||
from the [scheduled job
|
||||
runner](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#autopilot) (cron,
|
||||
systemd, etc.) that's running borgmatic. But note that if the job doesn't even
|
||||
get scheduled (e.g. due to the job runner not running), you probably won't get
|
||||
an alert at all! Still, this is a decent first line of defense, especially
|
||||
when combined with some of the other approaches below.
|
||||
2. **borgmatic error hooks**: The `on_error` hook allows you to run an arbitrary
|
||||
command or script when borgmatic itself encounters an error running your
|
||||
backups. So for instance, you can run a script to send yourself a text message
|
||||
alert. But note that if borgmatic doesn't actually run, this alert won't fire.
|
||||
See [error
|
||||
### Job runner alerts
|
||||
|
||||
The easiest place to start is with failure alerts from the [scheduled job
|
||||
runner](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#autopilot)
|
||||
(cron, systemd, etc.) that's running borgmatic. But note that if the job
|
||||
doesn't even get scheduled (e.g. due to the job runner not running), you
|
||||
probably won't get an alert at all! Still, this is a decent first line of
|
||||
defense, especially when combined with some of the other approaches below.
|
||||
|
||||
### Commands run on error
|
||||
|
||||
The `on_error` hook allows you to run an arbitrary command or script when
|
||||
borgmatic itself encounters an error running your backups. So for instance,
|
||||
you can run a script to send yourself a text message alert. But note that if
|
||||
borgmatic doesn't actually run, this alert won't fire. See [error
|
||||
hooks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#error-hooks)
|
||||
below for how to configure this.
|
||||
4. **borgmatic monitoring hooks**: This feature integrates with monitoring
|
||||
services like [Healthchecks](https://healthchecks.io/),
|
||||
[Cronitor](https://cronitor.io), [Cronhub](https://cronhub.io), and
|
||||
[PagerDuty](https://www.pagerduty.com/) and pings these services whenever
|
||||
borgmatic runs. That way, you'll receive an alert when something goes wrong or
|
||||
(for certain hooks) the service doesn't hear from borgmatic for a configured
|
||||
interval. See [Healthchecks
|
||||
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook), [Cronitor
|
||||
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook), [Cronhub
|
||||
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook), and
|
||||
[PagerDuty hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook)
|
||||
|
||||
### Third-party monitoring services
|
||||
|
||||
borgmatic integrates with monitoring services like
|
||||
[Healthchecks](https://healthchecks.io/), [Cronitor](https://cronitor.io),
|
||||
[Cronhub](https://cronhub.io), and [PagerDuty](https://www.pagerduty.com/) and
|
||||
pings these services whenever borgmatic runs. That way, you'll receive an
|
||||
alert when something goes wrong or (for certain hooks) the service doesn't
|
||||
hear from borgmatic for a configured interval. See [Healthchecks
|
||||
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook),
|
||||
[Cronitor
|
||||
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook),
|
||||
[Cronhub
|
||||
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook),
|
||||
and [PagerDuty
|
||||
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook)
|
||||
below for how to configure this.
|
||||
3. **Third-party monitoring software**: You can use traditional monitoring
|
||||
software to consume borgmatic JSON output and track when the last
|
||||
successful backup occurred. See [scripting
|
||||
|
||||
While these services offer different features, you probably only need to use
|
||||
one of them at most.
|
||||
|
||||
### Third-party monitoring software
|
||||
|
||||
You can use traditional monitoring software to consume borgmatic JSON output
|
||||
and track when the last successful backup occurred. See [scripting
|
||||
borgmatic](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#scripting-borgmatic)
|
||||
and [related
|
||||
software](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#related-software)
|
||||
below for how to configure this.
|
||||
5. **Borg hosting providers**: Most [Borg hosting
|
||||
|
||||
### Borg hosting providers
|
||||
|
||||
Most [Borg hosting
|
||||
providers](https://torsion.org/borgmatic/#hosting-providers) include
|
||||
monitoring and alerting as part of their offering. This gives you a dashboard
|
||||
to check on all of your backups, and can alert you if the service doesn't hear
|
||||
from borgmatic for a configured interval.
|
||||
6. **borgmatic consistency checks**: While not strictly part of monitoring, if you
|
||||
really want confidence that your backups are not only running but are
|
||||
restorable as well, you can configure particular [consistency
|
||||
|
||||
### Consistency checks
|
||||
|
||||
While not strictly part of monitoring, if you really want confidence that your
|
||||
backups are not only running but are restorable as well, you can configure
|
||||
particular [consistency
|
||||
checks](https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#consistency-check-configuration)
|
||||
or even script full [extract
|
||||
tests](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/).
|
||||
@@ -58,10 +83,10 @@ tests](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/).
|
||||
|
||||
## Error hooks
|
||||
|
||||
When an error occurs during a `prune`, `create`, or `check` action, borgmatic
|
||||
can run configurable shell commands to fire off custom error notifications or
|
||||
take other actions, so you can get alerted as soon as something goes wrong.
|
||||
Here's a not-so-useful example:
|
||||
When an error occurs during a `prune`, `compact`, `create`, or `check` action,
|
||||
borgmatic can run configurable shell commands to fire off custom error
|
||||
notifications or take other actions, so you can get alerted as soon as
|
||||
something goes wrong. Here's a not-so-useful example:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
@@ -79,10 +104,9 @@ hooks:
|
||||
- send-text-message.sh "{configuration_filename}" "{repository}"
|
||||
```
|
||||
|
||||
In this example, when the error occurs, borgmatic interpolates a few runtime
|
||||
values into the hook command: the borgmatic configuration filename, and the
|
||||
path of the repository. Here's the full set of supported variables you can use
|
||||
here:
|
||||
In this example, when the error occurs, borgmatic interpolates runtime values
|
||||
into the hook command: the borgmatic configuration filename, and the path of
|
||||
the repository. Here's the full set of supported variables you can use here:
|
||||
|
||||
* `configuration_filename`: borgmatic configuration filename in which the
|
||||
error occurred
|
||||
@@ -92,9 +116,9 @@ here:
|
||||
* `output`: output of the command that failed (may be blank if an error
|
||||
occurred without running a command)
|
||||
|
||||
Note that borgmatic runs the `on_error` hooks only for `prune`, `create`, or
|
||||
`check` actions or hooks in which an error occurs, and not other actions.
|
||||
borgmatic does not run `on_error` hooks if an error occurs within a
|
||||
Note that borgmatic runs the `on_error` hooks only for `prune`, `compact`,
|
||||
`create`, or `check` actions or hooks in which an error occurs, and not other
|
||||
actions. borgmatic does not run `on_error` hooks if an error occurs within a
|
||||
`before_everything` or `after_everything` hook. For more about hooks, see the
|
||||
[borgmatic hooks
|
||||
documentation](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/),
|
||||
@@ -112,14 +136,15 @@ URL" for your project. Here's an example:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
healthchecks: https://hc-ping.com/addffa72-da17-40ae-be9c-ff591afb942a
|
||||
healthchecks:
|
||||
ping_url: https://hc-ping.com/addffa72-da17-40ae-be9c-ff591afb942a
|
||||
```
|
||||
|
||||
With this hook in place, borgmatic pings your Healthchecks project when a
|
||||
backup begins, ends, or errors. Specifically, before the <a
|
||||
backup begins, ends, or errors. Specifically, after the <a
|
||||
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
|
||||
hooks</a> run, borgmatic lets Healthchecks know that it has started if any of
|
||||
the `prune`, `create`, or `check` actions are run.
|
||||
the `prune`, `compact`, `create`, or `check` actions are run.
|
||||
|
||||
Then, if the actions complete successfully, borgmatic notifies Healthchecks of
|
||||
the success after the `after_backup` hooks run, and includes borgmatic logs in
|
||||
@@ -127,14 +152,17 @@ the payload data sent to Healthchecks. This means that borgmatic logs show up
|
||||
in the Healthchecks UI, although be aware that Healthchecks currently has a
|
||||
10-kilobyte limit for the logs in each ping.
|
||||
|
||||
If an error occurs during any action, borgmatic notifies Healthchecks after
|
||||
the `on_error` hooks run, also tacking on logs including the error itself. But
|
||||
the logs are only included for errors that occur when a `prune`, `create`, or
|
||||
`check` action is run.
|
||||
If an error occurs during any action or hook, borgmatic notifies Healthchecks
|
||||
after the `on_error` hooks run, also tacking on logs including the error
|
||||
itself. But the logs are only included for errors that occur when a `prune`,
|
||||
`compact`, `create`, or `check` action is run.
|
||||
|
||||
You can customize the verbosity of the logs that are sent to Healthchecks with
|
||||
borgmatic's `--monitoring-verbosity` flag. The `--files` and `--stats` flags
|
||||
may also be of use. See `borgmatic --help` for more information.
|
||||
may also be of use. See `borgmatic --help` for more information. Additionally,
|
||||
see the [borgmatic configuration
|
||||
file](https://torsion.org/borgmatic/docs/reference/configuration/) for
|
||||
additional Healthchecks options.
|
||||
|
||||
You can configure Healthchecks to notify you by a [variety of
|
||||
mechanisms](https://healthchecks.io/#welcome-integrations) when backups fail
|
||||
@@ -152,17 +180,18 @@ API URL" for your monitor. Here's an example:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
cronitor: https://cronitor.link/d3x0c1
|
||||
cronitor:
|
||||
ping_url: https://cronitor.link/d3x0c1
|
||||
```
|
||||
|
||||
With this hook in place, borgmatic pings your Cronitor monitor when a backup
|
||||
begins, ends, or errors. Specifically, before the <a
|
||||
begins, ends, or errors. Specifically, after the <a
|
||||
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
|
||||
hooks</a> run, borgmatic lets Cronitor know that it has started if any of the
|
||||
`prune`, `create`, or `check` actions are run. Then, if the actions complete
|
||||
successfully, borgmatic notifies Cronitor of the success after the
|
||||
`after_backup` hooks run. And if an error occurs during any action, borgmatic
|
||||
notifies Cronitor after the `on_error` hooks run.
|
||||
`prune`, `compact`, `create`, or `check` actions are run. Then, if the actions
|
||||
complete successfully, borgmatic notifies Cronitor of the success after the
|
||||
`after_backup` hooks run. And if an error occurs during any action or hook,
|
||||
borgmatic notifies Cronitor after the `on_error` hooks run.
|
||||
|
||||
You can configure Cronitor to notify you by a [variety of
|
||||
mechanisms](https://cronitor.io/docs/cron-job-notifications) when backups fail
|
||||
@@ -180,17 +209,18 @@ URL" for your monitor. Here's an example:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
cronhub: https://cronhub.io/start/1f5e3410-254c-11e8-b61d-55875966d031
|
||||
cronhub:
|
||||
ping_url: https://cronhub.io/start/1f5e3410-254c-11e8-b61d-55875966d031
|
||||
```
|
||||
|
||||
With this hook in place, borgmatic pings your Cronhub monitor when a backup
|
||||
begins, ends, or errors. Specifically, before the <a
|
||||
begins, ends, or errors. Specifically, after the <a
|
||||
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
|
||||
hooks</a> run, borgmatic lets Cronhub know that it has started if any of the
|
||||
`prune`, `create`, or `check` actions are run. Then, if the actions complete
|
||||
successfully, borgmatic notifies Cronhub of the success after the
|
||||
`after_backup` hooks run. And if an error occurs during any action, borgmatic
|
||||
notifies Cronhub after the `on_error` hooks run.
|
||||
`prune`, `compact`, `create`, or `check` actions are run. Then, if the actions
|
||||
complete successfully, borgmatic notifies Cronhub of the success after the
|
||||
`after_backup` hooks run. And if an error occurs during any action or hook,
|
||||
borgmatic notifies Cronhub after the `on_error` hooks run.
|
||||
|
||||
Note that even though you configure borgmatic with the "start" variant of the
|
||||
ping URL, borgmatic substitutes the correct state into the URL when pinging
|
||||
@@ -222,14 +252,15 @@ Here's an example:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
pagerduty: a177cad45bd374409f78906a810a3074
|
||||
pagerduty:
|
||||
integration_key: a177cad45bd374409f78906a810a3074
|
||||
```
|
||||
|
||||
With this hook in place, borgmatic creates a PagerDuty event for your service
|
||||
whenever backups fail. Specifically, if an error occurs during a `create`,
|
||||
`prune`, or `check` action, borgmatic sends an event to PagerDuty after the
|
||||
`on_error` hooks run. Note that borgmatic does not contact PagerDuty when a
|
||||
backup starts or ends without error.
|
||||
`prune`, `compact`, or `check` action, borgmatic sends an event to PagerDuty
|
||||
before the `on_error` hooks run. Note that borgmatic does not contact
|
||||
PagerDuty when a backup starts or ends without error.
|
||||
|
||||
You can configure PagerDuty to notify you by a [variety of
|
||||
mechanisms](https://support.pagerduty.com/docs/notifications) when backups
|
||||
@@ -250,6 +281,11 @@ suppressed so as not to interfere with the captured JSON. Also note that JSON
|
||||
output only shows up at the console, and not in syslog.
|
||||
|
||||
|
||||
## Related software
|
||||
|
||||
* [Borgmacator GNOME AppIndicator](https://github.com/N-Coder/borgmacator/)
|
||||
|
||||
|
||||
### Successful backups
|
||||
|
||||
`borgmatic list` includes support for a `--successful` flag that only lists
|
||||
@@ -283,12 +319,3 @@ automated scripts. Here's an example:
|
||||
```bash
|
||||
borgmatic info --archive latest
|
||||
```
|
||||
|
||||
|
||||
## Related documentation
|
||||
|
||||
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
|
||||
* [Inspect your backups](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/)
|
||||
* [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/)
|
||||
* [Extract a backup](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/)
|
||||
* [Develop on borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: How to run arbitrary Borg commands
|
||||
eleventyNavigation:
|
||||
key: 🔧 Run arbitrary Borg commands
|
||||
parent: How-to guides
|
||||
order: 10
|
||||
---
|
||||
## Running Borg with borgmatic
|
||||
|
||||
Borg has several commands (and options) that borgmatic does not currently
|
||||
support. Sometimes though, as a borgmatic user, you may find yourself wanting
|
||||
to take advantage of these off-the-beaten-path Borg features. You could of
|
||||
course drop down to running Borg directly. But then you'd give up all the
|
||||
niceties of your borgmatic configuration. You could file a [borgmatic
|
||||
ticket](https://torsion.org/borgmatic/#issues) or even a [pull
|
||||
request](https://torsion.org/borgmatic/#contributing) to add the feature. But
|
||||
what if you need it *now*?
|
||||
|
||||
That's where borgmatic's support for running "arbitrary" Borg commands comes
|
||||
in. Running Borg commands with borgmatic takes advantage of the following, all
|
||||
based on your borgmatic configuration files or command-line arguments:
|
||||
|
||||
* configured repositories (automatically runs your Borg command once for each
|
||||
one)
|
||||
* local and remote Borg binary paths
|
||||
* SSH settings and Borg environment variables
|
||||
* lock wait settings
|
||||
* verbosity
|
||||
|
||||
|
||||
### borg action
|
||||
|
||||
The way you run Borg with borgmatic is via the `borg` action. Here's a simple
|
||||
example:
|
||||
|
||||
```bash
|
||||
borgmatic borg break-lock
|
||||
```
|
||||
|
||||
(No `borg` action in borgmatic? Time to upgrade!)
|
||||
|
||||
This runs Borg's `break-lock` command once on each configured borgmatic
|
||||
repository. Notice how the repository isn't present in the specified Borg
|
||||
options, as that part is provided by borgmatic.
|
||||
|
||||
You can also specify Borg options for relevant commands:
|
||||
|
||||
```bash
|
||||
borgmatic borg list --progress
|
||||
```
|
||||
|
||||
This runs Borg's `list` command once on each configured borgmatic
|
||||
repository. However, the native `borgmatic list` action should be preferred
|
||||
for most use.
|
||||
|
||||
What if you only want to run Borg on a single configured borgmatic repository
|
||||
when you've got several configured? Not a problem.
|
||||
|
||||
```bash
|
||||
borgmatic borg --repository repo.borg break-lock
|
||||
```
|
||||
|
||||
And what about a single archive?
|
||||
|
||||
```bash
|
||||
borgmatic borg --archive your-archive-name list
|
||||
```
|
||||
|
||||
### Limitations
|
||||
|
||||
borgmatic's `borg` action is not without limitations:
|
||||
|
||||
* The Borg command you want to run (`create`, `list`, etc.) *must* come first
|
||||
after the `borg` action. If you have any other Borg options to specify,
|
||||
provide them after. For instance, `borgmatic borg list --progress` will work,
|
||||
but `borgmatic borg --progress list` will not.
|
||||
* borgmatic supplies the repository/archive name to Borg for you (based on
|
||||
your borgmatic configuration or the `borgmatic borg --repository`/`--archive`
|
||||
arguments), so do not specify the repository/archive otherwise.
|
||||
* The `borg` action will not currently work for any Borg commands like `borg
|
||||
serve` that do not accept a repository/archive name.
|
||||
* Do not specify any global borgmatic arguments to the right of the `borg`
|
||||
action. (They will be passed to Borg instead of borgmatic.) If you have
|
||||
global borgmatic arguments, specify them *before* the `borg` action.
|
||||
* Unlike other borgmatic actions, you cannot combine the `borg` action with
|
||||
other borgmatic actions. This is to prevent ambiguity in commands like
|
||||
`borgmatic borg list`, in which `list` is both a valid Borg command and a
|
||||
borgmatic action. In this case, only the Borg command is run.
|
||||
* Unlike normal borgmatic actions that support JSON, the `borg` action will
|
||||
not disable certain borgmatic logs to avoid interfering with JSON output.
|
||||
|
||||
In general, this `borgmatic borg` feature should be considered an escape
|
||||
valve—a feature of second resort. In the long run, it's preferable to wrap
|
||||
Borg commands with borgmatic actions that can support them fully.
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: How to set up backups with borgmatic
|
||||
title: How to set up backups
|
||||
eleventyNavigation:
|
||||
key: 📥 Set up backups
|
||||
parent: How-to guides
|
||||
order: 0
|
||||
---
|
||||
## Installation
|
||||
|
||||
@@ -8,9 +12,10 @@ these instructions install and run borgmatic as root. If you don't need to
|
||||
backup such files, then you are welcome to install and run borgmatic as a
|
||||
non-root user.
|
||||
|
||||
First, [install
|
||||
First, manually [install
|
||||
Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at least
|
||||
version 1.1.
|
||||
version 1.1. borgmatic does not install Borg automatically so as to avoid
|
||||
conflicts with existing Borg installations.
|
||||
|
||||
Then, download and install borgmatic as a [user site
|
||||
installation](https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site)
|
||||
@@ -23,7 +28,7 @@ sudo pip3 install --user --upgrade borgmatic
|
||||
This installs borgmatic and its commands at the `/root/.local/bin` path.
|
||||
|
||||
Your pip binary may have a different name than "pip3". Make sure you're using
|
||||
Python 3, as borgmatic does not support Python 2.
|
||||
Python 3.7+, as borgmatic does not support older versions of Python.
|
||||
|
||||
The next step is to ensure that borgmatic's commands available are on your
|
||||
system `PATH`, so that you can run borgmatic:
|
||||
@@ -46,6 +51,11 @@ sudo borgmatic --version
|
||||
|
||||
If borgmatic is properly installed, that should output your borgmatic version.
|
||||
|
||||
As an alternative to adding the path to `~/.bashrc` file, if you're using sudo
|
||||
to run borgmatic, you can configure [sudo's
|
||||
`secure_path` option](https://man.archlinux.org/man/sudoers.5) to include
|
||||
borgmatic's path.
|
||||
|
||||
|
||||
### Global install option
|
||||
|
||||
@@ -63,9 +73,8 @@ sudo pip3 install --upgrade borgmatic
|
||||
|
||||
The main downside of a global install is that borgmatic is less cleanly
|
||||
separated from the rest of your Python software, and there's the theoretical
|
||||
possibility of libary conflicts. But if you're okay with that, for instance
|
||||
on a relatively dedicated system, then a global install can work out just
|
||||
fine.
|
||||
possibility of library conflicts. But if you're okay with that, for instance
|
||||
on a relatively dedicated system, then a global install can work out fine.
|
||||
|
||||
|
||||
### Other ways to install
|
||||
@@ -73,8 +82,8 @@ fine.
|
||||
Besides the approaches described above, there are several other options for
|
||||
installing borgmatic:
|
||||
|
||||
* [Docker image with scheduled backups](https://hub.docker.com/r/b3vis/borgmatic/)
|
||||
* [Docker base image](https://hub.docker.com/r/monachus/borgmatic/)
|
||||
* [Docker image with scheduled backups](https://hub.docker.com/r/b3vis/borgmatic/) (+ Docker Compose files)
|
||||
* [Docker image with multi-arch and Docker CLI support](https://hub.docker.com/r/modem7/borgmatic-docker/)
|
||||
* [Debian](https://tracker.debian.org/pkg/borgmatic)
|
||||
* [Ubuntu](https://launchpad.net/ubuntu/+source/borgmatic)
|
||||
* [Fedora official](https://bodhi.fedoraproject.org/updates/?search=borgmatic)
|
||||
@@ -83,22 +92,26 @@ installing borgmatic:
|
||||
* [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=borgmatic)
|
||||
* [OpenBSD](http://ports.su/sysutils/borgmatic)
|
||||
* [openSUSE](https://software.opensuse.org/package/borgmatic)
|
||||
* [stand-alone binary](https://github.com/cmarquardt/borgmatic-binary)
|
||||
* [Ansible role](https://github.com/borgbase/ansible-role-borgbackup)
|
||||
* [virtualenv](https://virtualenv.pypa.io/en/stable/)
|
||||
|
||||
|
||||
## Hosting providers
|
||||
|
||||
Need somewhere to store your encrypted offsite backups? The following hosting
|
||||
providers include specific support for Borg/borgmatic. Using these links and
|
||||
services helps support borgmatic development and hosting. (These are referral
|
||||
links, but without any tracking scripts or cookies.)
|
||||
Need somewhere to store your encrypted off-site backups? The following hosting
|
||||
providers include specific support for Borg/borgmatic—and fund borgmatic
|
||||
development and hosting when you use these links to sign up. (These are
|
||||
referral links, but without any tracking scripts or cookies.)
|
||||
|
||||
<ul>
|
||||
<li class="referral"><a href="https://www.rsync.net/cgi-bin/borg.cgi?campaign=borg&adgroup=borgmatic">rsync.net</a>: Cloud Storage provider with full support for borg and any other SSH/SFTP tool</li>
|
||||
<li class="referral"><a href="https://www.borgbase.com/?utm_source=borgmatic">BorgBase</a>: Borg hosting service with support for monitoring, 2FA, and append-only repos</li>
|
||||
</ul>
|
||||
|
||||
Additionally, [rsync.net](https://www.rsync.net/products/borg.html) and
|
||||
[Hetzner](https://www.hetzner.com/storage/storage-box) have compatible storage
|
||||
offerings, but do not currently fund borgmatic development or hosting.
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
After you install borgmatic, generate a sample configuration file:
|
||||
@@ -134,8 +147,7 @@ configuration](https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-your
|
||||
|
||||
### Encryption
|
||||
|
||||
Note that if you plan to run borgmatic on a schedule with cron, and you
|
||||
encrypt your Borg repository with a passphrase instead of a key file, you'll
|
||||
If you encrypt your Borg repository with a passphrase or a key file, you'll
|
||||
either need to set the borgmatic `encryption_passphrase` configuration
|
||||
variable or set the `BORG_PASSPHRASE` environment variable. See the
|
||||
[repository encryption
|
||||
@@ -149,6 +161,13 @@ FAQ](http://borgbackup.readthedocs.io/en/stable/faq.html#how-can-i-specify-the-e
|
||||
for more info.
|
||||
|
||||
|
||||
### Redundancy
|
||||
|
||||
If you'd like to configure your backups to go to multiple different
|
||||
repositories, see the documentation on how to [make backups
|
||||
redundant](https://torsion.org/borgmatic/docs/how-to/make-backups-redundant/).
|
||||
|
||||
|
||||
### Validation
|
||||
|
||||
If you'd like to validate that your borgmatic configuration is valid, the
|
||||
@@ -213,8 +232,8 @@ sudo borgmatic --verbosity 1 --files
|
||||
borgmatic. So try leaving it out, or upgrade borgmatic!)
|
||||
|
||||
By default, this will also prune any old backups as per the configured
|
||||
retention policy, and check backups for consistency problems due to things
|
||||
like file damage.
|
||||
retention policy, compact segments to free up space (with Borg 1.2+), and
|
||||
check backups for consistency problems due to things like file damage.
|
||||
|
||||
The verbosity flag makes borgmatic show the steps it's performing. And the
|
||||
files flag lists each file that's new or changed since the last backup.
|
||||
@@ -234,7 +253,7 @@ that, you can configure a separate job runner to invoke it periodically.
|
||||
### cron
|
||||
|
||||
If you're using cron, download the [sample cron
|
||||
file](https://projects.torsion.org/witten/borgmatic/src/master/sample/cron/borgmatic).
|
||||
file](https://projects.torsion.org/borgmatic-collective/borgmatic/src/master/sample/cron/borgmatic).
|
||||
Then, from the directory where you downloaded it:
|
||||
|
||||
```bash
|
||||
@@ -242,15 +261,26 @@ sudo mv borgmatic /etc/cron.d/borgmatic
|
||||
sudo chmod +x /etc/cron.d/borgmatic
|
||||
```
|
||||
|
||||
You can modify the cron file if you'd like to run borgmatic more or less frequently.
|
||||
If borgmatic is installed at a different location than
|
||||
`/root/.local/bin/borgmatic`, edit the cron file with the correct path. You
|
||||
can also modify the cron file if you'd like to run borgmatic more or less
|
||||
frequently.
|
||||
|
||||
### systemd
|
||||
|
||||
If you're using systemd instead of cron to run jobs, download the [sample
|
||||
systemd service
|
||||
file](https://projects.torsion.org/witten/borgmatic/raw/branch/master/sample/systemd/borgmatic.service)
|
||||
If you're using systemd instead of cron to run jobs, you can still configure
|
||||
borgmatic to run automatically.
|
||||
|
||||
(If you installed borgmatic from [Other ways to
|
||||
install](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#other-ways-to-install),
|
||||
you may already have borgmatic systemd service and timer files. If so, you may
|
||||
be able to skip some of the steps below.)
|
||||
|
||||
First, download the [sample systemd service
|
||||
file](https://projects.torsion.org/borgmatic-collective/borgmatic/raw/branch/master/sample/systemd/borgmatic.service)
|
||||
and the [sample systemd timer
|
||||
file](https://projects.torsion.org/witten/borgmatic/raw/branch/master/sample/systemd/borgmatic.timer).
|
||||
file](https://projects.torsion.org/borgmatic-collective/borgmatic/raw/branch/master/sample/systemd/borgmatic.timer).
|
||||
|
||||
Then, from the directory where you downloaded them:
|
||||
|
||||
```bash
|
||||
@@ -258,6 +288,10 @@ sudo mv borgmatic.service borgmatic.timer /etc/systemd/system/
|
||||
sudo systemctl enable --now borgmatic.timer
|
||||
```
|
||||
|
||||
Review the security settings in the service file and update them as needed.
|
||||
If `ProtectSystem=strict` is enabled and local repositories are used, then
|
||||
the repository path must be added to the `ReadWritePaths` list.
|
||||
|
||||
Feel free to modify the timer file based on how frequently you'd like
|
||||
borgmatic to run.
|
||||
|
||||
@@ -266,16 +300,43 @@ borgmatic to run.
|
||||
If you run borgmatic in macOS with launchd, you may encounter permissions
|
||||
issues when reading files to backup. If that happens to you, you may be
|
||||
interested in an [unofficial work-around for Full Disk
|
||||
Access](https://projects.torsion.org/witten/borgmatic/issues/293).
|
||||
Access](https://projects.torsion.org/borgmatic-collective/borgmatic/issues/293).
|
||||
|
||||
## Colored output
|
||||
|
||||
Borgmatic produces colored terminal output by default. It is disabled when a
|
||||
## Niceties
|
||||
|
||||
|
||||
### Shell completion
|
||||
|
||||
borgmatic includes a shell completion script (currently only for Bash) to
|
||||
support tab-completing borgmatic command-line actions and flags. Depending on
|
||||
how you installed borgmatic, this may be enabled by default. But if it's not,
|
||||
you can install the shell completion script globally:
|
||||
|
||||
```bash
|
||||
sudo su -c "borgmatic --bash-completion > /usr/share/bash-completion/completions/borgmatic"
|
||||
```
|
||||
|
||||
Alternatively, if you'd like to install the script for just the current user:
|
||||
|
||||
```bash
|
||||
mkdir --parents ~/.local/share/bash-completion/completions
|
||||
borgmatic --bash-completion > ~/.local/share/bash-completion/completions/borgmatic
|
||||
```
|
||||
|
||||
In either case, you may also need to install the `bash-completion` Linux
|
||||
package and restart your shell (`exit` and open a new shell).
|
||||
|
||||
|
||||
### Colored output
|
||||
|
||||
borgmatic produces colored terminal output by default. It is disabled when a
|
||||
non-interactive terminal is detected (like a cron job), or when you use the
|
||||
`--json` flag. Otherwise, you can disable it by passing the `--no-color` flag,
|
||||
setting the environment variable `PY_COLORS=False`, or setting the `color`
|
||||
option to `false` in the `output` section of configuration.
|
||||
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "found character that cannot start any token" error
|
||||
@@ -303,14 +364,3 @@ YAML library. If so, not to worry. borgmatic should install and function
|
||||
correctly even without the C YAML library. And borgmatic won't be any faster
|
||||
with the C library present, so you don't need to go out of your way to install
|
||||
it.
|
||||
|
||||
|
||||
## Related documentation
|
||||
|
||||
* [Make per-application backups](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/)
|
||||
* [Deal with very large backups](https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/)
|
||||
* [Inspect your backups](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/)
|
||||
* [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
|
||||
* [Upgrade borgmatic](https://torsion.org/borgmatic/docs/how-to/upgrade/)
|
||||
* [borgmatic configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/)
|
||||
* [borgmatic command-line reference](https://torsion.org/borgmatic/docs/reference/command-line/)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: How to upgrade borgmatic
|
||||
eleventyNavigation:
|
||||
key: 📦 Upgrade borgmatic
|
||||
parent: How-to guides
|
||||
order: 11
|
||||
---
|
||||
## Upgrading
|
||||
|
||||
@@ -111,8 +115,3 @@ sudo pip3 install --user borgmatic
|
||||
|
||||
That's it! borgmatic will continue using your /etc/borgmatic configuration
|
||||
files.
|
||||
|
||||
|
||||
## Related documentation
|
||||
|
||||
* [Develop on borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: borgmatic command-line reference
|
||||
title: Command-line reference
|
||||
eleventyNavigation:
|
||||
key: ⌨️ Command-line reference
|
||||
parent: Reference guides
|
||||
order: 1
|
||||
---
|
||||
## borgmatic options
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
---
|
||||
title: borgmatic configuration reference
|
||||
title: Configuration reference
|
||||
eleventyNavigation:
|
||||
key: ⚙️ Configuration reference
|
||||
parent: Reference guides
|
||||
order: 0
|
||||
---
|
||||
## Configuration file
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
eleventyNavigation:
|
||||
key: Reference guides
|
||||
permalink: false
|
||||
---
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Vendored
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,3 +1,3 @@
|
||||
# You can drop this file into /etc/cron.d/ to run borgmatic nightly.
|
||||
|
||||
0 3 * * * root PATH=$PATH:/usr/bin:/usr/local/bin /root/.local/bin/borgmatic --syslog-verbosity 1
|
||||
0 3 * * * root PATH=$PATH:/usr/bin:/usr/local/bin /root/.local/bin/borgmatic --verbosity -1 --syslog-verbosity 1
|
||||
|
||||
@@ -2,11 +2,50 @@
|
||||
Description=borgmatic backup
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
# Prevent borgmatic from running unless the machine is plugged into power. Remove this line if you
|
||||
# want to allow borgmatic to run anytime.
|
||||
ConditionACPower=true
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
# Security settings for systemd running as root, optional but recommended to improve security. You
|
||||
# can disable individual settings if they cause problems for your use case. For more details, see
|
||||
# the systemd manual: https://www.freedesktop.org/software/systemd/man/systemd.exec.html
|
||||
LockPersonality=true
|
||||
# Certain borgmatic features like Healthchecks integration need MemoryDenyWriteExecute to be off.
|
||||
# But you can try setting it to "yes" for improved security if you don't use those features.
|
||||
MemoryDenyWriteExecute=no
|
||||
NoNewPrivileges=yes
|
||||
PrivateDevices=yes
|
||||
PrivateTmp=yes
|
||||
ProtectClock=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectHostname=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelTunables=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
RestrictNamespaces=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictSUIDSGID=yes
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallErrorNumber=EPERM
|
||||
# To restrict write access further, change "ProtectSystem" to "strict" and uncomment
|
||||
# "ReadWritePaths", "ReadOnlyPaths", "ProtectHome", and "BindPaths". Then add any local repository
|
||||
# paths to the list of "ReadWritePaths" and local backup source paths to "ReadOnlyPaths". This
|
||||
# leaves most of the filesystem read-only to borgmatic.
|
||||
ProtectSystem=full
|
||||
# ReadWritePaths=-/mnt/my_backup_drive
|
||||
# ReadOnlyPaths=-/var/lib/my_backup_source
|
||||
# This will mount a tmpfs on top of /root and pass through needed paths
|
||||
# ProtectHome=tmpfs
|
||||
# BindPaths=-/root/.cache/borg -/root/.cache/borg -/root/.borgmatic
|
||||
|
||||
# May interfere with running external programs within borgmatic hooks.
|
||||
CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_NET_RAW
|
||||
|
||||
# Lower CPU and I/O priority.
|
||||
Nice=19
|
||||
CPUSchedulingPolicy=batch
|
||||
@@ -22,4 +61,4 @@ LogRateLimitIntervalSec=0
|
||||
# Delay start to prevent backups running during boot. Note that systemd-inhibit requires dbus and
|
||||
# dbus-user-session to be installed.
|
||||
ExecStartPre=sleep 1m
|
||||
ExecStart=systemd-inhibit --who="borgmatic" --why="Prevent interrupting scheduled backup" /root/.local/bin/borgmatic --syslog-verbosity 1
|
||||
ExecStart=systemd-inhibit --who="borgmatic" --why="Prevent interrupting scheduled backup" /root/.local/bin/borgmatic --verbosity -1 --syslog-verbosity 1
|
||||
|
||||
@@ -4,6 +4,7 @@ Description=Run borgmatic backup
|
||||
[Timer]
|
||||
OnCalendar=daily
|
||||
Persistent=true
|
||||
RandomizedDelaySec=3h
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
||||
@@ -38,7 +38,7 @@ for sub_command in prune create check list info; do
|
||||
| grep -v '^--json$' \
|
||||
| grep -v '^--keep-last$' \
|
||||
| grep -v '^--list$' \
|
||||
| grep -v '^--nobsdflags$' \
|
||||
| grep -v '^--bsdflags$' \
|
||||
| grep -v '^--pattern$' \
|
||||
| grep -v '^--progress$' \
|
||||
| grep -v '^--stats$' \
|
||||
@@ -54,7 +54,7 @@ for sub_command in prune create check list info; do
|
||||
| grep -v '^--format' \
|
||||
| grep -v '^--glob-archives' \
|
||||
| grep -v '^--last' \
|
||||
| grep -v '^--list-format' \
|
||||
| grep -v '^--format' \
|
||||
| grep -v '^--patterns-from' \
|
||||
| grep -v '^--prefix' \
|
||||
| grep -v '^--short' \
|
||||
|
||||
+10
-3
@@ -15,6 +15,12 @@ if [[ ! -f NEWS ]]; then
|
||||
fi
|
||||
|
||||
version=$(head --lines=1 NEWS)
|
||||
|
||||
if [[ $version =~ .*dev* ]]; then
|
||||
echo "Refusing to release a dev version: $version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git tag $version
|
||||
git push origin $version
|
||||
git push github $version
|
||||
@@ -25,14 +31,15 @@ python3 setup.py bdist_wheel
|
||||
python3 setup.py sdist
|
||||
gpg --detach-sign --armor dist/borgmatic-*.tar.gz
|
||||
gpg --detach-sign --armor dist/borgmatic-*-py3-none-any.whl
|
||||
twine upload -r pypi dist/borgmatic-*.tar.gz dist/borgmatic-*.tar.gz.asc
|
||||
twine upload -r pypi dist/borgmatic-*-py3-none-any.whl dist/borgmatic-*-py3-none-any.whl.asc
|
||||
twine upload -r pypi --username __token__ dist/borgmatic-*.tar.gz dist/borgmatic-*.tar.gz.asc
|
||||
twine upload -r pypi --username __token__ dist/borgmatic-*-py3-none-any.whl dist/borgmatic-*-py3-none-any.whl.asc
|
||||
|
||||
# Set release changelogs on projects.torsion.org and GitHub.
|
||||
release_changelog="$(cat NEWS | sed '/^$/q' | grep -v '^\S')"
|
||||
escaped_release_changelog="$(echo "$release_changelog" | sed -z 's/\n/\\n/g' | sed -z 's/\"/\\"/g')"
|
||||
curl --silent --request POST \
|
||||
"https://projects.torsion.org/api/v1/repos/witten/borgmatic/releases?access_token=$projects_token" \
|
||||
"https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/releases" \
|
||||
--header "Authorization: token $projects_token" \
|
||||
--header "Accept: application/json" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data "{\"body\": \"$escaped_release_changelog\", \"draft\": false, \"name\": \"borgmatic $version\", \"prerelease\": false, \"tag_name\": \"$version\"}"
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
|
||||
set -e
|
||||
|
||||
python -m pip install --upgrade pip==20.0.2
|
||||
pip install tox==3.14.3
|
||||
apk add --no-cache python3 py3-pip borgbackup postgresql-client mariadb-client mongodb-tools \
|
||||
py3-ruamel.yaml py3-ruamel.yaml.clib bash
|
||||
# If certain dependencies of black are available in this version of Alpine, install them.
|
||||
apk add --no-cache py3-typed-ast py3-regex || true
|
||||
python3 -m pip install --no-cache --upgrade pip==22.0.3 setuptools==60.8.1
|
||||
pip3 install tox==3.24.5
|
||||
export COVERAGE_FILE=/tmp/.coverage
|
||||
tox --workdir /tmp/.tox
|
||||
apk add --no-cache borgbackup postgresql-client mariadb-client
|
||||
tox --workdir /tmp/.tox -e end-to-end
|
||||
tox --workdir /tmp/.tox --sitepackages
|
||||
tox --workdir /tmp/.tox --sitepackages -e end-to-end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[metadata]
|
||||
description-file=README.md
|
||||
description_file=README.md
|
||||
|
||||
[tool:pytest]
|
||||
testpaths = tests
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
VERSION = '1.5.5'
|
||||
VERSION = '1.6.2'
|
||||
|
||||
|
||||
setup(
|
||||
@@ -30,11 +30,12 @@ setup(
|
||||
},
|
||||
obsoletes=['atticmatic'],
|
||||
install_requires=(
|
||||
'pykwalify>=1.6.0,<14.06',
|
||||
'requests',
|
||||
'ruamel.yaml>0.15.0,<0.17.0',
|
||||
'setuptools',
|
||||
'colorama>=0.4.1,<0.5',
|
||||
'jsonschema',
|
||||
'requests',
|
||||
'ruamel.yaml>0.15.0,<0.18.0',
|
||||
'setuptools',
|
||||
),
|
||||
include_package_data=True,
|
||||
python_requires='>=3.7',
|
||||
)
|
||||
|
||||
+21
-23
@@ -1,25 +1,23 @@
|
||||
appdirs==1.4.3
|
||||
atomicwrites==1.3.0
|
||||
attrs==19.3.0
|
||||
black==19.3b0; python_version >= '3.6'
|
||||
click==7.0
|
||||
colorama==0.4.1
|
||||
coverage==4.5.4
|
||||
docopt==0.6.2
|
||||
flake8==3.7.9
|
||||
appdirs==1.4.4; python_version >= '3.8'
|
||||
attrs==20.3.0; python_version >= '3.8'
|
||||
black==19.10b0; python_version >= '3.8'
|
||||
click==7.1.2; python_version >= '3.8'
|
||||
colorama==0.4.4
|
||||
coverage==5.3
|
||||
flake8==4.0.1
|
||||
flexmock==0.10.4
|
||||
isort==4.3.21
|
||||
isort==5.9.1
|
||||
mccabe==0.6.1
|
||||
more-itertools==7.2.0
|
||||
pluggy==0.13.0
|
||||
py==1.8.0
|
||||
pycodestyle==2.5.0
|
||||
pyflakes==2.1.1
|
||||
pykwalify==1.7.0
|
||||
pytest==5.2.2
|
||||
pytest-cov==2.8.1
|
||||
python-dateutil==2.8.0
|
||||
PyYAML==5.1.2
|
||||
requests==2.22.0
|
||||
ruamel.yaml>0.15.0,<0.17.0
|
||||
toml==0.10.0
|
||||
pluggy==0.13.1
|
||||
pathspec==0.8.1; python_version >= '3.8'
|
||||
py==1.10.0
|
||||
pycodestyle==2.8.0
|
||||
pyflakes==2.4.0
|
||||
jsonschema==3.2.0
|
||||
pytest==6.2.5
|
||||
pytest-cov==3.0.0
|
||||
regex; python_version >= '3.8'
|
||||
requests==2.25.0
|
||||
ruamel.yaml>0.15.0,<0.18.0
|
||||
toml==0.10.2; python_version >= '3.8'
|
||||
typed-ast; python_version >= '3.8'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
version: '3'
|
||||
services:
|
||||
postgresql:
|
||||
image: postgres:12.2-alpine
|
||||
image: postgres:13.1-alpine
|
||||
environment:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: test
|
||||
@@ -10,8 +10,13 @@ services:
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: test
|
||||
MYSQL_DATABASE: test
|
||||
mongodb:
|
||||
image: mongo:5.0.5
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: test
|
||||
tests:
|
||||
image: python:3.8-alpine3.11
|
||||
image: alpine:3.13
|
||||
volumes:
|
||||
- "../..:/app:ro"
|
||||
tmpfs:
|
||||
|
||||
@@ -19,6 +19,8 @@ def generate_configuration(config_path, repository_path):
|
||||
open(config_path)
|
||||
.read()
|
||||
.replace('user@backupserver:sourcehostname.borg', repository_path)
|
||||
.replace('- user@backupserver:{fqdn}', '')
|
||||
.replace('- /home/user/path with spaces', '')
|
||||
.replace('- /home', '- {}'.format(config_path))
|
||||
.replace('- /etc', '')
|
||||
.replace('- /var/log/syslog*', '')
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import subprocess
|
||||
|
||||
|
||||
def test_bash_completion_runs_without_error():
|
||||
subprocess.check_call('borgmatic --bash-completion | bash', shell=True)
|
||||
@@ -47,13 +47,22 @@ hooks:
|
||||
hostname: mysql
|
||||
username: root
|
||||
password: test
|
||||
mongodb_databases:
|
||||
- name: test
|
||||
hostname: mongodb
|
||||
username: root
|
||||
password: test
|
||||
authentication_database: admin
|
||||
- name: all
|
||||
hostname: mongodb
|
||||
username: root
|
||||
password: test
|
||||
'''.format(
|
||||
config_path, repository_path, borgmatic_source_directory, postgresql_dump_format
|
||||
)
|
||||
|
||||
config_file = open(config_path, 'w')
|
||||
config_file.write(config)
|
||||
config_file.close()
|
||||
with open(config_path, 'w') as config_file:
|
||||
config_file.write(config)
|
||||
|
||||
|
||||
def test_database_dump_and_restore():
|
||||
@@ -69,15 +78,15 @@ def test_database_dump_and_restore():
|
||||
write_configuration(config_path, repository_path, borgmatic_source_directory)
|
||||
|
||||
subprocess.check_call(
|
||||
'borgmatic -v 2 --config {} init --encryption repokey'.format(config_path).split(' ')
|
||||
['borgmatic', '-v', '2', '--config', config_path, 'init', '--encryption', 'repokey']
|
||||
)
|
||||
|
||||
# Run borgmatic to generate a backup archive including a database dump.
|
||||
subprocess.check_call('borgmatic create --config {} -v 2'.format(config_path).split(' '))
|
||||
subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2'])
|
||||
|
||||
# Get the created archive name.
|
||||
output = subprocess.check_output(
|
||||
'borgmatic --config {} list --json'.format(config_path).split(' ')
|
||||
['borgmatic', '--config', config_path, 'list', '--json']
|
||||
).decode(sys.stdout.encoding)
|
||||
parsed_output = json.loads(output)
|
||||
|
||||
@@ -87,9 +96,7 @@ def test_database_dump_and_restore():
|
||||
|
||||
# Restore the database from the archive.
|
||||
subprocess.check_call(
|
||||
'borgmatic --config {} restore --archive {}'.format(config_path, archive_name).split(
|
||||
' '
|
||||
)
|
||||
['borgmatic', '--config', config_path, 'restore', '--archive', archive_name]
|
||||
)
|
||||
finally:
|
||||
os.chdir(original_working_directory)
|
||||
@@ -114,15 +121,15 @@ def test_database_dump_and_restore_with_directory_format():
|
||||
)
|
||||
|
||||
subprocess.check_call(
|
||||
'borgmatic -v 2 --config {} init --encryption repokey'.format(config_path).split(' ')
|
||||
['borgmatic', '-v', '2', '--config', config_path, 'init', '--encryption', 'repokey']
|
||||
)
|
||||
|
||||
# Run borgmatic to generate a backup archive including a database dump.
|
||||
subprocess.check_call('borgmatic create --config {} -v 2'.format(config_path).split(' '))
|
||||
subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2'])
|
||||
|
||||
# Restore the database from the archive.
|
||||
subprocess.check_call(
|
||||
'borgmatic --config {} restore --archive latest'.format(config_path).split(' ')
|
||||
['borgmatic', '--config', config_path, 'restore', '--archive', 'latest']
|
||||
)
|
||||
finally:
|
||||
os.chdir(original_working_directory)
|
||||
@@ -142,7 +149,7 @@ def test_database_dump_with_error_causes_borgmatic_to_exit():
|
||||
write_configuration(config_path, repository_path, borgmatic_source_directory)
|
||||
|
||||
subprocess.check_call(
|
||||
'borgmatic -v 2 --config {} init --encryption repokey'.format(config_path).split(' ')
|
||||
['borgmatic', '-v', '2', '--config', config_path, 'init', '--encryption', 'repokey']
|
||||
)
|
||||
|
||||
# Run borgmatic with a config override such that the database dump fails.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from borgmatic.borg import feature as module
|
||||
|
||||
|
||||
def test_available_true_for_new_enough_borg_version():
|
||||
assert module.available(module.Feature.COMPACT, '1.3.7')
|
||||
|
||||
|
||||
def test_available_true_for_borg_version_introducing_feature():
|
||||
assert module.available(module.Feature.COMPACT, '1.2.0a2')
|
||||
|
||||
|
||||
def test_available_true_for_borg_stable_version_introducing_feature():
|
||||
assert module.available(module.Feature.COMPACT, '1.2.0')
|
||||
|
||||
|
||||
def test_available_false_for_too_old_borg_version():
|
||||
assert not module.available(module.Feature.COMPACT, '1.1.5')
|
||||
@@ -71,6 +71,35 @@ def test_parse_arguments_with_log_file_verbosity_overrides_default():
|
||||
assert global_arguments.log_file_verbosity == -1
|
||||
|
||||
|
||||
def test_parse_arguments_with_single_override_parses():
|
||||
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
|
||||
|
||||
arguments = module.parse_arguments('--override', 'foo.bar=baz')
|
||||
|
||||
global_arguments = arguments['global']
|
||||
assert global_arguments.overrides == ['foo.bar=baz']
|
||||
|
||||
|
||||
def test_parse_arguments_with_multiple_overrides_parses():
|
||||
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
|
||||
|
||||
arguments = module.parse_arguments('--override', 'foo.bar=baz', 'foo.quux=7')
|
||||
|
||||
global_arguments = arguments['global']
|
||||
assert global_arguments.overrides == ['foo.bar=baz', 'foo.quux=7']
|
||||
|
||||
|
||||
def test_parse_arguments_with_multiple_overrides_and_flags_parses():
|
||||
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
|
||||
|
||||
arguments = module.parse_arguments(
|
||||
'--override', 'foo.bar=baz', '--override', 'foo.quux=7', 'this.that=8'
|
||||
)
|
||||
|
||||
global_arguments = arguments['global']
|
||||
assert global_arguments.overrides == ['foo.bar=baz', 'foo.quux=7', 'this.that=8']
|
||||
|
||||
|
||||
def test_parse_arguments_with_list_json_overrides_default():
|
||||
arguments = module.parse_arguments('list', '--json')
|
||||
|
||||
@@ -134,6 +163,24 @@ def test_parse_arguments_with_help_and_action_shows_action_help(capsys):
|
||||
assert 'create arguments:' in captured.out
|
||||
|
||||
|
||||
def test_parse_arguments_with_action_before_global_options_parses_options():
|
||||
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
|
||||
|
||||
arguments = module.parse_arguments('prune', '--verbosity', '2')
|
||||
|
||||
assert 'prune' in arguments
|
||||
assert arguments['global'].verbosity == 2
|
||||
|
||||
|
||||
def test_parse_arguments_with_global_options_before_action_parses_options():
|
||||
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
|
||||
|
||||
arguments = module.parse_arguments('--verbosity', '2', 'prune')
|
||||
|
||||
assert 'prune' in arguments
|
||||
assert arguments['global'].verbosity == 2
|
||||
|
||||
|
||||
def test_parse_arguments_with_prune_action_leaves_other_actions_disabled():
|
||||
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from borgmatic.commands import completion as module
|
||||
|
||||
|
||||
def test_bash_completion_does_not_raise():
|
||||
assert module.bash_completion()
|
||||
@@ -1,13 +1,25 @@
|
||||
from borgmatic.commands import generate_config as module
|
||||
|
||||
|
||||
def test_parse_arguments_with_no_arguments_uses_defaults():
|
||||
def test_parse_arguments_with_no_arguments_uses_default_destination():
|
||||
parser = module.parse_arguments()
|
||||
|
||||
assert parser.destination_filename == module.DEFAULT_DESTINATION_CONFIG_FILENAME
|
||||
|
||||
|
||||
def test_parse_arguments_with_filename_argument_overrides_defaults():
|
||||
def test_parse_arguments_with_destination_argument_overrides_default():
|
||||
parser = module.parse_arguments('--destination', 'config.yaml')
|
||||
|
||||
assert parser.destination_filename == 'config.yaml'
|
||||
|
||||
|
||||
def test_parse_arguments_parses_source():
|
||||
parser = module.parse_arguments('--source', 'source.yaml', '--destination', 'config.yaml')
|
||||
|
||||
assert parser.source_filename == 'source.yaml'
|
||||
|
||||
|
||||
def test_parse_arguments_parses_overwrite():
|
||||
parser = module.parse_arguments('--destination', 'config.yaml', '--overwrite')
|
||||
|
||||
assert parser.overwrite
|
||||
|
||||
@@ -88,7 +88,7 @@ location:
|
||||
|
||||
|
||||
def test_render_configuration_converts_configuration_to_yaml_string():
|
||||
yaml_string = module._render_configuration({'foo': 'bar'})
|
||||
yaml_string = module.render_configuration({'foo': 'bar'})
|
||||
|
||||
assert yaml_string == 'foo: bar\n'
|
||||
|
||||
@@ -110,6 +110,12 @@ def test_write_configuration_with_already_existing_file_raises():
|
||||
module.write_configuration('config.yaml', 'config: yaml')
|
||||
|
||||
|
||||
def test_write_configuration_with_already_existing_file_and_overwrite_does_not_raise():
|
||||
flexmock(os.path).should_receive('exists').and_return(True)
|
||||
|
||||
module.write_configuration('config.yaml', 'config: yaml', overwrite=True)
|
||||
|
||||
|
||||
def test_write_configuration_with_already_existing_directory_does_not_raise():
|
||||
flexmock(os.path).should_receive('exists').and_return(False)
|
||||
flexmock(os).should_receive('makedirs').and_raise(FileExistsError)
|
||||
@@ -122,38 +128,44 @@ def test_write_configuration_with_already_existing_directory_does_not_raise():
|
||||
|
||||
def test_add_comments_to_configuration_sequence_of_strings_does_not_raise():
|
||||
config = module.yaml.comments.CommentedSeq(['foo', 'bar'])
|
||||
schema = {'seq': [{'type': 'str'}]}
|
||||
schema = {'type': 'array', 'items': {'type': 'string'}}
|
||||
|
||||
module.add_comments_to_configuration_sequence(config, schema)
|
||||
|
||||
|
||||
def test_add_comments_to_configuration_sequence_of_maps_does_not_raise():
|
||||
config = module.yaml.comments.CommentedSeq([module.yaml.comments.CommentedMap([('foo', 'yo')])])
|
||||
schema = {'seq': [{'map': {'foo': {'desc': 'yo'}}}]}
|
||||
schema = {
|
||||
'type': 'array',
|
||||
'items': {'type': 'object', 'properties': {'foo': {'description': 'yo'}}},
|
||||
}
|
||||
|
||||
module.add_comments_to_configuration_sequence(config, schema)
|
||||
|
||||
|
||||
def test_add_comments_to_configuration_sequence_of_maps_without_description_does_not_raise():
|
||||
config = module.yaml.comments.CommentedSeq([module.yaml.comments.CommentedMap([('foo', 'yo')])])
|
||||
schema = {'seq': [{'map': {'foo': {}}}]}
|
||||
schema = {'type': 'array', 'items': {'type': 'object', 'properties': {'foo': {}}}}
|
||||
|
||||
module.add_comments_to_configuration_sequence(config, schema)
|
||||
|
||||
|
||||
def test_add_comments_to_configuration_map_does_not_raise():
|
||||
def test_add_comments_to_configuration_object_does_not_raise():
|
||||
# Ensure that it can deal with fields both in the schema and missing from the schema.
|
||||
config = module.yaml.comments.CommentedMap([('foo', 33), ('bar', 44), ('baz', 55)])
|
||||
schema = {'map': {'foo': {'desc': 'Foo'}, 'bar': {'desc': 'Bar'}}}
|
||||
schema = {
|
||||
'type': 'object',
|
||||
'properties': {'foo': {'description': 'Foo'}, 'bar': {'description': 'Bar'}},
|
||||
}
|
||||
|
||||
module.add_comments_to_configuration_map(config, schema)
|
||||
module.add_comments_to_configuration_object(config, schema)
|
||||
|
||||
|
||||
def test_add_comments_to_configuration_map_with_skip_first_does_not_raise():
|
||||
def test_add_comments_to_configuration_object_with_skip_first_does_not_raise():
|
||||
config = module.yaml.comments.CommentedMap([('foo', 33)])
|
||||
schema = {'map': {'foo': {'desc': 'Foo'}}}
|
||||
schema = {'type': 'object', 'properties': {'foo': {'description': 'Foo'}}}
|
||||
|
||||
module.add_comments_to_configuration_map(config, schema, skip_first=True)
|
||||
module.add_comments_to_configuration_object(config, schema, skip_first=True)
|
||||
|
||||
|
||||
def test_remove_commented_out_sentinel_keeps_other_comments():
|
||||
@@ -194,7 +206,7 @@ def test_generate_sample_configuration_does_not_raise():
|
||||
flexmock(module.yaml).should_receive('round_trip_load')
|
||||
flexmock(module).should_receive('_schema_to_sample_configuration')
|
||||
flexmock(module).should_receive('merge_source_configuration_into_destination')
|
||||
flexmock(module).should_receive('_render_configuration')
|
||||
flexmock(module).should_receive('render_configuration')
|
||||
flexmock(module).should_receive('_comment_out_optional_configuration')
|
||||
flexmock(module).should_receive('write_configuration')
|
||||
|
||||
@@ -206,9 +218,10 @@ def test_generate_sample_configuration_with_source_filename_does_not_raise():
|
||||
builtins.should_receive('open').with_args('schema.yaml').and_return('')
|
||||
flexmock(module.yaml).should_receive('round_trip_load')
|
||||
flexmock(module.load).should_receive('load_configuration')
|
||||
flexmock(module.normalize).should_receive('normalize')
|
||||
flexmock(module).should_receive('_schema_to_sample_configuration')
|
||||
flexmock(module).should_receive('merge_source_configuration_into_destination')
|
||||
flexmock(module).should_receive('_render_configuration')
|
||||
flexmock(module).should_receive('render_configuration')
|
||||
flexmock(module).should_receive('_comment_out_optional_configuration')
|
||||
flexmock(module).should_receive('write_configuration')
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import io
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -14,49 +15,360 @@ def test_load_configuration_parses_contents():
|
||||
assert module.load_configuration('config.yaml') == {'key': 'value'}
|
||||
|
||||
|
||||
def test_load_configuration_inlines_include():
|
||||
def test_load_configuration_inlines_include_relative_to_current_directory():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('include.yaml').and_return('value')
|
||||
builtins.should_receive('open').with_args('config.yaml').and_return(
|
||||
'key: !include include.yaml'
|
||||
)
|
||||
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
|
||||
flexmock(module.os.path).should_receive('isabs').and_return(False)
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True)
|
||||
include_file = io.StringIO('value')
|
||||
include_file.name = 'include.yaml'
|
||||
builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
|
||||
config_file = io.StringIO('key: !include include.yaml')
|
||||
config_file.name = 'config.yaml'
|
||||
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
|
||||
|
||||
assert module.load_configuration('config.yaml') == {'key': 'value'}
|
||||
|
||||
|
||||
def test_load_configuration_inlines_include_relative_to_config_parent_directory():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
|
||||
flexmock(module.os.path).should_receive('isabs').with_args('/etc').and_return(True)
|
||||
flexmock(module.os.path).should_receive('isabs').with_args('/etc/config.yaml').and_return(True)
|
||||
flexmock(module.os.path).should_receive('isabs').with_args('include.yaml').and_return(False)
|
||||
flexmock(module.os.path).should_receive('exists').with_args('/tmp/include.yaml').and_return(
|
||||
False
|
||||
)
|
||||
flexmock(module.os.path).should_receive('exists').with_args('/etc/include.yaml').and_return(
|
||||
True
|
||||
)
|
||||
include_file = io.StringIO('value')
|
||||
include_file.name = 'include.yaml'
|
||||
builtins.should_receive('open').with_args('/etc/include.yaml').and_return(include_file)
|
||||
config_file = io.StringIO('key: !include include.yaml')
|
||||
config_file.name = '/etc/config.yaml'
|
||||
builtins.should_receive('open').with_args('/etc/config.yaml').and_return(config_file)
|
||||
|
||||
assert module.load_configuration('/etc/config.yaml') == {'key': 'value'}
|
||||
|
||||
|
||||
def test_load_configuration_raises_if_relative_include_does_not_exist():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
|
||||
flexmock(module.os.path).should_receive('isabs').with_args('/etc').and_return(True)
|
||||
flexmock(module.os.path).should_receive('isabs').with_args('/etc/config.yaml').and_return(True)
|
||||
flexmock(module.os.path).should_receive('isabs').with_args('include.yaml').and_return(False)
|
||||
flexmock(module.os.path).should_receive('exists').and_return(False)
|
||||
config_file = io.StringIO('key: !include include.yaml')
|
||||
config_file.name = '/etc/config.yaml'
|
||||
builtins.should_receive('open').with_args('/etc/config.yaml').and_return(config_file)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
module.load_configuration('/etc/config.yaml')
|
||||
|
||||
|
||||
def test_load_configuration_inlines_absolute_include():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
|
||||
flexmock(module.os.path).should_receive('isabs').and_return(True)
|
||||
flexmock(module.os.path).should_receive('exists').never()
|
||||
include_file = io.StringIO('value')
|
||||
include_file.name = '/root/include.yaml'
|
||||
builtins.should_receive('open').with_args('/root/include.yaml').and_return(include_file)
|
||||
config_file = io.StringIO('key: !include /root/include.yaml')
|
||||
config_file.name = 'config.yaml'
|
||||
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
|
||||
|
||||
assert module.load_configuration('config.yaml') == {'key': 'value'}
|
||||
|
||||
|
||||
def test_load_configuration_raises_if_absolute_include_does_not_exist():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
|
||||
flexmock(module.os.path).should_receive('isabs').and_return(True)
|
||||
builtins.should_receive('open').with_args('/root/include.yaml').and_raise(FileNotFoundError)
|
||||
config_file = io.StringIO('key: !include /root/include.yaml')
|
||||
config_file.name = 'config.yaml'
|
||||
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
assert module.load_configuration('config.yaml')
|
||||
|
||||
|
||||
def test_load_configuration_merges_include():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('include.yaml').and_return(
|
||||
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
|
||||
flexmock(module.os.path).should_receive('isabs').and_return(False)
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True)
|
||||
include_file = io.StringIO(
|
||||
'''
|
||||
foo: bar
|
||||
baz: quux
|
||||
'''
|
||||
)
|
||||
builtins.should_receive('open').with_args('config.yaml').and_return(
|
||||
include_file.name = 'include.yaml'
|
||||
builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
|
||||
config_file = io.StringIO(
|
||||
'''
|
||||
foo: override
|
||||
<<: !include include.yaml
|
||||
'''
|
||||
)
|
||||
config_file.name = 'config.yaml'
|
||||
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
|
||||
|
||||
assert module.load_configuration('config.yaml') == {'foo': 'override', 'baz': 'quux'}
|
||||
|
||||
|
||||
def test_load_configuration_does_not_merge_include_list():
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('include.yaml').and_return(
|
||||
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
|
||||
flexmock(module.os.path).should_receive('isabs').and_return(False)
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True)
|
||||
include_file = io.StringIO(
|
||||
'''
|
||||
- one
|
||||
- two
|
||||
'''
|
||||
)
|
||||
builtins.should_receive('open').with_args('config.yaml').and_return(
|
||||
include_file.name = 'include.yaml'
|
||||
builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
|
||||
config_file = io.StringIO(
|
||||
'''
|
||||
foo: bar
|
||||
repositories:
|
||||
<<: !include include.yaml
|
||||
'''
|
||||
)
|
||||
config_file.name = 'config.yaml'
|
||||
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
|
||||
|
||||
with pytest.raises(ruamel.yaml.error.YAMLError):
|
||||
assert module.load_configuration('config.yaml')
|
||||
|
||||
|
||||
def test_deep_merge_nodes_replaces_colliding_scalar_values():
|
||||
node_values = [
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag='tag:yaml.org,2002:map',
|
||||
value=[
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='keep_hourly'
|
||||
),
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='24'),
|
||||
),
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='keep_daily'
|
||||
),
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='7'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag='tag:yaml.org,2002:map',
|
||||
value=[
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='keep_daily'
|
||||
),
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='5'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
result = module.deep_merge_nodes(node_values)
|
||||
assert len(result) == 1
|
||||
(section_key, section_value) = result[0]
|
||||
assert section_key.value == 'retention'
|
||||
options = section_value.value
|
||||
assert len(options) == 2
|
||||
assert options[0][0].value == 'keep_hourly'
|
||||
assert options[0][1].value == '24'
|
||||
assert options[1][0].value == 'keep_daily'
|
||||
assert options[1][1].value == '5'
|
||||
|
||||
|
||||
def test_deep_merge_nodes_keeps_non_colliding_scalar_values():
|
||||
node_values = [
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag='tag:yaml.org,2002:map',
|
||||
value=[
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='keep_hourly'
|
||||
),
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='24'),
|
||||
),
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='keep_daily'
|
||||
),
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='7'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag='tag:yaml.org,2002:map',
|
||||
value=[
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='keep_minutely'
|
||||
),
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='10'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
result = module.deep_merge_nodes(node_values)
|
||||
assert len(result) == 1
|
||||
(section_key, section_value) = result[0]
|
||||
assert section_key.value == 'retention'
|
||||
options = section_value.value
|
||||
assert len(options) == 3
|
||||
assert options[0][0].value == 'keep_hourly'
|
||||
assert options[0][1].value == '24'
|
||||
assert options[1][0].value == 'keep_daily'
|
||||
assert options[1][1].value == '7'
|
||||
assert options[2][0].value == 'keep_minutely'
|
||||
assert options[2][1].value == '10'
|
||||
|
||||
|
||||
def test_deep_merge_nodes_keeps_deeply_nested_values():
|
||||
node_values = [
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='storage'),
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag='tag:yaml.org,2002:map',
|
||||
value=[
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='lock_wait'
|
||||
),
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='5'),
|
||||
),
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='extra_borg_options'
|
||||
),
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag='tag:yaml.org,2002:map',
|
||||
value=[
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='init'
|
||||
),
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='--init-option'
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='storage'),
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag='tag:yaml.org,2002:map',
|
||||
value=[
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='extra_borg_options'
|
||||
),
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag='tag:yaml.org,2002:map',
|
||||
value=[
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='prune'
|
||||
),
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='--prune-option'
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
result = module.deep_merge_nodes(node_values)
|
||||
assert len(result) == 1
|
||||
(section_key, section_value) = result[0]
|
||||
assert section_key.value == 'storage'
|
||||
options = section_value.value
|
||||
assert len(options) == 2
|
||||
assert options[0][0].value == 'lock_wait'
|
||||
assert options[0][1].value == '5'
|
||||
assert options[1][0].value == 'extra_borg_options'
|
||||
nested_options = options[1][1].value
|
||||
assert len(nested_options) == 2
|
||||
assert nested_options[0][0].value == 'init'
|
||||
assert nested_options[0][1].value == '--init-option'
|
||||
assert nested_options[1][0].value == 'prune'
|
||||
assert nested_options[1][1].value == '--prune-option'
|
||||
|
||||
|
||||
def test_deep_merge_nodes_appends_colliding_sequence_values():
|
||||
node_values = [
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'),
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag='tag:yaml.org,2002:map',
|
||||
value=[
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='before_backup'
|
||||
),
|
||||
ruamel.yaml.nodes.SequenceNode(
|
||||
tag='tag:yaml.org,2002:int', value=['echo 1', 'echo 2']
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'),
|
||||
ruamel.yaml.nodes.MappingNode(
|
||||
tag='tag:yaml.org,2002:map',
|
||||
value=[
|
||||
(
|
||||
ruamel.yaml.nodes.ScalarNode(
|
||||
tag='tag:yaml.org,2002:str', value='before_backup'
|
||||
),
|
||||
ruamel.yaml.nodes.SequenceNode(
|
||||
tag='tag:yaml.org,2002:int', value=['echo 3', 'echo 4']
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
result = module.deep_merge_nodes(node_values)
|
||||
assert len(result) == 1
|
||||
(section_key, section_value) = result[0]
|
||||
assert section_key.value == 'hooks'
|
||||
options = section_value.value
|
||||
assert len(options) == 1
|
||||
assert options[0][0].value == 'before_backup'
|
||||
assert options[0][1].value == ['echo 1', 'echo 2', 'echo 3', 'echo 4']
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
MAXIMUM_LINE_LENGTH = 80
|
||||
|
||||
|
||||
def test_schema_line_length_stays_under_limit():
|
||||
schema_file = open('borgmatic/config/schema.yaml')
|
||||
|
||||
for line in schema_file.readlines():
|
||||
assert len(line.rstrip('\n')) <= MAXIMUM_LINE_LENGTH
|
||||
@@ -21,14 +21,20 @@ def mock_config_and_schema(config_yaml, schema_yaml=None):
|
||||
when parsing the configuration.
|
||||
'''
|
||||
config_stream = io.StringIO(config_yaml)
|
||||
config_stream.name = 'config.yaml'
|
||||
|
||||
if schema_yaml is None:
|
||||
schema_stream = open(module.schema_filename())
|
||||
else:
|
||||
schema_stream = io.StringIO(schema_yaml)
|
||||
schema_stream.name = 'schema.yaml'
|
||||
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('config.yaml').and_return(config_stream)
|
||||
builtins.should_receive('open').with_args('schema.yaml').and_return(schema_stream)
|
||||
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
|
||||
flexmock(module.os.path).should_receive('isabs').and_return(False)
|
||||
flexmock(module.os.path).should_receive('exists').and_return(True)
|
||||
builtins.should_receive('open').with_args('/tmp/config.yaml').and_return(config_stream)
|
||||
builtins.should_receive('open').with_args('/tmp/schema.yaml').and_return(schema_stream)
|
||||
|
||||
|
||||
def test_parse_configuration_transforms_file_into_mapping():
|
||||
@@ -49,17 +55,17 @@ def test_parse_configuration_transforms_file_into_mapping():
|
||||
|
||||
consistency:
|
||||
checks:
|
||||
- repository
|
||||
- archives
|
||||
- name: repository
|
||||
- name: archives
|
||||
'''
|
||||
)
|
||||
|
||||
result = module.parse_configuration('config.yaml', 'schema.yaml')
|
||||
result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
|
||||
|
||||
assert result == {
|
||||
'location': {'source_directories': ['/home', '/etc'], 'repositories': ['hostname.borg']},
|
||||
'retention': {'keep_daily': 7, 'keep_hourly': 24, 'keep_minutely': 60},
|
||||
'consistency': {'checks': ['repository', 'archives']},
|
||||
'consistency': {'checks': [{'name': 'repository'}, {'name': 'archives'}]},
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +85,7 @@ def test_parse_configuration_passes_through_quoted_punctuation():
|
||||
)
|
||||
)
|
||||
|
||||
result = module.parse_configuration('config.yaml', 'schema.yaml')
|
||||
result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
|
||||
|
||||
assert result == {
|
||||
'location': {
|
||||
@@ -115,7 +121,7 @@ def test_parse_configuration_with_schema_lacking_examples_does_not_raise():
|
||||
''',
|
||||
)
|
||||
|
||||
module.parse_configuration('config.yaml', 'schema.yaml')
|
||||
module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
|
||||
|
||||
|
||||
def test_parse_configuration_inlines_include():
|
||||
@@ -133,14 +139,16 @@ def test_parse_configuration_inlines_include():
|
||||
'''
|
||||
)
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('include.yaml').and_return(
|
||||
include_file = io.StringIO(
|
||||
'''
|
||||
keep_daily: 7
|
||||
keep_hourly: 24
|
||||
'''
|
||||
)
|
||||
include_file.name = 'include.yaml'
|
||||
builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
|
||||
|
||||
result = module.parse_configuration('config.yaml', 'schema.yaml')
|
||||
result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
|
||||
|
||||
assert result == {
|
||||
'location': {'source_directories': ['/home'], 'repositories': ['hostname.borg']},
|
||||
@@ -164,14 +172,16 @@ def test_parse_configuration_merges_include():
|
||||
'''
|
||||
)
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('include.yaml').and_return(
|
||||
include_file = io.StringIO(
|
||||
'''
|
||||
keep_daily: 7
|
||||
keep_hourly: 24
|
||||
'''
|
||||
)
|
||||
include_file.name = 'include.yaml'
|
||||
builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
|
||||
|
||||
result = module.parse_configuration('config.yaml', 'schema.yaml')
|
||||
result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
|
||||
|
||||
assert result == {
|
||||
'location': {'source_directories': ['/home'], 'repositories': ['hostname.borg']},
|
||||
@@ -181,23 +191,23 @@ def test_parse_configuration_merges_include():
|
||||
|
||||
def test_parse_configuration_raises_for_missing_config_file():
|
||||
with pytest.raises(FileNotFoundError):
|
||||
module.parse_configuration('config.yaml', 'schema.yaml')
|
||||
module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
|
||||
|
||||
|
||||
def test_parse_configuration_raises_for_missing_schema_file():
|
||||
mock_config_and_schema('')
|
||||
builtins = flexmock(sys.modules['builtins'])
|
||||
builtins.should_receive('open').with_args('schema.yaml').and_raise(FileNotFoundError)
|
||||
builtins.should_receive('open').with_args('/tmp/schema.yaml').and_raise(FileNotFoundError)
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
module.parse_configuration('config.yaml', 'schema.yaml')
|
||||
module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
|
||||
|
||||
|
||||
def test_parse_configuration_raises_for_syntax_error():
|
||||
mock_config_and_schema('foo:\nbar')
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
module.parse_configuration('config.yaml', 'schema.yaml')
|
||||
module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
|
||||
|
||||
|
||||
def test_parse_configuration_raises_for_validation_error():
|
||||
@@ -211,7 +221,7 @@ def test_parse_configuration_raises_for_validation_error():
|
||||
)
|
||||
|
||||
with pytest.raises(module.Validation_error):
|
||||
module.parse_configuration('config.yaml', 'schema.yaml')
|
||||
module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
|
||||
|
||||
|
||||
def test_parse_configuration_applies_overrides():
|
||||
@@ -229,7 +239,7 @@ def test_parse_configuration_applies_overrides():
|
||||
)
|
||||
|
||||
result = module.parse_configuration(
|
||||
'config.yaml', 'schema.yaml', overrides=['location.local_path=borg2']
|
||||
'/tmp/config.yaml', '/tmp/schema.yaml', overrides=['location.local_path=borg2']
|
||||
)
|
||||
|
||||
assert result == {
|
||||
@@ -255,7 +265,7 @@ def test_parse_configuration_applies_normalization():
|
||||
'''
|
||||
)
|
||||
|
||||
result = module.parse_configuration('config.yaml', 'schema.yaml')
|
||||
result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
|
||||
|
||||
assert result == {
|
||||
'location': {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import logging
|
||||
|
||||
from flexmock import flexmock
|
||||
|
||||
from borgmatic.hooks import healthchecks as module
|
||||
|
||||
|
||||
def test_destroy_monitor_removes_healthchecks_handler():
|
||||
logger = logging.getLogger()
|
||||
original_handlers = list(logger.handlers)
|
||||
logger.addHandler(module.Forgetful_buffering_handler(byte_capacity=100, log_level=1))
|
||||
|
||||
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock())
|
||||
|
||||
assert logger.handlers == original_handlers
|
||||
|
||||
|
||||
def test_destroy_monitor_without_healthchecks_handler_does_not_raise():
|
||||
logger = logging.getLogger()
|
||||
original_handlers = list(logger.handlers)
|
||||
|
||||
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock())
|
||||
|
||||
assert logger.handlers == original_handlers
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from flexmock import flexmock
|
||||
@@ -88,13 +89,21 @@ def test_log_outputs_skips_error_output_in_exception_for_process_with_none_stdou
|
||||
|
||||
def test_log_outputs_kills_other_processes_when_one_errors():
|
||||
flexmock(module.logger).should_receive('log')
|
||||
flexmock(module).should_receive('exit_code_indicates_error').and_return(True)
|
||||
flexmock(module).should_receive('command_for_process').and_return('grep')
|
||||
|
||||
process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
flexmock(module).should_receive('exit_code_indicates_error').with_args(
|
||||
process, None, 'borg'
|
||||
).and_return(False)
|
||||
flexmock(module).should_receive('exit_code_indicates_error').with_args(
|
||||
process, 2, 'borg'
|
||||
).and_return(True)
|
||||
other_process = subprocess.Popen(
|
||||
['watch', 'true'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
||||
['sleep', '2'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
||||
)
|
||||
flexmock(module).should_receive('exit_code_indicates_error').with_args(
|
||||
other_process, None, 'borg'
|
||||
).and_return(False)
|
||||
flexmock(module).should_receive('output_buffer_for_process').with_args(process, ()).and_return(
|
||||
process.stdout
|
||||
)
|
||||
@@ -115,13 +124,87 @@ def test_log_outputs_kills_other_processes_when_one_errors():
|
||||
assert error.value.output
|
||||
|
||||
|
||||
def test_log_outputs_vents_other_processes_when_one_exits():
|
||||
'''
|
||||
Execute a command to generate a longish random string and pipe it into another command that
|
||||
exits quickly. The test is basically to ensure we don't hang forever waiting for the exited
|
||||
process to read the pipe, and that the string-generating process eventually gets vented and
|
||||
exits.
|
||||
'''
|
||||
flexmock(module.logger).should_receive('log')
|
||||
flexmock(module).should_receive('command_for_process').and_return('grep')
|
||||
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
'-c',
|
||||
"import random, string; print(''.join(random.choice(string.ascii_letters) for _ in range(40000)))",
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
other_process = subprocess.Popen(
|
||||
['true'], stdin=process.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
||||
)
|
||||
flexmock(module).should_receive('output_buffer_for_process').with_args(
|
||||
process, (process.stdout,)
|
||||
).and_return(process.stderr)
|
||||
flexmock(module).should_receive('output_buffer_for_process').with_args(
|
||||
other_process, (process.stdout,)
|
||||
).and_return(other_process.stdout)
|
||||
flexmock(process.stdout).should_call('readline').at_least().once()
|
||||
|
||||
module.log_outputs(
|
||||
(process, other_process),
|
||||
exclude_stdouts=(process.stdout,),
|
||||
output_log_level=logging.INFO,
|
||||
borg_local_path='borg',
|
||||
)
|
||||
|
||||
|
||||
def test_log_outputs_does_not_error_when_one_process_exits():
|
||||
flexmock(module.logger).should_receive('log')
|
||||
flexmock(module).should_receive('command_for_process').and_return('grep')
|
||||
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
'-c',
|
||||
"import random, string; print(''.join(random.choice(string.ascii_letters) for _ in range(40000)))",
|
||||
],
|
||||
stdout=None, # Specifically test the case of a process without stdout captured.
|
||||
stderr=None,
|
||||
)
|
||||
other_process = subprocess.Popen(
|
||||
['true'], stdin=process.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
||||
)
|
||||
flexmock(module).should_receive('output_buffer_for_process').with_args(
|
||||
process, (process.stdout,)
|
||||
).and_return(process.stderr)
|
||||
flexmock(module).should_receive('output_buffer_for_process').with_args(
|
||||
other_process, (process.stdout,)
|
||||
).and_return(other_process.stdout)
|
||||
|
||||
module.log_outputs(
|
||||
(process, other_process),
|
||||
exclude_stdouts=(process.stdout,),
|
||||
output_log_level=logging.INFO,
|
||||
borg_local_path='borg',
|
||||
)
|
||||
|
||||
|
||||
def test_log_outputs_truncates_long_error_output():
|
||||
flexmock(module).ERROR_OUTPUT_MAX_LINE_COUNT = 0
|
||||
flexmock(module.logger).should_receive('log')
|
||||
flexmock(module).should_receive('exit_code_indicates_error').and_return(True)
|
||||
flexmock(module).should_receive('command_for_process').and_return('grep')
|
||||
|
||||
process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
flexmock(module).should_receive('exit_code_indicates_error').with_args(
|
||||
process, None, 'borg'
|
||||
).and_return(False)
|
||||
flexmock(module).should_receive('exit_code_indicates_error').with_args(
|
||||
process, 2, 'borg'
|
||||
).and_return(True)
|
||||
flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout)
|
||||
|
||||
with pytest.raises(subprocess.CalledProcessError) as error:
|
||||
@@ -143,3 +226,16 @@ def test_log_outputs_with_no_output_logs_nothing():
|
||||
module.log_outputs(
|
||||
(process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg'
|
||||
)
|
||||
|
||||
|
||||
def test_log_outputs_with_unfinished_process_re_polls():
|
||||
flexmock(module.logger).should_receive('log').never()
|
||||
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
|
||||
|
||||
process = subprocess.Popen(['true'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
flexmock(process).should_receive('poll').and_return(None).and_return(0).twice()
|
||||
flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout)
|
||||
|
||||
module.log_outputs(
|
||||
(process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import logging
|
||||
|
||||
from flexmock import flexmock
|
||||
|
||||
from borgmatic.borg import borg as module
|
||||
|
||||
from ..test_verbosity import insert_logging_mock
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_calls_borg_with_parameters():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'break-lock', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg'
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['break-lock'],
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_with_log_info_calls_borg_with_info_parameter():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'break-lock', 'repo', '--info'),
|
||||
output_log_level=logging.WARNING,
|
||||
borg_local_path='borg',
|
||||
)
|
||||
insert_logging_mock(logging.INFO)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['break-lock'],
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_with_log_debug_calls_borg_with_debug_parameter():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'break-lock', 'repo', '--debug', '--show-rc'),
|
||||
output_log_level=logging.WARNING,
|
||||
borg_local_path='borg',
|
||||
)
|
||||
insert_logging_mock(logging.DEBUG)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['break-lock'],
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_with_lock_wait_calls_borg_with_lock_wait_parameters():
|
||||
storage_config = {'lock_wait': 5}
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'break-lock', 'repo', '--lock-wait', '5'),
|
||||
output_log_level=logging.WARNING,
|
||||
borg_local_path='borg',
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config=storage_config, options=['break-lock'],
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_with_archive_calls_borg_with_archive_parameter():
|
||||
storage_config = {}
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'break-lock', 'repo::archive'),
|
||||
output_log_level=logging.WARNING,
|
||||
borg_local_path='borg',
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config=storage_config, options=['break-lock'], archive='archive',
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_with_local_path_calls_borg_via_local_path():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg1', 'break-lock', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg1'
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['break-lock'], local_path='borg1',
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_with_remote_path_calls_borg_with_remote_path_parameters():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'break-lock', 'repo', '--remote-path', 'borg1'),
|
||||
output_log_level=logging.WARNING,
|
||||
borg_local_path='borg',
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['break-lock'], remote_path='borg1',
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_passes_borg_specific_parameters_to_borg():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'list', 'repo', '--progress'),
|
||||
output_log_level=logging.WARNING,
|
||||
borg_local_path='borg',
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['list', '--progress'],
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_omits_dash_dash_in_parameters_passed_to_borg():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'break-lock', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg',
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['--', 'break-lock'],
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_without_borg_specific_parameters_does_not_raise():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg',), output_log_level=logging.WARNING, borg_local_path='borg',
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=[],
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_passes_key_sub_command_to_borg_before_repository():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'key', 'export', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg',
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['key', 'export'],
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_passes_debug_sub_command_to_borg_before_repository():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'debug', 'dump-manifest', 'repo', 'path'),
|
||||
output_log_level=logging.WARNING,
|
||||
borg_local_path='borg',
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['debug', 'dump-manifest', 'path'],
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_with_debug_info_command_does_not_pass_borg_repository():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'debug', 'info'), output_log_level=logging.WARNING, borg_local_path='borg',
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['debug', 'info'],
|
||||
)
|
||||
|
||||
|
||||
def test_run_arbitrary_borg_with_debug_convert_profile_command_does_not_pass_borg_repository():
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'debug', 'convert-profile', 'in', 'out'),
|
||||
output_log_level=logging.WARNING,
|
||||
borg_local_path='borg',
|
||||
)
|
||||
|
||||
module.run_arbitrary_borg(
|
||||
repository='repo', storage_config={}, options=['debug', 'convert-profile', 'in', 'out'],
|
||||
)
|
||||
+341
-60
@@ -17,172 +17,336 @@ def insert_execute_command_never():
|
||||
|
||||
|
||||
def test_parse_checks_returns_them_as_tuple():
|
||||
checks = module._parse_checks({'checks': ['foo', 'disabled', 'bar']})
|
||||
checks = module.parse_checks({'checks': [{'name': 'foo'}, {'name': 'bar'}]})
|
||||
|
||||
assert checks == ('foo', 'bar')
|
||||
|
||||
|
||||
def test_parse_checks_with_missing_value_returns_defaults():
|
||||
checks = module._parse_checks({})
|
||||
checks = module.parse_checks({})
|
||||
|
||||
assert checks == module.DEFAULT_CHECKS
|
||||
assert checks == ('repository', 'archives')
|
||||
|
||||
|
||||
def test_parse_checks_with_blank_value_returns_defaults():
|
||||
checks = module._parse_checks({'checks': []})
|
||||
def test_parse_checks_with_empty_list_returns_defaults():
|
||||
checks = module.parse_checks({'checks': []})
|
||||
|
||||
assert checks == module.DEFAULT_CHECKS
|
||||
assert checks == ('repository', 'archives')
|
||||
|
||||
|
||||
def test_parse_checks_with_none_value_returns_defaults():
|
||||
checks = module._parse_checks({'checks': None})
|
||||
checks = module.parse_checks({'checks': None})
|
||||
|
||||
assert checks == module.DEFAULT_CHECKS
|
||||
assert checks == ('repository', 'archives')
|
||||
|
||||
|
||||
def test_parse_checks_with_disabled_returns_no_checks():
|
||||
checks = module._parse_checks({'checks': ['disabled']})
|
||||
checks = module.parse_checks({'checks': [{'name': 'foo'}, {'name': 'disabled'}]})
|
||||
|
||||
assert checks == ()
|
||||
|
||||
|
||||
def test_parse_checks_with_data_check_also_injects_archives():
|
||||
checks = module._parse_checks({'checks': ['data']})
|
||||
checks = module.parse_checks({'checks': [{'name': 'data'}]})
|
||||
|
||||
assert checks == ('data', 'archives')
|
||||
|
||||
|
||||
def test_parse_checks_with_data_check_passes_through_archives():
|
||||
checks = module._parse_checks({'checks': ['data', 'archives']})
|
||||
checks = module.parse_checks({'checks': [{'name': 'data'}, {'name': 'archives'}]})
|
||||
|
||||
assert checks == ('data', 'archives')
|
||||
|
||||
|
||||
def test_parse_checks_prefers_override_checks_to_configured_checks():
|
||||
checks = module._parse_checks({'checks': ['archives']}, only_checks=['repository', 'extract'])
|
||||
checks = module.parse_checks(
|
||||
{'checks': [{'name': 'archives'}]}, only_checks=['repository', 'extract']
|
||||
)
|
||||
|
||||
assert checks == ('repository', 'extract')
|
||||
|
||||
|
||||
def test_parse_checks_with_override_data_check_also_injects_archives():
|
||||
checks = module._parse_checks({'checks': ['extract']}, only_checks=['data'])
|
||||
checks = module.parse_checks({'checks': [{'name': 'extract'}]}, only_checks=['data'])
|
||||
|
||||
assert checks == ('data', 'archives')
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'frequency,expected_result',
|
||||
(
|
||||
(None, None),
|
||||
('always', None),
|
||||
('1 hour', module.datetime.timedelta(hours=1)),
|
||||
('2 hours', module.datetime.timedelta(hours=2)),
|
||||
('1 day', module.datetime.timedelta(days=1)),
|
||||
('2 days', module.datetime.timedelta(days=2)),
|
||||
('1 week', module.datetime.timedelta(weeks=1)),
|
||||
('2 weeks', module.datetime.timedelta(weeks=2)),
|
||||
('1 month', module.datetime.timedelta(days=30)),
|
||||
('2 months', module.datetime.timedelta(days=60)),
|
||||
('1 year', module.datetime.timedelta(days=365)),
|
||||
('2 years', module.datetime.timedelta(days=365 * 2)),
|
||||
),
|
||||
)
|
||||
def test_parse_frequency_parses_into_timedeltas(frequency, expected_result):
|
||||
assert module.parse_frequency(frequency) == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'frequency', ('sometime', 'x days', '3 decades',),
|
||||
)
|
||||
def test_parse_frequency_raises_on_parse_error(frequency):
|
||||
with pytest.raises(ValueError):
|
||||
module.parse_frequency(frequency)
|
||||
|
||||
|
||||
def test_filter_checks_on_frequency_without_config_uses_default_checks():
|
||||
flexmock(module).should_receive('parse_frequency').and_return(
|
||||
module.datetime.timedelta(weeks=4)
|
||||
)
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('read_check_time').and_return(None)
|
||||
|
||||
assert module.filter_checks_on_frequency(
|
||||
location_config={},
|
||||
consistency_config={},
|
||||
borg_repository_id='repo',
|
||||
checks=('repository', 'archives'),
|
||||
force=False,
|
||||
) == ('repository', 'archives')
|
||||
|
||||
|
||||
def test_filter_checks_on_frequency_retains_unconfigured_check():
|
||||
assert module.filter_checks_on_frequency(
|
||||
location_config={},
|
||||
consistency_config={},
|
||||
borg_repository_id='repo',
|
||||
checks=('data',),
|
||||
force=False,
|
||||
) == ('data',)
|
||||
|
||||
|
||||
def test_filter_checks_on_frequency_retains_check_without_frequency():
|
||||
flexmock(module).should_receive('parse_frequency').and_return(None)
|
||||
|
||||
assert module.filter_checks_on_frequency(
|
||||
location_config={},
|
||||
consistency_config={'checks': [{'name': 'archives'}]},
|
||||
borg_repository_id='repo',
|
||||
checks=('archives',),
|
||||
force=False,
|
||||
) == ('archives',)
|
||||
|
||||
|
||||
def test_filter_checks_on_frequency_retains_check_with_elapsed_frequency():
|
||||
flexmock(module).should_receive('parse_frequency').and_return(
|
||||
module.datetime.timedelta(hours=1)
|
||||
)
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('read_check_time').and_return(
|
||||
module.datetime.datetime(year=module.datetime.MINYEAR, month=1, day=1)
|
||||
)
|
||||
|
||||
assert module.filter_checks_on_frequency(
|
||||
location_config={},
|
||||
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
|
||||
borg_repository_id='repo',
|
||||
checks=('archives',),
|
||||
force=False,
|
||||
) == ('archives',)
|
||||
|
||||
|
||||
def test_filter_checks_on_frequency_retains_check_with_missing_check_time_file():
|
||||
flexmock(module).should_receive('parse_frequency').and_return(
|
||||
module.datetime.timedelta(hours=1)
|
||||
)
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('read_check_time').and_return(None)
|
||||
|
||||
assert module.filter_checks_on_frequency(
|
||||
location_config={},
|
||||
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
|
||||
borg_repository_id='repo',
|
||||
checks=('archives',),
|
||||
force=False,
|
||||
) == ('archives',)
|
||||
|
||||
|
||||
def test_filter_checks_on_frequency_skips_check_with_unelapsed_frequency():
|
||||
flexmock(module).should_receive('parse_frequency').and_return(
|
||||
module.datetime.timedelta(hours=1)
|
||||
)
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('read_check_time').and_return(module.datetime.datetime.now())
|
||||
|
||||
assert (
|
||||
module.filter_checks_on_frequency(
|
||||
location_config={},
|
||||
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
|
||||
borg_repository_id='repo',
|
||||
checks=('archives',),
|
||||
force=False,
|
||||
)
|
||||
== ()
|
||||
)
|
||||
|
||||
|
||||
def test_filter_checks_on_frequency_restains_check_with_unelapsed_frequency_and_force():
|
||||
assert module.filter_checks_on_frequency(
|
||||
location_config={},
|
||||
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
|
||||
borg_repository_id='repo',
|
||||
checks=('archives',),
|
||||
force=True,
|
||||
) == ('archives',)
|
||||
|
||||
|
||||
def test_make_check_flags_with_repository_check_returns_flag():
|
||||
flags = module._make_check_flags(('repository',))
|
||||
flags = module.make_check_flags(('repository',))
|
||||
|
||||
assert flags == ('--repository-only',)
|
||||
|
||||
|
||||
def test_make_check_flags_with_archives_check_returns_flag():
|
||||
flags = module._make_check_flags(('archives',))
|
||||
flags = module.make_check_flags(('archives',))
|
||||
|
||||
assert flags == ('--archives-only',)
|
||||
|
||||
|
||||
def test_make_check_flags_with_data_check_returns_flag():
|
||||
flags = module._make_check_flags(('data',))
|
||||
flags = module.make_check_flags(('data',))
|
||||
|
||||
assert flags == ('--verify-data',)
|
||||
|
||||
|
||||
def test_make_check_flags_with_extract_omits_extract_flag():
|
||||
flags = module._make_check_flags(('extract',))
|
||||
flags = module.make_check_flags(('extract',))
|
||||
|
||||
assert flags == ()
|
||||
|
||||
|
||||
def test_make_check_flags_with_default_checks_and_default_prefix_returns_default_flags():
|
||||
flags = module._make_check_flags(module.DEFAULT_CHECKS, prefix=module.DEFAULT_PREFIX)
|
||||
flags = module.make_check_flags(('repository', 'archives'), prefix=module.DEFAULT_PREFIX)
|
||||
|
||||
assert flags == ('--prefix', module.DEFAULT_PREFIX)
|
||||
|
||||
|
||||
def test_make_check_flags_with_all_checks_and_default_prefix_returns_default_flags():
|
||||
flags = module._make_check_flags(
|
||||
module.DEFAULT_CHECKS + ('extract',), prefix=module.DEFAULT_PREFIX
|
||||
flags = module.make_check_flags(
|
||||
('repository', 'archives', 'extract'), prefix=module.DEFAULT_PREFIX
|
||||
)
|
||||
|
||||
assert flags == ('--prefix', module.DEFAULT_PREFIX)
|
||||
|
||||
|
||||
def test_make_check_flags_with_archives_check_and_last_includes_last_flag():
|
||||
flags = module._make_check_flags(('archives',), check_last=3)
|
||||
flags = module.make_check_flags(('archives',), check_last=3)
|
||||
|
||||
assert flags == ('--archives-only', '--last', '3')
|
||||
|
||||
|
||||
def test_make_check_flags_with_repository_check_and_last_omits_last_flag():
|
||||
flags = module._make_check_flags(('repository',), check_last=3)
|
||||
flags = module.make_check_flags(('repository',), check_last=3)
|
||||
|
||||
assert flags == ('--repository-only',)
|
||||
|
||||
|
||||
def test_make_check_flags_with_default_checks_and_last_includes_last_flag():
|
||||
flags = module._make_check_flags(module.DEFAULT_CHECKS, check_last=3)
|
||||
flags = module.make_check_flags(('repository', 'archives'), check_last=3)
|
||||
|
||||
assert flags == ('--last', '3')
|
||||
|
||||
|
||||
def test_make_check_flags_with_archives_check_and_prefix_includes_prefix_flag():
|
||||
flags = module._make_check_flags(('archives',), prefix='foo-')
|
||||
flags = module.make_check_flags(('archives',), prefix='foo-')
|
||||
|
||||
assert flags == ('--archives-only', '--prefix', 'foo-')
|
||||
|
||||
|
||||
def test_make_check_flags_with_archives_check_and_empty_prefix_omits_prefix_flag():
|
||||
flags = module._make_check_flags(('archives',), prefix='')
|
||||
flags = module.make_check_flags(('archives',), prefix='')
|
||||
|
||||
assert flags == ('--archives-only',)
|
||||
|
||||
|
||||
def test_make_check_flags_with_archives_check_and_none_prefix_omits_prefix_flag():
|
||||
flags = module._make_check_flags(('archives',), prefix=None)
|
||||
flags = module.make_check_flags(('archives',), prefix=None)
|
||||
|
||||
assert flags == ('--archives-only',)
|
||||
|
||||
|
||||
def test_make_check_flags_with_repository_check_and_prefix_omits_prefix_flag():
|
||||
flags = module._make_check_flags(('repository',), prefix='foo-')
|
||||
flags = module.make_check_flags(('repository',), prefix='foo-')
|
||||
|
||||
assert flags == ('--repository-only',)
|
||||
|
||||
|
||||
def test_make_check_flags_with_default_checks_and_prefix_includes_prefix_flag():
|
||||
flags = module._make_check_flags(module.DEFAULT_CHECKS, prefix='foo-')
|
||||
flags = module.make_check_flags(('repository', 'archives'), prefix='foo-')
|
||||
|
||||
assert flags == ('--prefix', 'foo-')
|
||||
|
||||
|
||||
def test_read_check_time_does_not_raise():
|
||||
flexmock(module.os).should_receive('stat').and_return(flexmock(st_mtime=123))
|
||||
|
||||
assert module.read_check_time('/path')
|
||||
|
||||
|
||||
def test_read_check_time_on_missing_file_does_not_raise():
|
||||
flexmock(module.os).should_receive('stat').and_raise(FileNotFoundError)
|
||||
|
||||
assert module.read_check_time('/path') is None
|
||||
|
||||
|
||||
def test_check_archives_with_progress_calls_borg_with_progress_parameter():
|
||||
checks = ('repository',)
|
||||
consistency_config = {'check_last': None}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').and_return(())
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').and_return(())
|
||||
flexmock(module).should_receive('execute_command').never()
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'check', '--progress', 'repo'), output_file=module.DO_NOT_CAPTURE
|
||||
).once()
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
|
||||
module.check_archives(
|
||||
repository='repo', storage_config={}, consistency_config=consistency_config, progress=True
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
progress=True,
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_repair_calls_borg_with_repair_parameter():
|
||||
checks = ('repository',)
|
||||
consistency_config = {'check_last': None}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').and_return(())
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').and_return(())
|
||||
flexmock(module).should_receive('execute_command').never()
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
('borg', 'check', '--repair', 'repo'), output_file=module.DO_NOT_CAPTURE
|
||||
).once()
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
|
||||
module.check_archives(
|
||||
repository='repo', storage_config={}, consistency_config=consistency_config, repair=True
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
repair=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -198,64 +362,142 @@ def test_check_archives_with_repair_calls_borg_with_repair_parameter():
|
||||
def test_check_archives_calls_borg_with_parameters(checks):
|
||||
check_last = flexmock()
|
||||
consistency_config = {'check_last': check_last}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').with_args(
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').with_args(
|
||||
checks, check_last, module.DEFAULT_PREFIX
|
||||
).and_return(())
|
||||
insert_execute_command_mock(('borg', 'check', 'repo'))
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
|
||||
module.check_archives(
|
||||
repository='repo', storage_config={}, consistency_config=consistency_config
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_json_error_raises():
|
||||
checks = ('archives',)
|
||||
check_last = flexmock()
|
||||
consistency_config = {'check_last': check_last}
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"unexpected": {"id": "repo"}}'
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
module.check_archives(
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_missing_json_keys_raises():
|
||||
checks = ('archives',)
|
||||
check_last = flexmock()
|
||||
consistency_config = {'check_last': check_last}
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return('{invalid JSON')
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
module.check_archives(
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_extract_check_calls_extract_only():
|
||||
checks = ('extract',)
|
||||
check_last = flexmock()
|
||||
consistency_config = {'check_last': check_last}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').never()
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').never()
|
||||
flexmock(module.extract).should_receive('extract_last_archive_dry_run').once()
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
insert_execute_command_never()
|
||||
|
||||
module.check_archives(
|
||||
repository='repo', storage_config={}, consistency_config=consistency_config
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_log_info_calls_borg_with_info_parameter():
|
||||
checks = ('repository',)
|
||||
consistency_config = {'check_last': None}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').and_return(())
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').and_return(())
|
||||
insert_logging_mock(logging.INFO)
|
||||
insert_execute_command_mock(('borg', 'check', '--info', 'repo'))
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
|
||||
module.check_archives(
|
||||
repository='repo', storage_config={}, consistency_config=consistency_config
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_log_debug_calls_borg_with_debug_parameter():
|
||||
checks = ('repository',)
|
||||
consistency_config = {'check_last': None}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').and_return(())
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').and_return(())
|
||||
insert_logging_mock(logging.DEBUG)
|
||||
insert_execute_command_mock(('borg', 'check', '--debug', '--show-rc', 'repo'))
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
|
||||
module.check_archives(
|
||||
repository='repo', storage_config={}, consistency_config=consistency_config
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_without_any_checks_bails():
|
||||
consistency_config = {'check_last': None}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(())
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(())
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
insert_execute_command_never()
|
||||
|
||||
module.check_archives(
|
||||
repository='repo', storage_config={}, consistency_config=consistency_config
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -263,14 +505,21 @@ def test_check_archives_with_local_path_calls_borg_via_local_path():
|
||||
checks = ('repository',)
|
||||
check_last = flexmock()
|
||||
consistency_config = {'check_last': check_last}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').with_args(
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').with_args(
|
||||
checks, check_last, module.DEFAULT_PREFIX
|
||||
).and_return(())
|
||||
insert_execute_command_mock(('borg1', 'check', 'repo'))
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
|
||||
module.check_archives(
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
local_path='borg1',
|
||||
@@ -281,14 +530,21 @@ def test_check_archives_with_remote_path_calls_borg_with_remote_path_parameters(
|
||||
checks = ('repository',)
|
||||
check_last = flexmock()
|
||||
consistency_config = {'check_last': check_last}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').with_args(
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').with_args(
|
||||
checks, check_last, module.DEFAULT_PREFIX
|
||||
).and_return(())
|
||||
insert_execute_command_mock(('borg', 'check', '--remote-path', 'borg1', 'repo'))
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
|
||||
module.check_archives(
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
remote_path='borg1',
|
||||
@@ -299,14 +555,23 @@ def test_check_archives_with_lock_wait_calls_borg_with_lock_wait_parameters():
|
||||
checks = ('repository',)
|
||||
check_last = flexmock()
|
||||
consistency_config = {'check_last': check_last}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').with_args(
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').with_args(
|
||||
checks, check_last, module.DEFAULT_PREFIX
|
||||
).and_return(())
|
||||
insert_execute_command_mock(('borg', 'check', '--lock-wait', '5', 'repo'))
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
|
||||
module.check_archives(
|
||||
repository='repo', storage_config={'lock_wait': 5}, consistency_config=consistency_config
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={'lock_wait': 5},
|
||||
consistency_config=consistency_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -315,26 +580,42 @@ def test_check_archives_with_retention_prefix():
|
||||
check_last = flexmock()
|
||||
prefix = 'foo-'
|
||||
consistency_config = {'check_last': check_last, 'prefix': prefix}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').with_args(
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').with_args(
|
||||
checks, check_last, prefix
|
||||
).and_return(())
|
||||
insert_execute_command_mock(('borg', 'check', 'repo'))
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
|
||||
module.check_archives(
|
||||
repository='repo', storage_config={}, consistency_config=consistency_config
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={},
|
||||
consistency_config=consistency_config,
|
||||
)
|
||||
|
||||
|
||||
def test_check_archives_with_extra_borg_options_calls_borg_with_extra_options():
|
||||
checks = ('repository',)
|
||||
consistency_config = {'check_last': None}
|
||||
flexmock(module).should_receive('_parse_checks').and_return(checks)
|
||||
flexmock(module).should_receive('_make_check_flags').and_return(())
|
||||
flexmock(module).should_receive('parse_checks')
|
||||
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
|
||||
flexmock(module.info).should_receive('display_archives_info').and_return(
|
||||
'{"repository": {"id": "repo"}}'
|
||||
)
|
||||
flexmock(module).should_receive('make_check_flags').and_return(())
|
||||
insert_execute_command_mock(('borg', 'check', '--extra', '--options', 'repo'))
|
||||
flexmock(module).should_receive('make_check_time_path')
|
||||
flexmock(module).should_receive('write_check_time')
|
||||
|
||||
module.check_archives(
|
||||
repository='repo',
|
||||
location_config={},
|
||||
storage_config={'extra_borg_options': {'check': '--extra --options'}},
|
||||
consistency_config=consistency_config,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import logging
|
||||
|
||||
from flexmock import flexmock
|
||||
|
||||
from borgmatic.borg import compact as module
|
||||
|
||||
from ..test_verbosity import insert_logging_mock
|
||||
|
||||
|
||||
def insert_execute_command_mock(compact_command, output_log_level):
|
||||
flexmock(module).should_receive('execute_command').with_args(
|
||||
compact_command, output_log_level=output_log_level, borg_local_path=compact_command[0]
|
||||
).once()
|
||||
|
||||
|
||||
COMPACT_COMMAND = ('borg', 'compact')
|
||||
|
||||
|
||||
def test_compact_segments_calls_borg_with_parameters():
|
||||
insert_execute_command_mock(COMPACT_COMMAND + ('repo',), logging.INFO)
|
||||
|
||||
module.compact_segments(dry_run=False, repository='repo', storage_config={})
|
||||
|
||||
|
||||
def test_compact_segments_with_log_info_calls_borg_with_info_parameter():
|
||||
insert_execute_command_mock(COMPACT_COMMAND + ('--info', 'repo'), logging.INFO)
|
||||
insert_logging_mock(logging.INFO)
|
||||
|
||||
module.compact_segments(repository='repo', storage_config={}, dry_run=False)
|
||||
|
||||
|
||||
def test_compact_segments_with_log_debug_calls_borg_with_debug_parameter():
|
||||
insert_execute_command_mock(COMPACT_COMMAND + ('--debug', '--show-rc', 'repo'), logging.INFO)
|
||||
insert_logging_mock(logging.DEBUG)
|
||||
|
||||
module.compact_segments(repository='repo', storage_config={}, dry_run=False)
|
||||
|
||||
|
||||
def test_compact_segments_with_dry_run_skips_borg_call():
|
||||
flexmock(module).should_receive('execute_command').never()
|
||||
|
||||
module.compact_segments(repository='repo', storage_config={}, dry_run=True)
|
||||
|
||||
|
||||
def test_compact_segments_with_local_path_calls_borg_via_local_path():
|
||||
insert_execute_command_mock(('borg1',) + COMPACT_COMMAND[1:] + ('repo',), logging.INFO)
|
||||
|
||||
module.compact_segments(
|
||||
dry_run=False, repository='repo', storage_config={}, local_path='borg1',
|
||||
)
|
||||
|
||||
|
||||
def test_compact_segments_with_remote_path_calls_borg_with_remote_path_parameters():
|
||||
insert_execute_command_mock(COMPACT_COMMAND + ('--remote-path', 'borg1', 'repo'), logging.INFO)
|
||||
|
||||
module.compact_segments(
|
||||
dry_run=False, repository='repo', storage_config={}, remote_path='borg1',
|
||||
)
|
||||
|
||||
|
||||
def test_compact_segments_with_progress_calls_borg_with_progress_parameter():
|
||||
insert_execute_command_mock(COMPACT_COMMAND + ('--progress', 'repo'), logging.INFO)
|
||||
|
||||
module.compact_segments(
|
||||
dry_run=False, repository='repo', storage_config={}, progress=True,
|
||||
)
|
||||
|
||||
|
||||
def test_compact_segments_with_cleanup_commits_calls_borg_with_cleanup_commits_parameter():
|
||||
insert_execute_command_mock(COMPACT_COMMAND + ('--cleanup-commits', 'repo'), logging.INFO)
|
||||
|
||||
module.compact_segments(
|
||||
dry_run=False, repository='repo', storage_config={}, cleanup_commits=True,
|
||||
)
|
||||
|
||||
|
||||
def test_compact_segments_with_threshold_calls_borg_with_threshold_parameter():
|
||||
insert_execute_command_mock(COMPACT_COMMAND + ('--threshold', '20', 'repo'), logging.INFO)
|
||||
|
||||
module.compact_segments(
|
||||
dry_run=False, repository='repo', storage_config={}, threshold=20,
|
||||
)
|
||||
|
||||
|
||||
def test_compact_segments_with_umask_calls_borg_with_umask_parameters():
|
||||
storage_config = {'umask': '077'}
|
||||
insert_execute_command_mock(COMPACT_COMMAND + ('--umask', '077', 'repo'), logging.INFO)
|
||||
|
||||
module.compact_segments(
|
||||
dry_run=False, repository='repo', storage_config=storage_config,
|
||||
)
|
||||
|
||||
|
||||
def test_compact_segments_with_lock_wait_calls_borg_with_lock_wait_parameters():
|
||||
storage_config = {'lock_wait': 5}
|
||||
insert_execute_command_mock(COMPACT_COMMAND + ('--lock-wait', '5', 'repo'), logging.INFO)
|
||||
|
||||
module.compact_segments(
|
||||
dry_run=False, repository='repo', storage_config=storage_config,
|
||||
)
|
||||
|
||||
|
||||
def test_compact_segments_with_extra_borg_options_calls_borg_with_extra_options():
|
||||
insert_execute_command_mock(COMPACT_COMMAND + ('--extra', '--options', 'repo'), logging.INFO)
|
||||
|
||||
module.compact_segments(
|
||||
dry_run=False,
|
||||
repository='repo',
|
||||
storage_config={'extra_borg_options': {'compact': '--extra --options'}},
|
||||
)
|
||||
+670
-252
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user