Compare commits

...

131 Commits

Author SHA1 Message Date
cbce6707f4 Clarify one_file_system behavior in schema comment (#520). 2022-04-12 11:05:22 -07:00
e40e726687 Change Healthchecks logs truncation size from 10k bytes to 100k bytes, corresponding to that same change on Healthchecks.io. 2022-04-06 22:00:18 -07:00
0c027a3050 Fix handling of TERM signal to exit borgmatic, not just forward the signal to Borg (#516). 2022-04-03 13:12:48 -07:00
9f44bbad65 Fix borgmatic exit code (so it's zero) when initial Borg calls fail but later retries succeed (#517). 2022-04-02 22:28:41 -07:00
413a079f51 Clarify Python version support. 2022-03-28 21:57:40 -07:00
5b3cfc542d Switch to PyPI API token. 2022-03-14 14:00:03 -07:00
c838c1d11b Fix header placement in documentation guide. 2022-03-14 13:50:22 -07:00
4d1d8d7409 Bump version for release. 2022-03-14 13:43:24 -07:00
db7499db82 Document "repositories" context to for "before_*" and "after_*" command action hooks (#469). 2022-03-14 13:34:14 -07:00
6b500c2a8b Add repositories context for command hooks.
Reviewed-on: borgmatic-collective/borgmatic#469
2022-03-14 20:13:15 +00:00
95c518e59b Documentation tip about dealing with hangs when database hook is enabled. 2022-03-12 13:17:32 -08:00
976516d0e1 When loading a configuration file that is unreadable due to file permissions, warn instead of erroring (#444). 2022-03-08 10:19:36 -08:00
574eb91921 Fix Borg usage error in the "compact" action when running "borgmatic --dry-run". Now, skip "compact" entirely during a dry run (#507). 2022-03-07 21:46:12 -08:00
28fef3264b Fix handling of "patterns_from" and "exclude_from" options to error instead of warning when referencing unreadable files and running "create" action (#486). 2022-03-07 15:32:07 -08:00
9161dbcb7d Removing unnecessary leading underscores from functions. 2022-03-07 11:58:29 -08:00
4b3027e4fc Add test for new working_directory option (#431). 2022-03-03 11:48:18 -08:00
0eb2634f9b Working directory option to support source directories with relative paths (#431).
Reviewed-on: borgmatic-collective/borgmatic#477
2022-03-03 19:28:17 +00:00
7c5b68c98f Bump version for release. 2022-02-10 10:29:18 -08:00
9317cbaaf0 Code formatting. 2022-02-10 10:23:34 -08:00
1b5f04b79f When using the "remote_rate_limit" option, tailor the flags passed to Borg depending on the Borg version (#394). 2022-02-10 10:16:09 -08:00
948c86f62c When using the "numeric_owner" option with the "extract" action, tailor the flags passed to Borg depending on the Borg version (#394). 2022-02-10 10:09:18 -08:00
7e7209322a When using the "numeric_owner" option, tailor the flags passed to Borg depending on the Borg version (#394). 2022-02-10 09:51:13 -08:00
00a57fd947 Code formatting. 2022-02-09 21:20:28 -08:00
6bf6ac310b When using the "bsd_flags" option, tailor the flags passed to Borg depending on the Borg version (#394). 2022-02-09 21:11:00 -08:00
4b5af2770d When the "atime" option is used, tailor the flags passed to Borg depending on version (#394). 2022-02-09 16:54:35 -08:00
b525e70e1c Run "compact" action by default when no actions are specified (#394). 2022-02-09 14:33:12 -08:00
4498671233 Remove references to removed long-deprecated options (#394). 2022-02-09 11:08:02 -08:00
9997aa9a92 Fix capitalization on compact help. 2022-02-08 15:58:09 -08:00
cbf7284f64 Add compact action to command-line reference documentation. 2022-02-08 15:37:24 -08:00
ee466f870d Fixing ruamel.yaml.clib breakages harder. 2022-02-08 13:21:11 -08:00
e3f4bf0293 Build fix for ruamel.yaml.clib error. 2022-02-08 12:52:45 -08:00
46688f10b1 Merge branch 'master' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2022-02-08 12:10:57 -08:00
48f44d2f3d Add tests for compact action (#394). 2022-02-08 12:05:02 -08:00
bff1347ba3 Fix some test failures (#394). 2022-02-08 09:35:03 -08:00
9582324c88 Compact repository segments with new "borgmatic compact" action (#394). 2022-02-07 23:29:44 -08:00
bb0716421d Add comment about systemd service setting that may interfere with external commands in hooks (#492). 2022-01-25 09:26:11 -08:00
bec73245e9 Fix traceback when a YAML validation error occurs (#480, #482). 2022-01-19 20:39:03 -08:00
dcead12e86 Attempt to fix documentation build error introduced by Eleventy upgrade. 2022-01-09 14:21:27 -08:00
0119514c11 Add Python version requirements to setup.py. 2022-01-09 10:19:53 -08:00
b39f08694d Merge branch 'master' into pr-working-directory 2022-01-05 09:30:27 +00:00
80bdf1430b Bump version for release. 2022-01-04 20:20:13 -08:00
2ee75546f5 Add MongoDB database hook documentation. 2022-01-04 16:26:38 -08:00
07d7ae60d5 Add MongoDB database hook (#288).
Reviewed-on: borgmatic-collective/borgmatic#483
2022-01-04 23:50:25 +00:00
87001337b4 Merge master into mongodb_hook 2022-01-04 22:20:44 +01:00
2e9964c200 Remove references to Lima Labs (shut down their storage business).
Reviewed-on: borgmatic-collective/borgmatic#488
2022-01-03 17:34:38 +00:00
3ec3d8d045 Remove references to Lima Labs
From their homepage:
> Lima Labs is shutting down our storage business. We will try to keep data available as long as possible. No promises but we are targeting 3/1/2022 to bring down Archive and Canada.
2022-01-03 02:29:38 -05:00
96384d5ee1 Attempt to fix typed-ast build issue by relaxing version requirements in test. 2022-01-02 23:22:24 -08:00
8ed5467435 Drop support for Python 3.6. Add support for 3.10. 2022-01-02 23:17:57 -08:00
7c6ce9399c fix integration tests and mongodb auth 2021-12-29 22:18:50 +01:00
6b7653484b Add mongodb dump hook 2021-12-26 01:00:58 +01:00
Fabian Schilling
85e0334826 Add missing working_directory arg to pass tests 2021-12-10 18:24:41 +01:00
Fabian Schilling
2a80e48a92 Pass working directory to execute functions 2021-12-10 18:23:44 +01:00
Fabian Schilling
5821c6782e Add defaults to not set in schema 2021-12-10 18:23:08 +01:00
Fabian Schilling
f15498f6d9 Add working_directory to borgmatic schema 2021-12-10 17:58:27 +01:00
a1673d1fa1 Fix unicode error when restoring particular MySQL databases (#476). 2021-12-08 16:40:25 -08:00
2e99a1898c Fix f-string with missing expression. 2021-11-29 14:05:36 -08:00
7a086d8430 Fix import ordering. 2021-11-29 14:00:14 -08:00
0e8e9ced64 When command-line configuration override produces a parse error, error cleanly (#471). 2021-11-29 12:49:21 -08:00
f34951c088 Add MySQL dump command adjustment to NEWS. 2021-11-29 12:10:04 -08:00
c6f47d4d56 Move mysqldump options to the beginning of the command due to MySQL bug 30994 (#470).
Reviewed-on: borgmatic-collective/borgmatic#470
2021-11-29 20:08:59 +00:00
c3e76585fc
move mysqldump options to the beginning of the command due to MySQL bug 30994. 2021-11-26 17:16:03 +01:00
0014b149f8 remove configuration_filename as it's already set. 2021-11-26 11:38:58 +08:00
091c07bbe2 Add context for various hooks. 2021-11-26 11:35:10 +08:00
240547102f Enable auto-play on linked asciicast. 2021-11-25 13:09:55 -08:00
2bbd53e25a
Merge pull request #43 from acsfer/patch-1
Github doesn't allow script embedding
2021-11-25 13:06:43 -08:00
58f2f63977
Switch to HTML 2021-11-25 22:03:26 +01:00
7df6a78c30
Github doesn't allow script embedding 2021-11-25 21:36:31 +01:00
c646edf2c7 Bump version for release. 2021-11-22 13:19:15 -08:00
bcc820d646 Add list_options setting (#306).
Reviewed-on: borgmatic-collective/borgmatic#464
2021-11-22 21:14:02 +00:00
3729ba5ca3
add list_options setting, fixes #306 2021-11-20 15:43:58 +01:00
9c19591768 Revise hosting provider links. 2021-11-15 20:06:09 -08:00
38ebfd2969 Rename retry_timeout to retry_wait and standardize log formatting (#28). 2021-11-15 11:51:17 -08:00
180018fd81 Retry failing backups (#28, #432).
Reviewed-on: borgmatic-collective/borgmatic#432
2021-11-15 19:34:24 +00:00
794ae94ac4 Attempt to limit documentation pushing to commits (so, not pull requests). 2021-11-15 11:08:26 -08:00
4eb6359ed3 Remove now-unneeded build image workaround. 2021-11-15 10:56:12 -08:00
cadamswaite
976a877a25 Formatting 2021-11-14 22:37:42 +00:00
cadamswaite
b4117916b8 Add timeout and tests 2021-11-14 22:15:22 +00:00
cadamswaite
19cad89978 Add some tests for retry logic 2021-11-14 21:35:23 +00:00
6b182c9d2d Merge branch 'master' into master 2021-11-14 18:24:17 +00:00
4d6ed27f73 Add to changelog: Add support for old version (2.x) of jsonschema library. 2021-10-23 09:49:16 -07:00
745a8f9b8a Add support for both jsonschema v3 and old v2 (#459).
Reviewed-on: borgmatic-collective/borgmatic#459
2021-10-23 16:47:53 +00:00
6299d8115d Limit documentation build to master of main repo, as it pushes a Docker image. 2021-10-23 09:45:17 -07:00
717cfd2d37 validate: add support for both jsonschema v3 and old v2
RHEL8 and RHEL7 have old jsonschema v2. Try v3 (Draft7) first but
fallback to v2 (Draft4) if needed.
2021-10-23 15:04:07 +03:00
7881327004 Upgrade CI test dependencies. 2021-10-22 14:07:14 -07:00
549aa9a25f Update editable link. 2021-10-22 14:06:27 -07:00
1c6890492b Bump version for release. 2021-10-11 17:02:32 -07:00
a7c8e7c823 Bump version for release. 2021-10-11 11:13:32 -07:00
c8fcf6b336 Mention changing borgmatic path in cron documentation (#455). 2021-10-11 11:02:08 -07:00
449896f661 Fix error when configured source directories are not present on the filesystem at the time of backup (#387). 2021-10-11 10:40:10 -07:00
1004500d65 Update sample systemd service file comments about more granular read-only filesystem settings. 2021-10-11 09:33:07 -07:00
0a8d4e5dfb
Add more strict ProtectHome to systemd sample configuration.
Merge pull request #42 from VTimofeenko/systemd_protecthome
2021-10-11 09:26:28 -07:00
38e35bdb12 Skip TLS verify in documentation build clone to work around old drone/git CA certs. 2021-10-04 14:31:15 -07:00
65503e38b6 Sigh. 2021-10-04 13:14:19 -07:00
d0c5bf6f6f Another attempt to unbreak build. 2021-10-04 13:13:35 -07:00
f129e4c301 Attempt to work-around outdated CA certificates in drone/git Docker image. 2021-10-04 13:09:44 -07:00
fbbb096cec Note in documentation that borgmatic requires Python 3.6+. 2021-10-04 11:15:51 -07:00
77980511c6 Add another glob pattern example to exclude patterns. 2021-09-16 09:51:40 -07:00
4ba206f8f4 Update build server URL to new organization namespace. 2021-09-14 11:35:34 -07:00
ecc849dd07 Move Gitea hosting from a personal namespace to an organization. 2021-09-14 11:32:01 -07:00
7ff6066d47 Move GitHub hosting from a personal namespace to an organization. 2021-09-14 10:18:10 -07:00
2bb1fc9826 Mention Docker Compose under installation options. 2021-09-12 13:15:34 -07:00
Vladimir Timofeenko
6df6176f3a
Added more strict ProtectHome to systemd unit
This commit changes the comment in sample systemd service.

Using a combination of 'ProtectHome' and 'BindPaths' it's possible to
hide the irrelevant paths inside /root from borgmatic service when it is
run.

ReadWritePaths are suggested to be used only for paths that contain borg
repositories and the backup sources can be specified as ReadOnlyPaths.
2021-08-30 11:20:34 -07:00
acb2ca79d9 Fix traceback that can occur when dumping a database (#440). 2021-08-06 08:58:11 -07:00
c9211320e1 Fix dev version in changelog. 2021-08-04 15:32:51 -07:00
760286abe1 Dev release bump. 2021-07-30 09:49:07 -07:00
5890a1cb48 Fix "message too long" error when logging to rsyslog (#389). 2021-07-30 09:48:13 -07:00
b3f5a9d18f Fix error when configuration file contains "umask" option (#437). 2021-07-27 10:04:22 -07:00
80b33fbf8a Code style reformatting. 2021-07-27 09:39:48 -07:00
5389ff6160
Merge pull request #41 from mkszuba/tests_no_xxd
tests/integration/test_execute: use plain Python rather than xxd
2021-07-27 09:39:02 -07:00
Marek Szuba
e8b8d86592 tests/integration/test_execute: use plain Python rather than xxd
Removes this test's dependencies on vim and /dev/urandom.

Signed-off-by: Marek Szuba <marek.szuba@cern.ch>
2021-07-27 13:50:16 +01:00
92d729a9dd Try temporary work around for Drone build bug: https://github.com/drone-plugins/drone-docker/pull/327 2021-07-26 16:33:41 -07:00
c63219936e Wording tweaks to security policy. 2021-07-26 13:44:14 -07:00
0aff497430 Bump version for release. 2021-07-26 10:17:49 -07:00
1f3907a6a5 Fix for failing PostgreSQL directory format test (#430). 2021-07-26 09:42:14 -07:00
2a8692c64f Fix integration test to hopefully work on Alpine (#430). 2021-07-25 22:50:00 -07:00
1709f57ff0 Fix hang when restoring a PostgreSQL "tar" format database dump (#430). 2021-07-25 22:30:15 -07:00
cadamswaite
89baf757cf Sort imports 2021-07-14 23:17:35 +01:00
cadamswaite
4f36fe2b9f Run Black on changed file 2021-07-14 22:53:01 +01:00
cadamswaite
510449ce65 Change default retries to 0 2021-07-14 22:49:03 +01:00
cadamswaite
4cc4b8d484 Add queue based retry logic 2021-07-14 22:46:02 +01:00
9c972cb0e5 Add documentation note about systemd configuration with alternate install methods (#428). 2021-06-29 21:38:53 -07:00
9b1779065e Pin ruamel.yaml.clib to work around docs build issue. 2021-06-29 21:35:46 -07:00
057ec3e59b Add NEWS entry for #379: Suppress console output in sample crontab and systemd service files. 2021-06-23 10:35:41 -07:00
bc2e611a74 Suppress console output in sample crontab/systemd service files (#379).
Reviewed-on: witten/borgmatic#379
2021-06-23 17:32:47 +00:00
b6d3a1e02f Merge branch 'master' of ssh://projects.torsion.org:3022/witten/borgmatic 2021-06-23 10:22:07 -07:00
54d57e1349 Add test for #407: Fix syslog logging on FreeBSD. 2021-06-23 10:21:45 -07:00
af0b3da8ed Fix syslog logging on FreeBSD (#407).
Reviewed-on: witten/borgmatic#407
2021-06-23 17:21:25 +00:00
27d37b606b Better error messages! Switch the library used for validating configuration files (from pykwalify to jsonschema). 2021-06-22 13:27:59 -07:00
77a860cc62 Link borgmatic Ansible role from installation documentation. 2021-06-19 19:04:22 -07:00
d1c403999f
Reduce console output in sample crontab/systemd service files.
As borgmatic will log to syslog in the sample crontab/systemd service
files, this makes console output redundant. (cron will mail any console
output to the root user; systemd will log any console output to syslog.)

This adds --verbosity -1 to both files to reduce console output to the
minimum.
2021-04-13 01:40:57 +08:00
d2533313bc
Fix syslog logging on FreeBSD
The UNIX domain socket to use on FreeBSD is /var/run/log.
See syslogd FreeBSD man page: https://www.freebsd.org/cgi/man.cgi?query=syslogd&sektion=8
2021-04-02 14:11:50 +02:00
65 changed files with 2999 additions and 934 deletions

View File

@ -1,48 +1,3 @@
---
kind: pipeline
name: python-3-6-alpine-3-9
services:
- name: postgresql
image: postgres:11.9-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: alpine:3.9
pull: always
commands:
- scripts/run-full-tests
---
kind: pipeline
name: python-3-7-alpine-3-10
services:
- name: postgresql
image: postgres:11.9-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: alpine:3.10
pull: always
commands:
- scripts/run-full-tests
---
kind: pipeline kind: pipeline
name: python-3-8-alpine-3-13 name: python-3-8-alpine-3-13
@ -57,6 +12,14 @@ services:
environment: environment:
MYSQL_ROOT_PASSWORD: test MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: 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: steps:
- name: build - name: build
@ -68,6 +31,9 @@ steps:
kind: pipeline kind: pipeline
name: documentation name: documentation
clone:
skip_verify: true
steps: steps:
- name: build - name: build
image: plugins/docker image: plugins/docker
@ -80,5 +46,9 @@ steps:
dockerfile: docs/Dockerfile dockerfile: docs/Dockerfile
trigger: trigger:
repo:
- borgmatic-collective/borgmatic
branch: branch:
- master - master
event:
- push

View File

@ -36,6 +36,8 @@ module.exports = function(eleventyConfig) {
eleventyConfig.addPassthroughCopy({"docs/static": "static"}); eleventyConfig.addPassthroughCopy({"docs/static": "static"});
eleventyConfig.setLiquidOptions({dynamicPartials: false});
return { return {
templateFormats: [ templateFormats: [
"md", "md",

74
NEWS
View File

@ -1,3 +1,75 @@
1.5.25.dev0
* #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 1.5.15
* #419: Document use case of running backups conditionally based on laptop power level: * #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/ https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/
@ -551,7 +623,7 @@
* #49: Support for Borg experimental --patterns-from and --patterns options for specifying mixed * #49: Support for Borg experimental --patterns-from and --patterns options for specifying mixed
includes/excludes. includes/excludes.
* Moved issue tracker from Taiga to integrated Gitea tracker at * 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 1.1.12
* #46: Declare dependency on pykwalify 1.6 or above, as older versions yield "Unknown key: version" * #46: Declare dependency on pykwalify 1.6 or above, as older versions yield "Unknown key: version"

View File

@ -26,7 +26,6 @@ location:
repositories: repositories:
- 1234@usw-s001.rsync.net:backups.borg - 1234@usw-s001.rsync.net:backups.borg
- k8pDxu32@k8pDxu32.repo.borgbase.com:repo - k8pDxu32@k8pDxu32.repo.borgbase.com:repo
- user1@scp2.cdn.lima-labs.com:repo
- /var/lib/backups/local.borg - /var/lib/backups/local.borg
retention: retention:
@ -55,9 +54,9 @@ hooks:
``` ```
Want to see borgmatic in action? Check out the <a 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/). borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
@ -66,11 +65,11 @@ 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>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<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>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
@ -92,21 +91,19 @@ development and hosting when you use these links to sign up. (These are
referral links, but without any tracking scripts or cookies.) referral links, but without any tracking scripts or cookies.)
<ul> <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> <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>
<li class="referral"><a href="https://storage.lima-labs.com/special-pricing-offer-for-borgmatic-users/">Lima-Labs</a>: Affordable, reliable cloud data storage accessable via SSH/SCP/FTP for Borg backups or any other bulk storage needs</li>
</ul> </ul>
Additionally, [Hetzner](https://www.hetzner.com/storage/storage-box) has a Additionally, [rsync.net](https://www.rsync.net/products/borg.html) and
compatible storage offering, but does not currently fund borgmatic [Hetzner](https://www.hetzner.com/storage/storage-box) have compatible storage
development or hosting. offerings, but do not currently fund borgmatic development or hosting.
## Support and contributing ## Support and contributing
### Issues ### Issues
You've got issues? Or an idea for a feature enhancement? We've got an [issue 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 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 first](https://projects.torsion.org/user/login). Note that you can login with
an existing GitHub account if you prefer. an existing GitHub account if you prefer.
@ -129,15 +126,15 @@ Other questions or comments? Contact
### Contributing ### Contributing
borgmatic [source code is borgmatic [source code is
available](https://projects.torsion.org/witten/borgmatic) and is also mirrored available](https://projects.torsion.org/borgmatic-collective/borgmatic) and is also mirrored
on [GitHub](https://github.com/witten/borgmatic) for convenience. on [GitHub](https://github.com/borgmatic-collective/borgmatic) for convenience.
borgmatic is licensed under the GNU General Public License version 3 or any borgmatic is licensed under the GNU General Public License version 3 or any
later version. later version.
If you'd like to contribute to borgmatic development, please feel free to If you'd like to contribute to borgmatic development, please feel free to
submit a [Pull Request](https://projects.torsion.org/witten/borgmatic/pulls) submit a [Pull Request](https://projects.torsion.org/borgmatic-collective/borgmatic/pulls)
or open an [issue](https://projects.torsion.org/witten/borgmatic/issues) first 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 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! your thing. In general, contributions are very welcome. We don't bite!
@ -145,5 +142,5 @@ Also, please check out the [borgmatic development
how-to](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) for how-to](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) for
info on cloning source code, running tests, etc. info on cloning source code, running tests, etc.
<a href="https://build.torsion.org/witten/borgmatic" alt="build status">![Build Status](https://build.torsion.org/api/badges/witten/borgmatic/status.svg?ref=refs/heads/master)</a> <a href="https://build.torsion.org/borgmatic-collective/borgmatic" alt="build status">![Build Status](https://build.torsion.org/api/badges/borgmatic-collective/borgmatic/status.svg?ref=refs/heads/master)</a>

View File

@ -6,14 +6,13 @@ permalink: security-policy/index.html
## Supported versions ## Supported versions
While we want to hear about security vulnerabilities in all versions of While we want to hear about security vulnerabilities in all versions of
borgmatic, security fixes will only be made to the most recently released borgmatic, security fixes are only made to the most recently released version.
version. It's not practical for our small volunteer effort to maintain It's simply not practical for our small volunteer effort to maintain multiple
multiple different release branches and put out separate security patches for release branches and put out separate security patches for each.
each.
## Reporting a vulnerability ## Reporting a vulnerability
If you find a security vulnerability, please [file a If you find a security vulnerability, please [file a
ticket](https://torsion.org/borgmatic/#issues) or [send email ticket](https://torsion.org/borgmatic/#issues) or [send email
directly](mailto:witten@torsion.org) as appropriate. You should expect to hear 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.

41
borgmatic/borg/compact.py Normal file
View File

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

View File

@ -5,12 +5,13 @@ import os
import pathlib import pathlib
import tempfile import tempfile
from borgmatic.borg import feature
from borgmatic.execute import DO_NOT_CAPTURE, execute_command, execute_command_with_processes from borgmatic.execute import DO_NOT_CAPTURE, execute_command, execute_command_with_processes
logger = logging.getLogger(__name__) 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 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. 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] 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 Given a sequence of directory paths, expand tildes and globs in each one. Return all the
resulting directories as a single flattened tuple. resulting directories as a single flattened tuple.
@ -29,11 +30,11 @@ def _expand_directories(directories):
return () return ()
return tuple( 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. Given a sequence of directory paths, expand tildes in each one. Do not perform any globbing.
Return the results as a tuple. Return the results as a tuple.
@ -44,13 +45,18 @@ def _expand_home_directories(directories):
return tuple(os.path.expanduser(directory) for directory in directories) return tuple(os.path.expanduser(directory) for directory in directories)
def map_directories_to_devices(directories): # pragma: no cover def map_directories_to_devices(directories):
''' '''
Given a sequence of directories, return a map from directory to an identifier for the device on Given a sequence of directories, return a map from directory to an identifier for the device on
which that directory resides. This is handy for determining whether two different directories which that directory resides or None if the path doesn't exist.
are on the same filesystem (have the same device identifier).
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 for directory in directories} return {
directory: os.stat(directory).st_dev if os.path.exists(directory) else None
for directory in directories
}
def deduplicate_directories(directory_devices): def deduplicate_directories(directory_devices):
@ -82,6 +88,7 @@ def deduplicate_directories(directory_devices):
for parent in parents: for parent in parents:
if ( if (
pathlib.PurePath(other_directory) == parent pathlib.PurePath(other_directory) == parent
and directory_devices[directory] is not None
and directory_devices[other_directory] == directory_devices[directory] and directory_devices[other_directory] == directory_devices[directory]
): ):
if directory in deduplicated: if directory in deduplicated:
@ -91,7 +98,7 @@ def deduplicate_directories(directory_devices):
return tuple(sorted(deduplicated)) 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 Given a sequence of patterns, write them to a named temporary file and return it. Return None
if no patterns are provided. if no patterns are provided.
@ -106,7 +113,19 @@ def _write_pattern_file(patterns=None):
return pattern_file 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 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. any additional patterns, return the corresponding Borg flags for those files as a tuple.
@ -122,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 Given a location config dict with various exclude options, and a filename containing any exclude
patterns, return the corresponding Borg flags as a tuple. patterns, return the corresponding Borg flags as a tuple.
@ -181,6 +200,7 @@ def create_archive(
repository, repository,
location_config, location_config,
storage_config, storage_config,
local_borg_version,
local_path='borg', local_path='borg',
remote_path=None, remote_path=None,
progress=False, progress=False,
@ -198,16 +218,20 @@ def create_archive(
''' '''
sources = deduplicate_directories( sources = deduplicate_directories(
map_directories_to_devices( map_directories_to_devices(
_expand_directories( expand_directories(
location_config['source_directories'] location_config['source_directories']
+ borgmatic_source_directories(location_config.get('borgmatic_source_directory')) + borgmatic_source_directories(location_config.get('borgmatic_source_directory'))
) )
) )
) )
pattern_file = _write_pattern_file(location_config.get('patterns')) try:
exclude_file = _write_pattern_file( working_directory = os.path.expanduser(location_config.get('working_directory'))
_expand_home_directories(location_config.get('exclude_patterns')) 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) checkpoint_interval = storage_config.get('checkpoint_interval', None)
chunker_params = storage_config.get('chunker_params', None) chunker_params = storage_config.get('chunker_params', None)
@ -219,26 +243,52 @@ def create_archive(
archive_name_format = storage_config.get('archive_name_format', DEFAULT_ARCHIVE_NAME_FORMAT) archive_name_format = storage_config.get('archive_name_format', DEFAULT_ARCHIVE_NAME_FORMAT)
extra_borg_options = storage_config.get('extra_borg_options', {}).get('create', '') 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 = ( full_command = (
tuple(local_path.split(' ')) tuple(local_path.split(' '))
+ ('create',) + ('create',)
+ _make_pattern_flags(location_config, pattern_file.name if pattern_file else None) + 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) + make_exclude_flags(location_config, exclude_file.name if exclude_file else None)
+ (('--checkpoint-interval', str(checkpoint_interval)) if checkpoint_interval else ()) + (('--checkpoint-interval', str(checkpoint_interval)) if checkpoint_interval else ())
+ (('--chunker-params', chunker_params) if chunker_params else ()) + (('--chunker-params', chunker_params) if chunker_params else ())
+ (('--compression', compression) if compression else ()) + (('--compression', compression) if compression else ())
+ (('--remote-ratelimit', str(remote_rate_limit)) if remote_rate_limit else ()) + upload_ratelimit_flags
+ ( + (
('--one-file-system',) ('--one-file-system',)
if location_config.get('one_file_system') or stream_processes if location_config.get('one_file_system') or stream_processes
else () else ()
) )
+ (('--numeric-owner',) if location_config.get('numeric_owner') else ()) + numeric_ids_flags
+ (('--noatime',) if location_config.get('atime') is False else ()) + atime_flags
+ (('--noctime',) if location_config.get('ctime') is False else ()) + (('--noctime',) if location_config.get('ctime') is False else ())
+ (('--nobirthtime',) if location_config.get('birthtime') is False else ()) + (('--nobirthtime',) if location_config.get('birthtime') is False else ())
+ (('--read-special',) if (location_config.get('read_special') or stream_processes) 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 ()) + (('--files-cache', files_cache) if files_cache else ())
+ (('--remote-path', remote_path) if remote_path else ()) + (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ()) + (('--umask', str(umask)) if umask else ())
@ -277,6 +327,13 @@ def create_archive(
output_log_level, output_log_level,
output_file, output_file,
borg_local_path=local_path, 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,
)

View File

@ -2,6 +2,7 @@ import logging
import os import os
import subprocess import subprocess
from borgmatic.borg import feature
from borgmatic.execute import DO_NOT_CAPTURE, execute_command from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -61,6 +62,7 @@ def extract_archive(
paths, paths,
location_config, location_config,
storage_config, storage_config,
local_borg_version,
local_path='borg', local_path='borg',
remote_path=None, remote_path=None,
destination_path=None, destination_path=None,
@ -70,9 +72,9 @@ def extract_archive(
): ):
''' '''
Given a dry-run flag, a local or remote repository path, an archive name, zero or more paths to 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 restore from the archive, the local Borg version string, location/storage configuration dicts,
paths, and an optional destination path to extract to, extract the archive into the current optional local and remote Borg paths, and an optional destination path to extract to, extract
directory. the archive into the current directory.
If extract to stdout is True, then start the extraction streaming to stdout, and return that If extract to stdout is True, then start the extraction streaming to stdout, and return that
extract process as an instance of subprocess.Popen. extract process as an instance of subprocess.Popen.
@ -83,10 +85,15 @@ def extract_archive(
if progress and extract_to_stdout: if progress and extract_to_stdout:
raise ValueError('progress and extract_to_stdout cannot both be set') 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 = ( full_command = (
(local_path, 'extract') (local_path, 'extract')
+ (('--remote-path', remote_path) if remote_path else ()) + (('--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 ()) + (('--umask', str(umask)) if umask else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ()) + (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())

28
borgmatic/borg/feature.py Normal file
View File

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

25
borgmatic/borg/version.py Normal file
View File

@ -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')

View File

@ -6,6 +6,7 @@ from borgmatic.config import collect
SUBPARSER_ALIASES = { SUBPARSER_ALIASES = {
'init': ['--init', '-I'], 'init': ['--init', '-I'],
'prune': ['--prune', '-p'], 'prune': ['--prune', '-p'],
'compact': [],
'create': ['--create', '-C'], 'create': ['--create', '-C'],
'check': ['--check', '-k'], 'check': ['--check', '-k'],
'extract': ['--extract', '-x'], 'extract': ['--extract', '-x'],
@ -62,9 +63,9 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
arguments[canonical_name] = parsed 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: if not arguments and '--help' not in unparsed_arguments and '-h' not in unparsed_arguments:
for subparser_name in ('prune', 'create', 'check'): for subparser_name in ('prune', 'compact', 'create', 'check'):
subparser = subparsers[subparser_name] subparser = subparsers[subparser_name]
parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments) parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments)
arguments[subparser_name] = parsed arguments[subparser_name] = parsed
@ -199,8 +200,8 @@ def parse_arguments(*unparsed_arguments):
top_level_parser = ArgumentParser( top_level_parser = ArgumentParser(
description=''' description='''
Simple, configuration-driven backup software for servers and workstations. If none of 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 the action options are given, then borgmatic defaults to: prune, compact, create, and
archives. check.
''', ''',
parents=[global_parser], parents=[global_parser],
) )
@ -208,7 +209,7 @@ def parse_arguments(*unparsed_arguments):
subparsers = top_level_parser.add_subparsers( subparsers = top_level_parser.add_subparsers(
title='actions', title='actions',
metavar='', 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_parser = subparsers.add_parser(
'init', 'init',
@ -241,8 +242,8 @@ def parse_arguments(*unparsed_arguments):
prune_parser = subparsers.add_parser( prune_parser = subparsers.add_parser(
'prune', 'prune',
aliases=SUBPARSER_ALIASES['prune'], aliases=SUBPARSER_ALIASES['prune'],
help='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', description='Prune archives according to the retention policy (with Borg 1.2+, run compact afterwards to actually free space)',
add_help=False, add_help=False,
) )
prune_group = prune_parser.add_argument_group('prune arguments') prune_group = prune_parser.add_argument_group('prune arguments')
@ -258,6 +259,38 @@ def parse_arguments(*unparsed_arguments):
) )
prune_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') 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_parser = subparsers.add_parser(
'create', 'create',
aliases=SUBPARSER_ALIASES['create'], aliases=SUBPARSER_ALIASES['create'],

View File

@ -4,6 +4,8 @@ import json
import logging import logging
import os import os
import sys import sys
import time
from queue import Queue
from subprocess import CalledProcessError from subprocess import CalledProcessError
import colorama import colorama
@ -11,16 +13,19 @@ import pkg_resources
from borgmatic.borg import borg as borg_borg from borgmatic.borg import borg as borg_borg
from borgmatic.borg import check as borg_check 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 create as borg_create
from borgmatic.borg import environment as borg_environment from borgmatic.borg import environment as borg_environment
from borgmatic.borg import export_tar as borg_export_tar from borgmatic.borg import export_tar as borg_export_tar
from borgmatic.borg import extract as borg_extract 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 info as borg_info
from borgmatic.borg import init as borg_init from borgmatic.borg import init as borg_init
from borgmatic.borg import list as borg_list from borgmatic.borg import list as borg_list
from borgmatic.borg import mount as borg_mount from borgmatic.borg import mount as borg_mount
from borgmatic.borg import prune as borg_prune from borgmatic.borg import prune as borg_prune
from borgmatic.borg import umount as borg_umount 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.commands.arguments import parse_arguments
from borgmatic.config import checks, collect, convert, validate from borgmatic.config import checks, collect, convert, validate
from borgmatic.hooks import command, dispatch, dump, monitor from borgmatic.hooks import command, dispatch, dump, monitor
@ -36,8 +41,8 @@ LEGACY_CONFIG_PATH = '/etc/borgmatic/config'
def run_configuration(config_filename, config, arguments): def run_configuration(config_filename, config, arguments):
''' '''
Given a config filename, the corresponding parsed config dict, and command-line arguments as a 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, dict from subparser name to a namespace of parsed arguments, execute the defined prune, compact,
backups, consistency checks, and/or other actions. create, check, and/or other actions.
Yield a combination of: Yield a combination of:
@ -52,14 +57,28 @@ def run_configuration(config_filename, config, arguments):
local_path = location.get('local_path', 'borg') local_path = location.get('local_path', 'borg')
remote_path = location.get('remote_path') remote_path = location.get('remote_path')
retries = storage.get('retries', 0)
retry_wait = storage.get('retry_wait', 0)
borg_environment.initialize(storage) borg_environment.initialize(storage)
encountered_error = None encountered_error = None
error_repository = '' 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) monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
hook_context = {
'repositories': ','.join(location['repositories']),
}
try: 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( dispatch.call_hooks(
'initialize_monitor', 'initialize_monitor',
hooks, hooks,
@ -75,6 +94,15 @@ def run_configuration(config_filename, config, arguments):
config_filename, config_filename,
'pre-prune', 'pre-prune',
global_arguments.dry_run, 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 'create' in arguments: if 'create' in arguments:
command.execute_hook( command.execute_hook(
@ -83,6 +111,7 @@ def run_configuration(config_filename, config, arguments):
config_filename, config_filename,
'pre-backup', 'pre-backup',
global_arguments.dry_run, global_arguments.dry_run,
**hook_context,
) )
if 'check' in arguments: if 'check' in arguments:
command.execute_hook( command.execute_hook(
@ -91,6 +120,7 @@ def run_configuration(config_filename, config, arguments):
config_filename, config_filename,
'pre-check', 'pre-check',
global_arguments.dry_run, global_arguments.dry_run,
**hook_context,
) )
if 'extract' in arguments: if 'extract' in arguments:
command.execute_hook( command.execute_hook(
@ -99,8 +129,9 @@ def run_configuration(config_filename, config, arguments):
config_filename, config_filename,
'pre-extract', 'pre-extract',
global_arguments.dry_run, global_arguments.dry_run,
**hook_context,
) )
if prune_create_or_check: if using_primary_action:
dispatch.call_hooks( dispatch.call_hooks(
'ping_monitor', 'ping_monitor',
hooks, hooks,
@ -115,12 +146,19 @@ def run_configuration(config_filename, config, arguments):
return return
encountered_error = error encountered_error = error
yield from make_error_log_records( yield from log_error_records('{}: Error running pre hook'.format(config_filename), error)
'{}: Error running pre hook'.format(config_filename), error
)
if not encountered_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: try:
yield from run_actions( yield from run_actions(
arguments=arguments, arguments=arguments,
@ -131,14 +169,30 @@ def run_configuration(config_filename, config, arguments):
hooks=hooks, hooks=hooks,
local_path=local_path, local_path=local_path,
remote_path=remote_path, remote_path=remote_path,
local_borg_version=local_borg_version,
repository_path=repository_path, repository_path=repository_path,
) )
except (OSError, CalledProcessError, ValueError) as error: except (OSError, CalledProcessError, ValueError) as error:
encountered_error = error if retry_num < retries:
error_repository = repository_path repo_queue.put((repository_path, retry_num + 1),)
yield from make_error_log_records( 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
yield from log_error_records(
'{}: Error running actions for repository'.format(repository_path), error '{}: Error running actions for repository'.format(repository_path), error
) )
encountered_error = error
error_repository = repository_path
if not encountered_error: if not encountered_error:
try: try:
@ -149,6 +203,15 @@ def run_configuration(config_filename, config, arguments):
config_filename, config_filename,
'post-prune', 'post-prune',
global_arguments.dry_run, global_arguments.dry_run,
**hook_context,
)
if 'compact' in arguments:
command.execute_hook(
hooks.get('after_compact'),
hooks.get('umask'),
config_filename,
'post-compact',
global_arguments.dry_run,
) )
if 'create' in arguments: if 'create' in arguments:
dispatch.call_hooks( dispatch.call_hooks(
@ -165,6 +228,7 @@ def run_configuration(config_filename, config, arguments):
config_filename, config_filename,
'post-backup', 'post-backup',
global_arguments.dry_run, global_arguments.dry_run,
**hook_context,
) )
if 'check' in arguments: if 'check' in arguments:
command.execute_hook( command.execute_hook(
@ -173,6 +237,7 @@ def run_configuration(config_filename, config, arguments):
config_filename, config_filename,
'post-check', 'post-check',
global_arguments.dry_run, global_arguments.dry_run,
**hook_context,
) )
if 'extract' in arguments: if 'extract' in arguments:
command.execute_hook( command.execute_hook(
@ -181,8 +246,9 @@ def run_configuration(config_filename, config, arguments):
config_filename, config_filename,
'post-extract', 'post-extract',
global_arguments.dry_run, global_arguments.dry_run,
**hook_context,
) )
if prune_create_or_check: if using_primary_action:
dispatch.call_hooks( dispatch.call_hooks(
'ping_monitor', 'ping_monitor',
hooks, hooks,
@ -205,11 +271,11 @@ def run_configuration(config_filename, config, arguments):
return return
encountered_error = error encountered_error = error
yield from make_error_log_records( yield from log_error_records(
'{}: Error running post hook'.format(config_filename), error '{}: Error running post hook'.format(config_filename), error
) )
if encountered_error and prune_create_or_check: if encountered_error and using_primary_action:
try: try:
command.execute_hook( command.execute_hook(
hooks.get('on_error'), hooks.get('on_error'),
@ -242,7 +308,7 @@ def run_configuration(config_filename, config, arguments):
if command.considered_soft_failure(config_filename, error): if command.considered_soft_failure(config_filename, error):
return return
yield from make_error_log_records( yield from log_error_records(
'{}: Error running on-error hook'.format(config_filename), error '{}: Error running on-error hook'.format(config_filename), error
) )
@ -257,12 +323,13 @@ def run_actions(
hooks, hooks,
local_path, local_path,
remote_path, remote_path,
repository_path local_borg_version,
repository_path,
): # pragma: no cover ): # pragma: no cover
''' '''
Given parsed command-line arguments as an argparse.ArgumentParser instance, several different 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 configuration dicts, local and remote paths to Borg, a local Borg version string, and a
from the command-line arguments on the given repository. 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. Yield JSON output strings from executing any actions that produce JSON.
@ -295,6 +362,23 @@ def run_actions(
stats=arguments['prune'].stats, stats=arguments['prune'].stats,
files=arguments['prune'].files, files=arguments['prune'].files,
) )
if 'compact' in arguments:
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:
logger.info(
'{}: Skipping compact (only available/needed in Borg 1.2+)'.format(repository)
)
if 'create' in arguments: if 'create' in arguments:
logger.info('{}: Creating archive{}'.format(repository, dry_run_label)) logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
dispatch.call_hooks( dispatch.call_hooks(
@ -320,6 +404,7 @@ def run_actions(
repository, repository,
location, location,
storage, storage,
local_borg_version,
local_path=local_path, local_path=local_path,
remote_path=remote_path, remote_path=remote_path,
progress=arguments['create'].progress, progress=arguments['create'].progress,
@ -359,6 +444,7 @@ def run_actions(
arguments['extract'].paths, arguments['extract'].paths,
location, location,
storage, storage,
local_borg_version,
local_path=local_path, local_path=local_path,
remote_path=remote_path, remote_path=remote_path,
destination_path=arguments['extract'].destination, destination_path=arguments['extract'].destination,
@ -467,6 +553,7 @@ def run_actions(
paths=dump.convert_glob_patterns_to_borg_patterns([dump_pattern]), paths=dump.convert_glob_patterns_to_borg_patterns([dump_pattern]),
location_config=location, location_config=location,
storage_config=storage, storage_config=storage,
local_borg_version=local_borg_version,
local_path=local_path, local_path=local_path,
remote_path=remote_path, remote_path=remote_path,
destination_path='/', destination_path='/',
@ -578,6 +665,20 @@ def load_configurations(config_filenames, overrides=None):
configs[config_filename] = validate.parse_configuration( configs[config_filename] = validate.parse_configuration(
config_filename, validate.schema_filename(), overrides 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: except (ValueError, OSError, validate.Validation_error) as error:
logs.extend( logs.extend(
[ [
@ -610,28 +711,39 @@ def log_record(suppress_log=False, **kwargs):
return record 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 Given error message text, an optional exception object, an optional log level, and whether to
instances with error summary information. As a side effect, log each record. 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: if not error:
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message) yield log_record(levelno=levelno, levelname=level_name, msg=message)
return return
try: try:
raise error raise error
except CalledProcessError as 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: if error.output:
# Suppress these logs for now and save full error output for the log summary at the end. # Suppress these logs for now and save full error output for the log summary at the end.
yield log_record( 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: except (ValueError, OSError) as error:
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message) yield log_record(levelno=levelno, levelname=level_name, msg=message)
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error) yield log_record(levelno=levelno, levelname=level_name, msg=error)
except: # noqa: E722 except: # noqa: E722
# Raising above only as a means of determining the error type. Swallow the exception here # 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. # because we don't want the exception to propagate out of this function.
@ -670,11 +782,11 @@ def collect_configuration_run_summary_logs(configs, arguments):
try: try:
validate.guard_configuration_contains_repository(repository, configs) validate.guard_configuration_contains_repository(repository, configs)
except ValueError as error: except ValueError as error:
yield from make_error_log_records(str(error)) yield from log_error_records(str(error))
return return
if not configs: if not configs:
yield from make_error_log_records( yield from log_error_records(
'{}: No valid configuration files found'.format( '{}: No valid configuration files found'.format(
' '.join(arguments['global'].config_paths) ' '.join(arguments['global'].config_paths)
) )
@ -693,7 +805,7 @@ def collect_configuration_run_summary_logs(configs, arguments):
arguments['global'].dry_run, arguments['global'].dry_run,
) )
except (CalledProcessError, ValueError, OSError) as error: 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 return
# Execute the actions corresponding to each configuration file. # Execute the actions corresponding to each configuration file.
@ -703,7 +815,7 @@ def collect_configuration_run_summary_logs(configs, arguments):
error_logs = tuple(result for result in results if isinstance(result, logging.LogRecord)) error_logs = tuple(result for result in results if isinstance(result, logging.LogRecord))
if error_logs: if error_logs:
yield from make_error_log_records( yield from log_error_records(
'{}: Error running configuration file'.format(config_filename) '{}: Error running configuration file'.format(config_filename)
) )
yield from error_logs yield from error_logs
@ -725,7 +837,7 @@ def collect_configuration_run_summary_logs(configs, arguments):
mount_point=arguments['umount'].mount_point, local_path=get_local_path(configs) mount_point=arguments['umount'].mount_point, local_path=get_local_path(configs)
) )
except (CalledProcessError, OSError) as error: 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: if json_results:
sys.stdout.write(json.dumps(json_results)) sys.stdout.write(json.dumps(json_results))
@ -742,7 +854,7 @@ def collect_configuration_run_summary_logs(configs, arguments):
arguments['global'].dry_run, arguments['global'].dry_run,
) )
except (CalledProcessError, ValueError, OSError) as error: 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 def exit_with_help_link(): # pragma: no cover

View File

@ -17,7 +17,7 @@ def _convert_section(source_section_config, section_schema):
( (
option_name, option_name,
int(option_value) 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, else option_value,
) )
for option_name, option_value in source_section_config.items() 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( 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() 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(' ') destination_config['consistency']['checks'] = source_config.consistency['checks'].split(' ')
# Add comments to each section, and then add comments to the fields in each section. # 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(): for section_name, section_config in destination_config.items():
generate.add_comments_to_configuration_map( generate.add_comments_to_configuration_object(
section_config, schema['map'][section_name], indent=generate.INDENT section_config, schema['properties'][section_name], indent=generate.INDENT
) )
return destination_config return destination_config

View File

@ -24,29 +24,27 @@ def _insert_newline_before_comment(config, field_name):
def _schema_to_sample_configuration(schema, level=0, parent_is_sequence=False): 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 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') example = schema.get('example')
if example is not None: if example is not None:
return example return example
if 'seq' in schema: if schema_type == 'array':
config = yaml.comments.CommentedSeq( config = yaml.comments.CommentedSeq(
[ [_schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
_schema_to_sample_configuration(item_schema, level, parent_is_sequence=True)
for item_schema in schema['seq']
]
) )
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT)) add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
elif 'map' in schema: elif schema_type == 'object':
config = yaml.comments.CommentedMap( config = yaml.comments.CommentedMap(
[ [
(field_name, _schema_to_sample_configuration(sub_schema, level + 1)) (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) 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 config, schema, indent=indent, skip_first=parent_is_sequence
) )
else: else:
@ -132,8 +130,8 @@ def write_configuration(config_filename, rendered_config, mode=0o600):
def add_comments_to_configuration_sequence(config, schema, indent=0): 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 If the given config sequence's items are object, 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 object's first item, and slap that atop the sequence. Indent the comment the given number of
characters. characters.
Doing this for sequences of maps results in nice comments that look like: Doing this for sequences of maps results in nice comments that look like:
@ -142,16 +140,16 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
things: things:
# First key description. Added by this function. # First key description. Added by this function.
- key: foo - key: foo
# Second key description. Added by add_comments_to_configuration_map(). # Second key description. Added by add_comments_to_configuration_object().
other: bar other: bar
``` ```
''' '''
if 'map' not in schema['seq'][0]: if schema['items'].get('type') != 'object':
return return
for field_name in config[0].keys(): for field_name in config[0].keys():
field_schema = schema['seq'][0]['map'].get(field_name, {}) field_schema = schema['items']['properties'].get(field_name, {})
description = field_schema.get('desc') description = field_schema.get('description')
# No description to use? Skip it. # No description to use? Skip it.
if not field_schema or not description: if not field_schema or not description:
@ -160,7 +158,7 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
config[0].yaml_set_start_comment(description, indent=indent) 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 # 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 return
@ -169,7 +167,7 @@ REQUIRED_KEYS = {'source_directories', 'repositories', 'keep_daily'}
COMMENTED_OUT_SENTINEL = 'COMMENT_OUT' 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 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. config mapping, before each field. Indent the comment the given number of characters.
@ -178,8 +176,8 @@ def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False
if skip_first and index == 0: if skip_first and index == 0:
continue continue
field_schema = schema['map'].get(field_name, {}) field_schema = schema['properties'].get(field_name, {})
description = field_schema.get('desc', '').strip() description = field_schema.get('description', '').strip()
# If this is an optional key, add an indicator to the comment flagging it to be commented # If this 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 # out from the sample configuration. This sentinel is consumed by downstream processing that
@ -268,9 +266,9 @@ def merge_source_configuration_into_destination(destination_config, source_confi
def generate_sample_configuration(source_filename, destination_filename, schema_filename): def generate_sample_configuration(source_filename, destination_filename, schema_filename):
''' '''
Given an optional source configuration filename, and a required destination configuration 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 filename, and the path to a schema filename in a YAML rendition of the JSON Schema format,
sample configuration file based on that schema. If a source filename is provided, merge the write out a sample configuration file based on that schema. If a source filename is provided,
parsed contents of that configuration into the generated configuration. merge the parsed contents of that configuration into the generated configuration.
''' '''
schema = yaml.round_trip_load(open(schema_filename)) schema = yaml.round_trip_load(open(schema_filename))
source_config = None source_config = None

View File

@ -26,6 +26,8 @@ def convert_value_type(value):
''' '''
Given a string value, determine its logical type (string, boolean, integer, etc.), and return it Given a string value, determine its logical type (string, boolean, integer, etc.), and return it
converted to that type. 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)) return ruamel.yaml.YAML(typ='safe').load(io.StringIO(value))
@ -58,6 +60,8 @@ def parse_overrides(raw_overrides):
) )
except ValueError: except ValueError:
raise ValueError('Invalid override. Make sure you use the form: SECTION.OPTION=VALUE') raise ValueError('Invalid override. Make sure you use the form: SECTION.OPTION=VALUE')
except ruamel.yaml.error.YAMLError as error:
raise ValueError(f'Invalid override value: {error}')
def apply_overrides(config, raw_overrides): def apply_overrides(config, raw_overrides):

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,7 @@
import logging
import os import os
import jsonschema
import pkg_resources import pkg_resources
import pykwalify.core
import pykwalify.errors
import ruamel.yaml import ruamel.yaml
from borgmatic.config import load, normalize, override from borgmatic.config import load, normalize, override
@ -17,15 +15,40 @@ def schema_filename():
return pkg_resources.resource_filename('borgmatic', 'config/schema.yaml') 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): class Validation_error(ValueError):
''' '''
A collection of error message strings generated when attempting to validate a particular A collection of error messages generated when attempting to validate a particular
configurartion file. 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.config_filename = config_filename
self.error_messages = error_messages self.errors = errors
def __str__(self): def __str__(self):
''' '''
@ -33,7 +56,7 @@ class Validation_error(ValueError):
''' '''
return 'An error occurred while parsing a configuration file at {}:\n'.format( return 'An error occurred while parsing a configuration file at {}:\n'.format(
self.config_filename self.config_filename
) + '\n'.join(self.error_messages) ) + '\n'.join(error for error in self.errors)
def apply_logical_validation(config_filename, parsed_configuration): def apply_logical_validation(config_filename, parsed_configuration):
@ -65,29 +88,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): 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 Given the path to a config filename in YAML format, the path to a schema filename in a YAML
YAML schema format, a sequence of configuration file override strings in the form of rendition of JSON Schema format, a sequence of configuration file override strings in the form
"section.option=value", return the parsed configuration as a data structure of nested dicts and of "section.option=value", return the parsed configuration as a data structure of nested dicts
lists corresponding to the schema. Example return value: and lists corresponding to the schema. Example return value:
{'location': {'source_directories': ['/home', '/etc'], 'repository': 'hostname.borg'}, {'location': {'source_directories': ['/home', '/etc'], 'repository': 'hostname.borg'},
'retention': {'keep_daily': 7}, 'consistency': {'checks': ['repository', 'archives']}} 'retention': {'keep_daily': 7}, 'consistency': {'checks': ['repository', 'archives']}}
@ -95,8 +101,6 @@ def parse_configuration(config_filename, schema_filename, overrides=None):
Raise FileNotFoundError if the file does not exist, PermissionError if the user does not 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. have permissions to read the file, or Validation_error if the config does not match the schema.
''' '''
logging.getLogger('pykwalify').setLevel(logging.ERROR)
try: try:
config = load.load_configuration(config_filename) config = load.load_configuration(config_filename)
schema = load.load_configuration(schema_filename) schema = load.load_configuration(schema_filename)
@ -106,15 +110,20 @@ def parse_configuration(config_filename, schema_filename, overrides=None):
override.apply_overrides(config, overrides) override.apply_overrides(config, overrides)
normalize.normalize(config) normalize.normalize(config)
validator = pykwalify.core.Core(source_data=config, schema_data=remove_examples(schema)) try:
parsed_result = validator.validate(raise_exception=False) validator = jsonschema.Draft7Validator(schema)
except AttributeError: # pragma: no cover
validator = jsonschema.Draft4Validator(schema)
validation_errors = tuple(validator.iter_errors(config))
if validator.validation_errors: if validation_errors:
raise Validation_error(config_filename, validator.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): def normalize_repository_path(repository):

View File

@ -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. # Map from output buffer to sequence of last lines.
buffer_last_lines = collections.defaultdict(list) buffer_last_lines = collections.defaultdict(list)
output_buffers = [ process_for_output_buffer = {
output_buffer_for_process(process, exclude_stdouts) output_buffer_for_process(process, exclude_stdouts): process
for process in processes for process in processes
if process.stdout or process.stderr if process.stdout or process.stderr
] }
output_buffers = list(process_for_output_buffer.keys())
# Log output for each process until they all exit. # Log output for each process until they all exit.
while True: while True:
@ -71,8 +72,23 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
(ready_buffers, _, _) = select.select(output_buffers, [], []) (ready_buffers, _, _) = select.select(output_buffers, [], [])
for ready_buffer in ready_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() line = ready_buffer.readline().rstrip().decode()
if not line: if not line or not ready_process:
continue continue
# Keep the last few lines of output in case the process errors, and we need the output for # Keep the last few lines of output in case the process errors, and we need the output for
@ -123,9 +139,12 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
if not output_buffer: if not output_buffer:
continue 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) logger.log(output_log_level, remaining_output)

View File

@ -1,6 +1,6 @@
import logging 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__) logger = logging.getLogger(__name__)
@ -11,6 +11,7 @@ HOOK_NAME_TO_MODULE = {
'pagerduty': pagerduty, 'pagerduty': pagerduty,
'postgresql_databases': postgresql, 'postgresql_databases': postgresql,
'mysql_databases': mysql, 'mysql_databases': mysql,
'mongodb_databases': mongodb,
} }

View File

@ -6,7 +6,7 @@ from borgmatic.borg.create import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
logger = logging.getLogger(__name__) 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): def make_database_dump_path(borgmatic_source_directory, database_hook_name):

View File

@ -13,7 +13,7 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
} }
PAYLOAD_TRUNCATION_INDICATOR = '...\n' PAYLOAD_TRUNCATION_INDICATOR = '...\n'
PAYLOAD_LIMIT_BYTES = 10 * 1024 - len(PAYLOAD_TRUNCATION_INDICATOR) PAYLOAD_LIMIT_BYTES = 100 * 1024 - len(PAYLOAD_TRUNCATION_INDICATOR)
class Forgetful_buffering_handler(logging.Handler): class Forgetful_buffering_handler(logging.Handler):

162
borgmatic/hooks/mongodb.py Normal file
View File

@ -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

View File

@ -31,6 +31,7 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run_labe
show_command = ( show_command = (
('mysql',) ('mysql',)
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
+ (('--host', database['hostname']) if 'hostname' in database else ()) + (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ()) + (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or '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 = ( dump_command = (
('mysqldump',) ('mysqldump',)
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
+ ('--add-drop-database',) + ('--add-drop-database',)
+ (('--host', database['hostname']) if 'hostname' in database else ()) + (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ()) + (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ()) + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ()) + (('--user', database['username']) if 'username' in database else ())
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
+ ('--databases',) + ('--databases',)
+ dump_database_names + dump_database_names
# Use shell redirection rather than execute_command(output_file=open(...)) to prevent # Use shell redirection rather than execute_command(output_file=open(...)) to prevent
@ -151,7 +152,7 @@ def restore_database_dump(database_config, log_prefix, location_config, dry_run,
database = database_config[0] database = database_config[0]
restore_command = ( restore_command = (
('mysql', '--batch', '--verbose') ('mysql', '--batch')
+ (('--host', database['hostname']) if 'hostname' in database else ()) + (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ()) + (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ()) + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())

View File

@ -1,4 +1,5 @@
import logging import logging
import logging.handlers
import os import os
import sys import sys
@ -151,6 +152,8 @@ def configure_logging(
syslog_path = '/dev/log' syslog_path = '/dev/log'
elif os.path.exists('/var/run/syslog'): elif os.path.exists('/var/run/syslog'):
syslog_path = '/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(): if syslog_path and not interactive_console():
syslog_handler = logging.handlers.SysLogHandler(address=syslog_path) syslog_handler = logging.handlers.SysLogHandler(address=syslog_path)

View File

@ -1,23 +1,34 @@
import logging
import os import os
import signal 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 processes. 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 # Prevent infinite signal handler recursion. If the parent frame is this very same handler
# function, we know we're recursing. # function, we know we're recursing.
if frame.f_back.f_code.co_name == _handle_signal.__name__: if frame.f_back.f_code.co_name == handle_signal.__name__:
return return
os.killpg(os.getpgrp(), signal_number) 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 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. like Borg. Note that SIGINT gets passed through even without these changes.
''' '''
for signal_number in (signal.SIGHUP, signal.SIGTERM, signal.SIGUSR1, signal.SIGUSR2): for signal_number in (signal.SIGHUP, signal.SIGTERM, signal.SIGUSR1, signal.SIGUSR2):
signal.signal(signal_number, _handle_signal) signal.signal(signal_number, handle_signal)

View File

@ -1,9 +1,10 @@
FROM python:3.8-alpine3.12 as borgmatic FROM python:3.8-alpine3.13 as borgmatic
COPY . /app 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 pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
RUN borgmatic --help > /command-line.txt \ RUN borgmatic --help > /command-line.txt \
&& for action in init prune create check extract export-tar mount umount restore list info borg; 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 \ echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
&& borgmatic "$action" --help >> /command-line.txt; done && borgmatic "$action" --help >> /command-line.txt; done

View File

@ -1,17 +1,5 @@
<h2>Improve this documentation</h2> <h2>Improve this documentation</h2>
<p>Have an idea on how to make this documentation even better? Use our <a <p>Have an idea on how to make this documentation even better? Use our <a
href="https://projects.torsion.org/witten/borgmatic/issues">issue tracker</a> to send your href="https://projects.torsion.org/borgmatic-collective/borgmatic/issues">issue tracker</a> to send your
feedback!</p> feedback!</p>
<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>

View File

@ -258,6 +258,7 @@ footer.elv-layout {
/* Header */ /* Header */
.elv-header { .elv-header {
position: relative; position: relative;
text-align: center;
} }
.elv-header-default { .elv-header-default {
display: flex; display: flex;

View File

@ -33,9 +33,33 @@ 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 afterwards, but not if an error occurs in a previous hook or in the backups
themselves. themselves.
There are additional hooks for the `prune` and `check` actions as well. There are additional hooks that run before/after other actions as well. For
`before_prune` and `after_prune` run if there are any `prune` actions, while instance, `before_prune` runs before a `prune` action, while `after_prune`
`before_check` and `after_check` run if there are any `check` actions. 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}" "{repositories}"
```
In this example, when the hook is triggered, borgmatic interpolates runtime
values into the hook command: the borgmatic configuration filename and the
paths of all configured repositories. Here's the full set of supported
variables you can use here:
* `configuration_filename`: borgmatic configuration filename in which the
hook was defined
* `repositories`: comma-separated paths of all repositories configured in the
current borgmatic configuration file
## Global hooks
You can also use `before_everything` and `after_everything` hooks to perform You can also use `before_everything` and `after_everything` hooks to perform
global setup or cleanup: global setup or cleanup:
@ -58,6 +82,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 a backup or a backup hook, but not if an error occurs during a
`before_everything` hook. `before_everything` hook.
## Error hooks
borgmatic also runs `on_error` hooks if an error occurs, either when creating 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 a backup or running a backup hook. See the [monitoring and alerting
documentation](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/) documentation](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)

View File

@ -115,6 +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 * 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 a test to make sure that individual source directories are mounted and
available. Use your imagination! available. Use your imagination!
* The soft failure feature also works for `before_prune`, `after_prune`, * The soft failure feature also works for before/after hooks for other
`before_check`, and `after_check` hooks. But it is not implemented for actions as well. But it is not implemented for `before_everything` or
`before_everything` or `after_everything`. `after_everything`.

View File

@ -15,7 +15,8 @@ consistent snapshot that is more suited for backups.
Fortunately, borgmatic includes built-in support for creating database dumps Fortunately, borgmatic includes built-in support for creating database dumps
prior to running backups. For example, here is everything you need to dump and 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 ```yaml
hooks: hooks:
@ -24,12 +25,15 @@ hooks:
- name: orders - name: orders
mysql_databases: mysql_databases:
- name: posts - name: posts
mongodb_databases:
- name: messages
``` ```
As part of each backup, borgmatic streams a database dump for each configured 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 database directly to Borg, so it's included in the backup without consuming
additional disk space. (The one exception is PostgreSQL's "directory" dump additional disk space. (The exceptions are the PostgreSQL/MongoDB "directory"
format, which can't stream and therefore does consume temporary disk space.) dump formats, which can't stream and therefore do consume temporary disk
space.)
To support this, borgmatic creates temporary named pipes in `~/.borgmatic` by To support this, borgmatic creates temporary named pipes in `~/.borgmatic` by
default. To customize this path, set the `borgmatic_source_directory` option default. To customize this path, set the `borgmatic_source_directory` option
@ -59,6 +63,14 @@ hooks:
username: root username: root
password: trustsome1 password: trustsome1
options: "--skip-comments" 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: If you want to dump all databases on a host, use `all` for the database name:
@ -69,13 +81,15 @@ hooks:
- name: all - name: all
mysql_databases: mysql_databases:
- name: all - name: all
mongodb_databases:
- name: all
``` ```
Note that you may need to use a `username` of the `postgres` superuser for Note that you may need to use a `username` of the `postgres` superuser for
this to work with PostgreSQL. this to work with PostgreSQL.
If you would like to backup databases only and not source directories, you can If you would like to backup databases only and not source directories, you can
specify an empty `source_directories` value because it is a mandatory field: specify an empty `source_directories` value (as it is a mandatory field):
```yaml ```yaml
location: location:
@ -97,7 +111,7 @@ bring back any missing configuration files in order to restore a database.
## Supported databases ## 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 directly. But see below about general-purpose preparation and cleanup hooks as
a work-around with other database systems. Also, please [file a a work-around with other database systems. Also, please [file a
ticket](https://torsion.org/borgmatic/#issues) for additional database systems ticket](https://torsion.org/borgmatic/#issues) for additional database systems
@ -185,10 +199,10 @@ backups to avoid getting caught without a way to restore a database.
databases that share the exact same name on different hosts. databases that share the exact same name on different hosts.
4. Because database hooks implicitly enable the `read_special` configuration 4. Because database hooks implicitly enable the `read_special` configuration
setting to support dump and restore streaming, you'll need to ensure that any setting to support dump and restore streaming, you'll need to ensure that any
special files are excluded from backups (named pipes, block devices, and special files are excluded from backups (named pipes, block devices,
character devices) to prevent hanging. Try a command like `find / -type c,b,p` character devices, and sockets) to prevent hanging. Try a command like
to find such files. Common directories to exclude are `/dev` and `/run`, but `find /your/source/path -type c,b,p,s` to find such files. Common directories
that may not be exhaustive. to exclude are `/dev` and `/run`, but that may not be exhaustive.
### Manual restoration ### Manual restoration
@ -196,8 +210,8 @@ that may not be exhaustive.
If you prefer to restore a database without the help of borgmatic, first 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 [extract](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) an
archive containing a database dump, and then manually restore the dump file archive containing a database dump, and then manually restore the dump file
found within the extracted `~/.borgmatic/` path (e.g. with `pg_restore` or found within the extracted `~/.borgmatic/` path (e.g. with `pg_restore`,
`mysql` commands). `mysql`, or `mongorestore`, commands).
## Preparation and cleanup hooks ## Preparation and cleanup hooks
@ -230,5 +244,10 @@ hooks:
### borgmatic hangs during backup ### borgmatic hangs during backup
See Limitations above about `read_special`. You may need to exclude certain See Limitations above about `read_special`. You may need to exclude certain
paths with named pipes, block devices, or character devices on which borgmatic paths with named pipes, block devices, character devices, or sockets on which
is hanging. borgmatic is hanging.
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.

View File

@ -9,19 +9,20 @@ eleventyNavigation:
Borg itself is great for efficiently de-duplicating data across successive Borg itself is great for efficiently de-duplicating data across successive
backup archives, even when dealing with very large repositories. But you may 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 find that while borgmatic's default mode of `prune`, `compact`, `create`, and
well on small repositories, it's not so great on larger ones. That's because `check` works well on small repositories, it's not so great on larger ones.
running the default pruning and consistency checks take a long time on large That's because running the default pruning, compact, and consistency checks
repositories. take a long time on large repositories.
### A la carte actions ### A la carte actions
If you find yourself in this situation, you have some options. First, you can If you find yourself in this situation, you have some options. First, you can
run borgmatic's pruning, creating, or checking actions separately. For run borgmatic's `prune`, `compact`, `create`, or `check` actions separately.
instance, the following optional actions are available: For instance, the following optional actions are available:
```bash ```bash
borgmatic prune borgmatic prune
borgmatic compact
borgmatic create borgmatic create
borgmatic check borgmatic check
``` ```
@ -32,7 +33,7 @@ borgmatic check
You can run with only one of these actions provided, or you can mix and match 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 any number of them in a single borgmatic run. This supports approaches like
skipping certain actions while running others. For instance, this skips 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 ```bash
borgmatic create check borgmatic create check

View File

@ -10,17 +10,17 @@ eleventyNavigation:
To get set up to hack on borgmatic, first clone master via HTTPS or SSH: To get set up to hack on borgmatic, first clone master via HTTPS or SSH:
```bash ```bash
git clone https://projects.torsion.org/witten/borgmatic.git git clone https://projects.torsion.org/borgmatic-collective/borgmatic.git
``` ```
Or: Or:
```bash ```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 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 so that you can run borgmatic commands while you're hacking on them to
make sure your changes work. make sure your changes work.
@ -66,8 +66,6 @@ following:
tox -e black tox -e black
``` ```
Note that Black requires at minimum Python 3.6.
And if you get a complaint from the And if you get a complaint from the
[isort](https://github.com/timothycrosley/isort) Python import orderer, you [isort](https://github.com/timothycrosley/isort) Python import orderer, you
can ask isort to order your imports for you: can ask isort to order your imports for you:
@ -118,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 Each pull request triggers a continuous integration build which runs the test
suite. You can view these builds on 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. also linked from the commits list on each pull request.
## Documentation development ## Documentation development

View File

@ -22,7 +22,6 @@ location:
repositories: repositories:
- 1234@usw-s001.rsync.net:backups.borg - 1234@usw-s001.rsync.net:backups.borg
- k8pDxu32@k8pDxu32.repo.borgbase.com:repo - k8pDxu32@k8pDxu32.repo.borgbase.com:repo
- user1@scp2.cdn.lima-labs.com:repo
- /var/lib/backups/local.borg - /var/lib/backups/local.borg
``` ```
@ -35,8 +34,7 @@ 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` 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` 2. Backup `/home` and `/etc` to `k8pDxu32@k8pDxu32.repo.borgbase.com:repo`
3. Backup `/home` and `/etc` to `user1@scp2.cdn.lima-labs.com:repo` 3. Backup `/home` and `/etc` to `/var/lib/backups/local.borg`
4. Backup `/home` and `/etc` to `/var/lib/backups/local.borg`
This gives you redundancy of your data across repositories and even This gives you redundancy of your data across repositories and even
potentially across providers. potentially across providers.

View File

@ -83,10 +83,10 @@ tests](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/).
## Error hooks ## Error hooks
When an error occurs during a `prune`, `create`, or `check` action, borgmatic When an error occurs during a `prune`, `compact`, `create`, or `check` action,
can run configurable shell commands to fire off custom error notifications or borgmatic can run configurable shell commands to fire off custom error
take other actions, so you can get alerted as soon as something goes wrong. notifications or take other actions, so you can get alerted as soon as
Here's a not-so-useful example: something goes wrong. Here's a not-so-useful example:
```yaml ```yaml
hooks: hooks:
@ -104,10 +104,9 @@ hooks:
- send-text-message.sh "{configuration_filename}" "{repository}" - send-text-message.sh "{configuration_filename}" "{repository}"
``` ```
In this example, when the error occurs, borgmatic interpolates a few runtime In this example, when the error occurs, borgmatic interpolates runtime values
values into the hook command: the borgmatic configuration filename, and the into the hook command: the borgmatic configuration filename, and the path of
path of the repository. Here's the full set of supported variables you can use the repository. Here's the full set of supported variables you can use here:
here:
* `configuration_filename`: borgmatic configuration filename in which the * `configuration_filename`: borgmatic configuration filename in which the
error occurred error occurred
@ -117,9 +116,9 @@ here:
* `output`: output of the command that failed (may be blank if an error * `output`: output of the command that failed (may be blank if an error
occurred without running a command) occurred without running a command)
Note that borgmatic runs the `on_error` hooks only for `prune`, `create`, or Note that borgmatic runs the `on_error` hooks only for `prune`, `compact`,
`check` actions or hooks in which an error occurs, and not other actions. `create`, or `check` actions or hooks in which an error occurs, and not other
borgmatic does not run `on_error` hooks if an error occurs within a 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 `before_everything` or `after_everything` hook. For more about hooks, see the
[borgmatic hooks [borgmatic hooks
documentation](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/), documentation](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/),
@ -144,7 +143,7 @@ With this hook in place, borgmatic pings your Healthchecks project when a
backup begins, ends, or errors. Specifically, after 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` 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 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 Then, if the actions complete successfully, borgmatic notifies Healthchecks of
the success after the `after_backup` hooks run, and includes borgmatic logs in the success after the `after_backup` hooks run, and includes borgmatic logs in
@ -155,7 +154,7 @@ in the Healthchecks UI, although be aware that Healthchecks currently has a
If an error occurs during any action or hook, borgmatic notifies Healthchecks 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 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`, itself. But the logs are only included for errors that occur when a `prune`,
`create`, or `check` action is run. `compact`, `create`, or `check` action is run.
You can customize the verbosity of the logs that are sent to Healthchecks with You can customize the verbosity of the logs that are sent to Healthchecks with
borgmatic's `--monitoring-verbosity` flag. The `--files` and `--stats` flags borgmatic's `--monitoring-verbosity` flag. The `--files` and `--stats` flags
@ -184,8 +183,8 @@ With this hook in place, borgmatic pings your Cronitor monitor when a backup
begins, ends, or errors. Specifically, after 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` 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 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 `prune`, `compact`, `create`, or `check` actions are run. Then, if the actions
successfully, borgmatic notifies Cronitor of the success after the complete successfully, borgmatic notifies Cronitor of the success after the
`after_backup` hooks run. And if an error occurs during any action or hook, `after_backup` hooks run. And if an error occurs during any action or hook,
borgmatic notifies Cronitor after the `on_error` hooks run. borgmatic notifies Cronitor after the `on_error` hooks run.
@ -212,8 +211,8 @@ With this hook in place, borgmatic pings your Cronhub monitor when a backup
begins, ends, or errors. Specifically, after 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` 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 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 `prune`, `compact`, `create`, or `check` actions are run. Then, if the actions
successfully, borgmatic notifies Cronhub of the success after the complete successfully, borgmatic notifies Cronhub of the success after the
`after_backup` hooks run. And if an error occurs during any action or hook, `after_backup` hooks run. And if an error occurs during any action or hook,
borgmatic notifies Cronhub after the `on_error` hooks run. borgmatic notifies Cronhub after the `on_error` hooks run.
@ -252,9 +251,9 @@ hooks:
With this hook in place, borgmatic creates a PagerDuty event for your service With this hook in place, borgmatic creates a PagerDuty event for your service
whenever backups fail. Specifically, if an error occurs during a `create`, whenever backups fail. Specifically, if an error occurs during a `create`,
`prune`, or `check` action, borgmatic sends an event to PagerDuty before the `prune`, `compact`, or `check` action, borgmatic sends an event to PagerDuty
`on_error` hooks run. Note that borgmatic does not contact PagerDuty when a before the `on_error` hooks run. Note that borgmatic does not contact
backup starts or ends without error. PagerDuty when a backup starts or ends without error.
You can configure PagerDuty to notify you by a [variety of You can configure PagerDuty to notify you by a [variety of
mechanisms](https://support.pagerduty.com/docs/notifications) when backups mechanisms](https://support.pagerduty.com/docs/notifications) when backups

View File

@ -28,7 +28,7 @@ sudo pip3 install --user --upgrade borgmatic
This installs borgmatic and its commands at the `/root/.local/bin` path. 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 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 The next step is to ensure that borgmatic's commands available are on your
system `PATH`, so that you can run borgmatic: system `PATH`, so that you can run borgmatic:
@ -77,7 +77,7 @@ on a relatively dedicated system, then a global install can work out fine.
Besides the approaches described above, there are several other options for Besides the approaches described above, there are several other options for
installing borgmatic: installing borgmatic:
* [Docker image with scheduled backups](https://hub.docker.com/r/b3vis/borgmatic/) * [Docker image with scheduled backups](https://hub.docker.com/r/b3vis/borgmatic/) (+ Docker Compose files)
* [Docker base image](https://hub.docker.com/r/monachus/borgmatic/) * [Docker base image](https://hub.docker.com/r/monachus/borgmatic/)
* [Debian](https://tracker.debian.org/pkg/borgmatic) * [Debian](https://tracker.debian.org/pkg/borgmatic)
* [Ubuntu](https://launchpad.net/ubuntu/+source/borgmatic) * [Ubuntu](https://launchpad.net/ubuntu/+source/borgmatic)
@ -87,6 +87,7 @@ installing borgmatic:
* [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=borgmatic) * [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=borgmatic)
* [OpenBSD](http://ports.su/sysutils/borgmatic) * [OpenBSD](http://ports.su/sysutils/borgmatic)
* [openSUSE](https://software.opensuse.org/package/borgmatic) * [openSUSE](https://software.opensuse.org/package/borgmatic)
* [Ansible role](https://github.com/borgbase/ansible-role-borgbackup)
* [stand-alone binary](https://github.com/cmarquardt/borgmatic-binary) * [stand-alone binary](https://github.com/cmarquardt/borgmatic-binary)
* [virtualenv](https://virtualenv.pypa.io/en/stable/) * [virtualenv](https://virtualenv.pypa.io/en/stable/)
@ -99,14 +100,12 @@ development and hosting when you use these links to sign up. (These are
referral links, but without any tracking scripts or cookies.) referral links, but without any tracking scripts or cookies.)
<ul> <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> <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>
<li class="referral"><a href="https://storage.lima-labs.com/special-pricing-offer-for-borgmatic-users/">Lima-Labs</a>: Affordable, reliable cloud data storage accessable via SSH/SCP/FTP for Borg backups or any other bulk storage needs</li>
</ul> </ul>
Additionally, [Hetzner](https://www.hetzner.com/storage/storage-box) has a Additionally, [rsync.net](https://www.rsync.net/products/borg.html) and
compatible storage offering, but does not currently fund borgmatic [Hetzner](https://www.hetzner.com/storage/storage-box) have compatible storage
development or hosting. offerings, but do not currently fund borgmatic development or hosting.
## Configuration ## Configuration
@ -228,8 +227,8 @@ sudo borgmatic --verbosity 1 --files
borgmatic. So try leaving it out, or upgrade borgmatic!) borgmatic. So try leaving it out, or upgrade borgmatic!)
By default, this will also prune any old backups as per the configured By default, this will also prune any old backups as per the configured
retention policy, and check backups for consistency problems due to things retention policy, compact segments to free up space (with Borg 1.2+), and
like file damage. check backups for consistency problems due to things like file damage.
The verbosity flag makes borgmatic show the steps it's performing. And the 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. files flag lists each file that's new or changed since the last backup.
@ -249,7 +248,7 @@ that, you can configure a separate job runner to invoke it periodically.
### cron ### cron
If you're using cron, download the [sample 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: Then, from the directory where you downloaded it:
```bash ```bash
@ -257,15 +256,26 @@ sudo mv borgmatic /etc/cron.d/borgmatic
sudo chmod +x /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 ### systemd
If you're using systemd instead of cron to run jobs, download the [sample If you're using systemd instead of cron to run jobs, you can still configure
systemd service borgmatic to run automatically.
file](https://projects.torsion.org/witten/borgmatic/raw/branch/master/sample/systemd/borgmatic.service)
(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 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: Then, from the directory where you downloaded them:
```bash ```bash
@ -285,7 +295,7 @@ borgmatic to run.
If you run borgmatic in macOS with launchd, you may encounter permissions 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 issues when reading files to backup. If that happens to you, you may be
interested in an [unofficial work-around for Full Disk 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 ## Colored output

BIN
docs/static/mongodb.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

View File

@ -1,3 +1,3 @@
# You can drop this file into /etc/cron.d/ to run borgmatic nightly. # 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

View File

@ -32,14 +32,18 @@ RestrictSUIDSGID=yes
SystemCallArchitectures=native SystemCallArchitectures=native
SystemCallFilter=@system-service SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM SystemCallErrorNumber=EPERM
# Restrict write access # To restrict write access further, change "ProtectSystem" to "strict" and uncomment
# Change to 'ProtectSystem=strict' and uncomment 'ProtectHome' to make the whole file # "ReadWritePaths", "ReadOnlyPaths", "ProtectHome", and "BindPaths". Then add any local repository
# system read-only be default and uncomment 'ReadWritePaths' for the required write access. # paths to the list of "ReadWritePaths" and local backup source paths to "ReadOnlyPaths". This
# Add local repositroy paths to the list of 'ReadWritePaths' like '-/mnt/my_backup_drive'. # leaves most of the filesystem read-only to borgmatic.
ProtectSystem=full ProtectSystem=full
# ProtectHome=read-only # ReadWritePaths=-/mnt/my_backup_drive
# ReadWritePaths=-/root/.config/borg -/root/.cache/borg -/root/.borgmatic # 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 CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_NET_RAW
# Lower CPU and I/O priority. # Lower CPU and I/O priority.
@ -57,4 +61,4 @@ LogRateLimitIntervalSec=0
# Delay start to prevent backups running during boot. Note that systemd-inhibit requires dbus and # Delay start to prevent backups running during boot. Note that systemd-inhibit requires dbus and
# dbus-user-session to be installed. # dbus-user-session to be installed.
ExecStartPre=sleep 1m 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

View File

@ -38,7 +38,7 @@ for sub_command in prune create check list info; do
| grep -v '^--json$' \ | grep -v '^--json$' \
| grep -v '^--keep-last$' \ | grep -v '^--keep-last$' \
| grep -v '^--list$' \ | grep -v '^--list$' \
| grep -v '^--nobsdflags$' \ | grep -v '^--bsdflags$' \
| grep -v '^--pattern$' \ | grep -v '^--pattern$' \
| grep -v '^--progress$' \ | grep -v '^--progress$' \
| grep -v '^--stats$' \ | grep -v '^--stats$' \
@ -54,7 +54,7 @@ for sub_command in prune create check list info; do
| grep -v '^--format' \ | grep -v '^--format' \
| grep -v '^--glob-archives' \ | grep -v '^--glob-archives' \
| grep -v '^--last' \ | grep -v '^--last' \
| grep -v '^--list-format' \ | grep -v '^--format' \
| grep -v '^--patterns-from' \ | grep -v '^--patterns-from' \
| grep -v '^--prefix' \ | grep -v '^--prefix' \
| grep -v '^--short' \ | grep -v '^--short' \

View File

@ -31,14 +31,14 @@ python3 setup.py bdist_wheel
python3 setup.py sdist python3 setup.py sdist
gpg --detach-sign --armor dist/borgmatic-*.tar.gz gpg --detach-sign --armor dist/borgmatic-*.tar.gz
gpg --detach-sign --armor dist/borgmatic-*-py3-none-any.whl 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 --username __token__ 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-*-py3-none-any.whl dist/borgmatic-*-py3-none-any.whl.asc
# Set release changelogs on projects.torsion.org and GitHub. # Set release changelogs on projects.torsion.org and GitHub.
release_changelog="$(cat NEWS | sed '/^$/q' | grep -v '^\S')" 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')" escaped_release_changelog="$(echo "$release_changelog" | sed -z 's/\n/\\n/g' | sed -z 's/\"/\\"/g')"
curl --silent --request POST \ curl --silent --request POST \
"https://projects.torsion.org/api/v1/repos/witten/borgmatic/releases" \ "https://projects.torsion.org/api/v1/repos/borgmatic-collective/borgmatic/releases" \
--header "Authorization: token $projects_token" \ --header "Authorization: token $projects_token" \
--header "Accept: application/json" \ --header "Accept: application/json" \
--header "Content-Type: application/json" \ --header "Content-Type: application/json" \

View File

@ -10,11 +10,12 @@
set -e set -e
apk add --no-cache python3 py3-pip borgbackup postgresql-client mariadb-client apk add --no-cache python3 py3-pip borgbackup postgresql-client mariadb-client mongodb-tools \
py3-ruamel.yaml py3-ruamel.yaml.clib
# If certain dependencies of black are available in this version of Alpine, install them. # If certain dependencies of black are available in this version of Alpine, install them.
apk add --no-cache py3-typed-ast py3-regex || true apk add --no-cache py3-typed-ast py3-regex || true
python3 -m pip install --upgrade pip==20.2.4 setuptools==50.3.2 python3 -m pip install --no-cache --upgrade pip==22.0.3 setuptools==60.8.1
pip3 install tox==3.20.1 pip3 install tox==3.24.5
export COVERAGE_FILE=/tmp/.coverage export COVERAGE_FILE=/tmp/.coverage
tox --workdir /tmp/.tox --sitepackages tox --workdir /tmp/.tox --sitepackages
tox --workdir /tmp/.tox --sitepackages -e end-to-end tox --workdir /tmp/.tox --sitepackages -e end-to-end

View File

@ -1,6 +1,6 @@
from setuptools import find_packages, setup from setuptools import find_packages, setup
VERSION = '1.5.15' VERSION = '1.5.25.dev0'
setup( setup(
@ -30,11 +30,12 @@ setup(
}, },
obsoletes=['atticmatic'], obsoletes=['atticmatic'],
install_requires=( install_requires=(
'pykwalify>=1.6.0,<14.06', 'jsonschema',
'requests', 'requests',
'ruamel.yaml>0.15.0,<0.18.0', 'ruamel.yaml>0.15.0,<0.18.0',
'setuptools', 'setuptools',
'colorama>=0.4.1,<0.5', 'colorama>=0.4.1,<0.5',
), ),
include_package_data=True, include_package_data=True,
python_requires='>3.7.0',
) )

View File

@ -1,28 +1,23 @@
appdirs==1.4.4; python_version >= '3.8' appdirs==1.4.4; python_version >= '3.8'
atomicwrites==1.4.0
attrs==20.3.0; python_version >= '3.8' attrs==20.3.0; python_version >= '3.8'
black==19.10b0; python_version >= '3.8' black==19.10b0; python_version >= '3.8'
click==7.1.2; python_version >= '3.8' click==7.1.2; python_version >= '3.8'
colorama==0.4.4 colorama==0.4.4
coverage==5.3 coverage==5.3
docopt==0.6.2 flake8==4.0.1
flake8==3.8.4
flexmock==0.10.4 flexmock==0.10.4
isort==5.6.4 isort==5.9.1
mccabe==0.6.1 mccabe==0.6.1
more-itertools==8.6.0
pluggy==0.13.1 pluggy==0.13.1
pathspec==0.8.1; python_version >= '3.8' pathspec==0.8.1; python_version >= '3.8'
py==1.10.0 py==1.10.0
pycodestyle==2.6.0 pycodestyle==2.8.0
pyflakes==2.2.0 pyflakes==2.4.0
pykwalify==1.7.0 jsonschema==3.2.0
pytest==6.1.2 pytest==6.2.5
pytest-cov==2.10.1 pytest-cov==3.0.0
python-dateutil==2.8.1
PyYAML==5.4.1
regex; python_version >= '3.8' regex; python_version >= '3.8'
requests==2.25.0 requests==2.25.0
ruamel.yaml>0.15.0,<0.18.0 ruamel.yaml>0.15.0,<0.18.0
toml==0.10.2; python_version >= '3.8' toml==0.10.2; python_version >= '3.8'
typed-ast==1.4.2; python_version >= '3.8' typed-ast; python_version >= '3.8'

View File

@ -10,6 +10,11 @@ services:
environment: environment:
MYSQL_ROOT_PASSWORD: test MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test MYSQL_DATABASE: test
mongodb:
image: mongo:5.0.5
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: test
tests: tests:
image: alpine:3.13 image: alpine:3.13
volumes: volumes:

View File

@ -47,13 +47,22 @@ hooks:
hostname: mysql hostname: mysql
username: root username: root
password: test password: test
mongodb_databases:
- name: test
hostname: mongodb
username: root
password: test
authentication_database: admin
- name: all
hostname: mongodb
username: root
password: test
'''.format( '''.format(
config_path, repository_path, borgmatic_source_directory, postgresql_dump_format config_path, repository_path, borgmatic_source_directory, postgresql_dump_format
) )
config_file = open(config_path, 'w') with open(config_path, 'w') as config_file:
config_file.write(config) config_file.write(config)
config_file.close()
def test_database_dump_and_restore(): 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) write_configuration(config_path, repository_path, borgmatic_source_directory)
subprocess.check_call( 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. # 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. # Get the created archive name.
output = subprocess.check_output( output = subprocess.check_output(
'borgmatic --config {} list --json'.format(config_path).split(' ') ['borgmatic', '--config', config_path, 'list', '--json']
).decode(sys.stdout.encoding) ).decode(sys.stdout.encoding)
parsed_output = json.loads(output) parsed_output = json.loads(output)
@ -87,9 +96,7 @@ def test_database_dump_and_restore():
# Restore the database from the archive. # Restore the database from the archive.
subprocess.check_call( subprocess.check_call(
'borgmatic --config {} restore --archive {}'.format(config_path, archive_name).split( ['borgmatic', '--config', config_path, 'restore', '--archive', archive_name]
' '
)
) )
finally: finally:
os.chdir(original_working_directory) os.chdir(original_working_directory)
@ -114,15 +121,15 @@ def test_database_dump_and_restore_with_directory_format():
) )
subprocess.check_call( 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. # 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. # Restore the database from the archive.
subprocess.check_call( subprocess.check_call(
'borgmatic --config {} restore --archive latest'.format(config_path).split(' ') ['borgmatic', '--config', config_path, 'restore', '--archive', 'latest']
) )
finally: finally:
os.chdir(original_working_directory) 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) write_configuration(config_path, repository_path, borgmatic_source_directory)
subprocess.check_call( 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. # Run borgmatic with a config override such that the database dump fails.

View File

@ -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')

View File

@ -122,38 +122,44 @@ def test_write_configuration_with_already_existing_directory_does_not_raise():
def test_add_comments_to_configuration_sequence_of_strings_does_not_raise(): def test_add_comments_to_configuration_sequence_of_strings_does_not_raise():
config = module.yaml.comments.CommentedSeq(['foo', 'bar']) 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) module.add_comments_to_configuration_sequence(config, schema)
def test_add_comments_to_configuration_sequence_of_maps_does_not_raise(): def test_add_comments_to_configuration_sequence_of_maps_does_not_raise():
config = module.yaml.comments.CommentedSeq([module.yaml.comments.CommentedMap([('foo', 'yo')])]) 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) module.add_comments_to_configuration_sequence(config, schema)
def test_add_comments_to_configuration_sequence_of_maps_without_description_does_not_raise(): 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')])]) 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) 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. # 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)]) 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)]) 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(): def test_remove_commented_out_sentinel_keeps_other_comments():

View File

@ -1,5 +1,6 @@
import logging import logging
import subprocess import subprocess
import sys
import pytest import pytest
from flexmock import flexmock from flexmock import flexmock
@ -98,7 +99,7 @@ def test_log_outputs_kills_other_processes_when_one_errors():
process, 2, 'borg' process, 2, 'borg'
).and_return(True) ).and_return(True)
other_process = subprocess.Popen( 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( flexmock(module).should_receive('exit_code_indicates_error').with_args(
other_process, None, 'borg' other_process, None, 'borg'
@ -123,6 +124,75 @@ def test_log_outputs_kills_other_processes_when_one_errors():
assert error.value.output 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(): def test_log_outputs_truncates_long_error_output():
flexmock(module).ERROR_OUTPUT_MAX_LINE_COUNT = 0 flexmock(module).ERROR_OUTPUT_MAX_LINE_COUNT = 0
flexmock(module.logger).should_receive('log') flexmock(module.logger).should_receive('log')

View File

@ -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'}},
)

File diff suppressed because it is too large Load Diff

View File

@ -25,12 +25,14 @@ def test_extract_last_archive_dry_run_calls_borg_with_last_archive():
('borg', 'list', '--short', 'repo'), result='archive1\narchive2\n' ('borg', 'list', '--short', 'repo'), result='archive1\narchive2\n'
) )
insert_execute_command_mock(('borg', 'extract', '--dry-run', 'repo::archive2')) insert_execute_command_mock(('borg', 'extract', '--dry-run', 'repo::archive2'))
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_last_archive_dry_run(repository='repo', lock_wait=None) module.extract_last_archive_dry_run(repository='repo', lock_wait=None)
def test_extract_last_archive_dry_run_without_any_archives_should_not_raise(): def test_extract_last_archive_dry_run_without_any_archives_should_not_raise():
insert_execute_command_output_mock(('borg', 'list', '--short', 'repo'), result='\n') insert_execute_command_output_mock(('borg', 'list', '--short', 'repo'), result='\n')
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_last_archive_dry_run(repository='repo', lock_wait=None) module.extract_last_archive_dry_run(repository='repo', lock_wait=None)
@ -41,6 +43,7 @@ def test_extract_last_archive_dry_run_with_log_info_calls_borg_with_info_paramet
) )
insert_execute_command_mock(('borg', 'extract', '--dry-run', '--info', 'repo::archive2')) insert_execute_command_mock(('borg', 'extract', '--dry-run', '--info', 'repo::archive2'))
insert_logging_mock(logging.INFO) insert_logging_mock(logging.INFO)
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_last_archive_dry_run(repository='repo', lock_wait=None) module.extract_last_archive_dry_run(repository='repo', lock_wait=None)
@ -53,6 +56,7 @@ def test_extract_last_archive_dry_run_with_log_debug_calls_borg_with_debug_param
('borg', 'extract', '--dry-run', '--debug', '--show-rc', '--list', 'repo::archive2') ('borg', 'extract', '--dry-run', '--debug', '--show-rc', '--list', 'repo::archive2')
) )
insert_logging_mock(logging.DEBUG) insert_logging_mock(logging.DEBUG)
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_last_archive_dry_run(repository='repo', lock_wait=None) module.extract_last_archive_dry_run(repository='repo', lock_wait=None)
@ -62,6 +66,7 @@ def test_extract_last_archive_dry_run_calls_borg_via_local_path():
('borg1', 'list', '--short', 'repo'), result='archive1\narchive2\n' ('borg1', 'list', '--short', 'repo'), result='archive1\narchive2\n'
) )
insert_execute_command_mock(('borg1', 'extract', '--dry-run', 'repo::archive2')) insert_execute_command_mock(('borg1', 'extract', '--dry-run', 'repo::archive2'))
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_last_archive_dry_run(repository='repo', lock_wait=None, local_path='borg1') module.extract_last_archive_dry_run(repository='repo', lock_wait=None, local_path='borg1')
@ -73,6 +78,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_remote_path_parameters():
insert_execute_command_mock( insert_execute_command_mock(
('borg', 'extract', '--dry-run', '--remote-path', 'borg1', 'repo::archive2') ('borg', 'extract', '--dry-run', '--remote-path', 'borg1', 'repo::archive2')
) )
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_last_archive_dry_run(repository='repo', lock_wait=None, remote_path='borg1') module.extract_last_archive_dry_run(repository='repo', lock_wait=None, remote_path='borg1')
@ -84,6 +90,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_lock_wait_parameters():
insert_execute_command_mock( insert_execute_command_mock(
('borg', 'extract', '--dry-run', '--lock-wait', '5', 'repo::archive2') ('borg', 'extract', '--dry-run', '--lock-wait', '5', 'repo::archive2')
) )
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_last_archive_dry_run(repository='repo', lock_wait=5) module.extract_last_archive_dry_run(repository='repo', lock_wait=5)
@ -91,6 +98,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_lock_wait_parameters():
def test_extract_archive_calls_borg_with_path_parameters(): def test_extract_archive_calls_borg_with_path_parameters():
flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', 'repo::archive', 'path1', 'path2')) insert_execute_command_mock(('borg', 'extract', 'repo::archive', 'path1', 'path2'))
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -99,12 +107,14 @@ def test_extract_archive_calls_borg_with_path_parameters():
paths=['path1', 'path2'], paths=['path1', 'path2'],
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
) )
def test_extract_archive_calls_borg_with_remote_path_parameters(): def test_extract_archive_calls_borg_with_remote_path_parameters():
flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', '--remote-path', 'borg1', 'repo::archive')) insert_execute_command_mock(('borg', 'extract', '--remote-path', 'borg1', 'repo::archive'))
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -113,13 +123,18 @@ def test_extract_archive_calls_borg_with_remote_path_parameters():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
remote_path='borg1', remote_path='borg1',
) )
def test_extract_archive_calls_borg_with_numeric_owner_parameter(): @pytest.mark.parametrize(
'feature_available,option_flag', ((True, '--numeric-ids'), (False, '--numeric-owner'),),
)
def test_extract_archive_calls_borg_with_numeric_ids_parameter(feature_available, option_flag):
flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', '--numeric-owner', 'repo::archive')) insert_execute_command_mock(('borg', 'extract', option_flag, 'repo::archive'))
flexmock(module.feature).should_receive('available').and_return(feature_available)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -128,12 +143,14 @@ def test_extract_archive_calls_borg_with_numeric_owner_parameter():
paths=None, paths=None,
location_config={'numeric_owner': True}, location_config={'numeric_owner': True},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
) )
def test_extract_archive_calls_borg_with_umask_parameters(): def test_extract_archive_calls_borg_with_umask_parameters():
flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', '--umask', '0770', 'repo::archive')) insert_execute_command_mock(('borg', 'extract', '--umask', '0770', 'repo::archive'))
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -142,12 +159,14 @@ def test_extract_archive_calls_borg_with_umask_parameters():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={'umask': '0770'}, storage_config={'umask': '0770'},
local_borg_version='1.2.3',
) )
def test_extract_archive_calls_borg_with_lock_wait_parameters(): def test_extract_archive_calls_borg_with_lock_wait_parameters():
flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', '--lock-wait', '5', 'repo::archive')) insert_execute_command_mock(('borg', 'extract', '--lock-wait', '5', 'repo::archive'))
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -156,6 +175,7 @@ def test_extract_archive_calls_borg_with_lock_wait_parameters():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={'lock_wait': '5'}, storage_config={'lock_wait': '5'},
local_borg_version='1.2.3',
) )
@ -163,6 +183,7 @@ def test_extract_archive_with_log_info_calls_borg_with_info_parameter():
flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', '--info', 'repo::archive')) insert_execute_command_mock(('borg', 'extract', '--info', 'repo::archive'))
insert_logging_mock(logging.INFO) insert_logging_mock(logging.INFO)
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -171,6 +192,7 @@ def test_extract_archive_with_log_info_calls_borg_with_info_parameter():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
) )
@ -180,6 +202,7 @@ def test_extract_archive_with_log_debug_calls_borg_with_debug_parameters():
('borg', 'extract', '--debug', '--list', '--show-rc', 'repo::archive') ('borg', 'extract', '--debug', '--list', '--show-rc', 'repo::archive')
) )
insert_logging_mock(logging.DEBUG) insert_logging_mock(logging.DEBUG)
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -188,12 +211,14 @@ def test_extract_archive_with_log_debug_calls_borg_with_debug_parameters():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
) )
def test_extract_archive_calls_borg_with_dry_run_parameter(): def test_extract_archive_calls_borg_with_dry_run_parameter():
flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', '--dry-run', 'repo::archive')) insert_execute_command_mock(('borg', 'extract', '--dry-run', 'repo::archive'))
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=True, dry_run=True,
@ -202,12 +227,14 @@ def test_extract_archive_calls_borg_with_dry_run_parameter():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
) )
def test_extract_archive_calls_borg_with_destination_path(): def test_extract_archive_calls_borg_with_destination_path():
flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', 'repo::archive'), working_directory='/dest') insert_execute_command_mock(('borg', 'extract', 'repo::archive'), working_directory='/dest')
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -216,6 +243,7 @@ def test_extract_archive_calls_borg_with_destination_path():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
destination_path='/dest', destination_path='/dest',
) )
@ -223,6 +251,7 @@ def test_extract_archive_calls_borg_with_destination_path():
def test_extract_archive_calls_borg_with_strip_components(): def test_extract_archive_calls_borg_with_strip_components():
flexmock(module.os.path).should_receive('abspath').and_return('repo') flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', '--strip-components', '5', 'repo::archive')) insert_execute_command_mock(('borg', 'extract', '--strip-components', '5', 'repo::archive'))
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -231,6 +260,7 @@ def test_extract_archive_calls_borg_with_strip_components():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
strip_components=5, strip_components=5,
) )
@ -242,6 +272,7 @@ def test_extract_archive_calls_borg_with_progress_parameter():
output_file=module.DO_NOT_CAPTURE, output_file=module.DO_NOT_CAPTURE,
working_directory=None, working_directory=None,
).once() ).once()
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -250,6 +281,7 @@ def test_extract_archive_calls_borg_with_progress_parameter():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
progress=True, progress=True,
) )
@ -265,6 +297,7 @@ def test_extract_archive_with_progress_and_extract_to_stdout_raises():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
progress=True, progress=True,
extract_to_stdout=True, extract_to_stdout=True,
) )
@ -279,6 +312,7 @@ def test_extract_archive_calls_borg_with_stdout_parameter_and_returns_process():
working_directory=None, working_directory=None,
run_to_completion=False, run_to_completion=False,
).and_return(process).once() ).and_return(process).once()
flexmock(module.feature).should_receive('available').and_return(True)
assert ( assert (
module.extract_archive( module.extract_archive(
@ -288,6 +322,7 @@ def test_extract_archive_calls_borg_with_stdout_parameter_and_returns_process():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
extract_to_stdout=True, extract_to_stdout=True,
) )
== process == process
@ -299,6 +334,7 @@ def test_extract_archive_skips_abspath_for_remote_repository():
flexmock(module).should_receive('execute_command').with_args( flexmock(module).should_receive('execute_command').with_args(
('borg', 'extract', 'server:repo::archive'), working_directory=None ('borg', 'extract', 'server:repo::archive'), working_directory=None
).once() ).once()
flexmock(module.feature).should_receive('available').and_return(True)
module.extract_archive( module.extract_archive(
dry_run=False, dry_run=False,
@ -307,4 +343,5 @@ def test_extract_archive_skips_abspath_for_remote_repository():
paths=None, paths=None,
location_config={}, location_config={},
storage_config={}, storage_config={},
local_borg_version='1.2.3',
) )

View File

@ -0,0 +1,49 @@
import logging
import pytest
from flexmock import flexmock
from borgmatic.borg import version as module
from ..test_verbosity import insert_logging_mock
VERSION = '1.2.3'
def insert_execute_command_mock(command, borg_local_path='borg', version_output=f'borg {VERSION}'):
flexmock(module).should_receive('execute_command').with_args(
command, output_log_level=None, borg_local_path=borg_local_path
).once().and_return(version_output)
def test_local_borg_version_calls_borg_with_required_parameters():
insert_execute_command_mock(('borg', '--version'))
assert module.local_borg_version() == VERSION
def test_local_borg_version_with_log_info_calls_borg_with_info_parameter():
insert_execute_command_mock(('borg', '--version', '--info'))
insert_logging_mock(logging.INFO)
assert module.local_borg_version() == VERSION
def test_local_borg_version_with_log_debug_calls_borg_with_debug_parameters():
insert_execute_command_mock(('borg', '--version', '--debug', '--show-rc'))
insert_logging_mock(logging.DEBUG)
assert module.local_borg_version() == VERSION
def test_local_borg_version_with_local_borg_path_calls_borg_with_it():
insert_execute_command_mock(('borg1', '--version'), borg_local_path='borg1')
assert module.local_borg_version('borg1') == VERSION
def test_local_borg_version_with_invalid_version_raises():
insert_execute_command_mock(('borg', '--version'), version_output='wtf')
with pytest.raises(ValueError):
module.local_borg_version()

View File

@ -72,12 +72,14 @@ def test_parse_subparser_arguments_consumes_multiple_subparser_arguments():
def test_parse_subparser_arguments_applies_default_subparsers(): def test_parse_subparser_arguments_applies_default_subparsers():
prune_namespace = flexmock() prune_namespace = flexmock()
compact_namespace = flexmock()
create_namespace = flexmock(progress=True) create_namespace = flexmock(progress=True)
check_namespace = flexmock() check_namespace = flexmock()
subparsers = { subparsers = {
'prune': flexmock( 'prune': flexmock(
parse_known_args=lambda arguments: (prune_namespace, ['prune', '--progress']) parse_known_args=lambda arguments: (prune_namespace, ['prune', '--progress'])
), ),
'compact': flexmock(parse_known_args=lambda arguments: (compact_namespace, [])),
'create': flexmock(parse_known_args=lambda arguments: (create_namespace, [])), 'create': flexmock(parse_known_args=lambda arguments: (create_namespace, [])),
'check': flexmock(parse_known_args=lambda arguments: (check_namespace, [])), 'check': flexmock(parse_known_args=lambda arguments: (check_namespace, [])),
'other': flexmock(), 'other': flexmock(),
@ -87,6 +89,7 @@ def test_parse_subparser_arguments_applies_default_subparsers():
assert arguments == { assert arguments == {
'prune': prune_namespace, 'prune': prune_namespace,
'compact': compact_namespace,
'create': create_namespace, 'create': create_namespace,
'check': check_namespace, 'check': check_namespace,
} }

View File

@ -1,5 +1,6 @@
import logging import logging
import subprocess import subprocess
import time
from flexmock import flexmock from flexmock import flexmock
@ -9,6 +10,7 @@ from borgmatic.commands import borgmatic as module
def test_run_configuration_runs_actions_for_each_repository(): def test_run_configuration_runs_actions_for_each_repository():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
expected_results = [flexmock(), flexmock()] expected_results = [flexmock(), flexmock()]
flexmock(module).should_receive('run_actions').and_return(expected_results[:1]).and_return( flexmock(module).should_receive('run_actions').and_return(expected_results[:1]).and_return(
expected_results[1:] expected_results[1:]
@ -21,8 +23,21 @@ def test_run_configuration_runs_actions_for_each_repository():
assert results == expected_results assert results == expected_results
def test_run_configuration_with_invalid_borg_version_errors():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_raise(ValueError)
flexmock(module.command).should_receive('execute_hook').never()
flexmock(module.dispatch).should_receive('call_hooks').never()
flexmock(module).should_receive('run_actions').never()
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'prune': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_calls_hooks_for_prune_action(): def test_run_configuration_calls_hooks_for_prune_action():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook').twice() flexmock(module.command).should_receive('execute_hook').twice()
flexmock(module.dispatch).should_receive('call_hooks').at_least().twice() flexmock(module.dispatch).should_receive('call_hooks').at_least().twice()
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module).should_receive('run_actions').and_return([])
@ -32,8 +47,20 @@ def test_run_configuration_calls_hooks_for_prune_action():
list(module.run_configuration('test.yaml', config, arguments)) list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_calls_hooks_for_compact_action():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook').twice()
flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'compact': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_executes_and_calls_hooks_for_create_action(): def test_run_configuration_executes_and_calls_hooks_for_create_action():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook').twice() flexmock(module.command).should_receive('execute_hook').twice()
flexmock(module.dispatch).should_receive('call_hooks').at_least().twice() flexmock(module.dispatch).should_receive('call_hooks').at_least().twice()
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module).should_receive('run_actions').and_return([])
@ -45,6 +72,7 @@ def test_run_configuration_executes_and_calls_hooks_for_create_action():
def test_run_configuration_calls_hooks_for_check_action(): def test_run_configuration_calls_hooks_for_check_action():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook').twice() flexmock(module.command).should_receive('execute_hook').twice()
flexmock(module.dispatch).should_receive('call_hooks').at_least().twice() flexmock(module.dispatch).should_receive('call_hooks').at_least().twice()
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module).should_receive('run_actions').and_return([])
@ -56,6 +84,7 @@ def test_run_configuration_calls_hooks_for_check_action():
def test_run_configuration_calls_hooks_for_extract_action(): def test_run_configuration_calls_hooks_for_extract_action():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook').twice() flexmock(module.command).should_receive('execute_hook').twice()
flexmock(module.dispatch).should_receive('call_hooks').never() flexmock(module.dispatch).should_receive('call_hooks').never()
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module).should_receive('run_actions').and_return([])
@ -67,6 +96,7 @@ def test_run_configuration_calls_hooks_for_extract_action():
def test_run_configuration_does_not_trigger_hooks_for_list_action(): def test_run_configuration_does_not_trigger_hooks_for_list_action():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook').never() flexmock(module.command).should_receive('execute_hook').never()
flexmock(module.dispatch).should_receive('call_hooks').never() flexmock(module.dispatch).should_receive('call_hooks').never()
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module).should_receive('run_actions').and_return([])
@ -78,10 +108,11 @@ def test_run_configuration_does_not_trigger_hooks_for_list_action():
def test_run_configuration_logs_actions_error(): def test_run_configuration_logs_actions_error():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module.dispatch).should_receive('call_hooks') flexmock(module.dispatch).should_receive('call_hooks')
expected_results = [flexmock()] expected_results = [flexmock()]
flexmock(module).should_receive('make_error_log_records').and_return(expected_results) flexmock(module).should_receive('log_error_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_raise(OSError) flexmock(module).should_receive('run_actions').and_raise(OSError)
config = {'location': {'repositories': ['foo']}} config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False)} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False)}
@ -93,9 +124,10 @@ def test_run_configuration_logs_actions_error():
def test_run_configuration_logs_pre_hook_error(): def test_run_configuration_logs_pre_hook_error():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook').and_raise(OSError).and_return(None) flexmock(module.command).should_receive('execute_hook').and_raise(OSError).and_return(None)
expected_results = [flexmock()] expected_results = [flexmock()]
flexmock(module).should_receive('make_error_log_records').and_return(expected_results) flexmock(module).should_receive('log_error_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').never() flexmock(module).should_receive('run_actions').never()
config = {'location': {'repositories': ['foo']}} config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
@ -107,9 +139,10 @@ def test_run_configuration_logs_pre_hook_error():
def test_run_configuration_bails_for_pre_hook_soft_failure(): def test_run_configuration_bails_for_pre_hook_soft_failure():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.command).should_receive('execute_hook').and_raise(error).and_return(None) flexmock(module.command).should_receive('execute_hook').and_raise(error).and_return(None)
flexmock(module).should_receive('make_error_log_records').never() flexmock(module).should_receive('log_error_records').never()
flexmock(module).should_receive('run_actions').never() flexmock(module).should_receive('run_actions').never()
config = {'location': {'repositories': ['foo']}} config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
@ -121,12 +154,13 @@ def test_run_configuration_bails_for_pre_hook_soft_failure():
def test_run_configuration_logs_post_hook_error(): def test_run_configuration_logs_post_hook_error():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise( flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(
OSError OSError
).and_return(None) ).and_return(None)
flexmock(module.dispatch).should_receive('call_hooks') flexmock(module.dispatch).should_receive('call_hooks')
expected_results = [flexmock()] expected_results = [flexmock()]
flexmock(module).should_receive('make_error_log_records').and_return(expected_results) flexmock(module).should_receive('log_error_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}} config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
@ -138,12 +172,13 @@ def test_run_configuration_logs_post_hook_error():
def test_run_configuration_bails_for_post_hook_soft_failure(): def test_run_configuration_bails_for_post_hook_soft_failure():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise( flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(
error error
).and_return(None) ).and_return(None)
flexmock(module.dispatch).should_receive('call_hooks') flexmock(module.dispatch).should_receive('call_hooks')
flexmock(module).should_receive('make_error_log_records').never() flexmock(module).should_receive('log_error_records').never()
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}} config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
@ -155,9 +190,10 @@ def test_run_configuration_bails_for_post_hook_soft_failure():
def test_run_configuration_logs_on_error_hook_error(): def test_run_configuration_logs_on_error_hook_error():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook').and_raise(OSError) flexmock(module.command).should_receive('execute_hook').and_raise(OSError)
expected_results = [flexmock(), flexmock()] expected_results = [flexmock(), flexmock()]
flexmock(module).should_receive('make_error_log_records').and_return( flexmock(module).should_receive('log_error_records').and_return(
expected_results[:1] expected_results[:1]
).and_return(expected_results[1:]) ).and_return(expected_results[1:])
flexmock(module).should_receive('run_actions').and_raise(OSError) flexmock(module).should_receive('run_actions').and_raise(OSError)
@ -171,10 +207,11 @@ def test_run_configuration_logs_on_error_hook_error():
def test_run_configuration_bails_for_on_error_hook_soft_failure(): def test_run_configuration_bails_for_on_error_hook_soft_failure():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(error) flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(error)
expected_results = [flexmock()] expected_results = [flexmock()]
flexmock(module).should_receive('make_error_log_records').and_return(expected_results) flexmock(module).should_receive('log_error_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_raise(OSError) flexmock(module).should_receive('run_actions').and_raise(OSError)
config = {'location': {'repositories': ['foo']}} config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
@ -184,6 +221,196 @@ def test_run_configuration_bails_for_on_error_hook_soft_failure():
assert results == expected_results assert results == expected_results
def test_run_configuration_retries_soft_error():
# Run action first fails, second passes
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).and_return([])
flexmock(module).should_receive('log_error_records').and_return([flexmock()]).once()
config = {'location': {'repositories': ['foo']}, 'storage': {'retries': 1}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == []
def test_run_configuration_retries_hard_error():
# Run action fails twice
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).times(2)
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository',
OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()])
error_logs = [flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository', OSError,
).and_return(error_logs)
config = {'location': {'repositories': ['foo']}, 'storage': {'retries': 1}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == error_logs
def test_run_repos_ordered():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).times(2)
expected_results = [flexmock(), flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository', OSError
).and_return(expected_results[:1]).ordered()
flexmock(module).should_receive('log_error_records').with_args(
'bar: Error running actions for repository', OSError
).and_return(expected_results[1:]).ordered()
config = {'location': {'repositories': ['foo', 'bar']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results
def test_run_configuration_retries_round_robbin():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).times(4)
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository',
OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()]).ordered()
flexmock(module).should_receive('log_error_records').with_args(
'bar: Error running actions for repository',
OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()]).ordered()
foo_error_logs = [flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository', OSError
).and_return(foo_error_logs).ordered()
bar_error_logs = [flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'bar: Error running actions for repository', OSError
).and_return(bar_error_logs).ordered()
config = {'location': {'repositories': ['foo', 'bar']}, 'storage': {'retries': 1}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == foo_error_logs + bar_error_logs
def test_run_configuration_retries_one_passes():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return(
[]
).and_raise(OSError).times(4)
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository',
OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()]).ordered()
flexmock(module).should_receive('log_error_records').with_args(
'bar: Error running actions for repository',
OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return(flexmock()).ordered()
error_logs = [flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'bar: Error running actions for repository', OSError
).and_return(error_logs).ordered()
config = {'location': {'repositories': ['foo', 'bar']}, 'storage': {'retries': 1}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == error_logs
def test_run_configuration_retry_wait():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).times(4)
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository',
OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()]).ordered()
flexmock(time).should_receive('sleep').with_args(10).and_return().ordered()
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository',
OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()]).ordered()
flexmock(time).should_receive('sleep').with_args(20).and_return().ordered()
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository',
OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()]).ordered()
flexmock(time).should_receive('sleep').with_args(30).and_return().ordered()
error_logs = [flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository', OSError
).and_return(error_logs).ordered()
config = {'location': {'repositories': ['foo']}, 'storage': {'retries': 3, 'retry_wait': 10}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == error_logs
def test_run_configuration_retries_timeout_multiple_repos():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return(
[]
).and_raise(OSError).times(4)
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository',
OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()]).ordered()
flexmock(module).should_receive('log_error_records').with_args(
'bar: Error running actions for repository',
OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()]).ordered()
# Sleep before retrying foo (and passing)
flexmock(time).should_receive('sleep').with_args(10).and_return().ordered()
# Sleep before retrying bar (and failing)
flexmock(time).should_receive('sleep').with_args(10).and_return().ordered()
error_logs = [flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'bar: Error running actions for repository', OSError
).and_return(error_logs).ordered()
config = {
'location': {'repositories': ['foo', 'bar']},
'storage': {'retries': 1, 'retry_wait': 10},
}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == error_logs
def test_load_configurations_collects_parsed_configurations(): def test_load_configurations_collects_parsed_configurations():
configuration = flexmock() configuration = flexmock()
other_configuration = flexmock() other_configuration = flexmock()
@ -197,6 +424,15 @@ def test_load_configurations_collects_parsed_configurations():
assert logs == [] assert logs == []
def test_load_configurations_logs_warning_for_permission_error():
flexmock(module.validate).should_receive('parse_configuration').and_raise(PermissionError)
configs, logs = tuple(module.load_configurations(('test.yaml',)))
assert configs == {}
assert {log.levelno for log in logs} == {logging.WARNING}
def test_load_configurations_logs_critical_for_parse_error(): def test_load_configurations_logs_critical_for_parse_error():
flexmock(module.validate).should_receive('parse_configuration').and_raise(ValueError) flexmock(module.validate).should_receive('parse_configuration').and_raise(ValueError)
@ -214,48 +450,46 @@ def test_log_record_with_suppress_does_not_raise():
module.log_record(levelno=1, foo='bar', baz='quux', suppress_log=True) module.log_record(levelno=1, foo='bar', baz='quux', suppress_log=True)
def test_make_error_log_records_generates_output_logs_for_message_only(): def test_log_error_records_generates_output_logs_for_message_only():
flexmock(module).should_receive('log_record').replace_with(dict) flexmock(module).should_receive('log_record').replace_with(dict)
logs = tuple(module.make_error_log_records('Error')) logs = tuple(module.log_error_records('Error'))
assert {log['levelno'] for log in logs} == {logging.CRITICAL} assert {log['levelno'] for log in logs} == {logging.CRITICAL}
def test_make_error_log_records_generates_output_logs_for_called_process_error(): def test_log_error_records_generates_output_logs_for_called_process_error():
flexmock(module).should_receive('log_record').replace_with(dict) flexmock(module).should_receive('log_record').replace_with(dict)
flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING) flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING)
logs = tuple( logs = tuple(
module.make_error_log_records( module.log_error_records('Error', subprocess.CalledProcessError(1, 'ls', 'error output'))
'Error', subprocess.CalledProcessError(1, 'ls', 'error output')
)
) )
assert {log['levelno'] for log in logs} == {logging.CRITICAL} assert {log['levelno'] for log in logs} == {logging.CRITICAL}
assert any(log for log in logs if 'error output' in str(log)) assert any(log for log in logs if 'error output' in str(log))
def test_make_error_log_records_generates_logs_for_value_error(): def test_log_error_records_generates_logs_for_value_error():
flexmock(module).should_receive('log_record').replace_with(dict) flexmock(module).should_receive('log_record').replace_with(dict)
logs = tuple(module.make_error_log_records('Error', ValueError())) logs = tuple(module.log_error_records('Error', ValueError()))
assert {log['levelno'] for log in logs} == {logging.CRITICAL} assert {log['levelno'] for log in logs} == {logging.CRITICAL}
def test_make_error_log_records_generates_logs_for_os_error(): def test_log_error_records_generates_logs_for_os_error():
flexmock(module).should_receive('log_record').replace_with(dict) flexmock(module).should_receive('log_record').replace_with(dict)
logs = tuple(module.make_error_log_records('Error', OSError())) logs = tuple(module.log_error_records('Error', OSError()))
assert {log['levelno'] for log in logs} == {logging.CRITICAL} assert {log['levelno'] for log in logs} == {logging.CRITICAL}
def test_make_error_log_records_generates_nothing_for_other_error(): def test_log_error_records_generates_nothing_for_other_error():
flexmock(module).should_receive('log_record').replace_with(dict) flexmock(module).should_receive('log_record').replace_with(dict)
logs = tuple(module.make_error_log_records('Error', KeyError())) logs = tuple(module.log_error_records('Error', KeyError()))
assert logs == () assert logs == ()
@ -312,7 +546,7 @@ def test_collect_configuration_run_summary_logs_extract_with_repository_error():
ValueError ValueError
) )
expected_logs = (flexmock(),) expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs) flexmock(module).should_receive('log_error_records').and_return(expected_logs)
arguments = {'extract': flexmock(repository='repo')} arguments = {'extract': flexmock(repository='repo')}
logs = tuple( logs = tuple(
@ -339,7 +573,7 @@ def test_collect_configuration_run_summary_logs_mount_with_repository_error():
ValueError ValueError
) )
expected_logs = (flexmock(),) expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs) flexmock(module).should_receive('log_error_records').and_return(expected_logs)
arguments = {'mount': flexmock(repository='repo')} arguments = {'mount': flexmock(repository='repo')}
logs = tuple( logs = tuple(
@ -352,7 +586,7 @@ def test_collect_configuration_run_summary_logs_mount_with_repository_error():
def test_collect_configuration_run_summary_logs_missing_configs_error(): def test_collect_configuration_run_summary_logs_missing_configs_error():
arguments = {'global': flexmock(config_paths=[])} arguments = {'global': flexmock(config_paths=[])}
expected_logs = (flexmock(),) expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs) flexmock(module).should_receive('log_error_records').and_return(expected_logs)
logs = tuple(module.collect_configuration_run_summary_logs({}, arguments=arguments)) logs = tuple(module.collect_configuration_run_summary_logs({}, arguments=arguments))
@ -362,7 +596,7 @@ def test_collect_configuration_run_summary_logs_missing_configs_error():
def test_collect_configuration_run_summary_logs_pre_hook_error(): def test_collect_configuration_run_summary_logs_pre_hook_error():
flexmock(module.command).should_receive('execute_hook').and_raise(ValueError) flexmock(module.command).should_receive('execute_hook').and_raise(ValueError)
expected_logs = (flexmock(),) expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs) flexmock(module).should_receive('log_error_records').and_return(expected_logs)
arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)} arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)}
logs = tuple( logs = tuple(
@ -376,7 +610,7 @@ def test_collect_configuration_run_summary_logs_post_hook_error():
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(ValueError) flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(ValueError)
flexmock(module).should_receive('run_configuration').and_return([]) flexmock(module).should_receive('run_configuration').and_return([])
expected_logs = (flexmock(),) expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs) flexmock(module).should_receive('log_error_records').and_return(expected_logs)
arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)} arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)}
logs = tuple( logs = tuple(
@ -391,7 +625,7 @@ def test_collect_configuration_run_summary_logs_for_list_with_archive_and_reposi
ValueError ValueError
) )
expected_logs = (flexmock(),) expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs) flexmock(module).should_receive('log_error_records').and_return(expected_logs)
arguments = {'list': flexmock(repository='repo', archive='test')} arguments = {'list': flexmock(repository='repo', archive='test')}
logs = tuple( logs = tuple(
@ -417,7 +651,7 @@ def test_collect_configuration_run_summary_logs_run_configuration_error():
flexmock(module).should_receive('run_configuration').and_return( flexmock(module).should_receive('run_configuration').and_return(
[logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg='Error'))] [logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg='Error'))]
) )
flexmock(module).should_receive('make_error_log_records').and_return([]) flexmock(module).should_receive('log_error_records').and_return([])
arguments = {} arguments = {}
logs = tuple( logs = tuple(
@ -431,7 +665,7 @@ def test_collect_configuration_run_summary_logs_run_umount_error():
flexmock(module.validate).should_receive('guard_configuration_contains_repository') flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module).should_receive('run_configuration').and_return([]) flexmock(module).should_receive('run_configuration').and_return([])
flexmock(module.borg_umount).should_receive('unmount_archive').and_raise(OSError) flexmock(module.borg_umount).should_receive('unmount_archive').and_raise(OSError)
flexmock(module).should_receive('make_error_log_records').and_return( flexmock(module).should_receive('log_error_records').and_return(
[logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg='Error'))] [logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg='Error'))]
) )
arguments = {'umount': flexmock(mount_point='/mnt')} arguments = {'umount': flexmock(mount_point='/mnt')}

View File

@ -12,7 +12,7 @@ Parsed_config = namedtuple('Parsed_config', ('location', 'storage', 'retention',
def test_convert_section_generates_integer_value_for_integer_type_in_schema(): def test_convert_section_generates_integer_value_for_integer_type_in_schema():
flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict) flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
source_section_config = OrderedDict([('check_last', '3')]) source_section_config = OrderedDict([('check_last', '3')])
section_schema = {'map': {'check_last': {'type': 'int'}}} section_schema = {'type': 'object', 'properties': {'check_last': {'type': 'integer'}}}
destination_config = module._convert_section(source_section_config, section_schema) destination_config = module._convert_section(source_section_config, section_schema)
@ -21,7 +21,7 @@ def test_convert_section_generates_integer_value_for_integer_type_in_schema():
def test_convert_legacy_parsed_config_transforms_source_config_to_mapping(): def test_convert_legacy_parsed_config_transforms_source_config_to_mapping():
flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict) flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
flexmock(module.generate).should_receive('add_comments_to_configuration_map') flexmock(module.generate).should_receive('add_comments_to_configuration_object')
source_config = Parsed_config( source_config = Parsed_config(
location=OrderedDict([('source_directories', '/home'), ('repository', 'hostname.borg')]), location=OrderedDict([('source_directories', '/home'), ('repository', 'hostname.borg')]),
storage=OrderedDict([('encryption_passphrase', 'supersecret')]), storage=OrderedDict([('encryption_passphrase', 'supersecret')]),
@ -29,7 +29,10 @@ def test_convert_legacy_parsed_config_transforms_source_config_to_mapping():
consistency=OrderedDict([('checks', 'repository')]), consistency=OrderedDict([('checks', 'repository')]),
) )
source_excludes = ['/var'] source_excludes = ['/var']
schema = {'map': defaultdict(lambda: {'map': {}})} schema = {
'type': 'object',
'properties': defaultdict(lambda: {'type': 'object', 'properties': {}}),
}
destination_config = module.convert_legacy_parsed_config(source_config, source_excludes, schema) destination_config = module.convert_legacy_parsed_config(source_config, source_excludes, schema)
@ -54,7 +57,7 @@ def test_convert_legacy_parsed_config_transforms_source_config_to_mapping():
def test_convert_legacy_parsed_config_splits_space_separated_values(): def test_convert_legacy_parsed_config_splits_space_separated_values():
flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict) flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
flexmock(module.generate).should_receive('add_comments_to_configuration_map') flexmock(module.generate).should_receive('add_comments_to_configuration_object')
source_config = Parsed_config( source_config = Parsed_config(
location=OrderedDict( location=OrderedDict(
[('source_directories', '/home /etc'), ('repository', 'hostname.borg')] [('source_directories', '/home /etc'), ('repository', 'hostname.borg')]
@ -64,7 +67,10 @@ def test_convert_legacy_parsed_config_splits_space_separated_values():
consistency=OrderedDict([('checks', 'repository archives')]), consistency=OrderedDict([('checks', 'repository archives')]),
) )
source_excludes = ['/var'] source_excludes = ['/var']
schema = {'map': defaultdict(lambda: {'map': {}})} schema = {
'type': 'object',
'properties': defaultdict(lambda: {'type': 'object', 'properties': {}}),
}
destination_config = module.convert_legacy_parsed_config(source_config, source_excludes, schema) destination_config = module.convert_legacy_parsed_config(source_config, source_excludes, schema)

View File

@ -8,24 +8,32 @@ from borgmatic.config import generate as module
def test_schema_to_sample_configuration_generates_config_map_with_examples(): def test_schema_to_sample_configuration_generates_config_map_with_examples():
flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict) flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
flexmock(module).should_receive('add_comments_to_configuration_map') flexmock(module).should_receive('add_comments_to_configuration_object')
schema = { schema = {
'map': OrderedDict( 'type': 'object',
'properties': OrderedDict(
[ [
('section1', {'map': {'field1': OrderedDict([('example', 'Example 1')])}}), (
'section1',
{
'type': 'object',
'properties': {'field1': OrderedDict([('example', 'Example 1')])},
},
),
( (
'section2', 'section2',
{ {
'map': OrderedDict( 'type': 'object',
'properties': OrderedDict(
[ [
('field2', {'example': 'Example 2'}), ('field2', {'example': 'Example 2'}),
('field3', {'example': 'Example 3'}), ('field3', {'example': 'Example 3'}),
] ]
) ),
}, },
), ),
] ]
) ),
} }
config = module._schema_to_sample_configuration(schema) config = module._schema_to_sample_configuration(schema)
@ -41,7 +49,7 @@ def test_schema_to_sample_configuration_generates_config_map_with_examples():
def test_schema_to_sample_configuration_generates_config_sequence_of_strings_with_example(): def test_schema_to_sample_configuration_generates_config_sequence_of_strings_with_example():
flexmock(module.yaml.comments).should_receive('CommentedSeq').replace_with(list) flexmock(module.yaml.comments).should_receive('CommentedSeq').replace_with(list)
flexmock(module).should_receive('add_comments_to_configuration_sequence') flexmock(module).should_receive('add_comments_to_configuration_sequence')
schema = {'seq': [{'type': 'str'}], 'example': ['hi']} schema = {'type': 'array', 'items': {'type': 'string'}, 'example': ['hi']}
config = module._schema_to_sample_configuration(schema) config = module._schema_to_sample_configuration(schema)
@ -51,15 +59,15 @@ def test_schema_to_sample_configuration_generates_config_sequence_of_strings_wit
def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_examples(): def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_examples():
flexmock(module.yaml.comments).should_receive('CommentedSeq').replace_with(list) flexmock(module.yaml.comments).should_receive('CommentedSeq').replace_with(list)
flexmock(module).should_receive('add_comments_to_configuration_sequence') flexmock(module).should_receive('add_comments_to_configuration_sequence')
flexmock(module).should_receive('add_comments_to_configuration_map') flexmock(module).should_receive('add_comments_to_configuration_object')
schema = { schema = {
'seq': [ 'type': 'array',
{ 'items': {
'map': OrderedDict( 'type': 'object',
[('field1', {'example': 'Example 1'}), ('field2', {'example': 'Example 2'})] 'properties': OrderedDict(
) [('field1', {'example': 'Example 1'}), ('field2', {'example': 'Example 2'})]
} ),
] },
} }
config = module._schema_to_sample_configuration(schema) config = module._schema_to_sample_configuration(schema)

View File

@ -1,4 +1,5 @@
import pytest import pytest
import ruamel.yaml
from flexmock import flexmock from flexmock import flexmock
from borgmatic.config import override as module from borgmatic.config import override as module
@ -70,6 +71,14 @@ def test_parse_overrides_raises_on_missing_equal_sign():
module.parse_overrides(raw_overrides) module.parse_overrides(raw_overrides)
def test_parse_overrides_raises_on_invalid_override_value():
flexmock(module).should_receive('convert_value_type').and_raise(ruamel.yaml.parser.ParserError)
raw_overrides = ['section.option=[in valid]']
with pytest.raises(ValueError):
module.parse_overrides(raw_overrides)
def test_parse_overrides_allows_value_with_single_key(): def test_parse_overrides_allows_value_with_single_key():
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value) flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value)
raw_overrides = ['option=value'] raw_overrides = ['option=value']

View File

@ -4,7 +4,30 @@ from flexmock import flexmock
from borgmatic.config import validate as module from borgmatic.config import validate as module
def test_validation_error_str_contains_error_messages_and_config_filename(): def test_format_json_error_path_element_formats_array_index():
module.format_json_error_path_element(3) == '[3]'
def test_format_json_error_path_element_formats_property():
module.format_json_error_path_element('foo') == '.foo'
def test_format_json_error_formats_error_including_path():
flexmock(module).format_json_error_path_element = lambda element: '.{}'.format(element)
error = flexmock(message='oops', path=['foo', 'bar'])
assert module.format_json_error(error) == "At 'foo.bar': oops"
def test_format_json_error_formats_error_without_path():
flexmock(module).should_receive('format_json_error_path_element').never()
error = flexmock(message='oops', path=[])
assert module.format_json_error(error) == 'At the top level: oops'
def test_validation_error_string_contains_errors():
flexmock(module).format_json_error = lambda error: error.message
error = module.Validation_error('config.yaml', ('oops', 'uh oh')) error = module.Validation_error('config.yaml', ('oops', 'uh oh'))
result = str(error) result = str(error)
@ -15,6 +38,8 @@ def test_validation_error_str_contains_error_messages_and_config_filename():
def test_apply_logical_validation_raises_if_archive_name_format_present_without_prefix(): def test_apply_logical_validation_raises_if_archive_name_format_present_without_prefix():
flexmock(module).format_json_error = lambda error: error.message
with pytest.raises(module.Validation_error): with pytest.raises(module.Validation_error):
module.apply_logical_validation( module.apply_logical_validation(
'config.yaml', 'config.yaml',
@ -26,6 +51,8 @@ def test_apply_logical_validation_raises_if_archive_name_format_present_without_
def test_apply_logical_validation_raises_if_archive_name_format_present_without_retention_prefix(): def test_apply_logical_validation_raises_if_archive_name_format_present_without_retention_prefix():
flexmock(module).format_json_error = lambda error: error.message
with pytest.raises(module.Validation_error): with pytest.raises(module.Validation_error):
module.apply_logical_validation( module.apply_logical_validation(
'config.yaml', 'config.yaml',
@ -38,6 +65,8 @@ def test_apply_logical_validation_raises_if_archive_name_format_present_without_
def test_apply_locical_validation_raises_if_unknown_repository_in_check_repositories(): def test_apply_locical_validation_raises_if_unknown_repository_in_check_repositories():
flexmock(module).format_json_error = lambda error: error.message
with pytest.raises(module.Validation_error): with pytest.raises(module.Validation_error):
module.apply_logical_validation( module.apply_logical_validation(
'config.yaml', 'config.yaml',
@ -75,27 +104,6 @@ def test_apply_logical_validation_does_not_raise_otherwise():
module.apply_logical_validation('config.yaml', {'retention': {'keep_secondly': 1000}}) module.apply_logical_validation('config.yaml', {'retention': {'keep_secondly': 1000}})
def test_remove_examples_strips_examples_from_map():
schema = {
'map': {
'foo': {'desc': 'thing1', 'example': 'bar'},
'baz': {'desc': 'thing2', 'example': 'quux'},
}
}
module.remove_examples(schema)
assert schema == {'map': {'foo': {'desc': 'thing1'}, 'baz': {'desc': 'thing2'}}}
def test_remove_examples_strips_examples_from_sequence_of_maps():
schema = {'seq': [{'map': {'foo': {'desc': 'thing', 'example': 'bar'}}, 'example': 'stuff'}]}
module.remove_examples(schema)
assert schema == {'seq': [{'map': {'foo': {'desc': 'thing'}}}]}
def test_normalize_repository_path_passes_through_remote_repository(): def test_normalize_repository_path_passes_through_remote_repository():
repository = 'example.org:test.borg' repository = 'example.org:test.borg'

View File

@ -0,0 +1,308 @@
import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import mongodb as module
def test_dump_databases_runs_mongodump_for_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
processes = [flexmock(), flexmock()]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
for name, process in zip(('foo', 'bar'), processes):
flexmock(module).should_receive('execute_command').with_args(
['mongodump', '--archive', '--db', name, '>', 'databases/localhost/{}'.format(name)],
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == processes
def test_dump_databases_with_dry_run_skips_mongodump():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
flexmock(module).should_receive('execute_command').never()
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=True) == []
def test_dump_databases_runs_mongodump_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/database.example.org/foo'
)
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
[
'mongodump',
'--archive',
'--host',
'database.example.org',
'--port',
'5433',
'--db',
'foo',
'>',
'databases/database.example.org/foo',
],
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_mongodump_with_username_and_password():
databases = [
{
'name': 'foo',
'username': 'mongo',
'password': 'trustsome1',
'authentication_database': "admin",
}
]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
[
'mongodump',
'--archive',
'--username',
'mongo',
'--password',
'trustsome1',
'--authenticationDatabase',
'admin',
'--db',
'foo',
'>',
'databases/localhost/foo',
],
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_mongodump_with_directory_format():
databases = [{'name': 'foo', 'format': 'directory'}]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.dump).should_receive('create_parent_directory_for_dump')
flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
flexmock(module).should_receive('execute_command').with_args(
['mongodump', '--archive', 'databases/localhost/foo', '--db', 'foo'],
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_mongodump_with_options():
databases = [{'name': 'foo', 'options': '--stuff=such'}]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
['mongodump', '--archive', '--db', 'foo', '--stuff=such', '>', 'databases/localhost/foo'],
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_mongodumpall_for_all_databases():
databases = [{'name': 'all'}]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
['mongodump', '--archive', '>', 'databases/localhost/all'],
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_restore_database_dump_runs_pg_restore():
database_config = [{'name': 'foo'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
['mongorestore', '--archive', '--drop', '--db', 'foo'],
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
borg_local_path='borg',
).once()
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=False, extract_process=extract_process
)
def test_restore_database_dump_errors_on_multiple_database_config():
database_config = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').never()
flexmock(module).should_receive('execute_command').never()
with pytest.raises(ValueError):
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=False, extract_process=flexmock()
)
def test_restore_database_dump_runs_pg_restore_with_hostname_and_port():
database_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
[
'mongorestore',
'--archive',
'--drop',
'--db',
'foo',
'--host',
'database.example.org',
'--port',
'5433',
],
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
borg_local_path='borg',
).once()
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=False, extract_process=extract_process
)
def test_restore_database_dump_runs_pg_restore_with_username_and_password():
database_config = [
{
'name': 'foo',
'username': 'mongo',
'password': 'trustsome1',
'authentication_database': 'admin',
}
]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
[
'mongorestore',
'--archive',
'--drop',
'--db',
'foo',
'--username',
'mongo',
'--password',
'trustsome1',
'--authenticationDatabase',
'admin',
],
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
borg_local_path='borg',
).once()
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=False, extract_process=extract_process
)
def test_restore_database_dump_runs_psql_for_all_database_dump():
database_config = [{'name': 'all'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
['mongorestore', '--archive'],
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
borg_local_path='borg',
).once()
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=False, extract_process=extract_process
)
def test_restore_database_dump_with_dry_run_skips_restore():
database_config = [{'name': 'foo'}]
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').never()
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=True, extract_process=flexmock()
)
def test_restore_database_dump_without_extract_process_restores_from_disk():
database_config = [{'name': 'foo'}]
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('/dump/path')
flexmock(module).should_receive('execute_command_with_processes').with_args(
['mongorestore', '--archive', '/dump/path', '--drop', '--db', 'foo'],
processes=[],
output_log_level=logging.DEBUG,
input_file=None,
borg_local_path='borg',
).once()
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=False, extract_process=None
)

View File

@ -155,8 +155,8 @@ def test_dump_databases_runs_mysqldump_with_options():
flexmock(module).should_receive('execute_command').with_args( flexmock(module).should_receive('execute_command').with_args(
( (
'mysqldump', 'mysqldump',
'--add-drop-database',
'--stuff=such', '--stuff=such',
'--add-drop-database',
'--databases', '--databases',
'foo', 'foo',
'>', '>',
@ -198,6 +198,24 @@ def test_dump_databases_runs_mysqldump_for_all_databases():
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process] assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_database_names_to_dump_runs_mysql_with_list_options():
database = {'name': 'all', 'list_options': '--defaults-extra-file=my.cnf'}
flexmock(module).should_receive('execute_command').with_args(
(
'mysql',
'--defaults-extra-file=my.cnf',
'--skip-column-names',
'--batch',
'--execute',
'show schemas',
),
output_log_level=None,
extra_environment=None,
).and_return(('foo\nbar')).once()
assert module.database_names_to_dump(database, None, 'test.yaml', '') == ('foo', 'bar')
def test_dump_databases_errors_for_missing_all_databases(): def test_dump_databases_errors_for_missing_all_databases():
databases = [{'name': 'all'}] databases = [{'name': 'all'}]
process = flexmock() process = flexmock()
@ -216,7 +234,7 @@ def test_restore_database_dump_runs_mysql_to_restore():
extract_process = flexmock(stdout=flexmock()) extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args( flexmock(module).should_receive('execute_command_with_processes').with_args(
('mysql', '--batch', '--verbose'), ('mysql', '--batch'),
processes=[extract_process], processes=[extract_process],
output_log_level=logging.DEBUG, output_log_level=logging.DEBUG,
input_file=extract_process.stdout, input_file=extract_process.stdout,
@ -249,7 +267,6 @@ def test_restore_database_dump_runs_mysql_with_hostname_and_port():
( (
'mysql', 'mysql',
'--batch', '--batch',
'--verbose',
'--host', '--host',
'database.example.org', 'database.example.org',
'--port', '--port',
@ -274,7 +291,7 @@ def test_restore_database_dump_runs_mysql_with_username_and_password():
extract_process = flexmock(stdout=flexmock()) extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args( flexmock(module).should_receive('execute_command_with_processes').with_args(
('mysql', '--batch', '--verbose', '--user', 'root'), ('mysql', '--batch', '--user', 'root'),
processes=[extract_process], processes=[extract_process],
output_log_level=logging.DEBUG, output_log_level=logging.DEBUG,
input_file=extract_process.stdout, input_file=extract_process.stdout,

View File

@ -179,6 +179,26 @@ def test_configure_logging_probes_for_log_socket_on_macos():
module.configure_logging(logging.INFO) module.configure_logging(logging.INFO)
def test_configure_logging_probes_for_log_socket_on_freebsd():
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/var/run/syslog').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/var/run/log').and_return(True)
syslog_handler = logging.handlers.SysLogHandler()
flexmock(module.logging.handlers).should_receive('SysLogHandler').with_args(
address='/var/run/log'
).and_return(syslog_handler).once()
module.configure_logging(logging.INFO)
def test_configure_logging_sets_global_logger_to_most_verbose_log_level(): def test_configure_logging_sets_global_logger_to_most_verbose_log_level():
flexmock(module).should_receive('Multi_stream_handler').and_return( flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None) flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)

View File

@ -0,0 +1,40 @@
from flexmock import flexmock
from borgmatic import signals as module
def test_handle_signal_forwards_to_subprocesses():
signal_number = 100
frame = flexmock(f_back=flexmock(f_code=flexmock(co_name='something')))
process_group = flexmock()
flexmock(module.os).should_receive('getpgrp').and_return(process_group)
flexmock(module.os).should_receive('killpg').with_args(process_group, signal_number).once()
module.handle_signal(signal_number, frame)
def test_handle_signal_bails_on_recursion():
signal_number = 100
frame = flexmock(f_back=flexmock(f_code=flexmock(co_name='handle_signal')))
flexmock(module.os).should_receive('getpgrp').never()
flexmock(module.os).should_receive('killpg').never()
module.handle_signal(signal_number, frame)
def test_handle_signal_exits_on_sigterm():
signal_number = module.signal.SIGTERM
frame = flexmock(f_back=flexmock(f_code=flexmock(co_name='something')))
flexmock(module.os).should_receive('getpgrp').and_return(flexmock)
flexmock(module.os).should_receive('killpg')
flexmock(module.sys).should_receive('exit').with_args(
module.EXIT_CODE_FROM_SIGNAL + signal_number
).once()
module.handle_signal(signal_number, frame)
def test_configure_signals_installs_signal_handlers():
flexmock(module.signal).should_receive('signal').at_least().once()
module.configure_signals()

View File

@ -1,5 +1,5 @@
[tox] [tox]
envlist = py36,py37,py38,py39 envlist = py37,py38,py39,py310
skip_missing_interpreters = True skip_missing_interpreters = True
skipsdist = True skipsdist = True
minversion = 3.14.1 minversion = 3.14.1
@ -13,7 +13,7 @@ whitelist_externals =
passenv = COVERAGE_FILE passenv = COVERAGE_FILE
commands = commands =
pytest {posargs} pytest {posargs}
py38,py39: black --check . py38,py39,py310: black --check .
isort --check-only --settings-path setup.cfg . isort --check-only --settings-path setup.cfg .
flake8 borgmatic tests flake8 borgmatic tests