Compare commits

...

132 Commits

Author SHA1 Message Date
Dan Helfman 3561c93d74 Fix Healthchecks tests that leak global state, breaking downstream tests (discovered in #543). 2022-06-09 11:05:44 -07:00
Dan Helfman 331a503a25 Document the borgmatic version in which "borgmatic list --find" is available (#541). 2022-06-03 16:55:54 -07:00
Dan Helfman 9aefb5179f Fix None find paths (#541). 2022-06-03 15:20:05 -07:00
Dan Helfman d14f22e121 Add "borgmatic list --find" flag for searching for files across multiple archives (#541). 2022-06-03 15:12:14 -07:00
Dan Helfman b6893f6455 Exclude deprecated "borg list --successful" flag from getting passed to Borg. 2022-06-02 21:14:25 -07:00
Dan Helfman 80ec3e7d97 Deprecate "borgmatic list --successful" flag, as listing only non-checkpoint (successful) archives is now the default in newer versions of Borg. 2022-06-02 20:35:39 -07:00
Dan Helfman cd834311eb Clarify completion docs. 2022-06-01 10:57:23 -07:00
Dan Helfman d751cceeb0 Merge branch 'master' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2022-06-01 10:38:05 -07:00
Dan Helfman ce78b07e4b Add macOs to install and Bash completion documentation.
Reviewed-on: borgmatic-collective/borgmatic#540
2022-06-01 17:37:51 +00:00
adidalal 87f3c50931 setup: add macOS 2022-06-01 15:56:40 +00:00
Dan Helfman 8e9e06afe6 Bump version for release. 2022-05-31 09:41:20 -07:00
Dan Helfman 2bc91ac3d2 Add "generate-borgmatic-config --overwrite" flag to replace an existing destination file (#539). 2022-05-29 16:03:55 -07:00
Dan Helfman 5b615d51a4 Add support for "borgmatic borg debug" command (#538). 2022-05-29 15:43:03 -07:00
Dan Helfman c7f5d5fd0b Fix broken Bash completion of filenames, as in "-c config.yaml". 2022-05-29 10:49:33 -07:00
Dan Helfman 6ef7538eb0 Fix typo in Bash completions script. 2022-05-28 19:34:13 -07:00
Dan Helfman 8fa90053cf Add "borgmatic check --force" flag to ignore configured check frequencies (#523). 2022-05-28 19:29:33 -07:00
Dan Helfman b3682b61d1 Add another note about the consistency checks schema in old versions (#523). 2022-05-28 19:03:45 -07:00
Dan Helfman ad0e2e0d7c Tweak default check frequency to 1 month (#523). 2022-05-28 15:49:50 -07:00
Dan Helfman 6629f40cab In bash completion script, warn when script is out of date using script contents instead of version. (Fewer spurious warnings that way.) 2022-05-28 15:27:11 -07:00
Dan Helfman e76bfa555f Reduce the default consistency check frequency and support configuring the frequency independently for each check (#523). 2022-05-28 14:42:19 -07:00
Dan Helfman 8ddb7268eb Reuse "borg info" function. 2022-05-27 13:51:11 -07:00
Dan Helfman cb5fe02ebd Fix broken Bash completion end-to-end test. 2022-05-26 11:18:46 -07:00
Dan Helfman 77b84f8a48 Add Bash completion script so you can tab-complete the borgmatic command-line. 2022-05-26 10:27:53 -07:00
Dan Helfman 691ec96909 Fix python_requires to support all versions of 3.7 (#537).
Reviewed-on: borgmatic-collective/borgmatic#537
2022-05-26 15:51:46 +00:00
Steve Atwell 29b4666205 Fix python_requires to support all versions of 3.7
This is the standard way to support "Python 3.7 and newer" and it also
fixes use of borgmatic with some tools that do custom dependency
resolution.  E.g., using pex with --platform.
2022-05-26 07:05:04 -07:00
Dan Helfman 316a22701f Add documentation note about multiple merge limitation (#380). 2022-05-25 23:12:42 -07:00
Dan Helfman be59a3e574 Fix generate-borgmatic-config with "--source" flag to support more complex schema changes like the new Healthchecks configuration options (#536). 2022-05-25 10:26:26 -07:00
Dan Helfman 37327379bc Merge branch 'master' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2022-05-24 17:50:57 -07:00
Dan Helfman 22c2f13611 Remove trailing whitespace (#535).
Reviewed-on: borgmatic-collective/borgmatic#535
2022-05-25 00:50:12 +00:00
polyzen 8708ca07f4 Remove trailing whitespace 2022-05-25 00:43:40 +00:00
Dan Helfman 634d9e4946 Bump version for release. 2022-05-24 16:22:37 -07:00
Dan Helfman 54933ebef5 Change connection failures for monitoring hooks to be warnings instead of errors (#439). 2022-05-24 15:50:04 -07:00
Dan Helfman 157e59ac88 Add Healthchecks monitoring hook "send_logs" option to enable/disable sending borgmatic logs to the Healthchecks server (#460). 2022-05-24 14:44:33 -07:00
Dan Helfman 666f0dd751 Add missing Healthchecks "states" option example in configuration schema (#525). 2022-05-24 14:17:19 -07:00
Dan Helfman 8b179e4647 Reverse logic of Healtchecks "skip_states" option to just "states" (#525). 2022-05-24 14:09:42 -07:00
Dan Helfman 865eff7d98 Add Healthchecks monitoring hook "skip_states" option to disable pinging for particular monitoring states (#525). 2022-05-24 13:59:28 -07:00
Dan Helfman b9741f4d0b Add Healthchecks monitoring hook "ping_body_limit" option to configure how many bytes of logs to send to the Healthchecks server (#294). 2022-05-24 12:23:38 -07:00
Dan Helfman 02781662f8 Change monitoring hooks to specify the ping URL / integration key as a named option. 2022-05-23 20:02:10 -07:00
Dan Helfman 32a1043468 Remove the error when "archive_name_format" is specified but a retention prefix isn't (#402). 2022-05-23 16:11:24 -07:00
Dan Helfman 3e4aeec649 Warn when an unsupported variable is used in a hook command (#420). 2022-05-23 15:27:54 -07:00
Dan Helfman b98b827594 Remove stale comment. 2022-05-23 10:59:56 -07:00
Dan Helfman 255cc6ec23 When deep merging common configuration, merge colliding list values by appending them (#531). 2022-05-20 15:28:28 -07:00
Dan Helfman 51fc37d57a Improve the error message when a configuration override contains an invalid value (#528). 2022-05-20 13:38:53 -07:00
Dan Helfman 1921f55a9d Add emojis to documentation table of contents to make it easier to find particular how-to and reference guides at a glance. 2022-05-20 11:11:35 -07:00
Dan Helfman fbd381fcc1 Clarify manual database extraction documentation. 2022-05-20 10:06:19 -07:00
Dan Helfman cd88f9f2ea Better explain where to find the dump file when doing a manual restore (#510).
Reviewed-on: borgmatic-collective/borgmatic#510
2022-05-20 16:33:21 +00:00
Dan Helfman 788281cfb9 When a configuration include is a relative path, load it from either the current working directory or from the directory containing the file doing the including (#532). 2022-05-19 17:15:05 -07:00
Dan Helfman cd234b689d Link to additional borgmatic Docker image. 2022-05-12 12:00:12 -07:00
Dan Helfman 92354a77ee Mention that database dumps consumed disk space prior to borgmatic 1.5.3. 2022-05-09 16:08:47 -07:00
Dan Helfman 48ff3e70d1 Clarify documentation about include merging mappings vs. values. 2022-05-08 14:48:42 -07:00
Dan Helfman 7e9adfb899 Add NEWS entry for randomized systemd timer delay. 2022-05-07 23:11:26 -07:00
Dan Helfman e238e256f7
Add randomized delay to systemd timer.
Merge pull request from Daniel15/patch-1
2022-05-07 23:08:02 -07:00
Daniel Lo Nigro 3ecb92a8d2
Add randomized delay to systemd timer 2022-05-07 16:42:06 -07:00
Dan Helfman d58d450628 Remove stale borgmatic binary link. 2022-04-30 09:50:40 -07:00
Dan Helfman dee9c6e293 Remove link to stale borgmatic Docker image. 2022-04-30 09:46:08 -07:00
Dan Helfman 897c4487de Add mention in documentation about multiple backup scheduling needs (#511). 2022-04-28 11:16:31 -07:00
Dan Helfman 48b50b5209 Add documentation link to NEWS. 2022-04-26 10:24:25 -07:00
Dan Helfman 13bae8c23b Typo. 2022-04-26 10:12:02 -07:00
Dan Helfman 4a48e6aa04 Bump version for release. 2022-04-26 10:07:04 -07:00
Dan Helfman 525266ede6 Deep merging when including common configuration (#381). 2022-04-25 21:18:37 -07:00
Dan Helfman d045eb55ac Add mention of sudo's "secure_path" option in borgmatic installation documentation (#513). 2022-04-23 14:29:55 -07:00
Dan Helfman 0e6b425ac5 Fix "borgmatic borg key ..." to pass parameters to Borg in correct order (#515). 2022-04-23 14:03:15 -07:00
Dan Helfman bdc26f2117 Add note about old, pre-1.6.0 hooks behavior. 2022-04-22 19:58:28 -07:00
Dan Helfman ed7fe5c6d0 Instead of executing "before" command hooks before all borgmatic actions run (and "after" hooks after), execute these hooks right before/after the corresponding action (#473). 2022-04-21 22:08:25 -07:00
Dan Helfman cbce6707f4 Clarify one_file_system behavior in schema comment (#520). 2022-04-12 11:05:22 -07:00
Dan Helfman 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
Dan Helfman 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
Dan Helfman 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
Dan Helfman 413a079f51 Clarify Python version support. 2022-03-28 21:57:40 -07:00
gerdneuman 6f3accf691 Better explain where to find the dump file
I really had problem finding the dump file with the explanation as give before. I thought that the `~/.borgmatic/` would be my current user. So looked into `/home/gerd/.borgmatic` (wrong). Then I looked into `<EXTRACTED_DESTINATION_PATH/.borgmatic` (again wrong). Then finally (1h later and after having already prepared a bug ticketI figured out that the dump file is within `<EXTRACTED_DESTINATION_PATH/root/.borgmatic`. Hard to find because of course I d not only have `root` within `<EXTRACTED_DESTINATION_PATH/` but also all other backup'ed directories (including /etc/, /home/ on so on...)
2022-03-17 04:51:47 +00:00
Dan Helfman 5b3cfc542d Switch to PyPI API token. 2022-03-14 14:00:03 -07:00
Dan Helfman c838c1d11b Fix header placement in documentation guide. 2022-03-14 13:50:22 -07:00
Dan Helfman 4d1d8d7409 Bump version for release. 2022-03-14 13:43:24 -07:00
Dan Helfman db7499db82 Document "repositories" context to for "before_*" and "after_*" command action hooks (#469). 2022-03-14 13:34:14 -07:00
Dan Helfman 6b500c2a8b Add repositories context for command hooks.
Reviewed-on: borgmatic-collective/borgmatic#469
2022-03-14 20:13:15 +00:00
Dan Helfman 95c518e59b Documentation tip about dealing with hangs when database hook is enabled. 2022-03-12 13:17:32 -08:00
Dan Helfman 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
Dan Helfman 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
Dan Helfman 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
Dan Helfman 9161dbcb7d Removing unnecessary leading underscores from functions. 2022-03-07 11:58:29 -08:00
Dan Helfman 4b3027e4fc Add test for new working_directory option (#431). 2022-03-03 11:48:18 -08:00
Dan Helfman 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
Dan Helfman 7c5b68c98f Bump version for release. 2022-02-10 10:29:18 -08:00
Dan Helfman 9317cbaaf0 Code formatting. 2022-02-10 10:23:34 -08:00
Dan Helfman 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
Dan Helfman 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
Dan Helfman 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
Dan Helfman 00a57fd947 Code formatting. 2022-02-09 21:20:28 -08:00
Dan Helfman 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
Dan Helfman 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
Dan Helfman b525e70e1c Run "compact" action by default when no actions are specified (#394). 2022-02-09 14:33:12 -08:00
Dan Helfman 4498671233 Remove references to removed long-deprecated options (#394). 2022-02-09 11:08:02 -08:00
Dan Helfman 9997aa9a92 Fix capitalization on compact help. 2022-02-08 15:58:09 -08:00
Dan Helfman cbf7284f64 Add compact action to command-line reference documentation. 2022-02-08 15:37:24 -08:00
Dan Helfman ee466f870d Fixing ruamel.yaml.clib breakages harder. 2022-02-08 13:21:11 -08:00
Dan Helfman e3f4bf0293 Build fix for ruamel.yaml.clib error. 2022-02-08 12:52:45 -08:00
Dan Helfman 46688f10b1 Merge branch 'master' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2022-02-08 12:10:57 -08:00
Dan Helfman 48f44d2f3d Add tests for compact action (#394). 2022-02-08 12:05:02 -08:00
Dan Helfman bff1347ba3 Fix some test failures (#394). 2022-02-08 09:35:03 -08:00
Dan Helfman 9582324c88 Compact repository segments with new "borgmatic compact" action (#394). 2022-02-07 23:29:44 -08:00
Dan Helfman bb0716421d Add comment about systemd service setting that may interfere with external commands in hooks (#492). 2022-01-25 09:26:11 -08:00
Dan Helfman bec73245e9 Fix traceback when a YAML validation error occurs (#480, #482). 2022-01-19 20:39:03 -08:00
Dan Helfman dcead12e86 Attempt to fix documentation build error introduced by Eleventy upgrade. 2022-01-09 14:21:27 -08:00
Dan Helfman 0119514c11 Add Python version requirements to setup.py. 2022-01-09 10:19:53 -08:00
fabianschilling b39f08694d Merge branch 'master' into pr-working-directory 2022-01-05 09:30:27 +00:00
Dan Helfman 80bdf1430b Bump version for release. 2022-01-04 20:20:13 -08:00
Dan Helfman 2ee75546f5 Add MongoDB database hook documentation. 2022-01-04 16:26:38 -08:00
Dan Helfman 07d7ae60d5 Add MongoDB database hook (#288).
Reviewed-on: borgmatic-collective/borgmatic#483
2022-01-04 23:50:25 +00:00
Andrea Ghensi 87001337b4 Merge master into mongodb_hook 2022-01-04 22:20:44 +01:00
Dan Helfman 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
Ian Kerins 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
Dan Helfman 96384d5ee1 Attempt to fix typed-ast build issue by relaxing version requirements in test. 2022-01-02 23:22:24 -08:00
Dan Helfman 8ed5467435 Drop support for Python 3.6. Add support for 3.10. 2022-01-02 23:17:57 -08:00
Andrea Ghensi 7c6ce9399c fix integration tests and mongodb auth 2021-12-29 22:18:50 +01:00
Andrea Ghensi 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
Dan Helfman a1673d1fa1 Fix unicode error when restoring particular MySQL databases (#476). 2021-12-08 16:40:25 -08:00
Dan Helfman 2e99a1898c Fix f-string with missing expression. 2021-11-29 14:05:36 -08:00
Dan Helfman 7a086d8430 Fix import ordering. 2021-11-29 14:00:14 -08:00
Dan Helfman 0e8e9ced64 When command-line configuration override produces a parse error, error cleanly (#471). 2021-11-29 12:49:21 -08:00
Dan Helfman f34951c088 Add MySQL dump command adjustment to NEWS. 2021-11-29 12:10:04 -08:00
Dan Helfman 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
nebulon42 c3e76585fc
move mysqldump options to the beginning of the command due to MySQL bug 30994. 2021-11-26 17:16:03 +01:00
Chen Yufei 0014b149f8 remove configuration_filename as it's already set. 2021-11-26 11:38:58 +08:00
Chen Yufei 091c07bbe2 Add context for various hooks. 2021-11-26 11:35:10 +08:00
Dan Helfman 240547102f Enable auto-play on linked asciicast. 2021-11-25 13:09:55 -08:00
Dan Helfman 2bbd53e25a
Merge pull request #43 from acsfer/patch-1
Github doesn't allow script embedding
2021-11-25 13:06:43 -08:00
acsfer 58f2f63977
Switch to HTML 2021-11-25 22:03:26 +01:00
acsfer 7df6a78c30
Github doesn't allow script embedding 2021-11-25 21:36:31 +01:00
92 changed files with 4973 additions and 1349 deletions

View File

@ -1,54 +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
clone:
skip_verify: true
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
clone:
skip_verify: true
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
@ -63,6 +12,11 @@ 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: clone:
skip_verify: true skip_verify: true

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",

96
NEWS
View File

@ -1,3 +1,99 @@
1.6.3.dev0
* #541: Add "borgmatic list --find" flag for searching for files across multiple archives, useful
for hunting down that file you accidentally deleted so you can extract it. See the documentation
for more information:
https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#searching-for-a-file
* Deprecate "borgmatic list --successful" flag, as listing only non-checkpoint (successful)
archives is now the default in newer versions of Borg.
1.6.2
* #523: Reduce the default consistency check frequency and support configuring the frequency
independently for each check. Also add "borgmatic check --force" flag to ignore configured
frequencies. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#check-frequency
* #536: Fix generate-borgmatic-config to support more complex schema changes like the new
Healthchecks configuration options when the "--source" flag is used.
* #538: Add support for "borgmatic borg debug" command.
* #539: Add "generate-borgmatic-config --overwrite" flag to replace an existing destination file.
* Add Bash completion script so you can tab-complete the borgmatic command-line. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#shell-completion
1.6.1
* #294: Add Healthchecks monitoring hook "ping_body_limit" option to configure how many bytes of
logs to send to the Healthchecks server.
* #402: Remove the error when "archive_name_format" is specified but a retention prefix isn't.
* #420: Warn when an unsupported variable is used in a hook command.
* #439: Change connection failures for monitoring hooks (Healthchecks, Cronitor, PagerDuty, and
Cronhub) to be warnings instead of errors. This way, the monitoring system failing does not block
backups.
* #460: Add Healthchecks monitoring hook "send_logs" option to enable/disable sending borgmatic
logs to the Healthchecks server.
* #525: Add Healthchecks monitoring hook "states" option to only enable pinging for particular
monitoring states (start, finish, fail).
* #528: Improve the error message when a configuration override contains an invalid value.
* #531: BREAKING: When deep merging common configuration, merge colliding list values by appending
them. Previously, one list replaced the other.
* #532: When a configuration include is a relative path, load it from either the current working
directory or from the directory containing the file doing the including. Previously, only the
working directory was used.
* Add a randomized delay to the sample systemd timer to spread out the load on a server.
* Change the configuration format for borgmatic monitoring hooks (Healthchecks, Cronitor,
PagerDuty, and Cronhub) to specify the ping URL / integration key as a named option. The intent
is to support additional options (some in this release). This change is backwards-compatible.
* Add emojis to documentation table of contents to make it easier to find particular how-to and
reference guides at a glance.
1.6.0
* #381: BREAKING: Greatly simplify configuration file reuse by deep merging when including common
configuration. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#include-merging
* #473: BREAKING: Instead of executing "before" command hooks before all borgmatic actions run (and
"after" hooks after), execute these hooks right before/after the corresponding action. E.g.,
"before_check" now runs immediately before the "check" action. This better supports running
timing-sensitive tasks like pausing containers. Side effect: before/after command hooks now run
once for each configured repository instead of once per configuration file. Additionally, the
"repositories" interpolated variable has been changed to "repository", containing the path to the
current repository for the hook. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/
* #513: Add mention of sudo's "secure_path" option to borgmatic installation documentation.
* #515: Fix "borgmatic borg key ..." to pass parameters to Borg in the correct order.
* #516: Fix handling of TERM signal to exit borgmatic, not just forward the signal to Borg.
* #517: Fix borgmatic exit code (so it's zero) when initial Borg calls fail but later retries
succeed.
* Change Healthchecks logs truncation size from 10k bytes to 100k bytes, corresponding to that
same change on Healthchecks.io.
1.5.24
* #431: Add "working_directory" option to support source directories with relative paths.
* #444: When loading a configuration file that is unreadable due to file permissions, warn instead
of erroring. This supports running borgmatic as a non-root user with configuration in ~/.config
even if there is an unreadable global configuration file in /etc.
* #469: Add "repositories" context to "before_*" and "after_*" command action hooks. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/
* #486: Fix handling of "patterns_from" and "exclude_from" options to error instead of warning when
referencing unreadable files and "create" action is run.
* #507: Fix Borg usage error in the "compact" action when running "borgmatic --dry-run". Now, skip
"compact" entirely during a dry run.
1.5.23
* #394: Compact repository segments and free space with new "borgmatic compact" action. Borg 1.2+
only. Also run "compact" by default when no actions are specified, as "prune" in Borg 1.2 no
longer frees up space unless "compact" is run.
* #394: When using the "atime", "bsd_flags", "numeric_owner", or "remote_rate_limit" options,
tailor the flags passed to Borg depending on the Borg version.
* #480, #482: Fix traceback when a YAML validation error occurs.
1.5.22
* #288: Add database dump hook for MongoDB.
* #470: Move mysqldump options to the beginning of the command due to MySQL bug 30994.
* #471: When command-line configuration override produces a parse error, error cleanly instead of
tracebacking.
* #476: Fix unicode error when restoring particular MySQL databases.
* Drop support for Python 3.6, which has been end-of-lifed.
* Add support for Python 3.10.
1.5.21 1.5.21
* #28: Optionally retry failing backups via "retries" and "retry_wait" configuration options. * #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 * #306: Add "list_options" MySQL configuration option for passing additional arguments to MySQL

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:
@ -38,8 +37,9 @@ retention:
consistency: consistency:
# List of checks to run to validate your backups. # List of checks to run to validate your backups.
checks: checks:
- repository - name: repository
- archives - name: archives
frequency: 2 weeks
hooks: hooks:
# Custom preparation scripts to run. # Custom preparation scripts to run.
@ -55,9 +55,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 +66,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;
@ -93,7 +93,6 @@ referral links, but without any tracking scripts or cookies.)
<ul> <ul>
<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, [rsync.net](https://www.rsync.net/products/borg.html) and Additionally, [rsync.net](https://www.rsync.net/products/borg.html) and

View File

@ -7,6 +7,8 @@ logger = logging.getLogger(__name__)
REPOSITORYLESS_BORG_COMMANDS = {'serve', None} REPOSITORYLESS_BORG_COMMANDS = {'serve', None}
BORG_COMMANDS_WITH_SUBCOMMANDS = {'key', 'debug'}
BORG_SUBCOMMANDS_WITHOUT_REPOSITORY = (('debug', 'info'), ('debug', 'convert-profile'))
def run_arbitrary_borg( def run_arbitrary_borg(
@ -21,17 +23,25 @@ def run_arbitrary_borg(
try: try:
options = options[1:] if options[0] == '--' else options options = options[1:] if options[0] == '--' else options
borg_command = options[0]
command_options = tuple(options[1:]) # Borg commands like "key" have a sub-command ("export", etc.) that must follow it.
command_options_start_index = 2 if options[0] in BORG_COMMANDS_WITH_SUBCOMMANDS else 1
borg_command = tuple(options[:command_options_start_index])
command_options = tuple(options[command_options_start_index:])
except IndexError: except IndexError:
borg_command = None borg_command = ()
command_options = () command_options = ()
repository_archive = '::'.join((repository, archive)) if repository and archive else repository if borg_command in BORG_SUBCOMMANDS_WITHOUT_REPOSITORY:
repository_archive = None
else:
repository_archive = (
'::'.join((repository, archive)) if repository and archive else repository
)
full_command = ( full_command = (
(local_path,) (local_path,)
+ ((borg_command,) if borg_command else ()) + borg_command
+ ((repository_archive,) if borg_command and repository_archive else ()) + ((repository_archive,) if borg_command and repository_archive else ())
+ command_options + command_options
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())

View File

@ -1,46 +1,157 @@
import argparse
import datetime
import json
import logging import logging
import os
import pathlib
from borgmatic.borg import extract from borgmatic.borg import extract, info, state
from borgmatic.execute import DO_NOT_CAPTURE, execute_command from borgmatic.execute import DO_NOT_CAPTURE, execute_command
DEFAULT_CHECKS = ('repository', 'archives') DEFAULT_CHECKS = (
{'name': 'repository', 'frequency': '1 month'},
{'name': 'archives', 'frequency': '1 month'},
)
DEFAULT_PREFIX = '{hostname}-' DEFAULT_PREFIX = '{hostname}-'
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _parse_checks(consistency_config, only_checks=None): def parse_checks(consistency_config, only_checks=None):
''' '''
Given a consistency config with a "checks" list, and an optional list of override checks, Given a consistency config with a "checks" sequence of dicts and an optional list of override
transform them a tuple of named checks to run. checks, return a tuple of named checks to run.
For example, given a retention config of: For example, given a retention config of:
{'checks': ['repository', 'archives']} {'checks': ({'name': 'repository'}, {'name': 'archives'})}
This will be returned as: This will be returned as:
('repository', 'archives') ('repository', 'archives')
If no "checks" option is present in the config, return the DEFAULT_CHECKS. If the checks value If no "checks" option is present in the config, return the DEFAULT_CHECKS. If a checks value
is the string "disabled", return an empty tuple, meaning that no checks should be run. has a name of "disabled", return an empty tuple, meaning that no checks should be run.
If the "data" option is present, then make sure the "archives" option is included as well. If the "data" check is present, then make sure the "archives" check is included as well.
''' '''
checks = [ checks = only_checks or tuple(
check.lower() for check in (only_checks or consistency_config.get('checks', []) or []) check_config['name']
] for check_config in (consistency_config.get('checks', None) or DEFAULT_CHECKS)
if checks == ['disabled']: )
checks = tuple(check.lower() for check in checks)
if 'disabled' in checks:
if len(checks) > 1:
logger.warning(
'Multiple checks are configured, but one of them is "disabled"; not running any checks'
)
return () return ()
if 'data' in checks and 'archives' not in checks: if 'data' in checks and 'archives' not in checks:
checks.append('archives') return checks + ('archives',)
return tuple(check for check in checks if check not in ('disabled', '')) or DEFAULT_CHECKS return checks
def _make_check_flags(checks, check_last=None, prefix=None): def parse_frequency(frequency):
'''
Given a frequency string with a number and a unit of time, return a corresponding
datetime.timedelta instance or None if the frequency is None or "always".
For instance, given "3 weeks", return datetime.timedelta(weeks=3)
Raise ValueError if the given frequency cannot be parsed.
'''
if not frequency:
return None
frequency = frequency.strip().lower()
if frequency == 'always':
return None
try:
number, time_unit = frequency.split(' ')
number = int(number)
except ValueError:
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
if not time_unit.endswith('s'):
time_unit += 's'
if time_unit == 'months':
number *= 30
time_unit = 'days'
elif time_unit == 'years':
number *= 365
time_unit = 'days'
try:
return datetime.timedelta(**{time_unit: number})
except TypeError:
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
def filter_checks_on_frequency(
location_config, consistency_config, borg_repository_id, checks, force
):
'''
Given a location config, a consistency config with a "checks" sequence of dicts, a Borg
repository ID, a sequence of checks, and whether to force checks to run, filter down those
checks based on the configured "frequency" for each check as compared to its check time file.
In other words, a check whose check time file's timestamp is too new (based on the configured
frequency) will get cut from the returned sequence of checks. Example:
consistency_config = {
'checks': [
{
'name': 'archives',
'frequency': '2 weeks',
},
]
}
When this function is called with that consistency_config and "archives" in checks, "archives"
will get filtered out of the returned result if its check time file is newer than 2 weeks old,
indicating that it's not yet time to run that check again.
Raise ValueError if a frequency cannot be parsed.
'''
filtered_checks = list(checks)
if force:
return tuple(filtered_checks)
for check_config in consistency_config.get('checks', DEFAULT_CHECKS):
check = check_config['name']
if checks and check not in checks:
continue
frequency_delta = parse_frequency(check_config.get('frequency'))
if not frequency_delta:
continue
check_time = read_check_time(
make_check_time_path(location_config, borg_repository_id, check)
)
if not check_time:
continue
# If we've not yet reached the time when the frequency dictates we're ready for another
# check, skip this check.
if datetime.datetime.now() < check_time + frequency_delta:
remaining = check_time + frequency_delta - datetime.datetime.now()
logger.info(
f"Skipping {check} check due to configured frequency; {remaining} until next check"
)
filtered_checks.remove(check)
return tuple(filtered_checks)
def make_check_flags(checks, check_last=None, prefix=None):
''' '''
Given a parsed sequence of checks, transform it into tuple of command-line flags. Given a parsed sequence of checks, transform it into tuple of command-line flags.
@ -66,27 +177,67 @@ def _make_check_flags(checks, check_last=None, prefix=None):
last_flags = () last_flags = ()
prefix_flags = () prefix_flags = ()
if check_last: if check_last:
logger.warning( logger.info('Ignoring check_last option, as "archives" is not in consistency checks')
'Ignoring check_last option, as "archives" is not in consistency checks.'
)
if prefix: if prefix:
logger.warning( logger.info(
'Ignoring consistency prefix option, as "archives" is not in consistency checks.' 'Ignoring consistency prefix option, as "archives" is not in consistency checks'
) )
common_flags = last_flags + prefix_flags + (('--verify-data',) if 'data' in checks else ()) common_flags = last_flags + prefix_flags + (('--verify-data',) if 'data' in checks else ())
if set(DEFAULT_CHECKS).issubset(set(checks)): if {'repository', 'archives'}.issubset(set(checks)):
return common_flags return common_flags
return ( return (
tuple('--{}-only'.format(check) for check in checks if check in DEFAULT_CHECKS) tuple('--{}-only'.format(check) for check in checks if check in ('repository', 'archives'))
+ common_flags + common_flags
) )
def make_check_time_path(location_config, borg_repository_id, check_type):
'''
Given a location configuration dict, a Borg repository ID, and the name of a check type
("repository", "archives", etc.), return a path for recording that check's time (the time of
that check last occurring).
'''
return os.path.join(
os.path.expanduser(
location_config.get(
'borgmatic_source_directory', state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
)
),
'checks',
borg_repository_id,
check_type,
)
def write_check_time(path): # pragma: no cover
'''
Record a check time of now as the modification time of the given path.
'''
logger.debug(f'Writing check time at {path}')
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
pathlib.Path(path, mode=0o600).touch()
def read_check_time(path):
'''
Return the check time based on the modification time of the given path. Return None if the path
doesn't exist.
'''
logger.debug(f'Reading check time from {path}')
try:
return datetime.datetime.fromtimestamp(os.stat(path).st_mtime)
except FileNotFoundError:
return None
def check_archives( def check_archives(
repository, repository,
location_config,
storage_config, storage_config,
consistency_config, consistency_config,
local_path='borg', local_path='borg',
@ -94,6 +245,7 @@ def check_archives(
progress=None, progress=None,
repair=None, repair=None,
only_checks=None, only_checks=None,
force=None,
): ):
''' '''
Given a local or remote repository path, a storage config dict, a consistency config dict, Given a local or remote repository path, a storage config dict, a consistency config dict,
@ -102,13 +254,34 @@ def check_archives(
Borg archives for consistency. Borg archives for consistency.
If there are no consistency checks to run, skip running them. If there are no consistency checks to run, skip running them.
Raises ValueError if the Borg repository ID cannot be determined.
''' '''
checks = _parse_checks(consistency_config, only_checks) try:
borg_repository_id = json.loads(
info.display_archives_info(
repository,
storage_config,
argparse.Namespace(json=True, archive=None),
local_path,
remote_path,
)
)['repository']['id']
except (json.JSONDecodeError, KeyError):
raise ValueError(f'Cannot determine Borg repository ID for {repository}')
checks = filter_checks_on_frequency(
location_config,
consistency_config,
borg_repository_id,
parse_checks(consistency_config, only_checks),
force,
)
check_last = consistency_config.get('check_last', None) check_last = consistency_config.get('check_last', None)
lock_wait = None lock_wait = None
extra_borg_options = storage_config.get('extra_borg_options', {}).get('check', '') extra_borg_options = storage_config.get('extra_borg_options', {}).get('check', '')
if set(checks).intersection(set(DEFAULT_CHECKS + ('data',))): if set(checks).intersection({'repository', 'archives', 'data'}):
lock_wait = storage_config.get('lock_wait', None) lock_wait = storage_config.get('lock_wait', None)
verbosity_flags = () verbosity_flags = ()
@ -122,7 +295,7 @@ def check_archives(
full_command = ( full_command = (
(local_path, 'check') (local_path, 'check')
+ (('--repair',) if repair else ()) + (('--repair',) if repair else ())
+ _make_check_flags(checks, check_last, prefix) + make_check_flags(checks, check_last, prefix)
+ (('--remote-path', remote_path) if remote_path else ()) + (('--remote-path', remote_path) if remote_path else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ()) + (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags + verbosity_flags
@ -131,12 +304,16 @@ def check_archives(
+ (repository,) + (repository,)
) )
# The Borg repair option trigger an interactive prompt, which won't work when output is # The Borg repair option triggers an interactive prompt, which won't work when output is
# captured. And progress messes with the terminal directly. # captured. And progress messes with the terminal directly.
if repair or progress: if repair or progress:
execute_command(full_command, output_file=DO_NOT_CAPTURE) execute_command(full_command, output_file=DO_NOT_CAPTURE)
else: else:
execute_command(full_command) execute_command(full_command)
for check in checks:
write_check_time(make_check_time_path(location_config, borg_repository_id, check))
if 'extract' in checks: if 'extract' in checks:
extract.extract_last_archive_dry_run(repository, lock_wait, local_path, remote_path) extract.extract_last_archive_dry_run(repository, lock_wait, local_path, remote_path)
write_check_time(make_check_time_path(location_config, borg_repository_id, 'extract'))

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, state
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.
@ -97,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.
@ -112,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.
@ -128,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.
@ -162,7 +175,7 @@ def _make_exclude_flags(location_config, exclude_filename=None):
) )
DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic' DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
def borgmatic_source_directories(borgmatic_source_directory): def borgmatic_source_directories(borgmatic_source_directory):
@ -170,7 +183,7 @@ def borgmatic_source_directories(borgmatic_source_directory):
Return a list of borgmatic-specific source directories used for state like database backups. Return a list of borgmatic-specific source directories used for state like database backups.
''' '''
if not borgmatic_source_directory: if not borgmatic_source_directory:
borgmatic_source_directory = DEFAULT_BORGMATIC_SOURCE_DIRECTORY borgmatic_source_directory = state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
return ( return (
[borgmatic_source_directory] [borgmatic_source_directory]
@ -179,14 +192,12 @@ def borgmatic_source_directories(borgmatic_source_directory):
) )
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
def create_archive( def create_archive(
dry_run, dry_run,
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,
@ -204,16 +215,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)
@ -225,26 +240,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 ())
@ -283,6 +324,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)

View File

@ -1,6 +1,8 @@
import argparse
import logging import logging
import subprocess import subprocess
from borgmatic.borg import info
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__)
@ -23,17 +25,14 @@ def initialize_repository(
whether the repository should be append-only, and the storage quota to use, initialize the whether the repository should be append-only, and the storage quota to use, initialize the
repository. If the repository already exists, then log and skip initialization. repository. If the repository already exists, then log and skip initialization.
''' '''
info_command = (
(local_path, 'info')
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug',) if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--remote-path', remote_path) if remote_path else ())
+ (repository,)
)
logger.debug(' '.join(info_command))
try: try:
execute_command(info_command, output_log_level=None) info.display_archives_info(
repository,
storage_config,
argparse.Namespace(json=True, archive=None),
local_path,
remote_path,
)
logger.info('Repository already exists. Skipping initialization.') logger.info('Repository already exists. Skipping initialization.')
return return
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:

View File

@ -1,4 +1,6 @@
import copy
import logging import logging
import re
from borgmatic.borg.flags import make_flags, make_flags_from_arguments from borgmatic.borg.flags import make_flags, make_flags_from_arguments
from borgmatic.execute import execute_command from borgmatic.execute import execute_command
@ -6,17 +8,11 @@ from borgmatic.execute import execute_command
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# A hack to convince Borg to exclude archives ending in ".checkpoint". This assumes that a
# non-checkpoint archive name ends in a digit (e.g. from a timestamp).
BORG_EXCLUDE_CHECKPOINTS_GLOB = '*[0123456789]'
def resolve_archive_name(repository, archive, storage_config, local_path='borg', remote_path=None): def resolve_archive_name(repository, archive, storage_config, local_path='borg', remote_path=None):
''' '''
Given a local or remote repository path, an archive name, a storage config dict, a local Borg Given a local or remote repository path, an archive name, a storage config dict, a local Borg
path, and a remote Borg path, simply return the archive name. But if the archive name is path, and a remote Borg path, simply return the archive name. But if the archive name is
"latest", then instead introspect the repository for the latest successful (non-checkpoint) "latest", then instead introspect the repository for the latest archive and return its name.
archive, and return its name.
Raise ValueError if "latest" is given but there are no archives in the repository. Raise ValueError if "latest" is given but there are no archives in the repository.
''' '''
@ -31,7 +27,6 @@ def resolve_archive_name(repository, archive, storage_config, local_path='borg',
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ make_flags('remote-path', remote_path) + make_flags('remote-path', remote_path)
+ make_flags('lock-wait', lock_wait) + make_flags('lock-wait', lock_wait)
+ make_flags('glob-archives', BORG_EXCLUDE_CHECKPOINTS_GLOB)
+ make_flags('last', 1) + make_flags('last', 1)
+ ('--short', repository) + ('--short', repository)
) )
@ -47,17 +42,20 @@ def resolve_archive_name(repository, archive, storage_config, local_path='borg',
return latest_archive return latest_archive
def list_archives(repository, storage_config, list_arguments, local_path='borg', remote_path=None): MAKE_FLAGS_EXCLUDES = ('repository', 'archive', 'successful', 'paths', 'find_paths')
def make_list_command(
repository, storage_config, list_arguments, local_path='borg', remote_path=None
):
''' '''
Given a local or remote repository path, a storage config dict, and the arguments to the list Given a local or remote repository path, a storage config dict, the arguments to the list
action, display the output of listing Borg archives in the repository or return JSON output. Or, action, and local and remote Borg paths, return a command as a tuple to list archives or paths
if an archive name is given, listing the files in that archive. within an archive.
''' '''
lock_wait = storage_config.get('lock_wait', None) lock_wait = storage_config.get('lock_wait', None)
if list_arguments.successful:
list_arguments.glob_archives = BORG_EXCLUDE_CHECKPOINTS_GLOB
full_command = ( return (
(local_path, 'list') (local_path, 'list')
+ ( + (
('--info',) ('--info',)
@ -71,19 +69,92 @@ def list_archives(repository, storage_config, list_arguments, local_path='borg',
) )
+ make_flags('remote-path', remote_path) + make_flags('remote-path', remote_path)
+ make_flags('lock-wait', lock_wait) + make_flags('lock-wait', lock_wait)
+ make_flags_from_arguments( + make_flags_from_arguments(list_arguments, excludes=MAKE_FLAGS_EXCLUDES,)
list_arguments, excludes=('repository', 'archive', 'paths', 'successful')
)
+ ( + (
'::'.join((repository, list_arguments.archive)) ('::'.join((repository, list_arguments.archive)),)
if list_arguments.archive if list_arguments.archive
else repository, else (repository,)
) )
+ (tuple(list_arguments.paths) if list_arguments.paths else ()) + (tuple(list_arguments.paths) if list_arguments.paths else ())
) )
return execute_command(
full_command, def make_find_paths(find_paths):
output_log_level=None if list_arguments.json else logging.WARNING, '''
borg_local_path=local_path, Given a sequence of path fragments or patterns as passed to `--find`, transform all path
fragments into glob patterns. Pass through existing patterns untouched.
For example, given find_paths of:
['foo.txt', 'pp:root/somedir']
... transform that into:
['sh:**/*foo.txt*/**', 'pp:root/somedir']
'''
if not find_paths:
return ()
return tuple(
find_path
if re.compile(r'([-!+RrPp] )|(\w\w:)').match(find_path)
else f'sh:**/*{find_path}*/**'
for find_path in find_paths
) )
def list_archives(repository, storage_config, list_arguments, local_path='borg', remote_path=None):
'''
Given a local or remote repository path, a storage config dict, the arguments to the list
action, and local and remote Borg paths, display the output of listing Borg archives in the
repository or return JSON output. Or, if an archive name is given, list the files in that
archive. Or, if list_arguments.find_paths are given, list the files by searching across multiple
archives.
'''
# If there are any paths to find (and there's not a single archive already selected), start by
# getting a list of archives to search.
if list_arguments.find_paths and not list_arguments.archive:
repository_arguments = copy.copy(list_arguments)
repository_arguments.archive = None
repository_arguments.json = False
repository_arguments.format = None
# Ask Borg to list archives. Capture its output for use below.
archive_lines = tuple(
execute_command(
make_list_command(
repository, storage_config, repository_arguments, local_path, remote_path
),
output_log_level=None,
borg_local_path=local_path,
)
.strip('\n')
.split('\n')
)
else:
archive_lines = (list_arguments.archive,)
# For each archive listed by Borg, run list on the contents of that archive.
for archive_line in archive_lines:
try:
archive = archive_line.split()[0]
except (AttributeError, IndexError):
archive = None
if archive:
logger.warning(archive_line)
archive_arguments = copy.copy(list_arguments)
archive_arguments.archive = archive
main_command = make_list_command(
repository, storage_config, archive_arguments, local_path, remote_path
) + make_find_paths(list_arguments.find_paths)
output = execute_command(
main_command,
output_log_level=None if list_arguments.json else logging.WARNING,
borg_local_path=local_path,
)
if list_arguments.json:
return output

1
borgmatic/borg/state.py Normal file
View File

@ -0,0 +1 @@
DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic'

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
@ -108,10 +109,9 @@ class Extend_action(Action):
setattr(namespace, self.dest, list(values)) setattr(namespace, self.dest, list(values))
def parse_arguments(*unparsed_arguments): def make_parsers():
''' '''
Given command-line arguments with which this script was invoked, parse the arguments and return Build a top-level parser and its subparsers and return them as a tuple.
them as a dict mapping from subparser name (or "global") to an argparse.Namespace instance.
''' '''
config_paths = collect.get_default_config_paths(expand_home=True) config_paths = collect.get_default_config_paths(expand_home=True)
unexpanded_config_paths = collect.get_default_config_paths(expand_home=False) unexpanded_config_paths = collect.get_default_config_paths(expand_home=False)
@ -188,6 +188,12 @@ def parse_arguments(*unparsed_arguments):
action='extend', action='extend',
help='One or more configuration file options to override with specified values', help='One or more configuration file options to override with specified values',
) )
global_group.add_argument(
'--bash-completion',
default=False,
action='store_true',
help='Show bash completion script and exit',
)
global_group.add_argument( global_group.add_argument(
'--version', '--version',
dest='version', dest='version',
@ -199,8 +205,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 +214,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 +247,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 +264,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'],
@ -308,7 +346,7 @@ def parse_arguments(*unparsed_arguments):
dest='repair', dest='repair',
default=False, default=False,
action='store_true', action='store_true',
help='Attempt to repair any inconsistencies found (experimental and only for interactive use)', help='Attempt to repair any inconsistencies found (for interactive use)',
) )
check_group.add_argument( check_group.add_argument(
'--only', '--only',
@ -316,7 +354,13 @@ def parse_arguments(*unparsed_arguments):
choices=('repository', 'archives', 'data', 'extract'), choices=('repository', 'archives', 'data', 'extract'),
dest='only', dest='only',
action='append', action='append',
help='Run a particular consistency check (repository, archives, data, or extract) instead of configured checks; can specify flag multiple times', help='Run a particular consistency check (repository, archives, data, or extract) instead of configured checks (subject to configured frequency, can specify flag multiple times)',
)
check_group.add_argument(
'--force',
default=False,
action='store_true',
help='Ignore configured check frequencies and run checks unconditionally',
) )
check_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') check_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
@ -510,7 +554,14 @@ def parse_arguments(*unparsed_arguments):
metavar='PATH', metavar='PATH',
nargs='+', nargs='+',
dest='paths', dest='paths',
help='Paths to list from archive, defaults to the entire archive', help='Paths or patterns to list from a single selected archive (via "--archive"), defaults to listing the entire archive',
)
list_group.add_argument(
'--find',
metavar='PATH',
nargs='+',
dest='find_paths',
help='Partial paths or patterns to search for and list across multiple archives',
) )
list_group.add_argument( list_group.add_argument(
'--short', default=False, action='store_true', help='Output only archive or path names' '--short', default=False, action='store_true', help='Output only archive or path names'
@ -527,9 +578,9 @@ def parse_arguments(*unparsed_arguments):
) )
list_group.add_argument( list_group.add_argument(
'--successful', '--successful',
default=False, default=True,
action='store_true', action='store_true',
help='Only list archive names of successful (non-checkpoint) backups', help='Deprecated in favor of listing successful (non-checkpoint) backups by default in newer versions of Borg',
) )
list_group.add_argument( list_group.add_argument(
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys' '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
@ -614,6 +665,16 @@ def parse_arguments(*unparsed_arguments):
) )
borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
return top_level_parser, subparsers
def parse_arguments(*unparsed_arguments):
'''
Given command-line arguments with which this script was invoked, parse the arguments and return
them as a dict mapping from subparser name (or "global") to an argparse.Namespace instance.
'''
top_level_parser, subparsers = make_parsers()
arguments, remaining_arguments = parse_subparser_arguments( arguments, remaining_arguments = parse_subparser_arguments(
unparsed_arguments, subparsers.choices unparsed_arguments, subparsers.choices
) )
@ -627,9 +688,6 @@ def parse_arguments(*unparsed_arguments):
if 'init' in arguments and arguments['global'].dry_run: if 'init' in arguments and arguments['global'].dry_run:
raise ValueError('The init action cannot be used with the --dry-run option') raise ValueError('The init action cannot be used with the --dry-run option')
if 'list' in arguments and arguments['list'].glob_archives and arguments['list'].successful:
raise ValueError('The --glob-archives and --successful options cannot be used together')
if ( if (
'list' in arguments 'list' in arguments
and 'info' in arguments and 'info' in arguments

View File

@ -11,18 +11,22 @@ from subprocess import CalledProcessError
import colorama import colorama
import pkg_resources import pkg_resources
import borgmatic.commands.completion
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
@ -38,8 +42,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:
@ -59,11 +63,19 @@ def run_configuration(config_filename, config, arguments):
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)
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,
@ -72,39 +84,7 @@ def run_configuration(config_filename, config, arguments):
monitoring_log_level, monitoring_log_level,
global_arguments.dry_run, global_arguments.dry_run,
) )
if 'prune' in arguments: if using_primary_action:
command.execute_hook(
hooks.get('before_prune'),
hooks.get('umask'),
config_filename,
'pre-prune',
global_arguments.dry_run,
)
if 'create' in arguments:
command.execute_hook(
hooks.get('before_backup'),
hooks.get('umask'),
config_filename,
'pre-backup',
global_arguments.dry_run,
)
if 'check' in arguments:
command.execute_hook(
hooks.get('before_check'),
hooks.get('umask'),
config_filename,
'pre-check',
global_arguments.dry_run,
)
if 'extract' in arguments:
command.execute_hook(
hooks.get('before_extract'),
hooks.get('umask'),
config_filename,
'pre-extract',
global_arguments.dry_run,
)
if prune_create_or_check:
dispatch.call_hooks( dispatch.call_hooks(
'ping_monitor', 'ping_monitor',
hooks, hooks,
@ -119,9 +99,7 @@ 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 pinging monitor'.format(config_filename), error)
'{}: Error running pre hook'.format(config_filename), error
)
if not encountered_error: if not encountered_error:
repo_queue = Queue() repo_queue = Queue()
@ -137,6 +115,7 @@ def run_configuration(config_filename, config, arguments):
try: try:
yield from run_actions( yield from run_actions(
arguments=arguments, arguments=arguments,
config_filename=config_filename,
location=location, location=location,
storage=storage, storage=storage,
retention=retention, retention=retention,
@ -144,64 +123,37 @@ 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:
yield from make_error_log_records(
'{}: Error running actions for repository'.format(repository_path), error
)
if retry_num < retries: if retry_num < retries:
repo_queue.put((repository_path, retry_num + 1),) repo_queue.put((repository_path, retry_num + 1),)
tuple( # Consume the generator so as to trigger logging.
log_error_records(
'{}: Error running actions for repository'.format(repository_path),
error,
levelno=logging.WARNING,
log_command_error_output=True,
)
)
logger.warning( logger.warning(
f'{config_filename}: Retrying... attempt {retry_num + 1}/{retries}' f'{config_filename}: Retrying... attempt {retry_num + 1}/{retries}'
) )
continue continue
if command.considered_soft_failure(config_filename, error):
return
yield from log_error_records(
'{}: Error running actions for repository'.format(repository_path), error
)
encountered_error = error encountered_error = error
error_repository = repository_path error_repository = repository_path
if not encountered_error: if not encountered_error:
try: try:
if 'prune' in arguments: if using_primary_action:
command.execute_hook(
hooks.get('after_prune'),
hooks.get('umask'),
config_filename,
'post-prune',
global_arguments.dry_run,
)
if 'create' in arguments:
dispatch.call_hooks(
'remove_database_dumps',
hooks,
config_filename,
dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
command.execute_hook(
hooks.get('after_backup'),
hooks.get('umask'),
config_filename,
'post-backup',
global_arguments.dry_run,
)
if 'check' in arguments:
command.execute_hook(
hooks.get('after_check'),
hooks.get('umask'),
config_filename,
'post-check',
global_arguments.dry_run,
)
if 'extract' in arguments:
command.execute_hook(
hooks.get('after_extract'),
hooks.get('umask'),
config_filename,
'post-extract',
global_arguments.dry_run,
)
if prune_create_or_check:
dispatch.call_hooks( dispatch.call_hooks(
'ping_monitor', 'ping_monitor',
hooks, hooks,
@ -224,11 +176,9 @@ 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 pinging monitor'.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'),
@ -261,7 +211,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
) )
@ -269,6 +219,7 @@ def run_configuration(config_filename, config, arguments):
def run_actions( def run_actions(
*, *,
arguments, arguments,
config_filename,
location, location,
storage, storage,
retention, retention,
@ -276,21 +227,30 @@ def run_actions(
hooks, hooks,
local_path, local_path,
remote_path, remote_path,
local_borg_version,
repository_path, repository_path,
): # 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, the configuration
configuration dicts, local and remote paths to Borg, and a repository name, run all actions filename, several different configuration dicts, local and remote paths to Borg, a local Borg
from the command-line arguments on the given repository. version string, and a repository name, run all actions from the command-line arguments on the
given repository.
Yield JSON output strings from executing any actions that produce JSON. Yield JSON output strings from executing any actions that produce JSON.
Raise OSError or subprocess.CalledProcessError if an error occurs running a command for an Raise OSError or subprocess.CalledProcessError if an error occurs running a command for an
action. Raise ValueError if the arguments or configuration passed to action are invalid. action or a hook. Raise ValueError if the arguments or configuration passed to action are
invalid.
''' '''
repository = os.path.expanduser(repository_path) repository = os.path.expanduser(repository_path)
global_arguments = arguments['global'] global_arguments = arguments['global']
dry_run_label = ' (dry run; not making any changes)' if global_arguments.dry_run else '' dry_run_label = ' (dry run; not making any changes)' if global_arguments.dry_run else ''
hook_context = {
'repository': repository_path,
# Deprecated: For backwards compatibility with borgmatic < 1.6.0.
'repositories': ','.join(location['repositories']),
}
if 'init' in arguments: if 'init' in arguments:
logger.info('{}: Initializing repository'.format(repository)) logger.info('{}: Initializing repository'.format(repository))
borg_init.initialize_repository( borg_init.initialize_repository(
@ -303,6 +263,14 @@ def run_actions(
remote_path=remote_path, remote_path=remote_path,
) )
if 'prune' in arguments: if 'prune' in arguments:
command.execute_hook(
hooks.get('before_prune'),
hooks.get('umask'),
config_filename,
'pre-prune',
global_arguments.dry_run,
**hook_context,
)
logger.info('{}: Pruning archives{}'.format(repository, dry_run_label)) logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
borg_prune.prune_archives( borg_prune.prune_archives(
global_arguments.dry_run, global_arguments.dry_run,
@ -314,7 +282,54 @@ def run_actions(
stats=arguments['prune'].stats, stats=arguments['prune'].stats,
files=arguments['prune'].files, files=arguments['prune'].files,
) )
command.execute_hook(
hooks.get('after_prune'),
hooks.get('umask'),
config_filename,
'post-prune',
global_arguments.dry_run,
**hook_context,
)
if 'compact' in arguments:
command.execute_hook(
hooks.get('before_compact'),
hooks.get('umask'),
config_filename,
'pre-compact',
global_arguments.dry_run,
)
if borg_feature.available(borg_feature.Feature.COMPACT, local_borg_version):
logger.info('{}: Compacting segments{}'.format(repository, dry_run_label))
borg_compact.compact_segments(
global_arguments.dry_run,
repository,
storage,
local_path=local_path,
remote_path=remote_path,
progress=arguments['compact'].progress,
cleanup_commits=arguments['compact'].cleanup_commits,
threshold=arguments['compact'].threshold,
)
else: # pragma: nocover
logger.info(
'{}: Skipping compact (only available/needed in Borg 1.2+)'.format(repository)
)
command.execute_hook(
hooks.get('after_compact'),
hooks.get('umask'),
config_filename,
'post-compact',
global_arguments.dry_run,
)
if 'create' in arguments: if 'create' in arguments:
command.execute_hook(
hooks.get('before_backup'),
hooks.get('umask'),
config_filename,
'pre-backup',
global_arguments.dry_run,
**hook_context,
)
logger.info('{}: Creating archive{}'.format(repository, dry_run_label)) logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
dispatch.call_hooks( dispatch.call_hooks(
'remove_database_dumps', 'remove_database_dumps',
@ -339,6 +354,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,
@ -347,13 +363,39 @@ def run_actions(
files=arguments['create'].files, files=arguments['create'].files,
stream_processes=stream_processes, stream_processes=stream_processes,
) )
if json_output: if json_output: # pragma: nocover
yield json.loads(json_output) yield json.loads(json_output)
dispatch.call_hooks(
'remove_database_dumps',
hooks,
config_filename,
dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
command.execute_hook(
hooks.get('after_backup'),
hooks.get('umask'),
config_filename,
'post-backup',
global_arguments.dry_run,
**hook_context,
)
if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency): if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency):
command.execute_hook(
hooks.get('before_check'),
hooks.get('umask'),
config_filename,
'pre-check',
global_arguments.dry_run,
**hook_context,
)
logger.info('{}: Running consistency checks'.format(repository)) logger.info('{}: Running consistency checks'.format(repository))
borg_check.check_archives( borg_check.check_archives(
repository, repository,
location,
storage, storage,
consistency, consistency,
local_path=local_path, local_path=local_path,
@ -361,8 +403,25 @@ def run_actions(
progress=arguments['check'].progress, progress=arguments['check'].progress,
repair=arguments['check'].repair, repair=arguments['check'].repair,
only_checks=arguments['check'].only, only_checks=arguments['check'].only,
force=arguments['check'].force,
)
command.execute_hook(
hooks.get('after_check'),
hooks.get('umask'),
config_filename,
'post-check',
global_arguments.dry_run,
**hook_context,
) )
if 'extract' in arguments: if 'extract' in arguments:
command.execute_hook(
hooks.get('before_extract'),
hooks.get('umask'),
config_filename,
'pre-extract',
global_arguments.dry_run,
**hook_context,
)
if arguments['extract'].repository is None or validate.repositories_match( if arguments['extract'].repository is None or validate.repositories_match(
repository, arguments['extract'].repository repository, arguments['extract'].repository
): ):
@ -378,12 +437,21 @@ 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,
strip_components=arguments['extract'].strip_components, strip_components=arguments['extract'].strip_components,
progress=arguments['extract'].progress, progress=arguments['extract'].progress,
) )
command.execute_hook(
hooks.get('after_extract'),
hooks.get('umask'),
config_filename,
'post-extract',
global_arguments.dry_run,
**hook_context,
)
if 'export-tar' in arguments: if 'export-tar' in arguments:
if arguments['export-tar'].repository is None or validate.repositories_match( if arguments['export-tar'].repository is None or validate.repositories_match(
repository, arguments['export-tar'].repository repository, arguments['export-tar'].repository
@ -416,7 +484,7 @@ def run_actions(
logger.info( logger.info(
'{}: Mounting archive {}'.format(repository, arguments['mount'].archive) '{}: Mounting archive {}'.format(repository, arguments['mount'].archive)
) )
else: else: # pragma: nocover
logger.info('{}: Mounting repository'.format(repository)) logger.info('{}: Mounting repository'.format(repository))
borg_mount.mount_archive( borg_mount.mount_archive(
@ -432,7 +500,7 @@ def run_actions(
local_path=local_path, local_path=local_path,
remote_path=remote_path, remote_path=remote_path,
) )
if 'restore' in arguments: if 'restore' in arguments: # pragma: nocover
if arguments['restore'].repository is None or validate.repositories_match( if arguments['restore'].repository is None or validate.repositories_match(
repository, arguments['restore'].repository repository, arguments['restore'].repository
): ):
@ -486,6 +554,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='/',
@ -530,7 +599,7 @@ def run_actions(
repository, arguments['list'].repository repository, arguments['list'].repository
): ):
list_arguments = copy.copy(arguments['list']) list_arguments = copy.copy(arguments['list'])
if not list_arguments.json: if not list_arguments.json: # pragma: nocover
logger.warning('{}: Listing archives'.format(repository)) logger.warning('{}: Listing archives'.format(repository))
list_arguments.archive = borg_list.resolve_archive_name( list_arguments.archive = borg_list.resolve_archive_name(
repository, list_arguments.archive, storage, local_path, remote_path repository, list_arguments.archive, storage, local_path, remote_path
@ -542,14 +611,14 @@ def run_actions(
local_path=local_path, local_path=local_path,
remote_path=remote_path, remote_path=remote_path,
) )
if json_output: if json_output: # pragma: nocover
yield json.loads(json_output) yield json.loads(json_output)
if 'info' in arguments: if 'info' in arguments:
if arguments['info'].repository is None or validate.repositories_match( if arguments['info'].repository is None or validate.repositories_match(
repository, arguments['info'].repository repository, arguments['info'].repository
): ):
info_arguments = copy.copy(arguments['info']) info_arguments = copy.copy(arguments['info'])
if not info_arguments.json: if not info_arguments.json: # pragma: nocover
logger.warning('{}: Displaying summary info for archives'.format(repository)) logger.warning('{}: Displaying summary info for archives'.format(repository))
info_arguments.archive = borg_list.resolve_archive_name( info_arguments.archive = borg_list.resolve_archive_name(
repository, info_arguments.archive, storage, local_path, remote_path repository, info_arguments.archive, storage, local_path, remote_path
@ -561,7 +630,7 @@ def run_actions(
local_path=local_path, local_path=local_path,
remote_path=remote_path, remote_path=remote_path,
) )
if json_output: if json_output: # pragma: nocover
yield json.loads(json_output) yield json.loads(json_output)
if 'borg' in arguments: if 'borg' in arguments:
if arguments['borg'].repository is None or validate.repositories_match( if arguments['borg'].repository is None or validate.repositories_match(
@ -597,6 +666,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(
[ [
@ -629,28 +712,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.
@ -689,11 +783,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)
) )
@ -712,7 +806,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.
@ -722,7 +816,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
@ -744,7 +838,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))
@ -761,7 +855,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
@ -793,6 +887,9 @@ def main(): # pragma: no cover
if global_arguments.version: if global_arguments.version:
print(pkg_resources.require('borgmatic')[0].version) print(pkg_resources.require('borgmatic')[0].version)
sys.exit(0) sys.exit(0)
if global_arguments.bash_completion:
print(borgmatic.commands.completion.bash_completion())
sys.exit(0)
config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths)) config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
configs, parse_logs = load_configurations(config_filenames, global_arguments.overrides) configs, parse_logs = load_configurations(config_filenames, global_arguments.overrides)

View File

@ -0,0 +1,58 @@
from borgmatic.commands import arguments
UPGRADE_MESSAGE = '''
Your bash completions script is from a different version of borgmatic than is
currently installed. Please upgrade your script so your completions match the
command-line flags in your installed borgmatic! Try this to upgrade:
sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"
source $BASH_SOURCE
'''
def parser_flags(parser):
'''
Given an argparse.ArgumentParser instance, return its argument flags in a space-separated
string.
'''
return ' '.join(option for action in parser._actions for option in action.option_strings)
def bash_completion():
'''
Return a bash completion script for the borgmatic command. Produce this by introspecting
borgmatic's command-line argument parsers.
'''
top_level_parser, subparsers = arguments.make_parsers()
global_flags = parser_flags(top_level_parser)
actions = ' '.join(subparsers.choices.keys())
# Avert your eyes.
return '\n'.join(
(
'set -uo pipefail',
'check_version() {',
' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"',
' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"',
' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];'
' then cat << EOF\n%s\nEOF' % UPGRADE_MESSAGE,
' fi',
'}',
'complete_borgmatic() {',
)
+ tuple(
''' if [[ " ${COMP_WORDS[*]} " =~ " %s " ]]; then
COMPREPLY=($(compgen -W "%s %s %s" -- "${COMP_WORDS[COMP_CWORD]}"))
return 0
fi'''
% (action, parser_flags(subparser), actions, global_flags)
for action, subparser in subparsers.choices.items()
)
+ (
' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))'
% (actions, global_flags),
' (check_version &)',
'}',
'\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic',
)
)

View File

@ -23,10 +23,16 @@ def parse_arguments(*arguments):
'--destination', '--destination',
dest='destination_filename', dest='destination_filename',
default=DEFAULT_DESTINATION_CONFIG_FILENAME, default=DEFAULT_DESTINATION_CONFIG_FILENAME,
help='Destination YAML configuration file. Default: {}'.format( help='Destination YAML configuration file, default: {}'.format(
DEFAULT_DESTINATION_CONFIG_FILENAME DEFAULT_DESTINATION_CONFIG_FILENAME
), ),
) )
parser.add_argument(
'--overwrite',
default=False,
action='store_true',
help='Whether to overwrite any existing destination file, defaults to false',
)
return parser.parse_args(arguments) return parser.parse_args(arguments)
@ -36,7 +42,10 @@ def main(): # pragma: no cover
args = parse_arguments(*sys.argv[1:]) args = parse_arguments(*sys.argv[1:])
generate.generate_sample_configuration( generate.generate_sample_configuration(
args.source_filename, args.destination_filename, validate.schema_filename() args.source_filename,
args.destination_filename,
validate.schema_filename(),
overwrite=args.overwrite,
) )
print('Generated a sample configuration file at {}.'.format(args.destination_filename)) print('Generated a sample configuration file at {}.'.format(args.destination_filename))

View File

@ -5,7 +5,7 @@ import re
from ruamel import yaml from ruamel import yaml
from borgmatic.config import load from borgmatic.config import load, normalize
INDENT = 4 INDENT = 4
SEQUENCE_INDENT = 2 SEQUENCE_INDENT = 2
@ -109,13 +109,18 @@ def render_configuration(config):
return rendered.getvalue() return rendered.getvalue()
def write_configuration(config_filename, rendered_config, mode=0o600): def write_configuration(config_filename, rendered_config, mode=0o600, overwrite=False):
''' '''
Given a target config filename and rendered config YAML, write it out to file. Create any Given a target config filename and rendered config YAML, write it out to file. Create any
containing directories as needed. containing directories as needed. But if the file already exists and overwrite is False,
abort before writing anything.
''' '''
if os.path.exists(config_filename): if not overwrite and os.path.exists(config_filename):
raise FileExistsError('{} already exists. Aborting.'.format(config_filename)) raise FileExistsError(
'{} already exists. Aborting. Use --overwrite to replace the file.'.format(
config_filename
)
)
try: try:
os.makedirs(os.path.dirname(config_filename), mode=0o700) os.makedirs(os.path.dirname(config_filename), mode=0o700)
@ -263,18 +268,22 @@ def merge_source_configuration_into_destination(destination_config, source_confi
return destination_config return destination_config
def generate_sample_configuration(source_filename, destination_filename, schema_filename): def generate_sample_configuration(
source_filename, destination_filename, schema_filename, overwrite=False
):
''' '''
Given an optional source configuration filename, and a required destination configuration Given an optional source configuration filename, and a required destination configuration
filename, and the path to a schema filename in a YAML rendition of the JSON Schema format, filename, the path to a schema filename in a YAML rendition of the JSON Schema format, and
write out a sample configuration file based on that schema. If a source filename is provided, whether to overwrite a destination file, write out a sample configuration file based on that
merge the parsed contents of that configuration into the generated configuration. schema. If a source filename is provided, merge the parsed contents of that configuration into
the generated configuration.
''' '''
schema = yaml.round_trip_load(open(schema_filename)) schema = yaml.round_trip_load(open(schema_filename))
source_config = None source_config = None
if source_filename: if source_filename:
source_config = load.load_configuration(source_filename) source_config = load.load_configuration(source_filename)
normalize.normalize(source_config)
destination_config = merge_source_configuration_into_destination( destination_config = merge_source_configuration_into_destination(
_schema_to_sample_configuration(schema), source_config _schema_to_sample_configuration(schema), source_config
@ -283,4 +292,5 @@ def generate_sample_configuration(source_filename, destination_filename, schema_
write_configuration( write_configuration(
destination_filename, destination_filename,
_comment_out_optional_configuration(render_configuration(destination_config)), _comment_out_optional_configuration(render_configuration(destination_config)),
overwrite=overwrite,
) )

View File

@ -6,6 +6,19 @@ import ruamel.yaml
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Yaml_with_loader_stream(ruamel.yaml.YAML):
'''
A derived class of ruamel.yaml.YAML that simply tacks the loaded stream (file object) onto the
loader class so that it's available anywhere that's passed a loader (in this case,
include_configuration() below).
'''
def get_constructor_parser(self, stream):
constructor, parser = super(Yaml_with_loader_stream, self).get_constructor_parser(stream)
constructor.loader.stream = stream
return constructor, parser
def load_configuration(filename): def load_configuration(filename):
''' '''
Load the given configuration file and return its contents as a data structure of nested dicts Load the given configuration file and return its contents as a data structure of nested dicts
@ -14,7 +27,7 @@ def load_configuration(filename):
Raise ruamel.yaml.error.YAMLError if something goes wrong parsing the YAML, or RecursionError Raise ruamel.yaml.error.YAMLError if something goes wrong parsing the YAML, or RecursionError
if there are too many recursive includes. if there are too many recursive includes.
''' '''
yaml = ruamel.yaml.YAML(typ='safe') yaml = Yaml_with_loader_stream(typ='safe')
yaml.Constructor = Include_constructor yaml.Constructor = Include_constructor
return yaml.load(open(filename)) return yaml.load(open(filename))
@ -22,10 +35,146 @@ def load_configuration(filename):
def include_configuration(loader, filename_node): def include_configuration(loader, filename_node):
''' '''
Load the given YAML filename (ignoring the given loader so we can use our own), and return its Load the given YAML filename (ignoring the given loader so we can use our own) and return its
contents as a data structure of nested dicts and lists. contents as a data structure of nested dicts and lists. If the filename is relative, probe for
it within 1. the current working directory and 2. the directory containing the YAML file doing
the including.
Raise FileNotFoundError if an included file was not found.
''' '''
return load_configuration(os.path.expanduser(filename_node.value)) include_directories = [os.getcwd(), os.path.abspath(os.path.dirname(loader.stream.name))]
include_filename = os.path.expanduser(filename_node.value)
if not os.path.isabs(include_filename):
candidate_filenames = [
os.path.join(directory, include_filename) for directory in include_directories
]
for candidate_filename in candidate_filenames:
if os.path.exists(candidate_filename):
include_filename = candidate_filename
break
else:
raise FileNotFoundError(
f'Could not find include {filename_node.value} at {" or ".join(candidate_filenames)}'
)
return load_configuration(include_filename)
DELETED_NODE = object()
def deep_merge_nodes(nodes):
'''
Given a nested borgmatic configuration data structure as a list of tuples in the form of:
(
ruamel.yaml.nodes.ScalarNode as a key,
ruamel.yaml.nodes.MappingNode or other Node as a value,
),
... deep merge any node values corresponding to duplicate keys and return the result. If
there are colliding keys with non-MappingNode values (e.g., integers or strings), the last
of the values wins.
For instance, given node values of:
[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
MappingNode(tag='tag:yaml.org,2002:map', value=[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_hourly'),
ScalarNode(tag='tag:yaml.org,2002:int', value='24')
),
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
ScalarNode(tag='tag:yaml.org,2002:int', value='7')
),
]),
),
(
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
MappingNode(tag='tag:yaml.org,2002:map', value=[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
ScalarNode(tag='tag:yaml.org,2002:int', value='5')
),
]),
),
]
... the returned result would be:
[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
MappingNode(tag='tag:yaml.org,2002:map', value=[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_hourly'),
ScalarNode(tag='tag:yaml.org,2002:int', value='24')
),
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
ScalarNode(tag='tag:yaml.org,2002:int', value='5')
),
]),
),
]
The purpose of deep merging like this is to support, for instance, merging one borgmatic
configuration file into another for reuse, such that a configuration section ("retention",
etc.) does not completely replace the corresponding section in a merged file.
'''
# Map from original node key/value to the replacement merged node. DELETED_NODE as a replacement
# node indications deletion.
replaced_nodes = {}
# To find nodes that require merging, compare each node with each other node.
for a_key, a_value in nodes:
for b_key, b_value in nodes:
# If we've already considered one of the nodes for merging, skip it.
if (a_key, a_value) in replaced_nodes or (b_key, b_value) in replaced_nodes:
continue
# If the keys match and the values are different, we need to merge these two A and B nodes.
if a_key.tag == b_key.tag and a_key.value == b_key.value and a_value != b_value:
# Since we're merging into the B node, consider the A node a duplicate and remove it.
replaced_nodes[(a_key, a_value)] = DELETED_NODE
# If we're dealing with MappingNodes, recurse and merge its values as well.
if isinstance(b_value, ruamel.yaml.nodes.MappingNode):
replaced_nodes[(b_key, b_value)] = (
b_key,
ruamel.yaml.nodes.MappingNode(
tag=b_value.tag,
value=deep_merge_nodes(a_value.value + b_value.value),
start_mark=b_value.start_mark,
end_mark=b_value.end_mark,
flow_style=b_value.flow_style,
comment=b_value.comment,
anchor=b_value.anchor,
),
)
# If we're dealing with SequenceNodes, merge by appending one sequence to the other.
elif isinstance(b_value, ruamel.yaml.nodes.SequenceNode):
replaced_nodes[(b_key, b_value)] = (
b_key,
ruamel.yaml.nodes.SequenceNode(
tag=b_value.tag,
value=a_value.value + b_value.value,
start_mark=b_value.start_mark,
end_mark=b_value.end_mark,
flow_style=b_value.flow_style,
comment=b_value.comment,
anchor=b_value.anchor,
),
)
return [
replaced_nodes.get(node, node) for node in nodes if replaced_nodes.get(node) != DELETED_NODE
]
class Include_constructor(ruamel.yaml.SafeConstructor): class Include_constructor(ruamel.yaml.SafeConstructor):
@ -40,14 +189,19 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
def flatten_mapping(self, node): def flatten_mapping(self, node):
''' '''
Support the special case of shallow merging included configuration into an existing mapping Support the special case of deep merging included configuration into an existing mapping
using the YAML '<<' merge key. Example syntax: using the YAML '<<' merge key. Example syntax:
``` ```
retention: retention:
keep_daily: 1 keep_daily: 1
<<: !include common.yaml
<<: !include common.yaml
``` ```
These includes are deep merged into the current configuration file. For instance, in this
example, any "retention" options in common.yaml will get merged into the "retention" section
in the example configuration file.
''' '''
representer = ruamel.yaml.representer.SafeRepresenter() representer = ruamel.yaml.representer.SafeRepresenter()
@ -57,3 +211,5 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
node.value[index] = (key_node, included_value) node.value[index] = (key_node, included_value)
super(Include_constructor, self).flatten_mapping(node) super(Include_constructor, self).flatten_mapping(node)
node.value = deep_merge_nodes(node.value)

View File

@ -3,8 +3,29 @@ def normalize(config):
Given a configuration dict, apply particular hard-coded rules to normalize its contents to Given a configuration dict, apply particular hard-coded rules to normalize its contents to
adhere to the configuration schema. adhere to the configuration schema.
''' '''
# Upgrade exclude_if_present from a string to a list.
exclude_if_present = config.get('location', {}).get('exclude_if_present') exclude_if_present = config.get('location', {}).get('exclude_if_present')
# "Upgrade" exclude_if_present from a string to a list.
if isinstance(exclude_if_present, str): if isinstance(exclude_if_present, str):
config['location']['exclude_if_present'] = [exclude_if_present] config['location']['exclude_if_present'] = [exclude_if_present]
# Upgrade various monitoring hooks from a string to a dict.
healthchecks = config.get('hooks', {}).get('healthchecks')
if isinstance(healthchecks, str):
config['hooks']['healthchecks'] = {'ping_url': healthchecks}
cronitor = config.get('hooks', {}).get('cronitor')
if isinstance(cronitor, str):
config['hooks']['cronitor'] = {'ping_url': cronitor}
pagerduty = config.get('hooks', {}).get('pagerduty')
if isinstance(pagerduty, str):
config['hooks']['pagerduty'] = {'integration_key': pagerduty}
cronhub = config.get('hooks', {}).get('cronhub')
if isinstance(cronhub, str):
config['hooks']['cronhub'] = {'ping_url': cronhub}
# Upgrade consistency checks from a list of strings to a list of dicts.
checks = config.get('consistency', {}).get('checks')
if isinstance(checks, list) and len(checks) and isinstance(checks[0], str):
config['consistency']['checks'] = [{'name': check_type} for check_type in checks]

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))
@ -50,14 +52,20 @@ def parse_overrides(raw_overrides):
if not raw_overrides: if not raw_overrides:
return () return ()
try: parsed_overrides = []
return tuple(
(tuple(raw_keys.split('.')), convert_value_type(value)) for raw_override in raw_overrides:
for raw_override in raw_overrides try:
for raw_keys, value in (raw_override.split('=', 1),) raw_keys, value = raw_override.split('=', 1)
) parsed_overrides.append((tuple(raw_keys.split('.')), convert_value_type(value),))
except ValueError: except ValueError:
raise ValueError('Invalid override. Make sure you use the form: SECTION.OPTION=VALUE') raise ValueError(
f"Invalid override '{raw_override}'. Make sure you use the form: SECTION.OPTION=VALUE"
)
except ruamel.yaml.error.YAMLError as error:
raise ValueError(f"Invalid override '{raw_override}': {error.problem}")
return tuple(parsed_overrides)
def apply_overrides(config, raw_overrides): def apply_overrides(config, raw_overrides):

View File

@ -42,13 +42,21 @@ properties:
example: example:
- user@backupserver:sourcehostname.borg - user@backupserver:sourcehostname.borg
- "user@backupserver:{fqdn}" - "user@backupserver:{fqdn}"
working_directory:
type: string
description: |
Working directory for the "borg create" command. Tildes are
expanded. Useful for backing up using relative paths. See
http://borgbackup.readthedocs.io/en/stable/usage/create.html
for details. Defaults to not set.
example: /path/to/working/directory
one_file_system: one_file_system:
type: boolean type: boolean
description: | description: |
Stay in same file system (do not cross mount points). Stay in same file system: do not cross mount points beyond
Defaults to false. But when a database hook is used, the the given source directories. Defaults to false. But when a
setting here is ignored and one_file_system is considered database hook is used, the setting here is ignored and
true. one_file_system is considered true.
example: true example: true
numeric_owner: numeric_owner:
type: boolean type: boolean
@ -58,7 +66,9 @@ properties:
example: true example: true
atime: atime:
type: boolean type: boolean
description: Store atime into archive. Defaults to true. description: |
Store atime into archive. Defaults to true in Borg < 1.2,
false in Borg 1.2+.
example: false example: false
ctime: ctime:
type: boolean type: boolean
@ -109,10 +119,10 @@ properties:
type: string type: string
description: | description: |
Any paths matching these patterns are included/excluded from Any paths matching these patterns are included/excluded from
backups. Globs are expanded. (Tildes are not.) Note that backups. Globs are expanded. (Tildes are not.) See the
Borg considers this option experimental. See the output of output of "borg help patterns" for more details. Quote any
"borg help patterns" for more details. Quote any value if it value if it contains leading punctuation, so it parses
contains leading punctuation, so it parses correctly. correctly.
example: example:
- 'R /' - 'R /'
- '- /home/*/.cache' - '- /home/*/.cache'
@ -322,10 +332,10 @@ properties:
Name of the archive. Borg placeholders can be used. See the Name of the archive. Borg placeholders can be used. See the
output of "borg help placeholders" for details. Defaults to output of "borg help placeholders" for details. Defaults to
"{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}". If you specify this "{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}". If you specify this
option, you must also specify a prefix in the retention option, consider also specifying a prefix in the retention
section to avoid accidental pruning of archives with a and consistency sections to avoid accidental
different archive name format. And you should also specify a pruning/checking of archives with different archive name
prefix in the consistency section as well. formats.
example: "{hostname}-documents-{now}" example: "{hostname}-documents-{now}"
relocated_repo_access_is_ok: relocated_repo_access_is_ok:
type: boolean type: boolean
@ -346,23 +356,28 @@ properties:
init: init:
type: string type: string
description: | description: |
Extra command-line options to pass to "borg init". Extra command-line options to pass to "borg init".
example: "--make-parent-dirs" example: "--extra-option"
prune: prune:
type: string type: string
description: | description: |
Extra command-line options to pass to "borg prune". Extra command-line options to pass to "borg prune".
example: "--save-space" example: "--extra-option"
compact:
type: string
description: |
Extra command-line options to pass to "borg compact".
example: "--extra-option"
create: create:
type: string type: string
description: | description: |
Extra command-line options to pass to "borg create". Extra command-line options to pass to "borg create".
example: "--no-files-cache" example: "--extra-option"
check: check:
type: string type: string
description: | description: |
Extra command-line options to pass to "borg check". Extra command-line options to pass to "borg check".
example: "--save-space" example: "--extra-option"
description: | description: |
Additional options to pass directly to particular Borg Additional options to pass directly to particular Borg
commands, handy for Borg options that borgmatic does not yet commands, handy for Borg options that borgmatic does not yet
@ -432,26 +447,45 @@ properties:
checks: checks:
type: array type: array
items: items:
type: string type: object
enum: required: ['name']
- repository additionalProperties: false
- archives properties:
- data name:
- extract type: string
- disabled enum:
uniqueItems: true - repository
- archives
- data
- extract
- disabled
description: |
Name of consistency check to run: "repository",
"archives", "data", and/or "extract". Set to
"disabled" to disable all consistency checks.
"repository" checks the consistency of the
repository, "archives" checks all of the
archives, "data" verifies the integrity of the
data within the archives, and "extract" does an
extraction dry-run of the most recent archive.
Note that "data" implies "archives".
example: repository
frequency:
type: string
description: |
How frequently to run this type of consistency
check (as a best effort). The value is a number
followed by a unit of time. E.g., "2 weeks" to
run this consistency check no more than every
two weeks for a given repository or "1 month" to
run it no more than monthly. Defaults to
"always": running this check every time checks
are run.
example: 2 weeks
description: | description: |
List of one or more consistency checks to run: "repository", List of one or more consistency checks to run on a periodic
"archives", "data", and/or "extract". Defaults to basis (if "frequency" is set) or every time borgmatic runs
"repository" and "archives". Set to "disabled" to disable checks (if "frequency" is omitted).
all consistency checks. "repository" checks the consistency
of the repository, "archives" checks all of the archives,
"data" verifies the integrity of the data within the
archives, and "extract" does an extraction dry-run of the
most recent archive. Note that "data" implies "archives".
example:
- repository
- archives
check_repositories: check_repositories:
type: array type: array
items: items:
@ -522,6 +556,15 @@ properties:
before pruning, run once per configuration file. before pruning, run once per configuration file.
example: example:
- echo "Starting pruning." - echo "Starting pruning."
before_compact:
type: array
items:
type: string
description: |
List of one or more shell commands or scripts to execute
before compaction, run once per configuration file.
example:
- echo "Starting compaction."
before_check: before_check:
type: array type: array
items: items:
@ -549,6 +592,15 @@ properties:
after creating a backup, run once per configuration file. after creating a backup, run once per configuration file.
example: example:
- echo "Finished a backup." - echo "Finished a backup."
after_compact:
type: array
items:
type: string
description: |
List of one or more shell commands or scripts to execute
after compaction, run once per configuration file.
example:
- echo "Finished compaction."
after_prune: after_prune:
type: array type: array
items: items:
@ -582,10 +634,11 @@ properties:
type: string type: string
description: | description: |
List of one or more shell commands or scripts to execute List of one or more shell commands or scripts to execute
when an exception occurs during a "prune", "create", or when an exception occurs during a "prune", "compact",
"check" action or an associated before/after hook. "create", or "check" action or an associated before/after
hook.
example: example:
- echo "Error during prune/create/check." - echo "Error during prune/compact/create/check."
before_everything: before_everything:
type: array type: array
items: items:
@ -773,42 +826,174 @@ properties:
mysqldump/mysql commands (from either MySQL or MariaDB). See mysqldump/mysql commands (from either MySQL or MariaDB). See
https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html or https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html or
https://mariadb.com/kb/en/library/mysqldump/ for details. https://mariadb.com/kb/en/library/mysqldump/ for details.
mongodb_databases:
type: array
items:
type: object
required: ['name']
additionalProperties: false
properties:
name:
type: string
description: |
Database name (required if using this hook). Or
"all" to dump all databases on the host. Note
that using this database hook implicitly enables
both read_special and one_file_system (see
above) to support dump and restore streaming.
example: users
hostname:
type: string
description: |
Database hostname to connect to. Defaults to
connecting to localhost.
example: database.example.org
port:
type: integer
description: Port to connect to. Defaults to 27017.
example: 27018
username:
type: string
description: |
Username with which to connect to the database.
Skip it if no authentication is needed.
example: dbuser
password:
type: string
description: |
Password with which to connect to the database.
Skip it if no authentication is needed.
example: trustsome1
authentication_database:
type: string
description: |
Authentication database where the specified
username exists. If no authentication database
is specified, the database provided in "name"
is used. If "name" is "all", the "admin"
database is used.
example: admin
format:
type: string
enum: ['archive', 'directory']
description: |
Database dump output format. One of "archive",
or "directory". Defaults to "archive". See
mongodump documentation for details. Note that
format is ignored when the database name is
"all".
example: directory
options:
type: string
description: |
Additional mongodump options to pass
directly to the dump command, without performing
any validation on them. See mongodump
documentation for details.
example: --role=someone
description: |
List of one or more MongoDB databases to dump before
creating a backup, run once per configuration file. The
database dumps are added to your source directories at
runtime, backed up, and removed afterwards. Requires
mongodump/mongorestore commands. See
https://docs.mongodb.com/database-tools/mongodump/ and
https://docs.mongodb.com/database-tools/mongorestore/ for
details.
healthchecks: healthchecks:
type: string type: object
required: ['ping_url']
additionalProperties: false
properties:
ping_url:
type: string
description: |
Healthchecks ping URL or UUID to notify when a
backup begins, ends, or errors.
example: https://hc-ping.com/your-uuid-here
send_logs:
type: boolean
description: |
Send borgmatic logs to Healthchecks as part the
"finish" state. Defaults to true.
example: false
ping_body_limit:
type: integer
description: |
Number of bytes of borgmatic logs to send to
Healthchecks, ideally the same as PING_BODY_LIMIT
configured on the Healthchecks server. Set to 0 to
send all logs and disable this truncation. Defaults
to 100000.
example: 200000
states:
type: array
items:
type: string
enum:
- start
- finish
- fail
uniqueItems: true
description: |
List of one or more monitoring states to ping for:
"start", "finish", and/or "fail". Defaults to
pinging for all states.
example:
- finish
description: | description: |
Healthchecks ping URL or UUID to notify when a backup Configuration for a monitoring integration with
begins, ends, or errors. Create an account at Healthchecks. Create an account at https://healthchecks.io
https://healthchecks.io if you'd like to use this service. (or self-host Healthchecks) if you'd like to use this
See borgmatic monitoring documentation for details.
example:
https://hc-ping.com/your-uuid-here
cronitor:
type: string
description: |
Cronitor ping URL to notify when a backup begins, ends, or
errors. Create an account at https://cronitor.io if you'd
like to use this service. See borgmatic monitoring
documentation for details.
example:
https://cronitor.link/d3x0c1
pagerduty:
type: string
description: |
PagerDuty integration key used to notify PagerDuty when a
backup errors. Create an account at
https://www.pagerduty.com/ if you'd like to use this
service. See borgmatic monitoring documentation for details. service. See borgmatic monitoring documentation for details.
example: cronitor:
a177cad45bd374409f78906a810a3074 type: object
cronhub: required: ['ping_url']
type: string additionalProperties: false
properties:
ping_url:
type: string
description: |
Cronitor ping URL to notify when a backup begins,
ends, or errors.
example: https://cronitor.link/d3x0c1
description: | description: |
Cronhub ping URL to notify when a backup begins, ends, or Configuration for a monitoring integration with Cronitor.
errors. Create an account at https://cronhub.io if you'd Create an account at https://cronitor.io if you'd
like to use this service. See borgmatic monitoring like to use this service. See borgmatic monitoring
documentation for details. documentation for details.
example: pagerduty:
https://cronhub.io/start/1f5e3410-254c-11e8-b61d-55875966d01 type: object
required: ['integration_key']
additionalProperties: false
properties:
integration_key:
type: string
description: |
PagerDuty integration key used to notify PagerDuty
when a backup errors.
example: a177cad45bd374409f78906a810a3074
description: |
Configuration for a monitoring integration with PagerDuty.
Create an account at https://www.pagerduty.com/ if you'd
like to use this service. See borgmatic monitoring
documentation for details.
cronhub:
type: object
required: ['ping_url']
additionalProperties: false
properties:
ping_url:
type: string
description: |
Cronhub ping URL to notify when a backup begins,
ends, or errors.
example: https://cronhub.io/ping/1f5e3410-254c-5587
description: |
Configuration for a monitoring integration with Crunhub.
Create an account at https://cronhub.io if you'd like to
use this service. See borgmatic monitoring documentation
for details.
umask: umask:
type: integer type: integer
description: | description: |

View File

@ -15,7 +15,7 @@ def schema_filename():
return pkg_resources.resource_filename('borgmatic', 'config/schema.yaml') return pkg_resources.resource_filename('borgmatic', 'config/schema.yaml')
def format_error_path_element(path_element): def format_json_error_path_element(path_element):
''' '''
Given a path element into a JSON data structure, format it for display as a string. Given a path element into a JSON data structure, format it for display as a string.
''' '''
@ -25,14 +25,14 @@ def format_error_path_element(path_element):
return str('.{}'.format(path_element)) return str('.{}'.format(path_element))
def format_error(error): def format_json_error(error):
''' '''
Given an instance of jsonschema.exceptions.ValidationError, format it for display as a string. Given an instance of jsonschema.exceptions.ValidationError, format it for display as a string.
''' '''
if not error.path: if not error.path:
return 'At the top level: {}'.format(error.message) return 'At the top level: {}'.format(error.message)
formatted_path = ''.join(format_error_path_element(element) for element in error.path) formatted_path = ''.join(format_json_error_path_element(element) for element in error.path)
return "At '{}': {}".format(formatted_path.lstrip('.'), error.message) return "At '{}': {}".format(formatted_path.lstrip('.'), error.message)
@ -44,8 +44,8 @@ class Validation_error(ValueError):
def __init__(self, config_filename, errors): def __init__(self, config_filename, errors):
''' '''
Given a configuration filename path and a sequence of Given a configuration filename path and a sequence of string error messages, create a
jsonschema.exceptions.ValidationError instances, create a Validation_error. Validation_error.
''' '''
self.config_filename = config_filename self.config_filename = config_filename
self.errors = errors self.errors = errors
@ -56,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(format_error(error) for error in self.errors) ) + '\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,15 +65,6 @@ def apply_logical_validation(config_filename, parsed_configuration):
below), run through any additional logical validation checks. If there are any such validation below), run through any additional logical validation checks. If there are any such validation
problems, raise a Validation_error. problems, raise a Validation_error.
''' '''
archive_name_format = parsed_configuration.get('storage', {}).get('archive_name_format')
prefix = parsed_configuration.get('retention', {}).get('prefix')
if archive_name_format and not prefix:
raise Validation_error(
config_filename,
('If you provide an archive_name_format, you must also specify a retention prefix.',),
)
location_repositories = parsed_configuration.get('location', {}).get('repositories') location_repositories = parsed_configuration.get('location', {}).get('repositories')
check_repositories = parsed_configuration.get('consistency', {}).get('check_repositories', []) check_repositories = parsed_configuration.get('consistency', {}).get('check_repositories', [])
for repository in check_repositories: for repository in check_repositories:
@ -117,7 +108,9 @@ def parse_configuration(config_filename, schema_filename, overrides=None):
validation_errors = tuple(validator.iter_errors(config)) validation_errors = tuple(validator.iter_errors(config))
if validation_errors: if validation_errors:
raise Validation_error(config_filename, validation_errors) raise Validation_error(
config_filename, tuple(format_json_error(error) for error in validation_errors)
)
apply_logical_validation(config_filename, config) apply_logical_validation(config_filename, config)

View File

@ -1,5 +1,6 @@
import logging import logging
import os import os
import re
from borgmatic import execute from borgmatic import execute
@ -9,14 +10,19 @@ logger = logging.getLogger(__name__)
SOFT_FAIL_EXIT_CODE = 75 SOFT_FAIL_EXIT_CODE = 75
def interpolate_context(command, context): def interpolate_context(config_filename, hook_description, command, context):
''' '''
Given a single hook command and a dict of context names/values, interpolate the values by Given a config filename, a hook description, a single hook command, and a dict of context
"{name}" into the command and return the result. names/values, interpolate the values by "{name}" into the command and return the result.
''' '''
for name, value in context.items(): for name, value in context.items():
command = command.replace('{%s}' % name, str(value)) command = command.replace('{%s}' % name, str(value))
for unsupported_variable in re.findall(r'{\w+}', command):
logger.warning(
f"{config_filename}: Variable '{unsupported_variable}' is not supported in {hook_description} hook"
)
return command return command
@ -26,8 +32,7 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
a hook description, and whether this is a dry run, run the given commands. Or, don't run them a hook description, and whether this is a dry run, run the given commands. Or, don't run them
if this is a dry run. if this is a dry run.
The context contains optional values interpolated by name into the hook commands. Currently, The context contains optional values interpolated by name into the hook commands.
this only applies to the on_error hook.
Raise ValueError if the umask cannot be parsed. Raise ValueError if the umask cannot be parsed.
Raise subprocesses.CalledProcessError if an error occurs in a hook. Raise subprocesses.CalledProcessError if an error occurs in a hook.
@ -39,7 +44,9 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
dry_run_label = ' (dry run; not actually running hooks)' if dry_run else '' dry_run_label = ' (dry run; not actually running hooks)' if dry_run else ''
context['configuration_filename'] = config_filename context['configuration_filename'] = config_filename
commands = [interpolate_context(command, context) for command in commands] commands = [
interpolate_context(config_filename, description, command, context) for command in commands
]
if len(commands) == 1: if len(commands) == 1:
logger.info( logger.info(

View File

@ -22,14 +22,18 @@ def initialize_monitor(
pass pass
def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run): def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
''' '''
Ping the given Cronhub URL, modified with the monitor.State. Use the given configuration Ping the configured Cronhub URL, modified with the monitor.State. Use the given configuration
filename in any log entries. If this is a dry run, then don't actually ping anything. filename in any log entries. If this is a dry run, then don't actually ping anything.
''' '''
dry_run_label = ' (dry run; not actually pinging)' if dry_run else '' dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
formatted_state = '/{}/'.format(MONITOR_STATE_TO_CRONHUB[state]) formatted_state = '/{}/'.format(MONITOR_STATE_TO_CRONHUB[state])
ping_url = ping_url.replace('/start/', formatted_state).replace('/ping/', formatted_state) ping_url = (
hook_config['ping_url']
.replace('/start/', formatted_state)
.replace('/ping/', formatted_state)
)
logger.info( logger.info(
'{}: Pinging Cronhub {}{}'.format(config_filename, state.name.lower(), dry_run_label) '{}: Pinging Cronhub {}{}'.format(config_filename, state.name.lower(), dry_run_label)
@ -38,7 +42,10 @@ def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run
if not dry_run: if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR) logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.get(ping_url) try:
requests.get(ping_url)
except requests.exceptions.RequestException as error:
logger.warning(f'{config_filename}: Cronhub error: {error}')
def destroy_monitor( def destroy_monitor(

View File

@ -22,13 +22,13 @@ def initialize_monitor(
pass pass
def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run): def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
''' '''
Ping the given Cronitor URL, modified with the monitor.State. Use the given configuration Ping the configured Cronitor URL, modified with the monitor.State. Use the given configuration
filename in any log entries. If this is a dry run, then don't actually ping anything. filename in any log entries. If this is a dry run, then don't actually ping anything.
''' '''
dry_run_label = ' (dry run; not actually pinging)' if dry_run else '' dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
ping_url = '{}/{}'.format(ping_url, MONITOR_STATE_TO_CRONITOR[state]) ping_url = '{}/{}'.format(hook_config['ping_url'], MONITOR_STATE_TO_CRONITOR[state])
logger.info( logger.info(
'{}: Pinging Cronitor {}{}'.format(config_filename, state.name.lower(), dry_run_label) '{}: Pinging Cronitor {}{}'.format(config_filename, state.name.lower(), dry_run_label)
@ -37,7 +37,10 @@ def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run
if not dry_run: if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR) logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.get(ping_url) try:
requests.get(ping_url)
except requests.exceptions.RequestException as error:
logger.warning(f'{config_filename}: Cronitor error: {error}')
def destroy_monitor( def destroy_monitor(

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

@ -2,11 +2,11 @@ import logging
import os import os
import shutil import shutil
from borgmatic.borg.create import DEFAULT_BORGMATIC_SOURCE_DIRECTORY from borgmatic.borg.state 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,13 +13,14 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
} }
PAYLOAD_TRUNCATION_INDICATOR = '...\n' PAYLOAD_TRUNCATION_INDICATOR = '...\n'
PAYLOAD_LIMIT_BYTES = 10 * 1024 - len(PAYLOAD_TRUNCATION_INDICATOR) DEFAULT_PING_BODY_LIMIT_BYTES = 100000
class Forgetful_buffering_handler(logging.Handler): class Forgetful_buffering_handler(logging.Handler):
''' '''
A buffering log handler that stores log messages in memory, and throws away messages (oldest A buffering log handler that stores log messages in memory, and throws away messages (oldest
first) once a particular capacity in bytes is reached. first) once a particular capacity in bytes is reached. But if the given byte capacity is zero,
don't throw away any messages.
''' '''
def __init__(self, byte_capacity, log_level): def __init__(self, byte_capacity, log_level):
@ -36,6 +37,9 @@ class Forgetful_buffering_handler(logging.Handler):
self.byte_count += len(message) self.byte_count += len(message)
self.buffer.append(message) self.buffer.append(message)
if not self.byte_capacity:
return
while self.byte_count > self.byte_capacity and self.buffer: while self.byte_count > self.byte_capacity and self.buffer:
self.byte_count -= len(self.buffer[0]) self.byte_count -= len(self.buffer[0])
self.buffer.pop(0) self.buffer.pop(0)
@ -65,31 +69,45 @@ def format_buffered_logs_for_payload():
return payload return payload
def initialize_monitor( def initialize_monitor(hook_config, config_filename, monitoring_log_level, dry_run):
ping_url_or_uuid, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
''' '''
Add a handler to the root logger that stores in memory the most recent logs emitted. That Add a handler to the root logger that stores in memory the most recent logs emitted. That way,
way, we can send them all to Healthchecks upon a finish or failure state. we can send them all to Healthchecks upon a finish or failure state. But skip this if the
"send_logs" option is false.
''' '''
if hook_config.get('send_logs') is False:
return
ping_body_limit = max(
hook_config.get('ping_body_limit', DEFAULT_PING_BODY_LIMIT_BYTES)
- len(PAYLOAD_TRUNCATION_INDICATOR),
0,
)
logging.getLogger().addHandler( logging.getLogger().addHandler(
Forgetful_buffering_handler(PAYLOAD_LIMIT_BYTES, monitoring_log_level) Forgetful_buffering_handler(ping_body_limit, monitoring_log_level)
) )
def ping_monitor(ping_url_or_uuid, config_filename, state, monitoring_log_level, dry_run): def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
''' '''
Ping the given Healthchecks URL or UUID, modified with the monitor.State. Use the given Ping the configured Healthchecks URL or UUID, modified with the monitor.State. Use the given
configuration filename in any log entries, and log to Healthchecks with the giving log level. configuration filename in any log entries, and log to Healthchecks with the giving log level.
If this is a dry run, then don't actually ping anything. If this is a dry run, then don't actually ping anything.
''' '''
ping_url = ( ping_url = (
ping_url_or_uuid hook_config['ping_url']
if ping_url_or_uuid.startswith('http') if hook_config['ping_url'].startswith('http')
else 'https://hc-ping.com/{}'.format(ping_url_or_uuid) else 'https://hc-ping.com/{}'.format(hook_config['ping_url'])
) )
dry_run_label = ' (dry run; not actually pinging)' if dry_run else '' dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
if 'states' in hook_config and state.name.lower() not in hook_config['states']:
logger.info(
f'{config_filename}: Skipping Healthchecks {state.name.lower()} ping due to configured states'
)
return
healthchecks_state = MONITOR_STATE_TO_HEALTHCHECKS.get(state) healthchecks_state = MONITOR_STATE_TO_HEALTHCHECKS.get(state)
if healthchecks_state: if healthchecks_state:
ping_url = '{}/{}'.format(ping_url, healthchecks_state) ping_url = '{}/{}'.format(ping_url, healthchecks_state)
@ -106,10 +124,13 @@ def ping_monitor(ping_url_or_uuid, config_filename, state, monitoring_log_level,
if not dry_run: if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR) logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.post(ping_url, data=payload.encode('utf-8')) try:
requests.post(ping_url, data=payload.encode('utf-8'))
except requests.exceptions.RequestException as error:
logger.warning(f'{config_filename}: Healthchecks error: {error}')
def destroy_monitor(ping_url_or_uuid, config_filename, monitoring_log_level, dry_run): def destroy_monitor(hook_config, config_filename, monitoring_log_level, dry_run):
''' '''
Remove the monitor handler that was added to the root logger. This prevents the handler from Remove the monitor handler that was added to the root logger. This prevents the handler from
getting reused by other instances of this monitor. getting reused by other instances of this monitor.

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

@ -82,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
@ -152,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

@ -21,10 +21,10 @@ def initialize_monitor(
pass pass
def ping_monitor(integration_key, config_filename, state, monitoring_log_level, dry_run): def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
''' '''
If this is an error state, create a PagerDuty event with the given integration key. Use the If this is an error state, create a PagerDuty event with the configured integration key. Use
given configuration filename in any log entries. If this is a dry run, then don't actually the given configuration filename in any log entries. If this is a dry run, then don't actually
create an event. create an event.
''' '''
if state != monitor.State.FAIL: if state != monitor.State.FAIL:
@ -47,7 +47,7 @@ def ping_monitor(integration_key, config_filename, state, monitoring_log_level,
) )
payload = json.dumps( payload = json.dumps(
{ {
'routing_key': integration_key, 'routing_key': hook_config['integration_key'],
'event_action': 'trigger', 'event_action': 'trigger',
'payload': { 'payload': {
'summary': 'backup failed on {}'.format(hostname), 'summary': 'backup failed on {}'.format(hostname),
@ -68,7 +68,10 @@ def ping_monitor(integration_key, config_filename, state, monitoring_log_level,
logger.debug('{}: Using PagerDuty payload: {}'.format(config_filename, payload)) logger.debug('{}: Using PagerDuty payload: {}'.format(config_filename, payload))
logging.getLogger('urllib3').setLevel(logging.ERROR) logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.post(EVENTS_API_URL, data=payload.encode('utf-8')) try:
requests.post(EVENTS_API_URL, data=payload.encode('utf-8'))
except requests.exceptions.RequestException as error:
logger.warning(f'{config_filename}: PagerDuty error: {error}')
def destroy_monitor( def destroy_monitor(

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 pip install --no-cache ruamel.yaml.clib==0.2.2 /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml RUN apk add --no-cache py3-ruamel.yaml py3-ruamel.yaml.clib
RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
RUN borgmatic --help > /command-line.txt \ 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

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

@ -1,17 +1,18 @@
--- ---
title: How to add preparation and cleanup steps to backups title: How to add preparation and cleanup steps to backups
eleventyNavigation: eleventyNavigation:
key: Add preparation and cleanup steps key: 🧹 Add preparation and cleanup steps
parent: How-to guides parent: How-to guides
order: 8 order: 8
--- ---
## Preparation and cleanup hooks ## Preparation and cleanup hooks
If you find yourself performing prepraration tasks before your backup runs, or If you find yourself performing preparation tasks before your backup runs, or
cleanup work afterwards, borgmatic hooks may be of interest. Hooks are shell cleanup work afterwards, borgmatic hooks may be of interest. Hooks are shell
commands that borgmatic executes for you at various points, and they're commands that borgmatic executes for you at various points as it runs, and
configured in the `hooks` section of your configuration file. But if you're they're configured in the `hooks` section of your configuration file. But if
looking to backup a database, it's probably easier to use the [database backup you're looking to backup a database, it's probably easier to use the [database
backup
feature](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/) feature](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/)
instead. instead.
@ -27,15 +28,40 @@ hooks:
- umount /some/filesystem - umount /some/filesystem
``` ```
The `before_backup` and `after_backup` hooks each run once per configuration The `before_backup` and `after_backup` hooks each run once per repository in a
file. `before_backup` hooks run prior to backups of all repositories in a configuration file. `before_backup` hooks runs right before the `create`
configuration file, right before the `create` action. `after_backup` hooks run action for a particular repository, and `after_backup` hooks run afterwards,
afterwards, but not if an error occurs in a previous hook or in the backups but not if an error occurs in a previous hook or in the backups themselves.
themselves. (Prior to borgmatic 1.6.0, these hooks instead ran once per configuration file
rather than once per repository.)
There are additional hooks for the `prune` and `check` actions as well. 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 for a repository, while
`before_check` and `after_check` run if there are any `check` actions. `after_prune` runs after it.
## Variable interpolation
The before and after action hooks support interpolating particular runtime
variables into the hook command. Here's an example that assumes you provide a
separate shell script:
```yaml
hooks:
after_prune:
- record-prune.sh "{configuration_filename}" "{repository}"
```
In this example, when the hook is triggered, borgmatic interpolates runtime
values into the hook command: the borgmatic configuration filename and the
paths of the current Borg repository. Here's the full set of supported
variables you can use here:
* `configuration_filename`: borgmatic configuration filename in which the
hook was defined
* `repository`: path of the current repository as configured in the current
borgmatic configuration file
## Global hooks
You can also use `before_everything` and `after_everything` hooks to perform You can also use `before_everything` and `after_everything` hooks to perform
global setup or cleanup: global setup or cleanup:
@ -58,6 +84,8 @@ but only if there is a `create` action. It runs even if an error occurs during
a backup or a backup hook, but not if an error occurs during a 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

@ -1,7 +1,7 @@
--- ---
title: How to backup to a removable drive or an intermittent server title: How to backup to a removable drive or an intermittent server
eleventyNavigation: eleventyNavigation:
key: Backup to a removable drive or server key: 💾 Backup to a removable drive/server
parent: How-to guides parent: How-to guides
order: 9 order: 9
--- ---
@ -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

@ -1,7 +1,7 @@
--- ---
title: How to backup your databases title: How to backup your databases
eleventyNavigation: eleventyNavigation:
key: Backup your databases key: 🗄️ Backup your databases
parent: How-to guides parent: How-to guides
order: 7 order: 7
--- ---
@ -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,16 @@ 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. Additionally, prior to borgmatic 1.5.3, all database dumps consumed
temporary disk space.)
To support this, borgmatic creates temporary named pipes in `~/.borgmatic` by 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 +64,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 +82,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 +112,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,19 +200,26 @@ 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
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.
found within the extracted `~/.borgmatic/` path (e.g. with `pg_restore` or
`mysql` commands). borgmatic extracts the dump file into the *`username`*`/.borgmatic/` directory
within the extraction destination path, where *`username`* is the user that
created the backup. For example, if you created the backup with the `root`
user and you're extracting to `/tmp`, then the dump will be in
`/tmp/root/.borgmatic`.
After extraction, you can manually restore the dump file using native database
commands like `pg_restore`, `mysql`, `mongorestore` or similar.
## Preparation and cleanup hooks ## Preparation and cleanup hooks
@ -230,5 +252,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

@ -1,7 +1,7 @@
--- ---
title: How to deal with very large backups title: How to deal with very large backups
eleventyNavigation: eleventyNavigation:
key: Deal with very large backups key: 📏 Deal with very large backups
parent: How-to guides parent: How-to guides
order: 3 order: 3
--- ---
@ -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
@ -48,7 +49,7 @@ consistency checks with `check` on a much less frequent basis (e.g. with
Another option is to customize your consistency checks. The default Another option is to customize your consistency checks. The default
consistency checks run both full-repository checks and per-archive checks consistency checks run both full-repository checks and per-archive checks
within each repository. within each repository no more than once a month.
But if you find that archive checks are too slow, for example, you can But if you find that archive checks are too slow, for example, you can
configure borgmatic to run repository checks only. Configure this in the configure borgmatic to run repository checks only. Configure this in the
@ -57,9 +58,11 @@ configure borgmatic to run repository checks only. Configure this in the
```yaml ```yaml
consistency: consistency:
checks: checks:
- repository - name: repository
``` ```
(Prior to borgmatic 1.6.2, `checks` was a plain list of strings without the `name:` part.)
Here are the available checks from fastest to slowest: Here are the available checks from fastest to slowest:
* `repository`: Checks the consistency of the repository itself. * `repository`: Checks the consistency of the repository itself.
@ -69,6 +72,36 @@ Here are the available checks from fastest to slowest:
See [Borg's check documentation](https://borgbackup.readthedocs.io/en/stable/usage/check.html) for more information. See [Borg's check documentation](https://borgbackup.readthedocs.io/en/stable/usage/check.html) for more information.
### Check frequency
As of borgmatic 1.6.2, you can optionally configure checks to run on a
periodic basis rather than every time borgmatic runs checks. For instance:
```yaml
consistency:
checks:
- name: repository
frequency: 2 weeks
```
This tells borgmatic to run this consistency check at most once every two
weeks for a given repository. The `frequency` value is a number followed by a
unit of time, e.g. "3 days", "1 week", "2 months", etc. The `frequency`
defaults to "always", which means run this check every time checks run.
Unlike a real scheduler like cron, borgmatic only makes a best effort to run
checks on the configured frequency. It compares that frequency with how long
it's been since the last check for a given repository (as recorded in a file
within `~/.borgmatic/checks`). If it hasn't been long enough, the check is
skipped. And you still have to run `borgmatic check` (or just `borgmatic`) in
order for checks to run, even when a `frequency` is configured!
If you want to temporarily ignore your configured frequencies, you can invoke
`borgmatic check --force` to run checks unconditionally.
### Disabling checks
If that's still too slow, you can disable consistency checks entirely, If that's still too slow, you can disable consistency checks entirely,
either for a single repository or for all repositories. either for a single repository or for all repositories.
@ -77,7 +110,7 @@ Disabling all consistency checks looks like this:
```yaml ```yaml
consistency: consistency:
checks: checks:
- disabled - name: disabled
``` ```
Or, if you have multiple repositories in your borgmatic configuration file, Or, if you have multiple repositories in your borgmatic configuration file,
@ -98,7 +131,8 @@ borgmatic check --only data --only extract
``` ```
This is useful for running slow consistency checks on an infrequent basis, This is useful for running slow consistency checks on an infrequent basis,
separate from your regular checks. separate from your regular checks. It is still subject to any configured
check frequencies unless the `--force` flag is used.
## Troubleshooting ## Troubleshooting

View File

@ -1,7 +1,7 @@
--- ---
title: How to develop on borgmatic title: How to develop on borgmatic
eleventyNavigation: eleventyNavigation:
key: Develop on borgmatic key: 🏗️ Develop on borgmatic
parent: How-to guides parent: How-to guides
order: 12 order: 12
--- ---

View File

@ -1,7 +1,7 @@
--- ---
title: How to extract a backup title: How to extract a backup
eleventyNavigation: eleventyNavigation:
key: Extract a backup key: 📤 Extract a backup
parent: How-to guides parent: How-to guides
order: 6 order: 6
--- ---
@ -116,7 +116,7 @@ Omit the `--archive` flag to mount all archives (lazy-loaded):
borgmatic mount --mount-point /mnt borgmatic mount --mount-point /mnt
``` ```
Or use the "latest" value for the archive to mount the latest successful archive: Or use the "latest" value for the archive to mount the latest archive:
```bash ```bash
borgmatic mount --archive latest --mount-point /mnt borgmatic mount --archive latest --mount-point /mnt

View File

@ -1,7 +1,7 @@
--- ---
title: How to inspect your backups title: How to inspect your backups
eleventyNavigation: eleventyNavigation:
key: Inspect your backups key: 🔎 Inspect your backups
parent: How-to guides parent: How-to guides
order: 4 order: 4
--- ---
@ -51,6 +51,31 @@ borgmatic info
`--info`. Or upgrade borgmatic!) `--info`. Or upgrade borgmatic!)
### Searching for a file
Let's say you've accidentally deleted a file and want to find the backup
archive(s) containing it. `borgmatic list` provides a `--find` flag for
exactly this purpose (as of borgmatic 1.6.3). For instance, if you're looking
for a `foo.txt`:
```bash
borgmatic list --find foo.txt
```
This will list your archives and indicate those with files matching
`*foo.txt*` anywhere in the archive. The `--find` parameter can alternatively
be a [Borg
pattern](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-patterns).
To limit the archives searched, use the standard `list` parameters for
filtering archives such as `--last`, `--archive`, `--glob-archives`, etc. For
example, to search only the last five archives:
```bash
borgmatic list --find foo.txt --last 5
```
## Logging ## Logging
By default, borgmatic logs to a local syslog-compatible daemon if one is By default, borgmatic logs to a local syslog-compatible daemon if one is

View File

@ -1,7 +1,7 @@
--- ---
title: How to make backups redundant title: How to make backups redundant
eleventyNavigation: eleventyNavigation:
key: Make backups redundant key: ☁️ Make backups redundant
parent: How-to guides parent: How-to guides
order: 2 order: 2
--- ---
@ -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

@ -1,7 +1,7 @@
--- ---
title: How to make per-application backups title: How to make per-application backups
eleventyNavigation: eleventyNavigation:
key: Make per-application backups key: 🔀 Make per-application backups
parent: How-to guides parent: How-to guides
order: 1 order: 1
--- ---
@ -32,10 +32,16 @@ perform any merging of configuration files by default. If you'd like borgmatic
to merge your configuration files, see below about configuration includes. to merge your configuration files, see below about configuration includes.
Additionally, the `~/.config/borgmatic.d/` directory works the same way as Additionally, the `~/.config/borgmatic.d/` directory works the same way as
`/etc/borgmatic.d`. If you need even more customizability, you can specify `/etc/borgmatic.d`.
alternate configuration paths on the command-line with borgmatic's `--config`
flag. See `borgmatic --help` for more information.
If you need even more customizability, you can specify alternate configuration
paths on the command-line with borgmatic's `--config` flag. (See `borgmatic
--help` for more information.) For instance, if you want to schedule your
various borgmatic backups to run at different times, you'll need multiple
entries in your [scheduling software of
choice](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#autopilot),
each entry using borgmatic's `--config` flag instead of relying on
`/etc/borgmatic.d`.
## Configuration includes ## Configuration includes
@ -69,6 +75,10 @@ themselves and complaining that they are not valid configuration files, you
should put them in a directory other than `/etc/borgmatic.d/`. (A subdirectory should put them in a directory other than `/etc/borgmatic.d/`. (A subdirectory
is fine.) is fine.)
When a configuration include is a relative path, borgmatic loads it from either
the current working directory or from the directory containing the file doing
the including.
Note that this form of include must be a YAML value rather than a key. For Note that this form of include must be a YAML value rather than a key. For
example, this will not work: example, this will not work:
@ -80,7 +90,7 @@ location:
!include /etc/borgmatic/common_retention.yaml !include /etc/borgmatic/common_retention.yaml
``` ```
But if you do want to merge in a YAML key and its values, keep reading! But if you do want to merge in a YAML key *and* its values, keep reading!
## Include merging ## Include merging
@ -89,35 +99,44 @@ If you need to get even fancier and pull in common configuration options while
potentially overriding individual options, you can perform a YAML merge of potentially overriding individual options, you can perform a YAML merge of
included configuration using the YAML `<<` key. For instance, here's an included configuration using the YAML `<<` key. For instance, here's an
example of a main configuration file that pulls in two retention options via example of a main configuration file that pulls in two retention options via
an include, and then overrides one of them locally: an include and then overrides one of them locally:
```yaml ```yaml
<<: !include /etc/borgmatic/common.yaml
location: location:
... ...
retention: retention:
keep_daily: 5 keep_daily: 5
<<: !include /etc/borgmatic/common_retention.yaml
``` ```
This is what `common_retention.yaml` might look like: This is what `common.yaml` might look like:
```yaml ```yaml
keep_hourly: 24 retention:
keep_daily: 7 keep_hourly: 24
keep_daily: 7
``` ```
Once this include gets merged in, the resulting configuration would have a Once this include gets merged in, the resulting configuration would have a
`keep_hourly` value of `24` and an overridden `keep_daily` value of `5`. `keep_hourly` value of `24` and an overridden `keep_daily` value of `5`.
When there is a collision of an option between the local file and the merged When there's an option collision between the local file and the merged
include, the local file's option takes precedent. And note that this is a include, the local file's option takes precedence. And as of borgmatic 1.6.0,
shallow merge rather than a deep merge, so the merging does not descend into this feature performs a deep merge, meaning that values are merged at all
nested values. levels in the two configuration files. Colliding list values are appended
together. This allows you to include common configuration—up to full borgmatic
configuration files—while overriding only the parts you want to customize.
Note that this `<<` include merging syntax is only for merging in mappings Note that this `<<` include merging syntax is only for merging in mappings
(keys/values). If you'd like to include other types like scalars or lists (configuration options and their values). But if you'd like to include a
directly, please see the section above about standard includes. single value directly, please see the section above about standard includes.
Additionally, there is a limitation preventing multiple `<<` include merges
per section. So for instance, that means you can do one `<<` merge at the
global level, another `<<` within each configuration section, etc. (This is a
YAML limitation.)
## Configuration overrides ## Configuration overrides
@ -162,7 +181,14 @@ borgmatic create --override location.repositories=[test1.borg,test2.borg]
Or even a single list element: Or even a single list element:
```bash ```bash
borgmatic create --override location.repositories=[/root/test1.borg] borgmatic create --override location.repositories=[/root/test.borg]
```
If your override value contains special YAML characters like colons, then
you'll need quotes for it to parse correctly:
```bash
borgmatic create --override location.repositories="['user@server:test.borg']"
``` ```
There is not currently a way to override a single element of a list without There is not currently a way to override a single element of a list without

View File

@ -1,7 +1,7 @@
--- ---
title: How to monitor your backups title: How to monitor your backups
eleventyNavigation: eleventyNavigation:
key: Monitor your backups key: 🚨 Monitor your backups
parent: How-to guides parent: How-to guides
order: 5 order: 5
--- ---
@ -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/),
@ -137,14 +136,15 @@ URL" for your project. Here's an example:
```yaml ```yaml
hooks: hooks:
healthchecks: https://hc-ping.com/addffa72-da17-40ae-be9c-ff591afb942a healthchecks:
ping_url: https://hc-ping.com/addffa72-da17-40ae-be9c-ff591afb942a
``` ```
With this hook in place, borgmatic pings your Healthchecks project when a 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,11 +155,14 @@ 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
may also be of use. See `borgmatic --help` for more information. may also be of use. See `borgmatic --help` for more information. Additionally,
see the [borgmatic configuration
file](https://torsion.org/borgmatic/docs/reference/configuration/) for
additional Healthchecks options.
You can configure Healthchecks to notify you by a [variety of You can configure Healthchecks to notify you by a [variety of
mechanisms](https://healthchecks.io/#welcome-integrations) when backups fail mechanisms](https://healthchecks.io/#welcome-integrations) when backups fail
@ -177,15 +180,16 @@ API URL" for your monitor. Here's an example:
```yaml ```yaml
hooks: hooks:
cronitor: https://cronitor.link/d3x0c1 cronitor:
ping_url: https://cronitor.link/d3x0c1
``` ```
With this hook in place, borgmatic pings your Cronitor monitor when a backup 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.
@ -205,15 +209,16 @@ URL" for your monitor. Here's an example:
```yaml ```yaml
hooks: hooks:
cronhub: https://cronhub.io/start/1f5e3410-254c-11e8-b61d-55875966d031 cronhub:
ping_url: https://cronhub.io/start/1f5e3410-254c-11e8-b61d-55875966d031
``` ```
With this hook in place, borgmatic pings your Cronhub monitor when a backup 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.
@ -247,14 +252,15 @@ Here's an example:
```yaml ```yaml
hooks: hooks:
pagerduty: a177cad45bd374409f78906a810a3074 pagerduty:
integration_key: a177cad45bd374409f78906a810a3074
``` ```
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
@ -280,35 +286,12 @@ output only shows up at the console, and not in syslog.
* [Borgmacator GNOME AppIndicator](https://github.com/N-Coder/borgmacator/) * [Borgmacator GNOME AppIndicator](https://github.com/N-Coder/borgmacator/)
### Successful backups
`borgmatic list` includes support for a `--successful` flag that only lists
successful (non-checkpoint) backups. This flag works via a basic heuristic: It
assumes that non-checkpoint archive names end with a digit (e.g. from a
timestamp), while checkpoint archive names do not. This means that if you're
using custom archive names that do not end in a digit, the `--successful` flag
will not work as expected.
Combined with a built-in Borg flag like `--last`, you can list the last
successful backup for use in your monitoring scripts. Here's an example
combined with `--json`:
```bash
borgmatic list --successful --last 1 --json
```
Note that this particular combination will only work if you've got a single
backup "series" in your repository. If you're instead backing up, say, from
multiple different hosts into a single repository, then you'll need to get
fancier with your archive listing. See `borg list --help` for more flags.
### Latest backups ### Latest backups
All borgmatic actions that accept an "--archive" flag allow you to specify an All borgmatic actions that accept an "--archive" flag allow you to specify an
archive name of "latest". This lets you get the latest successful archive archive name of "latest". This lets you get the latest archive without having
without having to first run "borgmatic list" manually, which can be handy in to first run "borgmatic list" manually, which can be handy in automated
automated scripts. Here's an example: scripts. Here's an example:
```bash ```bash
borgmatic info --archive latest borgmatic info --archive latest

View File

@ -1,7 +1,7 @@
--- ---
title: How to run arbitrary Borg commands title: How to run arbitrary Borg commands
eleventyNavigation: eleventyNavigation:
key: Run arbitrary Borg commands key: 🔧 Run arbitrary Borg commands
parent: How-to guides parent: How-to guides
order: 10 order: 10
--- ---

View File

@ -1,7 +1,7 @@
--- ---
title: How to set up backups title: How to set up backups
eleventyNavigation: eleventyNavigation:
key: Set up backups key: 📥 Set up backups
parent: How-to guides parent: How-to guides
order: 0 order: 0
--- ---
@ -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.6+, 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:
@ -51,6 +51,11 @@ sudo borgmatic --version
If borgmatic is properly installed, that should output your borgmatic version. If borgmatic is properly installed, that should output your borgmatic version.
As an alternative to adding the path to `~/.bashrc` file, if you're using sudo
to run borgmatic, you can configure [sudo's
`secure_path` option](https://man.archlinux.org/man/sudoers.5) to include
borgmatic's path.
### Global install option ### Global install option
@ -78,7 +83,7 @@ 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 Compose files) * [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 image with multi-arch and Docker CLI support](https://hub.docker.com/r/modem7/borgmatic-docker/)
* [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)
* [Fedora official](https://bodhi.fedoraproject.org/updates/?search=borgmatic) * [Fedora official](https://bodhi.fedoraproject.org/updates/?search=borgmatic)
@ -87,8 +92,8 @@ 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)
* [macOS (via Homebrew)](https://formulae.brew.sh/formula/borgmatic)
* [Ansible role](https://github.com/borgbase/ansible-role-borgbackup) * [Ansible role](https://github.com/borgbase/ansible-role-borgbackup)
* [stand-alone binary](https://github.com/cmarquardt/borgmatic-binary)
* [virtualenv](https://virtualenv.pypa.io/en/stable/) * [virtualenv](https://virtualenv.pypa.io/en/stable/)
@ -101,13 +106,13 @@ referral links, but without any tracking scripts or cookies.)
<ul> <ul>
<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, [rsync.net](https://www.rsync.net/products/borg.html) and Additionally, [rsync.net](https://www.rsync.net/products/borg.html) and
[Hetzner](https://www.hetzner.com/storage/storage-box) have compatible storage [Hetzner](https://www.hetzner.com/storage/storage-box) have compatible storage
offerings, but do not currently fund borgmatic development or hosting. offerings, but do not currently fund borgmatic development or hosting.
## Configuration ## Configuration
After you install borgmatic, generate a sample configuration file: After you install borgmatic, generate a sample configuration file:
@ -228,8 +233,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.
@ -299,9 +304,43 @@ interested in an [unofficial work-around for Full Disk
Access](https://projects.torsion.org/borgmatic-collective/borgmatic/issues/293). Access](https://projects.torsion.org/borgmatic-collective/borgmatic/issues/293).
## Colored output ## Niceties
Borgmatic produces colored terminal output by default. It is disabled when a
### Shell completion
borgmatic includes a shell completion script (currently only for Bash) to
support tab-completing borgmatic command-line actions and flags. Depending on
how you installed borgmatic, this may be enabled by default. But if it's not,
start by installing the `bash-completion` Linux package or the
[`bash-completion@2`](https://formulae.brew.sh/formula/bash-completion@2)
macOS Homebrew formula. Then, install the shell completion script globally:
```bash
sudo su -c "borgmatic --bash-completion > $(pkg-config --variable=completionsdir bash-completion)/borgmatic"
```
If you don't have `pkg-config` installed, you can try the following path
instead:
```bash
sudo su -c "borgmatic --bash-completion > /usr/share/bash-completion/completions/borgmatic"
```
Or, if you'd like to install the script for just the current user:
```bash
mkdir --parents ~/.local/share/bash-completion/completions
borgmatic --bash-completion > ~/.local/share/bash-completion/completions/borgmatic
```
Finally, restart your shell (`exit` and open a new shell) so the completions
take effect.
### Colored output
borgmatic produces colored terminal output by default. It is disabled when a
non-interactive terminal is detected (like a cron job), or when you use the non-interactive terminal is detected (like a cron job), or when you use the
`--json` flag. Otherwise, you can disable it by passing the `--no-color` flag, `--json` flag. Otherwise, you can disable it by passing the `--no-color` flag,
setting the environment variable `PY_COLORS=False`, or setting the `color` setting the environment variable `PY_COLORS=False`, or setting the `color`

View File

@ -1,7 +1,7 @@
--- ---
title: How to upgrade borgmatic title: How to upgrade borgmatic
eleventyNavigation: eleventyNavigation:
key: Upgrade borgmatic key: 📦 Upgrade borgmatic
parent: How-to guides parent: How-to guides
order: 11 order: 11
--- ---

View File

@ -1,7 +1,7 @@
--- ---
title: Command-line reference title: Command-line reference
eleventyNavigation: eleventyNavigation:
key: Command-line reference key: ⌨️ Command-line reference
parent: Reference guides parent: Reference guides
order: 1 order: 1
--- ---

View File

@ -1,7 +1,7 @@
--- ---
title: Configuration reference title: Configuration reference
eleventyNavigation: eleventyNavigation:
key: Configuration reference key: ⚙️ Configuration reference
parent: Reference guides parent: Reference guides
order: 0 order: 0
--- ---

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

@ -43,6 +43,7 @@ ProtectSystem=full
# ProtectHome=tmpfs # ProtectHome=tmpfs
# BindPaths=-/root/.cache/borg -/root/.cache/borg -/root/.borgmatic # 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.

View File

@ -4,6 +4,7 @@ Description=Run borgmatic backup
[Timer] [Timer]
OnCalendar=daily OnCalendar=daily
Persistent=true Persistent=true
RandomizedDelaySec=3h
[Install] [Install]
WantedBy=timers.target WantedBy=timers.target

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,8 +31,8 @@ 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')"

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 bash
# 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==21.3.1 setuptools==58.2.0 python3 -m pip install --no-cache --upgrade pip==22.0.3 setuptools==60.8.1
pip3 install tox==3.24.4 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.21' VERSION = '1.6.3.dev0'
setup( setup(
@ -30,11 +30,12 @@ setup(
}, },
obsoletes=['atticmatic'], obsoletes=['atticmatic'],
install_requires=( install_requires=(
'colorama>=0.4.1,<0.5',
'jsonschema', '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',
), ),
include_package_data=True, include_package_data=True,
python_requires='>=3.7',
) )

View File

@ -4,20 +4,20 @@ 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
flake8==3.8.4 flake8==4.0.1
flexmock==0.10.4 flexmock==0.10.4
isort==5.9.1 isort==5.9.1
mccabe==0.6.1 mccabe==0.6.1
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
jsonschema==3.2.0 jsonschema==3.2.0
pytest==6.1.2 pytest==6.2.5
pytest-cov==2.10.1 pytest-cov==3.0.0
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

@ -0,0 +1,5 @@
import subprocess
def test_bash_completion_runs_without_error():
subprocess.check_call('borgmatic --bash-completion | bash', shell=True)

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

@ -296,15 +296,6 @@ def test_parse_arguments_disallows_init_and_dry_run():
) )
def test_parse_arguments_disallows_glob_archives_with_successful():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(ValueError):
module.parse_arguments(
'--config', 'myconfig', 'list', '--glob-archives', '*glob*', '--successful'
)
def test_parse_arguments_disallows_repository_unless_action_consumes_it(): def test_parse_arguments_disallows_repository_unless_action_consumes_it():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])

View File

@ -0,0 +1,5 @@
from borgmatic.commands import completion as module
def test_bash_completion_does_not_raise():
assert module.bash_completion()

View File

@ -1,13 +1,25 @@
from borgmatic.commands import generate_config as module from borgmatic.commands import generate_config as module
def test_parse_arguments_with_no_arguments_uses_defaults(): def test_parse_arguments_with_no_arguments_uses_default_destination():
parser = module.parse_arguments() parser = module.parse_arguments()
assert parser.destination_filename == module.DEFAULT_DESTINATION_CONFIG_FILENAME assert parser.destination_filename == module.DEFAULT_DESTINATION_CONFIG_FILENAME
def test_parse_arguments_with_filename_argument_overrides_defaults(): def test_parse_arguments_with_destination_argument_overrides_default():
parser = module.parse_arguments('--destination', 'config.yaml') parser = module.parse_arguments('--destination', 'config.yaml')
assert parser.destination_filename == 'config.yaml' assert parser.destination_filename == 'config.yaml'
def test_parse_arguments_parses_source():
parser = module.parse_arguments('--source', 'source.yaml', '--destination', 'config.yaml')
assert parser.source_filename == 'source.yaml'
def test_parse_arguments_parses_overwrite():
parser = module.parse_arguments('--destination', 'config.yaml', '--overwrite')
assert parser.overwrite

View File

@ -87,7 +87,7 @@ location:
assert module._comment_out_optional_configuration(config.strip()) == expected_config.strip() assert module._comment_out_optional_configuration(config.strip()) == expected_config.strip()
def testrender_configuration_converts_configuration_to_yaml_string(): def test_render_configuration_converts_configuration_to_yaml_string():
yaml_string = module.render_configuration({'foo': 'bar'}) yaml_string = module.render_configuration({'foo': 'bar'})
assert yaml_string == 'foo: bar\n' assert yaml_string == 'foo: bar\n'
@ -110,6 +110,12 @@ def test_write_configuration_with_already_existing_file_raises():
module.write_configuration('config.yaml', 'config: yaml') module.write_configuration('config.yaml', 'config: yaml')
def test_write_configuration_with_already_existing_file_and_overwrite_does_not_raise():
flexmock(os.path).should_receive('exists').and_return(True)
module.write_configuration('config.yaml', 'config: yaml', overwrite=True)
def test_write_configuration_with_already_existing_directory_does_not_raise(): def test_write_configuration_with_already_existing_directory_does_not_raise():
flexmock(os.path).should_receive('exists').and_return(False) flexmock(os.path).should_receive('exists').and_return(False)
flexmock(os).should_receive('makedirs').and_raise(FileExistsError) flexmock(os).should_receive('makedirs').and_raise(FileExistsError)
@ -212,6 +218,7 @@ def test_generate_sample_configuration_with_source_filename_does_not_raise():
builtins.should_receive('open').with_args('schema.yaml').and_return('') builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.yaml).should_receive('round_trip_load') flexmock(module.yaml).should_receive('round_trip_load')
flexmock(module.load).should_receive('load_configuration') flexmock(module.load).should_receive('load_configuration')
flexmock(module.normalize).should_receive('normalize')
flexmock(module).should_receive('_schema_to_sample_configuration') flexmock(module).should_receive('_schema_to_sample_configuration')
flexmock(module).should_receive('merge_source_configuration_into_destination') flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('render_configuration') flexmock(module).should_receive('render_configuration')

View File

@ -1,3 +1,4 @@
import io
import sys import sys
import pytest import pytest
@ -14,49 +15,360 @@ def test_load_configuration_parses_contents():
assert module.load_configuration('config.yaml') == {'key': 'value'} assert module.load_configuration('config.yaml') == {'key': 'value'}
def test_load_configuration_inlines_include(): def test_load_configuration_inlines_include_relative_to_current_directory():
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('include.yaml').and_return('value') flexmock(module.os).should_receive('getcwd').and_return('/tmp')
builtins.should_receive('open').with_args('config.yaml').and_return( flexmock(module.os.path).should_receive('isabs').and_return(False)
'key: !include include.yaml' flexmock(module.os.path).should_receive('exists').and_return(True)
) include_file = io.StringIO('value')
include_file.name = 'include.yaml'
builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
config_file = io.StringIO('key: !include include.yaml')
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
assert module.load_configuration('config.yaml') == {'key': 'value'} assert module.load_configuration('config.yaml') == {'key': 'value'}
def test_load_configuration_inlines_include_relative_to_config_parent_directory():
builtins = flexmock(sys.modules['builtins'])
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
flexmock(module.os.path).should_receive('isabs').with_args('/etc').and_return(True)
flexmock(module.os.path).should_receive('isabs').with_args('/etc/config.yaml').and_return(True)
flexmock(module.os.path).should_receive('isabs').with_args('include.yaml').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/tmp/include.yaml').and_return(
False
)
flexmock(module.os.path).should_receive('exists').with_args('/etc/include.yaml').and_return(
True
)
include_file = io.StringIO('value')
include_file.name = 'include.yaml'
builtins.should_receive('open').with_args('/etc/include.yaml').and_return(include_file)
config_file = io.StringIO('key: !include include.yaml')
config_file.name = '/etc/config.yaml'
builtins.should_receive('open').with_args('/etc/config.yaml').and_return(config_file)
assert module.load_configuration('/etc/config.yaml') == {'key': 'value'}
def test_load_configuration_raises_if_relative_include_does_not_exist():
builtins = flexmock(sys.modules['builtins'])
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
flexmock(module.os.path).should_receive('isabs').with_args('/etc').and_return(True)
flexmock(module.os.path).should_receive('isabs').with_args('/etc/config.yaml').and_return(True)
flexmock(module.os.path).should_receive('isabs').with_args('include.yaml').and_return(False)
flexmock(module.os.path).should_receive('exists').and_return(False)
config_file = io.StringIO('key: !include include.yaml')
config_file.name = '/etc/config.yaml'
builtins.should_receive('open').with_args('/etc/config.yaml').and_return(config_file)
with pytest.raises(FileNotFoundError):
module.load_configuration('/etc/config.yaml')
def test_load_configuration_inlines_absolute_include():
builtins = flexmock(sys.modules['builtins'])
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
flexmock(module.os.path).should_receive('isabs').and_return(True)
flexmock(module.os.path).should_receive('exists').never()
include_file = io.StringIO('value')
include_file.name = '/root/include.yaml'
builtins.should_receive('open').with_args('/root/include.yaml').and_return(include_file)
config_file = io.StringIO('key: !include /root/include.yaml')
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
assert module.load_configuration('config.yaml') == {'key': 'value'}
def test_load_configuration_raises_if_absolute_include_does_not_exist():
builtins = flexmock(sys.modules['builtins'])
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
flexmock(module.os.path).should_receive('isabs').and_return(True)
builtins.should_receive('open').with_args('/root/include.yaml').and_raise(FileNotFoundError)
config_file = io.StringIO('key: !include /root/include.yaml')
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
with pytest.raises(FileNotFoundError):
assert module.load_configuration('config.yaml')
def test_load_configuration_merges_include(): def test_load_configuration_merges_include():
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('include.yaml').and_return( flexmock(module.os).should_receive('getcwd').and_return('/tmp')
flexmock(module.os.path).should_receive('isabs').and_return(False)
flexmock(module.os.path).should_receive('exists').and_return(True)
include_file = io.StringIO(
''' '''
foo: bar foo: bar
baz: quux baz: quux
''' '''
) )
builtins.should_receive('open').with_args('config.yaml').and_return( include_file.name = 'include.yaml'
builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
config_file = io.StringIO(
''' '''
foo: override foo: override
<<: !include include.yaml <<: !include include.yaml
''' '''
) )
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
assert module.load_configuration('config.yaml') == {'foo': 'override', 'baz': 'quux'} assert module.load_configuration('config.yaml') == {'foo': 'override', 'baz': 'quux'}
def test_load_configuration_does_not_merge_include_list(): def test_load_configuration_does_not_merge_include_list():
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('include.yaml').and_return( flexmock(module.os).should_receive('getcwd').and_return('/tmp')
flexmock(module.os.path).should_receive('isabs').and_return(False)
flexmock(module.os.path).should_receive('exists').and_return(True)
include_file = io.StringIO(
''' '''
- one - one
- two - two
''' '''
) )
builtins.should_receive('open').with_args('config.yaml').and_return( include_file.name = 'include.yaml'
builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
config_file = io.StringIO(
''' '''
foo: bar foo: bar
repositories: repositories:
<<: !include include.yaml <<: !include include.yaml
''' '''
) )
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
with pytest.raises(ruamel.yaml.error.YAMLError): with pytest.raises(ruamel.yaml.error.YAMLError):
assert module.load_configuration('config.yaml') assert module.load_configuration('config.yaml')
def test_deep_merge_nodes_replaces_colliding_scalar_values():
node_values = [
(
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='keep_hourly'
),
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='24'),
),
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='keep_daily'
),
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='7'),
),
],
),
),
(
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='keep_daily'
),
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='5'),
),
],
),
),
]
result = module.deep_merge_nodes(node_values)
assert len(result) == 1
(section_key, section_value) = result[0]
assert section_key.value == 'retention'
options = section_value.value
assert len(options) == 2
assert options[0][0].value == 'keep_hourly'
assert options[0][1].value == '24'
assert options[1][0].value == 'keep_daily'
assert options[1][1].value == '5'
def test_deep_merge_nodes_keeps_non_colliding_scalar_values():
node_values = [
(
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='keep_hourly'
),
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='24'),
),
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='keep_daily'
),
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='7'),
),
],
),
),
(
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='keep_minutely'
),
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='10'),
),
],
),
),
]
result = module.deep_merge_nodes(node_values)
assert len(result) == 1
(section_key, section_value) = result[0]
assert section_key.value == 'retention'
options = section_value.value
assert len(options) == 3
assert options[0][0].value == 'keep_hourly'
assert options[0][1].value == '24'
assert options[1][0].value == 'keep_daily'
assert options[1][1].value == '7'
assert options[2][0].value == 'keep_minutely'
assert options[2][1].value == '10'
def test_deep_merge_nodes_keeps_deeply_nested_values():
node_values = [
(
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='storage'),
ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='lock_wait'
),
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='5'),
),
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='extra_borg_options'
),
ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='init'
),
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='--init-option'
),
),
],
),
),
],
),
),
(
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='storage'),
ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='extra_borg_options'
),
ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='prune'
),
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='--prune-option'
),
),
],
),
),
],
),
),
]
result = module.deep_merge_nodes(node_values)
assert len(result) == 1
(section_key, section_value) = result[0]
assert section_key.value == 'storage'
options = section_value.value
assert len(options) == 2
assert options[0][0].value == 'lock_wait'
assert options[0][1].value == '5'
assert options[1][0].value == 'extra_borg_options'
nested_options = options[1][1].value
assert len(nested_options) == 2
assert nested_options[0][0].value == 'init'
assert nested_options[0][1].value == '--init-option'
assert nested_options[1][0].value == 'prune'
assert nested_options[1][1].value == '--prune-option'
def test_deep_merge_nodes_appends_colliding_sequence_values():
node_values = [
(
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'),
ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='before_backup'
),
ruamel.yaml.nodes.SequenceNode(
tag='tag:yaml.org,2002:int', value=['echo 1', 'echo 2']
),
),
],
),
),
(
ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='hooks'),
ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='before_backup'
),
ruamel.yaml.nodes.SequenceNode(
tag='tag:yaml.org,2002:int', value=['echo 3', 'echo 4']
),
),
],
),
),
]
result = module.deep_merge_nodes(node_values)
assert len(result) == 1
(section_key, section_value) = result[0]
assert section_key.value == 'hooks'
options = section_value.value
assert len(options) == 1
assert options[0][0].value == 'before_backup'
assert options[0][1].value == ['echo 1', 'echo 2', 'echo 3', 'echo 4']

View File

@ -21,14 +21,20 @@ def mock_config_and_schema(config_yaml, schema_yaml=None):
when parsing the configuration. when parsing the configuration.
''' '''
config_stream = io.StringIO(config_yaml) config_stream = io.StringIO(config_yaml)
config_stream.name = 'config.yaml'
if schema_yaml is None: if schema_yaml is None:
schema_stream = open(module.schema_filename()) schema_stream = open(module.schema_filename())
else: else:
schema_stream = io.StringIO(schema_yaml) schema_stream = io.StringIO(schema_yaml)
schema_stream.name = 'schema.yaml'
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('config.yaml').and_return(config_stream) flexmock(module.os).should_receive('getcwd').and_return('/tmp')
builtins.should_receive('open').with_args('schema.yaml').and_return(schema_stream) flexmock(module.os.path).should_receive('isabs').and_return(False)
flexmock(module.os.path).should_receive('exists').and_return(True)
builtins.should_receive('open').with_args('/tmp/config.yaml').and_return(config_stream)
builtins.should_receive('open').with_args('/tmp/schema.yaml').and_return(schema_stream)
def test_parse_configuration_transforms_file_into_mapping(): def test_parse_configuration_transforms_file_into_mapping():
@ -49,17 +55,17 @@ def test_parse_configuration_transforms_file_into_mapping():
consistency: consistency:
checks: checks:
- repository - name: repository
- archives - name: archives
''' '''
) )
result = module.parse_configuration('config.yaml', 'schema.yaml') result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
assert result == { assert result == {
'location': {'source_directories': ['/home', '/etc'], 'repositories': ['hostname.borg']}, 'location': {'source_directories': ['/home', '/etc'], 'repositories': ['hostname.borg']},
'retention': {'keep_daily': 7, 'keep_hourly': 24, 'keep_minutely': 60}, 'retention': {'keep_daily': 7, 'keep_hourly': 24, 'keep_minutely': 60},
'consistency': {'checks': ['repository', 'archives']}, 'consistency': {'checks': [{'name': 'repository'}, {'name': 'archives'}]},
} }
@ -79,7 +85,7 @@ def test_parse_configuration_passes_through_quoted_punctuation():
) )
) )
result = module.parse_configuration('config.yaml', 'schema.yaml') result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
assert result == { assert result == {
'location': { 'location': {
@ -115,7 +121,7 @@ def test_parse_configuration_with_schema_lacking_examples_does_not_raise():
''', ''',
) )
module.parse_configuration('config.yaml', 'schema.yaml') module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
def test_parse_configuration_inlines_include(): def test_parse_configuration_inlines_include():
@ -133,14 +139,16 @@ def test_parse_configuration_inlines_include():
''' '''
) )
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('include.yaml').and_return( include_file = io.StringIO(
''' '''
keep_daily: 7 keep_daily: 7
keep_hourly: 24 keep_hourly: 24
''' '''
) )
include_file.name = 'include.yaml'
builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
result = module.parse_configuration('config.yaml', 'schema.yaml') result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
assert result == { assert result == {
'location': {'source_directories': ['/home'], 'repositories': ['hostname.borg']}, 'location': {'source_directories': ['/home'], 'repositories': ['hostname.borg']},
@ -164,14 +172,16 @@ def test_parse_configuration_merges_include():
''' '''
) )
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('include.yaml').and_return( include_file = io.StringIO(
''' '''
keep_daily: 7 keep_daily: 7
keep_hourly: 24 keep_hourly: 24
''' '''
) )
include_file.name = 'include.yaml'
builtins.should_receive('open').with_args('/tmp/include.yaml').and_return(include_file)
result = module.parse_configuration('config.yaml', 'schema.yaml') result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
assert result == { assert result == {
'location': {'source_directories': ['/home'], 'repositories': ['hostname.borg']}, 'location': {'source_directories': ['/home'], 'repositories': ['hostname.borg']},
@ -181,23 +191,23 @@ def test_parse_configuration_merges_include():
def test_parse_configuration_raises_for_missing_config_file(): def test_parse_configuration_raises_for_missing_config_file():
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
module.parse_configuration('config.yaml', 'schema.yaml') module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
def test_parse_configuration_raises_for_missing_schema_file(): def test_parse_configuration_raises_for_missing_schema_file():
mock_config_and_schema('') mock_config_and_schema('')
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_raise(FileNotFoundError) builtins.should_receive('open').with_args('/tmp/schema.yaml').and_raise(FileNotFoundError)
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
module.parse_configuration('config.yaml', 'schema.yaml') module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
def test_parse_configuration_raises_for_syntax_error(): def test_parse_configuration_raises_for_syntax_error():
mock_config_and_schema('foo:\nbar') mock_config_and_schema('foo:\nbar')
with pytest.raises(ValueError): with pytest.raises(ValueError):
module.parse_configuration('config.yaml', 'schema.yaml') module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
def test_parse_configuration_raises_for_validation_error(): def test_parse_configuration_raises_for_validation_error():
@ -211,7 +221,7 @@ def test_parse_configuration_raises_for_validation_error():
) )
with pytest.raises(module.Validation_error): with pytest.raises(module.Validation_error):
module.parse_configuration('config.yaml', 'schema.yaml') module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
def test_parse_configuration_applies_overrides(): def test_parse_configuration_applies_overrides():
@ -229,7 +239,7 @@ def test_parse_configuration_applies_overrides():
) )
result = module.parse_configuration( result = module.parse_configuration(
'config.yaml', 'schema.yaml', overrides=['location.local_path=borg2'] '/tmp/config.yaml', '/tmp/schema.yaml', overrides=['location.local_path=borg2']
) )
assert result == { assert result == {
@ -255,7 +265,7 @@ def test_parse_configuration_applies_normalization():
''' '''
) )
result = module.parse_configuration('config.yaml', 'schema.yaml') result = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
assert result == { assert result == {
'location': { 'location': {

View File

@ -121,3 +121,47 @@ def test_run_arbitrary_borg_without_borg_specific_parameters_does_not_raise():
module.run_arbitrary_borg( module.run_arbitrary_borg(
repository='repo', storage_config={}, options=[], repository='repo', storage_config={}, options=[],
) )
def test_run_arbitrary_borg_passes_key_sub_command_to_borg_before_repository():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'key', 'export', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg',
)
module.run_arbitrary_borg(
repository='repo', storage_config={}, options=['key', 'export'],
)
def test_run_arbitrary_borg_passes_debug_sub_command_to_borg_before_repository():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'debug', 'dump-manifest', 'repo', 'path'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
module.run_arbitrary_borg(
repository='repo', storage_config={}, options=['debug', 'dump-manifest', 'path'],
)
def test_run_arbitrary_borg_with_debug_info_command_does_not_pass_borg_repository():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'debug', 'info'), output_log_level=logging.WARNING, borg_local_path='borg',
)
module.run_arbitrary_borg(
repository='repo', storage_config={}, options=['debug', 'info'],
)
def test_run_arbitrary_borg_with_debug_convert_profile_command_does_not_pass_borg_repository():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'debug', 'convert-profile', 'in', 'out'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
module.run_arbitrary_borg(
repository='repo', storage_config={}, options=['debug', 'convert-profile', 'in', 'out'],
)

View File

@ -17,172 +17,336 @@ def insert_execute_command_never():
def test_parse_checks_returns_them_as_tuple(): def test_parse_checks_returns_them_as_tuple():
checks = module._parse_checks({'checks': ['foo', 'disabled', 'bar']}) checks = module.parse_checks({'checks': [{'name': 'foo'}, {'name': 'bar'}]})
assert checks == ('foo', 'bar') assert checks == ('foo', 'bar')
def test_parse_checks_with_missing_value_returns_defaults(): def test_parse_checks_with_missing_value_returns_defaults():
checks = module._parse_checks({}) checks = module.parse_checks({})
assert checks == module.DEFAULT_CHECKS assert checks == ('repository', 'archives')
def test_parse_checks_with_blank_value_returns_defaults(): def test_parse_checks_with_empty_list_returns_defaults():
checks = module._parse_checks({'checks': []}) checks = module.parse_checks({'checks': []})
assert checks == module.DEFAULT_CHECKS assert checks == ('repository', 'archives')
def test_parse_checks_with_none_value_returns_defaults(): def test_parse_checks_with_none_value_returns_defaults():
checks = module._parse_checks({'checks': None}) checks = module.parse_checks({'checks': None})
assert checks == module.DEFAULT_CHECKS assert checks == ('repository', 'archives')
def test_parse_checks_with_disabled_returns_no_checks(): def test_parse_checks_with_disabled_returns_no_checks():
checks = module._parse_checks({'checks': ['disabled']}) checks = module.parse_checks({'checks': [{'name': 'foo'}, {'name': 'disabled'}]})
assert checks == () assert checks == ()
def test_parse_checks_with_data_check_also_injects_archives(): def test_parse_checks_with_data_check_also_injects_archives():
checks = module._parse_checks({'checks': ['data']}) checks = module.parse_checks({'checks': [{'name': 'data'}]})
assert checks == ('data', 'archives') assert checks == ('data', 'archives')
def test_parse_checks_with_data_check_passes_through_archives(): def test_parse_checks_with_data_check_passes_through_archives():
checks = module._parse_checks({'checks': ['data', 'archives']}) checks = module.parse_checks({'checks': [{'name': 'data'}, {'name': 'archives'}]})
assert checks == ('data', 'archives') assert checks == ('data', 'archives')
def test_parse_checks_prefers_override_checks_to_configured_checks(): def test_parse_checks_prefers_override_checks_to_configured_checks():
checks = module._parse_checks({'checks': ['archives']}, only_checks=['repository', 'extract']) checks = module.parse_checks(
{'checks': [{'name': 'archives'}]}, only_checks=['repository', 'extract']
)
assert checks == ('repository', 'extract') assert checks == ('repository', 'extract')
def test_parse_checks_with_override_data_check_also_injects_archives(): def test_parse_checks_with_override_data_check_also_injects_archives():
checks = module._parse_checks({'checks': ['extract']}, only_checks=['data']) checks = module.parse_checks({'checks': [{'name': 'extract'}]}, only_checks=['data'])
assert checks == ('data', 'archives') assert checks == ('data', 'archives')
@pytest.mark.parametrize(
'frequency,expected_result',
(
(None, None),
('always', None),
('1 hour', module.datetime.timedelta(hours=1)),
('2 hours', module.datetime.timedelta(hours=2)),
('1 day', module.datetime.timedelta(days=1)),
('2 days', module.datetime.timedelta(days=2)),
('1 week', module.datetime.timedelta(weeks=1)),
('2 weeks', module.datetime.timedelta(weeks=2)),
('1 month', module.datetime.timedelta(days=30)),
('2 months', module.datetime.timedelta(days=60)),
('1 year', module.datetime.timedelta(days=365)),
('2 years', module.datetime.timedelta(days=365 * 2)),
),
)
def test_parse_frequency_parses_into_timedeltas(frequency, expected_result):
assert module.parse_frequency(frequency) == expected_result
@pytest.mark.parametrize(
'frequency', ('sometime', 'x days', '3 decades',),
)
def test_parse_frequency_raises_on_parse_error(frequency):
with pytest.raises(ValueError):
module.parse_frequency(frequency)
def test_filter_checks_on_frequency_without_config_uses_default_checks():
flexmock(module).should_receive('parse_frequency').and_return(
module.datetime.timedelta(weeks=4)
)
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('read_check_time').and_return(None)
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={},
borg_repository_id='repo',
checks=('repository', 'archives'),
force=False,
) == ('repository', 'archives')
def test_filter_checks_on_frequency_retains_unconfigured_check():
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={},
borg_repository_id='repo',
checks=('data',),
force=False,
) == ('data',)
def test_filter_checks_on_frequency_retains_check_without_frequency():
flexmock(module).should_receive('parse_frequency').and_return(None)
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={'checks': [{'name': 'archives'}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
) == ('archives',)
def test_filter_checks_on_frequency_retains_check_with_elapsed_frequency():
flexmock(module).should_receive('parse_frequency').and_return(
module.datetime.timedelta(hours=1)
)
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('read_check_time').and_return(
module.datetime.datetime(year=module.datetime.MINYEAR, month=1, day=1)
)
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
) == ('archives',)
def test_filter_checks_on_frequency_retains_check_with_missing_check_time_file():
flexmock(module).should_receive('parse_frequency').and_return(
module.datetime.timedelta(hours=1)
)
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('read_check_time').and_return(None)
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
) == ('archives',)
def test_filter_checks_on_frequency_skips_check_with_unelapsed_frequency():
flexmock(module).should_receive('parse_frequency').and_return(
module.datetime.timedelta(hours=1)
)
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('read_check_time').and_return(module.datetime.datetime.now())
assert (
module.filter_checks_on_frequency(
location_config={},
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
)
== ()
)
def test_filter_checks_on_frequency_restains_check_with_unelapsed_frequency_and_force():
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
checks=('archives',),
force=True,
) == ('archives',)
def test_make_check_flags_with_repository_check_returns_flag(): def test_make_check_flags_with_repository_check_returns_flag():
flags = module._make_check_flags(('repository',)) flags = module.make_check_flags(('repository',))
assert flags == ('--repository-only',) assert flags == ('--repository-only',)
def test_make_check_flags_with_archives_check_returns_flag(): def test_make_check_flags_with_archives_check_returns_flag():
flags = module._make_check_flags(('archives',)) flags = module.make_check_flags(('archives',))
assert flags == ('--archives-only',) assert flags == ('--archives-only',)
def test_make_check_flags_with_data_check_returns_flag(): def test_make_check_flags_with_data_check_returns_flag():
flags = module._make_check_flags(('data',)) flags = module.make_check_flags(('data',))
assert flags == ('--verify-data',) assert flags == ('--verify-data',)
def test_make_check_flags_with_extract_omits_extract_flag(): def test_make_check_flags_with_extract_omits_extract_flag():
flags = module._make_check_flags(('extract',)) flags = module.make_check_flags(('extract',))
assert flags == () assert flags == ()
def test_make_check_flags_with_default_checks_and_default_prefix_returns_default_flags(): def test_make_check_flags_with_default_checks_and_default_prefix_returns_default_flags():
flags = module._make_check_flags(module.DEFAULT_CHECKS, prefix=module.DEFAULT_PREFIX) flags = module.make_check_flags(('repository', 'archives'), prefix=module.DEFAULT_PREFIX)
assert flags == ('--prefix', module.DEFAULT_PREFIX) assert flags == ('--prefix', module.DEFAULT_PREFIX)
def test_make_check_flags_with_all_checks_and_default_prefix_returns_default_flags(): def test_make_check_flags_with_all_checks_and_default_prefix_returns_default_flags():
flags = module._make_check_flags( flags = module.make_check_flags(
module.DEFAULT_CHECKS + ('extract',), prefix=module.DEFAULT_PREFIX ('repository', 'archives', 'extract'), prefix=module.DEFAULT_PREFIX
) )
assert flags == ('--prefix', module.DEFAULT_PREFIX) assert flags == ('--prefix', module.DEFAULT_PREFIX)
def test_make_check_flags_with_archives_check_and_last_includes_last_flag(): def test_make_check_flags_with_archives_check_and_last_includes_last_flag():
flags = module._make_check_flags(('archives',), check_last=3) flags = module.make_check_flags(('archives',), check_last=3)
assert flags == ('--archives-only', '--last', '3') assert flags == ('--archives-only', '--last', '3')
def test_make_check_flags_with_repository_check_and_last_omits_last_flag(): def test_make_check_flags_with_repository_check_and_last_omits_last_flag():
flags = module._make_check_flags(('repository',), check_last=3) flags = module.make_check_flags(('repository',), check_last=3)
assert flags == ('--repository-only',) assert flags == ('--repository-only',)
def test_make_check_flags_with_default_checks_and_last_includes_last_flag(): def test_make_check_flags_with_default_checks_and_last_includes_last_flag():
flags = module._make_check_flags(module.DEFAULT_CHECKS, check_last=3) flags = module.make_check_flags(('repository', 'archives'), check_last=3)
assert flags == ('--last', '3') assert flags == ('--last', '3')
def test_make_check_flags_with_archives_check_and_prefix_includes_prefix_flag(): def test_make_check_flags_with_archives_check_and_prefix_includes_prefix_flag():
flags = module._make_check_flags(('archives',), prefix='foo-') flags = module.make_check_flags(('archives',), prefix='foo-')
assert flags == ('--archives-only', '--prefix', 'foo-') assert flags == ('--archives-only', '--prefix', 'foo-')
def test_make_check_flags_with_archives_check_and_empty_prefix_omits_prefix_flag(): def test_make_check_flags_with_archives_check_and_empty_prefix_omits_prefix_flag():
flags = module._make_check_flags(('archives',), prefix='') flags = module.make_check_flags(('archives',), prefix='')
assert flags == ('--archives-only',) assert flags == ('--archives-only',)
def test_make_check_flags_with_archives_check_and_none_prefix_omits_prefix_flag(): def test_make_check_flags_with_archives_check_and_none_prefix_omits_prefix_flag():
flags = module._make_check_flags(('archives',), prefix=None) flags = module.make_check_flags(('archives',), prefix=None)
assert flags == ('--archives-only',) assert flags == ('--archives-only',)
def test_make_check_flags_with_repository_check_and_prefix_omits_prefix_flag(): def test_make_check_flags_with_repository_check_and_prefix_omits_prefix_flag():
flags = module._make_check_flags(('repository',), prefix='foo-') flags = module.make_check_flags(('repository',), prefix='foo-')
assert flags == ('--repository-only',) assert flags == ('--repository-only',)
def test_make_check_flags_with_default_checks_and_prefix_includes_prefix_flag(): def test_make_check_flags_with_default_checks_and_prefix_includes_prefix_flag():
flags = module._make_check_flags(module.DEFAULT_CHECKS, prefix='foo-') flags = module.make_check_flags(('repository', 'archives'), prefix='foo-')
assert flags == ('--prefix', 'foo-') assert flags == ('--prefix', 'foo-')
def test_read_check_time_does_not_raise():
flexmock(module.os).should_receive('stat').and_return(flexmock(st_mtime=123))
assert module.read_check_time('/path')
def test_read_check_time_on_missing_file_does_not_raise():
flexmock(module.os).should_receive('stat').and_raise(FileNotFoundError)
assert module.read_check_time('/path') is None
def test_check_archives_with_progress_calls_borg_with_progress_parameter(): def test_check_archives_with_progress_calls_borg_with_progress_parameter():
checks = ('repository',) checks = ('repository',)
consistency_config = {'check_last': None} consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').and_return(()) flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').and_return(())
flexmock(module).should_receive('execute_command').never() flexmock(module).should_receive('execute_command').never()
flexmock(module).should_receive('execute_command').with_args( flexmock(module).should_receive('execute_command').with_args(
('borg', 'check', '--progress', 'repo'), output_file=module.DO_NOT_CAPTURE ('borg', 'check', '--progress', 'repo'), output_file=module.DO_NOT_CAPTURE
).once() ).once()
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives( module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config, progress=True repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
progress=True,
) )
def test_check_archives_with_repair_calls_borg_with_repair_parameter(): def test_check_archives_with_repair_calls_borg_with_repair_parameter():
checks = ('repository',) checks = ('repository',)
consistency_config = {'check_last': None} consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').and_return(()) flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').and_return(())
flexmock(module).should_receive('execute_command').never() flexmock(module).should_receive('execute_command').never()
flexmock(module).should_receive('execute_command').with_args( flexmock(module).should_receive('execute_command').with_args(
('borg', 'check', '--repair', 'repo'), output_file=module.DO_NOT_CAPTURE ('borg', 'check', '--repair', 'repo'), output_file=module.DO_NOT_CAPTURE
).once() ).once()
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives( module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config, repair=True repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
repair=True,
) )
@ -198,64 +362,142 @@ def test_check_archives_with_repair_calls_borg_with_repair_parameter():
def test_check_archives_calls_borg_with_parameters(checks): def test_check_archives_calls_borg_with_parameters(checks):
check_last = flexmock() check_last = flexmock()
consistency_config = {'check_last': check_last} consistency_config = {'check_last': check_last}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').with_args( flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').with_args(
checks, check_last, module.DEFAULT_PREFIX checks, check_last, module.DEFAULT_PREFIX
).and_return(()) ).and_return(())
insert_execute_command_mock(('borg', 'check', 'repo')) insert_execute_command_mock(('borg', 'check', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives( module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
) )
def test_check_archives_with_json_error_raises():
checks = ('archives',)
check_last = flexmock()
consistency_config = {'check_last': check_last}
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"unexpected": {"id": "repo"}}'
)
with pytest.raises(ValueError):
module.check_archives(
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
)
def test_check_archives_with_missing_json_keys_raises():
checks = ('archives',)
check_last = flexmock()
consistency_config = {'check_last': check_last}
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return('{invalid JSON')
with pytest.raises(ValueError):
module.check_archives(
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
)
def test_check_archives_with_extract_check_calls_extract_only(): def test_check_archives_with_extract_check_calls_extract_only():
checks = ('extract',) checks = ('extract',)
check_last = flexmock() check_last = flexmock()
consistency_config = {'check_last': check_last} consistency_config = {'check_last': check_last}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').never() flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').never()
flexmock(module.extract).should_receive('extract_last_archive_dry_run').once() flexmock(module.extract).should_receive('extract_last_archive_dry_run').once()
flexmock(module).should_receive('write_check_time')
insert_execute_command_never() insert_execute_command_never()
module.check_archives( module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
) )
def test_check_archives_with_log_info_calls_borg_with_info_parameter(): def test_check_archives_with_log_info_calls_borg_with_info_parameter():
checks = ('repository',) checks = ('repository',)
consistency_config = {'check_last': None} consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').and_return(()) flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').and_return(())
insert_logging_mock(logging.INFO) insert_logging_mock(logging.INFO)
insert_execute_command_mock(('borg', 'check', '--info', 'repo')) insert_execute_command_mock(('borg', 'check', '--info', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives( module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
) )
def test_check_archives_with_log_debug_calls_borg_with_debug_parameter(): def test_check_archives_with_log_debug_calls_borg_with_debug_parameter():
checks = ('repository',) checks = ('repository',)
consistency_config = {'check_last': None} consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').and_return(()) flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').and_return(())
insert_logging_mock(logging.DEBUG) insert_logging_mock(logging.DEBUG)
insert_execute_command_mock(('borg', 'check', '--debug', '--show-rc', 'repo')) insert_execute_command_mock(('borg', 'check', '--debug', '--show-rc', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives( module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
) )
def test_check_archives_without_any_checks_bails(): def test_check_archives_without_any_checks_bails():
consistency_config = {'check_last': None} consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(()) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(())
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
insert_execute_command_never() insert_execute_command_never()
module.check_archives( module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
) )
@ -263,14 +505,21 @@ def test_check_archives_with_local_path_calls_borg_via_local_path():
checks = ('repository',) checks = ('repository',)
check_last = flexmock() check_last = flexmock()
consistency_config = {'check_last': check_last} consistency_config = {'check_last': check_last}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').with_args( flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').with_args(
checks, check_last, module.DEFAULT_PREFIX checks, check_last, module.DEFAULT_PREFIX
).and_return(()) ).and_return(())
insert_execute_command_mock(('borg1', 'check', 'repo')) insert_execute_command_mock(('borg1', 'check', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives( module.check_archives(
repository='repo', repository='repo',
location_config={},
storage_config={}, storage_config={},
consistency_config=consistency_config, consistency_config=consistency_config,
local_path='borg1', local_path='borg1',
@ -281,14 +530,21 @@ def test_check_archives_with_remote_path_calls_borg_with_remote_path_parameters(
checks = ('repository',) checks = ('repository',)
check_last = flexmock() check_last = flexmock()
consistency_config = {'check_last': check_last} consistency_config = {'check_last': check_last}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').with_args( flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').with_args(
checks, check_last, module.DEFAULT_PREFIX checks, check_last, module.DEFAULT_PREFIX
).and_return(()) ).and_return(())
insert_execute_command_mock(('borg', 'check', '--remote-path', 'borg1', 'repo')) insert_execute_command_mock(('borg', 'check', '--remote-path', 'borg1', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives( module.check_archives(
repository='repo', repository='repo',
location_config={},
storage_config={}, storage_config={},
consistency_config=consistency_config, consistency_config=consistency_config,
remote_path='borg1', remote_path='borg1',
@ -299,14 +555,23 @@ def test_check_archives_with_lock_wait_calls_borg_with_lock_wait_parameters():
checks = ('repository',) checks = ('repository',)
check_last = flexmock() check_last = flexmock()
consistency_config = {'check_last': check_last} consistency_config = {'check_last': check_last}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').with_args( flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').with_args(
checks, check_last, module.DEFAULT_PREFIX checks, check_last, module.DEFAULT_PREFIX
).and_return(()) ).and_return(())
insert_execute_command_mock(('borg', 'check', '--lock-wait', '5', 'repo')) insert_execute_command_mock(('borg', 'check', '--lock-wait', '5', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives( module.check_archives(
repository='repo', storage_config={'lock_wait': 5}, consistency_config=consistency_config repository='repo',
location_config={},
storage_config={'lock_wait': 5},
consistency_config=consistency_config,
) )
@ -315,26 +580,42 @@ def test_check_archives_with_retention_prefix():
check_last = flexmock() check_last = flexmock()
prefix = 'foo-' prefix = 'foo-'
consistency_config = {'check_last': check_last, 'prefix': prefix} consistency_config = {'check_last': check_last, 'prefix': prefix}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').with_args( flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').with_args(
checks, check_last, prefix checks, check_last, prefix
).and_return(()) ).and_return(())
insert_execute_command_mock(('borg', 'check', 'repo')) insert_execute_command_mock(('borg', 'check', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives( module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
) )
def test_check_archives_with_extra_borg_options_calls_borg_with_extra_options(): def test_check_archives_with_extra_borg_options_calls_borg_with_extra_options():
checks = ('repository',) checks = ('repository',)
consistency_config = {'check_last': None} consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(checks) flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('_make_check_flags').and_return(()) flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').and_return(())
insert_execute_command_mock(('borg', 'check', '--extra', '--options', 'repo')) insert_execute_command_mock(('borg', 'check', '--extra', '--options', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives( module.check_archives(
repository='repo', repository='repo',
location_config={},
storage_config={'extra_borg_options': {'check': '--extra --options'}}, storage_config={'extra_borg_options': {'check': '--extra --options'}},
consistency_config=consistency_config, consistency_config=consistency_config,
) )

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

@ -13,11 +13,11 @@ INIT_COMMAND = ('borg', 'init', '--encryption', 'repokey')
def insert_info_command_found_mock(): def insert_info_command_found_mock():
flexmock(module).should_receive('execute_command') flexmock(module.info).should_receive('display_archives_info')
def insert_info_command_not_found_mock(): def insert_info_command_not_found_mock():
flexmock(module).should_receive('execute_command').and_raise( flexmock(module.info).should_receive('display_archives_info').and_raise(
subprocess.CalledProcessError(module.INFO_REPOSITORY_NOT_FOUND_EXIT_CODE, []) subprocess.CalledProcessError(module.INFO_REPOSITORY_NOT_FOUND_EXIT_CODE, [])
) )
@ -48,13 +48,13 @@ def test_initialize_repository_raises_for_borg_init_error():
def test_initialize_repository_skips_initialization_when_repository_already_exists(): def test_initialize_repository_skips_initialization_when_repository_already_exists():
flexmock(module).should_receive('execute_command').once() insert_info_command_found_mock()
module.initialize_repository(repository='repo', storage_config={}, encryption_mode='repokey') module.initialize_repository(repository='repo', storage_config={}, encryption_mode='repokey')
def test_initialize_repository_raises_for_unknown_info_command_error(): def test_initialize_repository_raises_for_unknown_info_command_error():
flexmock(module).should_receive('execute_command').and_raise( flexmock(module.info).should_receive('display_archives_info').and_raise(
subprocess.CalledProcessError(INFO_SOME_UNKNOWN_EXIT_CODE, []) subprocess.CalledProcessError(INFO_SOME_UNKNOWN_EXIT_CODE, [])
) )

View File

@ -1,3 +1,4 @@
import argparse
import logging import logging
import pytest import pytest
@ -8,8 +9,6 @@ from borgmatic.borg import list as module
from ..test_verbosity import insert_logging_mock from ..test_verbosity import insert_logging_mock
BORG_LIST_LATEST_ARGUMENTS = ( BORG_LIST_LATEST_ARGUMENTS = (
'--glob-archives',
module.BORG_EXCLUDE_CHECKPOINTS_GLOB,
'--last', '--last',
'1', '1',
'--short', '--short',
@ -108,156 +107,125 @@ def test_resolve_archive_name_with_lock_wait_calls_borg_with_lock_wait_parameter
) )
def test_list_archives_calls_borg_with_parameters(): def test_make_list_command_includes_log_info():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg'
)
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False),
)
def test_list_archives_with_log_info_calls_borg_with_info_parameter():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--info', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg'
)
insert_logging_mock(logging.INFO) insert_logging_mock(logging.INFO)
module.list_archives( command = module.make_list_command(
repository='repo', repository='repo',
storage_config={}, storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False), list_arguments=flexmock(archive=None, paths=None, json=False),
) )
assert command == ('borg', 'list', '--info', 'repo')
def test_list_archives_with_log_info_and_json_suppresses_most_borg_output():
flexmock(module).should_receive('execute_command').with_args( def test_make_list_command_includes_json_but_not_info():
('borg', 'list', '--json', 'repo'), output_log_level=None, borg_local_path='borg'
)
insert_logging_mock(logging.INFO) insert_logging_mock(logging.INFO)
module.list_archives( command = module.make_list_command(
repository='repo', repository='repo',
storage_config={}, storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=True, successful=False), list_arguments=flexmock(archive=None, paths=None, json=True),
) )
assert command == ('borg', 'list', '--json', 'repo')
def test_list_archives_with_log_debug_calls_borg_with_debug_parameter():
flexmock(module).should_receive('execute_command').with_args( def test_make_list_command_includes_log_debug():
('borg', 'list', '--debug', '--show-rc', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
insert_logging_mock(logging.DEBUG) insert_logging_mock(logging.DEBUG)
module.list_archives( command = module.make_list_command(
repository='repo', repository='repo',
storage_config={}, storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False), list_arguments=flexmock(archive=None, paths=None, json=False),
) )
assert command == ('borg', 'list', '--debug', '--show-rc', 'repo')
def test_list_archives_with_log_debug_and_json_suppresses_most_borg_output():
flexmock(module).should_receive('execute_command').with_args( def test_make_list_command_includes_json_but_not_debug():
('borg', 'list', '--json', 'repo'), output_log_level=None, borg_local_path='borg'
)
insert_logging_mock(logging.DEBUG) insert_logging_mock(logging.DEBUG)
module.list_archives( command = module.make_list_command(
repository='repo', repository='repo',
storage_config={}, storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=True, successful=False), list_arguments=flexmock(archive=None, paths=None, json=True),
) )
assert command == ('borg', 'list', '--json', 'repo')
def test_list_archives_with_lock_wait_calls_borg_with_lock_wait_parameters():
storage_config = {'lock_wait': 5}
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--lock-wait', '5', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
module.list_archives(
repository='repo',
storage_config=storage_config,
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False),
)
def test_list_archives_with_archive_calls_borg_with_archive_parameter(): def test_make_list_command_includes_json():
storage_config = {} command = module.make_list_command(
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', 'repo::archive'), output_log_level=logging.WARNING, borg_local_path='borg'
)
module.list_archives(
repository='repo',
storage_config=storage_config,
list_arguments=flexmock(archive='archive', paths=None, json=False, successful=False),
)
def test_list_archives_with_path_calls_borg_with_path_parameter():
storage_config = {}
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', 'repo::archive', 'var/lib'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
module.list_archives(
repository='repo',
storage_config=storage_config,
list_arguments=flexmock(archive='archive', paths=['var/lib'], json=False, successful=False),
)
def test_list_archives_with_local_path_calls_borg_via_local_path():
flexmock(module).should_receive('execute_command').with_args(
('borg1', 'list', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg1'
)
module.list_archives(
repository='repo', repository='repo',
storage_config={}, storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False), list_arguments=flexmock(archive=None, paths=None, json=True),
local_path='borg1',
) )
assert command == ('borg', 'list', '--json', 'repo')
def test_list_archives_with_remote_path_calls_borg_with_remote_path_parameters():
flexmock(module).should_receive('execute_command').with_args( def test_make_list_command_includes_lock_wait():
('borg', 'list', '--remote-path', 'borg1', 'repo'), command = module.make_list_command(
output_log_level=logging.WARNING, repository='repo',
borg_local_path='borg', storage_config={'lock_wait': 5},
list_arguments=flexmock(archive=None, paths=None, json=False),
) )
module.list_archives( assert command == ('borg', 'list', '--lock-wait', '5', 'repo')
def test_make_list_command_includes_archive():
command = module.make_list_command(
repository='repo', repository='repo',
storage_config={}, storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False), list_arguments=flexmock(archive='archive', paths=None, json=False),
remote_path='borg1',
) )
assert command == ('borg', 'list', 'repo::archive')
def test_list_archives_with_short_calls_borg_with_short_parameter():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--short', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
).and_return('[]')
module.list_archives( def test_make_list_command_includes_archive_and_path():
command = module.make_list_command(
repository='repo', repository='repo',
storage_config={}, storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False, short=True), list_arguments=flexmock(archive='archive', paths=['var/lib'], json=False),
) )
assert command == ('borg', 'list', 'repo::archive', 'var/lib')
def test_make_list_command_includes_local_path():
command = module.make_list_command(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=False),
local_path='borg2',
)
assert command == ('borg2', 'list', 'repo')
def test_make_list_command_includes_remote_path():
command = module.make_list_command(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=False),
remote_path='borg2',
)
assert command == ('borg', 'list', '--remote-path', 'borg2', 'repo')
def test_make_list_command_includes_short():
command = module.make_list_command(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=False, short=True),
)
assert command == ('borg', 'list', '--short', 'repo')
@pytest.mark.parametrize( @pytest.mark.parametrize(
'argument_name', 'argument_name',
@ -273,45 +241,156 @@ def test_list_archives_with_short_calls_borg_with_short_parameter():
'patterns_from', 'patterns_from',
), ),
) )
def test_list_archives_passes_through_arguments_to_borg(argument_name): def test_make_list_command_includes_additional_flags(argument_name):
flexmock(module).should_receive('execute_command').with_args( command = module.make_list_command(
('borg', 'list', '--' + argument_name.replace('_', '-'), 'value', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
).and_return('[]')
module.list_archives(
repository='repo', repository='repo',
storage_config={}, storage_config={},
list_arguments=flexmock( list_arguments=flexmock(
archive=None, paths=None, json=False, successful=False, **{argument_name: 'value'} archive=None,
paths=None,
json=False,
find_paths=None,
format=None,
**{argument_name: 'value'}
), ),
) )
assert command == ('borg', 'list', '--' + argument_name.replace('_', '-'), 'value', 'repo')
def test_list_archives_with_successful_calls_borg_to_exclude_checkpoints():
def test_make_find_paths_considers_none_as_empty_paths():
assert module.make_find_paths(None) == ()
def test_make_find_paths_passes_through_patterns():
find_paths = (
'fm:*',
'sh:**/*.txt',
're:^.*$',
'pp:root/somedir',
'pf:root/foo.txt',
'R /',
'r /',
'p /',
'P /',
'+ /',
'- /',
'! /',
)
assert module.make_find_paths(find_paths) == find_paths
def test_make_find_paths_adds_globs_to_path_fragments():
assert module.make_find_paths(('foo.txt',)) == ('sh:**/*foo.txt*/**',)
def test_list_archives_calls_borg_with_parameters():
list_arguments = argparse.Namespace(archive=None, paths=None, json=False, find_paths=None)
flexmock(module).should_receive('make_list_command').with_args(
repository='repo',
storage_config={},
list_arguments=list_arguments,
local_path='borg',
remote_path=None,
).and_return(('borg', 'list', 'repo'))
flexmock(module).should_receive('make_find_paths').and_return(())
flexmock(module).should_receive('execute_command').with_args( flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--glob-archives', module.BORG_EXCLUDE_CHECKPOINTS_GLOB, 'repo'), ('borg', 'list', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg'
output_log_level=logging.WARNING, ).once()
borg_local_path='borg',
).and_return('[]')
module.list_archives( module.list_archives(
repository='repo', repository='repo', storage_config={}, list_arguments=list_arguments,
storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=False, successful=True),
) )
def test_list_archives_with_json_calls_borg_with_json_parameter(): def test_list_archives_with_json_suppresses_most_borg_output():
list_arguments = argparse.Namespace(archive=None, paths=None, json=True, find_paths=None)
flexmock(module).should_receive('make_list_command').with_args(
repository='repo',
storage_config={},
list_arguments=list_arguments,
local_path='borg',
remote_path=None,
).and_return(('borg', 'list', 'repo'))
flexmock(module).should_receive('make_find_paths').and_return(())
flexmock(module).should_receive('execute_command').with_args( flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--json', 'repo'), output_log_level=None, borg_local_path='borg' ('borg', 'list', 'repo'), output_log_level=None, borg_local_path='borg'
).and_return('[]') ).once()
json_output = module.list_archives( module.list_archives(
repository='repo', repository='repo', storage_config={}, list_arguments=list_arguments,
storage_config={},
list_arguments=flexmock(archive=None, paths=None, json=True, successful=False),
) )
assert json_output == '[]'
def test_list_archives_calls_borg_with_local_path():
list_arguments = argparse.Namespace(archive=None, paths=None, json=False, find_paths=None)
flexmock(module).should_receive('make_list_command').with_args(
repository='repo',
storage_config={},
list_arguments=list_arguments,
local_path='borg2',
remote_path=None,
).and_return(('borg2', 'list', 'repo'))
flexmock(module).should_receive('make_find_paths').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg2', 'list', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg2'
).once()
module.list_archives(
repository='repo', storage_config={}, list_arguments=list_arguments, local_path='borg2',
)
def test_list_archives_calls_borg_multiple_times_with_find_paths():
glob_paths = ('**/*foo.txt*/**',)
list_arguments = argparse.Namespace(
archive=None, paths=None, json=False, find_paths=['foo.txt'], format=None
)
flexmock(module).should_receive('make_list_command').and_return(
('borg', 'list', 'repo')
).and_return(('borg', 'list', 'repo::archive1')).and_return(('borg', 'list', 'repo::archive2'))
flexmock(module).should_receive('make_find_paths').and_return(glob_paths)
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', 'repo'), output_log_level=None, borg_local_path='borg'
).and_return(
'archive1 Sun, 2022-05-29 15:27:04 [abc]\narchive2 Mon, 2022-05-30 19:47:15 [xyz]'
).once()
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', 'repo::archive1') + glob_paths,
output_log_level=logging.WARNING,
borg_local_path='borg',
).once()
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', 'repo::archive2') + glob_paths,
output_log_level=logging.WARNING,
borg_local_path='borg',
).once()
module.list_archives(
repository='repo', storage_config={}, list_arguments=list_arguments,
)
def test_list_archives_calls_borg_with_archive():
list_arguments = argparse.Namespace(archive='archive', paths=None, json=False, find_paths=None)
flexmock(module).should_receive('make_list_command').with_args(
repository='repo',
storage_config={},
list_arguments=list_arguments,
local_path='borg',
remote_path=None,
).and_return(('borg', 'list', 'repo::archive'))
flexmock(module).should_receive('make_find_paths').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', 'repo::archive'), output_log_level=logging.WARNING, borg_local_path='borg'
).once()
module.list_archives(
repository='repo', storage_config={}, list_arguments=list_arguments,
)

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

@ -10,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:]
@ -22,81 +23,26 @@ def test_run_configuration_runs_actions_for_each_repository():
assert results == expected_results assert results == expected_results
def test_run_configuration_calls_hooks_for_prune_action(): def test_run_configuration_with_invalid_borg_version_errors():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').twice() flexmock(module.borg_version).should_receive('local_borg_version').and_raise(ValueError)
flexmock(module.dispatch).should_receive('call_hooks').at_least().twice() flexmock(module.command).should_receive('execute_hook').never()
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module.dispatch).should_receive('call_hooks').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), 'prune': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'prune': flexmock()}
list(module.run_configuration('test.yaml', config, arguments)) list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_executes_and_calls_hooks_for_create_action(): def test_run_configuration_logs_monitor_start_error():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').twice() flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').at_least().twice() flexmock(module.dispatch).should_receive('call_hooks').and_raise(OSError).and_return(
flexmock(module).should_receive('run_actions').and_return([]) None
config = {'location': {'repositories': ['foo']}} ).and_return(None)
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_calls_hooks_for_check_action():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').twice()
flexmock(module.dispatch).should_receive('call_hooks').at_least().twice()
flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'check': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_calls_hooks_for_extract_action():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').twice()
flexmock(module.dispatch).should_receive('call_hooks').never()
flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'extract': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_does_not_trigger_hooks_for_list_action():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').never()
flexmock(module.dispatch).should_receive('call_hooks').never()
flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'list': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_logs_actions_error():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook')
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)
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False)}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results
def test_run_configuration_logs_pre_hook_error():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').and_raise(OSError).and_return(None)
expected_results = [flexmock()]
flexmock(module).should_receive('make_error_log_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()}
@ -106,11 +52,12 @@ def test_run_configuration_logs_pre_hook_error():
assert results == expected_results assert results == expected_results
def test_run_configuration_bails_for_pre_hook_soft_failure(): def test_run_configuration_bails_for_monitor_start_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.dispatch).should_receive('call_hooks').and_raise(error)
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()}
@ -120,14 +67,46 @@ def test_run_configuration_bails_for_pre_hook_soft_failure():
assert results == [] assert results == []
def test_run_configuration_logs_post_hook_error(): def test_run_configuration_logs_actions_error():
flexmock(module.borg_environment).should_receive('initialize') flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise( flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
OSError flexmock(module.command).should_receive('execute_hook')
).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_raise(OSError)
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False)}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results
def test_run_configuration_bails_for_actions_soft_failure():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks')
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module).should_receive('run_actions').and_raise(error)
flexmock(module).should_receive('log_error_records').never()
flexmock(module.command).should_receive('considered_soft_failure').and_return(True)
config = {'location': {'repositories': ['foo']}}
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_logs_monitor_finish_error():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
None
).and_raise(OSError)
expected_results = [flexmock()]
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()}
@ -137,15 +116,16 @@ def test_run_configuration_logs_post_hook_error():
assert results == expected_results assert results == expected_results
def test_run_configuration_bails_for_post_hook_soft_failure(): def test_run_configuration_bails_for_monitor_finish_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.dispatch).should_receive('call_hooks').and_return(None).and_return(
error None
).and_return(None) ).and_raise(error)
flexmock(module.dispatch).should_receive('call_hooks') flexmock(module).should_receive('log_error_records').never()
flexmock(module).should_receive('make_error_log_records').never()
flexmock(module).should_receive('run_actions').and_return([]) flexmock(module).should_receive('run_actions').and_return([])
flexmock(module.command).should_receive('considered_soft_failure').and_return(True)
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()}
@ -156,9 +136,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)
@ -172,10 +153,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_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()}
@ -188,44 +170,48 @@ def test_run_configuration_bails_for_on_error_hook_soft_failure():
def test_run_configuration_retries_soft_error(): def test_run_configuration_retries_soft_error():
# Run action first fails, second passes # Run action first fails, second passes
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).should_receive('run_actions').and_raise(OSError).and_return([]) flexmock(module).should_receive('run_actions').and_raise(OSError).and_return([])
expected_results = [flexmock()] flexmock(module).should_receive('log_error_records').and_return([flexmock()]).once()
flexmock(module).should_receive('make_error_log_records').and_return(expected_results).once()
config = {'location': {'repositories': ['foo']}, 'storage': {'retries': 1}} config = {'location': {'repositories': ['foo']}, 'storage': {'retries': 1}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments)) results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results assert results == []
def test_run_configuration_retries_hard_error(): def test_run_configuration_retries_hard_error():
# Run action fails twice # Run action fails twice
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).should_receive('run_actions').and_raise(OSError).times(2) flexmock(module).should_receive('run_actions').and_raise(OSError).times(2)
expected_results = [flexmock(), flexmock()] flexmock(module).should_receive('log_error_records').with_args(
flexmock(module).should_receive('make_error_log_records').with_args( 'foo: Error running actions for repository',
'foo: Error running actions for repository', OSError OSError,
).and_return(expected_results[:1]).with_args( levelno=logging.WARNING,
'foo: Error running actions for repository', OSError log_command_error_output=True,
).and_return( ).and_return([flexmock()])
expected_results[1:] error_logs = [flexmock()]
).twice() 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}} config = {'location': {'repositories': ['foo']}, 'storage': {'retries': 1}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments)) results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results assert results == error_logs
def test_run_repos_ordered(): def test_run_repos_ordered():
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).should_receive('run_actions').and_raise(OSError).times(2) flexmock(module).should_receive('run_actions').and_raise(OSError).times(2)
expected_results = [flexmock(), flexmock()] expected_results = [flexmock(), flexmock()]
flexmock(module).should_receive('make_error_log_records').with_args( flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository', OSError 'foo: Error running actions for repository', OSError
).and_return(expected_results[:1]).ordered() ).and_return(expected_results[:1]).ordered()
flexmock(module).should_receive('make_error_log_records').with_args( flexmock(module).should_receive('log_error_records').with_args(
'bar: Error running actions for repository', OSError 'bar: Error running actions for repository', OSError
).and_return(expected_results[1:]).ordered() ).and_return(expected_results[1:]).ordered()
config = {'location': {'repositories': ['foo', 'bar']}} config = {'location': {'repositories': ['foo', 'bar']}}
@ -236,107 +222,448 @@ def test_run_repos_ordered():
def test_run_configuration_retries_round_robbin(): def test_run_configuration_retries_round_robbin():
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).should_receive('run_actions').and_raise(OSError).times(4) flexmock(module).should_receive('run_actions').and_raise(OSError).times(4)
expected_results = [flexmock(), flexmock(), flexmock(), flexmock()] flexmock(module).should_receive('log_error_records').with_args(
flexmock(module).should_receive('make_error_log_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 'foo: Error running actions for repository', OSError
).and_return(expected_results[0:1]).ordered() ).and_return(foo_error_logs).ordered()
flexmock(module).should_receive('make_error_log_records').with_args( bar_error_logs = [flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'bar: Error running actions for repository', OSError 'bar: Error running actions for repository', OSError
).and_return(expected_results[1:2]).ordered() ).and_return(bar_error_logs).ordered()
flexmock(module).should_receive('make_error_log_records').with_args(
'foo: Error running actions for repository', OSError
).and_return(expected_results[2:3]).ordered()
flexmock(module).should_receive('make_error_log_records').with_args(
'bar: Error running actions for repository', OSError
).and_return(expected_results[3:4]).ordered()
config = {'location': {'repositories': ['foo', 'bar']}, 'storage': {'retries': 1}} config = {'location': {'repositories': ['foo', 'bar']}, 'storage': {'retries': 1}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments)) results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results assert results == foo_error_logs + bar_error_logs
def test_run_configuration_retries_one_passes(): def test_run_configuration_retries_one_passes():
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).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return( flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return(
[] []
).and_raise(OSError).times(4) ).and_raise(OSError).times(4)
expected_results = [flexmock(), flexmock(), flexmock()] flexmock(module).should_receive('log_error_records').with_args(
flexmock(module).should_receive('make_error_log_records').with_args( 'foo: Error running actions for repository',
'foo: Error running actions for repository', OSError OSError,
).and_return(expected_results[0:1]).ordered() levelno=logging.WARNING,
flexmock(module).should_receive('make_error_log_records').with_args( 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 'bar: Error running actions for repository', OSError
).and_return(expected_results[1:2]).ordered() ).and_return(error_logs).ordered()
flexmock(module).should_receive('make_error_log_records').with_args(
'bar: Error running actions for repository', OSError
).and_return(expected_results[2:3]).ordered()
config = {'location': {'repositories': ['foo', 'bar']}, 'storage': {'retries': 1}} config = {'location': {'repositories': ['foo', 'bar']}, 'storage': {'retries': 1}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments)) results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results assert results == error_logs
def test_run_configuration_retry_wait(): def test_run_configuration_retry_wait():
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).should_receive('run_actions').and_raise(OSError).times(4) flexmock(module).should_receive('run_actions').and_raise(OSError).times(4)
expected_results = [flexmock(), flexmock(), flexmock(), flexmock()] flexmock(module).should_receive('log_error_records').with_args(
flexmock(module).should_receive('make_error_log_records').with_args( 'foo: Error running actions for repository',
'foo: Error running actions for repository', OSError OSError,
).and_return(expected_results[0:1]).ordered() levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()]).ordered()
flexmock(time).should_receive('sleep').with_args(10).and_return().ordered() flexmock(time).should_receive('sleep').with_args(10).and_return().ordered()
flexmock(module).should_receive('make_error_log_records').with_args( flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository', OSError 'foo: Error running actions for repository',
).and_return(expected_results[1:2]).ordered() 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(time).should_receive('sleep').with_args(20).and_return().ordered()
flexmock(module).should_receive('make_error_log_records').with_args( flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository', OSError 'foo: Error running actions for repository',
).and_return(expected_results[2:3]).ordered() OSError,
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()]).ordered()
flexmock(time).should_receive('sleep').with_args(30).and_return().ordered() flexmock(time).should_receive('sleep').with_args(30).and_return().ordered()
flexmock(module).should_receive('make_error_log_records').with_args( error_logs = [flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'foo: Error running actions for repository', OSError 'foo: Error running actions for repository', OSError
).and_return(expected_results[3:4]).ordered() ).and_return(error_logs).ordered()
config = {'location': {'repositories': ['foo']}, 'storage': {'retries': 3, 'retry_wait': 10}} config = {'location': {'repositories': ['foo']}, 'storage': {'retries': 3, 'retry_wait': 10}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments)) results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results assert results == error_logs
def test_run_configuration_retries_timeout_multiple_repos(): def test_run_configuration_retries_timeout_multiple_repos():
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).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return( flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return(
[] []
).and_raise(OSError).times(4) ).and_raise(OSError).times(4)
expected_results = [flexmock(), flexmock(), flexmock()] flexmock(module).should_receive('log_error_records').with_args(
flexmock(module).should_receive('make_error_log_records').with_args( 'foo: Error running actions for repository',
'foo: Error running actions for repository', OSError OSError,
).and_return(expected_results[0:1]).ordered() levelno=logging.WARNING,
flexmock(module).should_receive('make_error_log_records').with_args( log_command_error_output=True,
'bar: Error running actions for repository', OSError ).and_return([flexmock()]).ordered()
).and_return(expected_results[1:2]).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) # Sleep before retrying foo (and passing)
flexmock(time).should_receive('sleep').with_args(10).and_return().ordered() flexmock(time).should_receive('sleep').with_args(10).and_return().ordered()
# Sleep before retrying bar (and failing) # Sleep before retrying bar (and failing)
flexmock(time).should_receive('sleep').with_args(10).and_return().ordered() flexmock(time).should_receive('sleep').with_args(10).and_return().ordered()
flexmock(module).should_receive('make_error_log_records').with_args( error_logs = [flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'bar: Error running actions for repository', OSError 'bar: Error running actions for repository', OSError
).and_return(expected_results[2:3]).ordered() ).and_return(error_logs).ordered()
config = { config = {
'location': {'repositories': ['foo', 'bar']}, 'location': {'repositories': ['foo', 'bar']},
'storage': {'retries': 1, 'retry_wait': 10}, 'storage': {'retries': 1, 'retry_wait': 10},
} }
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments)) results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results assert results == error_logs
def test_run_actions_does_not_raise_for_init_action():
flexmock(module.borg_init).should_receive('initialize_repository')
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'init': flexmock(
encryption_mode=flexmock(), append_only=flexmock(), storage_quota=flexmock()
),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_run_actions_calls_hooks_for_prune_action():
flexmock(module.borg_prune).should_receive('prune_archives')
flexmock(module.command).should_receive('execute_hook').twice()
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'prune': flexmock(stats=flexmock(), files=flexmock()),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_run_actions_calls_hooks_for_compact_action():
flexmock(module.borg_feature).should_receive('available').and_return(True)
flexmock(module.borg_compact).should_receive('compact_segments')
flexmock(module.command).should_receive('execute_hook').twice()
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'compact': flexmock(progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_run_actions_executes_and_calls_hooks_for_create_action():
flexmock(module.borg_create).should_receive('create_archive')
flexmock(module.command).should_receive('execute_hook').twice()
flexmock(module.dispatch).should_receive('call_hooks').and_return({}).times(3)
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'create': flexmock(
progress=flexmock(), stats=flexmock(), json=flexmock(), files=flexmock()
),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_run_actions_calls_hooks_for_check_action():
flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(True)
flexmock(module.borg_check).should_receive('check_archives')
flexmock(module.command).should_receive('execute_hook').twice()
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'check': flexmock(
progress=flexmock(), repair=flexmock(), only=flexmock(), force=flexmock()
),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_run_actions_calls_hooks_for_extract_action():
flexmock(module.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borg_extract).should_receive('extract_archive')
flexmock(module.command).should_receive('execute_hook').twice()
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'extract': flexmock(
paths=flexmock(),
progress=flexmock(),
destination=flexmock(),
strip_components=flexmock(),
archive=flexmock(),
repository='repo',
),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_run_actions_does_not_raise_for_export_tar_action():
flexmock(module.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borg_export_tar).should_receive('export_tar_archive')
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'export-tar': flexmock(
repository=flexmock(),
archive=flexmock(),
paths=flexmock(),
destination=flexmock(),
tar_filter=flexmock(),
files=flexmock(),
strip_components=flexmock(),
),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_run_actions_does_not_raise_for_mount_action():
flexmock(module.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borg_mount).should_receive('mount_archive')
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'mount': flexmock(
repository=flexmock(),
archive=flexmock(),
mount_point=flexmock(),
paths=flexmock(),
foreground=flexmock(),
options=flexmock(),
),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_run_actions_does_not_raise_for_list_action():
flexmock(module.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borg_list).should_receive('resolve_archive_name').and_return(flexmock())
flexmock(module.borg_list).should_receive('list_archives')
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'list': flexmock(repository=flexmock(), archive=flexmock(), json=flexmock()),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_run_actions_does_not_raise_for_info_action():
flexmock(module.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borg_list).should_receive('resolve_archive_name').and_return(flexmock())
flexmock(module.borg_info).should_receive('display_archives_info')
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'info': flexmock(repository=flexmock(), archive=flexmock(), json=flexmock()),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_run_actions_does_not_raise_for_borg_action():
flexmock(module.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borg_list).should_receive('resolve_archive_name').and_return(flexmock())
flexmock(module.borg_borg).should_receive('run_arbitrary_borg')
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'borg': flexmock(repository=flexmock(), archive=flexmock(), options=flexmock()),
}
list(
module.run_actions(
arguments=arguments,
config_filename='test.yaml',
location={'repositories': ['repo']},
storage={},
retention={},
consistency={},
hooks={},
local_path=None,
remote_path=None,
local_borg_version=None,
repository_path='repo',
)
)
def test_load_configurations_collects_parsed_configurations(): def test_load_configurations_collects_parsed_configurations():
@ -352,6 +679,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)
@ -369,48 +705,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 == ()
@ -467,7 +801,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(
@ -494,7 +828,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(
@ -507,7 +841,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))
@ -517,7 +851,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(
@ -531,7 +865,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(
@ -546,7 +880,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(
@ -572,7 +906,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(
@ -586,7 +920,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

@ -19,6 +19,26 @@ from borgmatic.config import normalize as module
{'location': {'source_directories': ['foo', 'bar']}}, {'location': {'source_directories': ['foo', 'bar']}},
), ),
({'storage': {'compression': 'yes_please'}}, {'storage': {'compression': 'yes_please'}}), ({'storage': {'compression': 'yes_please'}}, {'storage': {'compression': 'yes_please'}}),
(
{'hooks': {'healthchecks': 'https://example.com'}},
{'hooks': {'healthchecks': {'ping_url': 'https://example.com'}}},
),
(
{'hooks': {'cronitor': 'https://example.com'}},
{'hooks': {'cronitor': {'ping_url': 'https://example.com'}}},
),
(
{'hooks': {'pagerduty': 'https://example.com'}},
{'hooks': {'pagerduty': {'integration_key': 'https://example.com'}}},
),
(
{'hooks': {'cronhub': 'https://example.com'}},
{'hooks': {'cronhub': {'ping_url': 'https://example.com'}}},
),
(
{'consistency': {'checks': ['archives']}},
{'consistency': {'checks': [{'name': 'archives'}]}},
),
), ),
) )
def test_normalize_applies_hard_coded_normalization_to_config(config, expected_config): def test_normalize_applies_hard_coded_normalization_to_config(config, expected_config):

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,33 +4,31 @@ from flexmock import flexmock
from borgmatic.config import validate as module from borgmatic.config import validate as module
def test_format_error_path_element_formats_array_index(): def test_format_json_error_path_element_formats_array_index():
module.format_error_path_element(3) == '[3]' module.format_json_error_path_element(3) == '[3]'
def test_format_error_path_element_formats_property(): def test_format_json_error_path_element_formats_property():
module.format_error_path_element('foo') == '.foo' module.format_json_error_path_element('foo') == '.foo'
def test_format_error_formats_error_including_path(): def test_format_json_error_formats_error_including_path():
flexmock(module).format_error_path_element = lambda element: '.{}'.format(element) flexmock(module).format_json_error_path_element = lambda element: '.{}'.format(element)
error = flexmock(message='oops', path=['foo', 'bar']) error = flexmock(message='oops', path=['foo', 'bar'])
assert module.format_error(error) == "At 'foo.bar': oops" assert module.format_json_error(error) == "At 'foo.bar': oops"
def test_format_error_formats_error_without_path(): def test_format_json_error_formats_error_without_path():
flexmock(module).should_receive('format_error_path_element').never() flexmock(module).should_receive('format_json_error_path_element').never()
error = flexmock(message='oops', path=[]) error = flexmock(message='oops', path=[])
assert module.format_error(error) == 'At the top level: oops' assert module.format_json_error(error) == 'At the top level: oops'
def test_validation_error_string_contains_error_messages_and_config_filename(): def test_validation_error_string_contains_errors():
flexmock(module).format_error = lambda error: error.message flexmock(module).format_json_error = lambda error: error.message
error = module.Validation_error( error = module.Validation_error('config.yaml', ('oops', 'uh oh'))
'config.yaml', (flexmock(message='oops', path=None), flexmock(message='uh oh'))
)
result = str(error) result = str(error)
@ -39,35 +37,8 @@ def test_validation_error_string_contains_error_messages_and_config_filename():
assert 'uh oh' in result assert 'uh oh' in result
def test_apply_logical_validation_raises_if_archive_name_format_present_without_prefix():
flexmock(module).format_error = lambda error: error.message
with pytest.raises(module.Validation_error):
module.apply_logical_validation(
'config.yaml',
{
'storage': {'archive_name_format': '{hostname}-{now}'},
'retention': {'keep_daily': 7},
},
)
def test_apply_logical_validation_raises_if_archive_name_format_present_without_retention_prefix():
flexmock(module).format_error = lambda error: error.message
with pytest.raises(module.Validation_error):
module.apply_logical_validation(
'config.yaml',
{
'storage': {'archive_name_format': '{hostname}-{now}'},
'retention': {'keep_daily': 7},
'consistency': {'prefix': '{hostname}-'},
},
)
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_error = lambda error: error.message 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(

View File

@ -7,22 +7,34 @@ from borgmatic.hooks import command as module
def test_interpolate_context_passes_through_command_without_variable(): def test_interpolate_context_passes_through_command_without_variable():
assert module.interpolate_context('ls', {'foo': 'bar'}) == 'ls' assert module.interpolate_context('test.yaml', 'pre-backup', 'ls', {'foo': 'bar'}) == 'ls'
def test_interpolate_context_passes_through_command_with_unknown_variable(): def test_interpolate_context_passes_through_command_with_unknown_variable():
assert module.interpolate_context('ls {baz}', {'foo': 'bar'}) == 'ls {baz}' assert (
module.interpolate_context('test.yaml', 'pre-backup', 'ls {baz}', {'foo': 'bar'})
== 'ls {baz}'
)
def test_interpolate_context_interpolates_variables(): def test_interpolate_context_interpolates_variables():
context = {'foo': 'bar', 'baz': 'quux'} context = {'foo': 'bar', 'baz': 'quux'}
assert module.interpolate_context('ls {foo}{baz} {baz}', context) == 'ls barquux quux' assert (
module.interpolate_context('test.yaml', 'pre-backup', 'ls {foo}{baz} {baz}', context)
== 'ls barquux quux'
)
def test_interpolate_context_does_not_touch_unknown_variables():
context = {'foo': 'bar', 'baz': 'quux'}
assert module.interpolate_context('test.yaml', 'pre-backup', 'ls {wtf}', context) == 'ls {wtf}'
def test_execute_hook_invokes_each_command(): def test_execute_hook_invokes_each_command():
flexmock(module).should_receive('interpolate_context').replace_with( flexmock(module).should_receive('interpolate_context').replace_with(
lambda command, context: command lambda config_file, hook_description, command, context: command
) )
flexmock(module.execute).should_receive('execute_command').with_args( flexmock(module.execute).should_receive('execute_command').with_args(
[':'], output_log_level=logging.WARNING, shell=True [':'], output_log_level=logging.WARNING, shell=True
@ -33,7 +45,7 @@ def test_execute_hook_invokes_each_command():
def test_execute_hook_with_multiple_commands_invokes_each_command(): def test_execute_hook_with_multiple_commands_invokes_each_command():
flexmock(module).should_receive('interpolate_context').replace_with( flexmock(module).should_receive('interpolate_context').replace_with(
lambda command, context: command lambda config_file, hook_description, command, context: command
) )
flexmock(module.execute).should_receive('execute_command').with_args( flexmock(module.execute).should_receive('execute_command').with_args(
[':'], output_log_level=logging.WARNING, shell=True [':'], output_log_level=logging.WARNING, shell=True
@ -47,7 +59,7 @@ def test_execute_hook_with_multiple_commands_invokes_each_command():
def test_execute_hook_with_umask_sets_that_umask(): def test_execute_hook_with_umask_sets_that_umask():
flexmock(module).should_receive('interpolate_context').replace_with( flexmock(module).should_receive('interpolate_context').replace_with(
lambda command, context: command lambda config_file, hook_description, command, context: command
) )
flexmock(module.os).should_receive('umask').with_args(0o77).and_return(0o22).once() flexmock(module.os).should_receive('umask').with_args(0o77).and_return(0o22).once()
flexmock(module.os).should_receive('umask').with_args(0o22).once() flexmock(module.os).should_receive('umask').with_args(0o22).once()
@ -60,7 +72,7 @@ def test_execute_hook_with_umask_sets_that_umask():
def test_execute_hook_with_dry_run_skips_commands(): def test_execute_hook_with_dry_run_skips_commands():
flexmock(module).should_receive('interpolate_context').replace_with( flexmock(module).should_receive('interpolate_context').replace_with(
lambda command, context: command lambda config_file, hook_description, command, context: command
) )
flexmock(module.execute).should_receive('execute_command').never() flexmock(module.execute).should_receive('execute_command').never()
@ -73,7 +85,7 @@ def test_execute_hook_with_empty_commands_does_not_raise():
def test_execute_hook_on_error_logs_as_error(): def test_execute_hook_on_error_logs_as_error():
flexmock(module).should_receive('interpolate_context').replace_with( flexmock(module).should_receive('interpolate_context').replace_with(
lambda command, context: command lambda config_file, hook_description, command, context: command
) )
flexmock(module.execute).should_receive('execute_command').with_args( flexmock(module.execute).should_receive('execute_command').with_args(
[':'], output_log_level=logging.ERROR, shell=True [':'], output_log_level=logging.ERROR, shell=True

View File

@ -4,45 +4,72 @@ from borgmatic.hooks import cronhub as module
def test_ping_monitor_rewrites_ping_url_for_start_state(): def test_ping_monitor_rewrites_ping_url_for_start_state():
ping_url = 'https://example.com/start/abcdef' hook_config = {'ping_url': 'https://example.com/start/abcdef'}
flexmock(module.requests).should_receive('get').with_args('https://example.com/start/abcdef') flexmock(module.requests).should_receive('get').with_args('https://example.com/start/abcdef')
module.ping_monitor( module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False hook_config,
'config.yaml',
module.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
) )
def test_ping_monitor_rewrites_ping_url_and_state_for_start_state(): def test_ping_monitor_rewrites_ping_url_and_state_for_start_state():
ping_url = 'https://example.com/ping/abcdef' hook_config = {'ping_url': 'https://example.com/ping/abcdef'}
flexmock(module.requests).should_receive('get').with_args('https://example.com/start/abcdef') flexmock(module.requests).should_receive('get').with_args('https://example.com/start/abcdef')
module.ping_monitor( module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False hook_config,
'config.yaml',
module.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
) )
def test_ping_monitor_rewrites_ping_url_for_finish_state(): def test_ping_monitor_rewrites_ping_url_for_finish_state():
ping_url = 'https://example.com/start/abcdef' hook_config = {'ping_url': 'https://example.com/start/abcdef'}
flexmock(module.requests).should_receive('get').with_args('https://example.com/finish/abcdef') flexmock(module.requests).should_receive('get').with_args('https://example.com/finish/abcdef')
module.ping_monitor( module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False hook_config,
'config.yaml',
module.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
) )
def test_ping_monitor_rewrites_ping_url_for_fail_state(): def test_ping_monitor_rewrites_ping_url_for_fail_state():
ping_url = 'https://example.com/start/abcdef' hook_config = {'ping_url': 'https://example.com/start/abcdef'}
flexmock(module.requests).should_receive('get').with_args('https://example.com/fail/abcdef') flexmock(module.requests).should_receive('get').with_args('https://example.com/fail/abcdef')
module.ping_monitor( module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
) )
def test_ping_monitor_dry_run_does_not_hit_ping_url(): def test_ping_monitor_dry_run_does_not_hit_ping_url():
ping_url = 'https://example.com' hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').never() flexmock(module.requests).should_receive('get').never()
module.ping_monitor( module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True hook_config, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True
)
def test_ping_monitor_with_connection_error_does_not_raise():
hook_config = {'ping_url': 'https://example.com/start/abcdef'}
flexmock(module.requests).should_receive('get').and_raise(
module.requests.exceptions.ConnectionError
)
module.ping_monitor(
hook_config,
'config.yaml',
module.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
) )

View File

@ -4,36 +4,59 @@ from borgmatic.hooks import cronitor as module
def test_ping_monitor_hits_ping_url_for_start_state(): def test_ping_monitor_hits_ping_url_for_start_state():
ping_url = 'https://example.com' hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'run')) flexmock(module.requests).should_receive('get').with_args('https://example.com/run')
module.ping_monitor( module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False hook_config,
'config.yaml',
module.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
) )
def test_ping_monitor_hits_ping_url_for_finish_state(): def test_ping_monitor_hits_ping_url_for_finish_state():
ping_url = 'https://example.com' hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'complete')) flexmock(module.requests).should_receive('get').with_args('https://example.com/complete')
module.ping_monitor( module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False hook_config,
'config.yaml',
module.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
) )
def test_ping_monitor_hits_ping_url_for_fail_state(): def test_ping_monitor_hits_ping_url_for_fail_state():
ping_url = 'https://example.com' hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'fail')) flexmock(module.requests).should_receive('get').with_args('https://example.com/fail')
module.ping_monitor( module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
) )
def test_ping_monitor_dry_run_does_not_hit_ping_url(): def test_ping_monitor_dry_run_does_not_hit_ping_url():
ping_url = 'https://example.com' hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').never() flexmock(module.requests).should_receive('get').never()
module.ping_monitor( module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True hook_config, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True
)
def test_ping_monitor_with_connection_error_does_not_raise():
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').and_raise(
module.requests.exceptions.ConnectionError
)
module.ping_monitor(
hook_config,
'config.yaml',
module.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
) )

View File

@ -12,6 +12,15 @@ def test_forgetful_buffering_handler_emit_collects_log_records():
assert not handler.forgot assert not handler.forgot
def test_forgetful_buffering_handler_emit_collects_log_records_with_zero_byte_capacity():
handler = module.Forgetful_buffering_handler(byte_capacity=0, log_level=1)
handler.emit(flexmock(getMessage=lambda: 'foo'))
handler.emit(flexmock(getMessage=lambda: 'bar'))
assert handler.buffer == ['foo\n', 'bar\n']
assert not handler.forgot
def test_forgetful_buffering_handler_emit_forgets_log_records_when_capacity_reached(): def test_forgetful_buffering_handler_emit_forgets_log_records_when_capacity_reached():
handler = module.Forgetful_buffering_handler(byte_capacity=len('foo\nbar\n'), log_level=1) handler = module.Forgetful_buffering_handler(byte_capacity=len('foo\nbar\n'), log_level=1)
handler.emit(flexmock(getMessage=lambda: 'foo')) handler.emit(flexmock(getMessage=lambda: 'foo'))
@ -60,15 +69,80 @@ def test_format_buffered_logs_for_payload_without_handler_produces_empty_payload
assert payload == '' assert payload == ''
def mock_logger():
logger = flexmock()
logger.should_receive('addHandler')
logger.should_receive('removeHandler')
flexmock(module.logging).should_receive('getLogger').and_return(logger)
def test_initialize_monitor_creates_log_handler_with_ping_body_limit():
ping_body_limit = 100
monitoring_log_level = 1
mock_logger()
flexmock(module).should_receive('Forgetful_buffering_handler').with_args(
ping_body_limit - len(module.PAYLOAD_TRUNCATION_INDICATOR), monitoring_log_level
).once()
module.initialize_monitor(
{'ping_body_limit': ping_body_limit}, 'test.yaml', monitoring_log_level, dry_run=False
)
def test_initialize_monitor_creates_log_handler_with_default_ping_body_limit():
monitoring_log_level = 1
mock_logger()
flexmock(module).should_receive('Forgetful_buffering_handler').with_args(
module.DEFAULT_PING_BODY_LIMIT_BYTES - len(module.PAYLOAD_TRUNCATION_INDICATOR),
monitoring_log_level,
).once()
module.initialize_monitor({}, 'test.yaml', monitoring_log_level, dry_run=False)
def test_initialize_monitor_creates_log_handler_with_zero_ping_body_limit():
ping_body_limit = 0
monitoring_log_level = 1
mock_logger()
flexmock(module).should_receive('Forgetful_buffering_handler').with_args(
ping_body_limit, monitoring_log_level
).once()
module.initialize_monitor(
{'ping_body_limit': ping_body_limit}, 'test.yaml', monitoring_log_level, dry_run=False
)
def test_initialize_monitor_creates_log_handler_when_send_logs_true():
mock_logger()
flexmock(module).should_receive('Forgetful_buffering_handler').once()
module.initialize_monitor(
{'send_logs': True}, 'test.yaml', monitoring_log_level=1, dry_run=False
)
def test_initialize_monitor_bails_when_send_logs_false():
mock_logger()
flexmock(module).should_receive('Forgetful_buffering_handler').never()
module.initialize_monitor(
{'send_logs': False}, 'test.yaml', monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_hits_ping_url_for_start_state(): def test_ping_monitor_hits_ping_url_for_start_state():
flexmock(module).should_receive('Forgetful_buffering_handler') flexmock(module).should_receive('Forgetful_buffering_handler')
ping_url = 'https://example.com' hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('post').with_args( flexmock(module.requests).should_receive('post').with_args(
'{}/{}'.format(ping_url, 'start'), data=''.encode('utf-8') 'https://example.com/start', data=''.encode('utf-8')
) )
module.ping_monitor( module.ping_monitor(
ping_url, hook_config,
'config.yaml', 'config.yaml',
state=module.monitor.State.START, state=module.monitor.State.START,
monitoring_log_level=1, monitoring_log_level=1,
@ -77,15 +151,15 @@ def test_ping_monitor_hits_ping_url_for_start_state():
def test_ping_monitor_hits_ping_url_for_finish_state(): def test_ping_monitor_hits_ping_url_for_finish_state():
ping_url = 'https://example.com' hook_config = {'ping_url': 'https://example.com'}
payload = 'data' payload = 'data'
flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload) flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
flexmock(module.requests).should_receive('post').with_args( flexmock(module.requests).should_receive('post').with_args(
ping_url, data=payload.encode('utf-8') 'https://example.com', data=payload.encode('utf-8')
) )
module.ping_monitor( module.ping_monitor(
ping_url, hook_config,
'config.yaml', 'config.yaml',
state=module.monitor.State.FINISH, state=module.monitor.State.FINISH,
monitoring_log_level=1, monitoring_log_level=1,
@ -94,15 +168,15 @@ def test_ping_monitor_hits_ping_url_for_finish_state():
def test_ping_monitor_hits_ping_url_for_fail_state(): def test_ping_monitor_hits_ping_url_for_fail_state():
ping_url = 'https://example.com' hook_config = {'ping_url': 'https://example.com'}
payload = 'data' payload = 'data'
flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload) flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
flexmock(module.requests).should_receive('post').with_args( flexmock(module.requests).should_receive('post').with_args(
'{}/{}'.format(ping_url, 'fail'), data=payload.encode('utf') 'https://example.com/fail', data=payload.encode('utf')
) )
module.ping_monitor( module.ping_monitor(
ping_url, hook_config,
'config.yaml', 'config.yaml',
state=module.monitor.State.FAIL, state=module.monitor.State.FAIL,
monitoring_log_level=1, monitoring_log_level=1,
@ -111,15 +185,15 @@ def test_ping_monitor_hits_ping_url_for_fail_state():
def test_ping_monitor_with_ping_uuid_hits_corresponding_url(): def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
ping_uuid = 'abcd-efgh-ijkl-mnop' hook_config = {'ping_url': 'abcd-efgh-ijkl-mnop'}
payload = 'data' payload = 'data'
flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload) flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
flexmock(module.requests).should_receive('post').with_args( flexmock(module.requests).should_receive('post').with_args(
'https://hc-ping.com/{}'.format(ping_uuid), data=payload.encode('utf-8') 'https://hc-ping.com/{}'.format(hook_config['ping_url']), data=payload.encode('utf-8')
) )
module.ping_monitor( module.ping_monitor(
ping_uuid, hook_config,
'config.yaml', 'config.yaml',
state=module.monitor.State.FINISH, state=module.monitor.State.FINISH,
monitoring_log_level=1, monitoring_log_level=1,
@ -129,13 +203,60 @@ def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
def test_ping_monitor_dry_run_does_not_hit_ping_url(): def test_ping_monitor_dry_run_does_not_hit_ping_url():
flexmock(module).should_receive('Forgetful_buffering_handler') flexmock(module).should_receive('Forgetful_buffering_handler')
ping_url = 'https://example.com' hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('post').never() flexmock(module.requests).should_receive('post').never()
module.ping_monitor( module.ping_monitor(
ping_url, hook_config,
'config.yaml', 'config.yaml',
state=module.monitor.State.START, state=module.monitor.State.START,
monitoring_log_level=1, monitoring_log_level=1,
dry_run=True, dry_run=True,
) )
def test_ping_monitor_does_not_hit_ping_url_when_states_not_matching():
flexmock(module).should_receive('Forgetful_buffering_handler')
hook_config = {'ping_url': 'https://example.com', 'states': ['finish']}
flexmock(module.requests).should_receive('post').never()
module.ping_monitor(
hook_config,
'config.yaml',
state=module.monitor.State.START,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_hits_ping_url_when_states_matching():
flexmock(module).should_receive('Forgetful_buffering_handler')
hook_config = {'ping_url': 'https://example.com', 'states': ['start', 'finish']}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8')
)
module.ping_monitor(
hook_config,
'config.yaml',
state=module.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_connection_error_does_not_raise():
flexmock(module).should_receive('Forgetful_buffering_handler')
flexmock(module.logger).should_receive('warning')
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8')
).and_raise(module.requests.exceptions.ConnectionError)
module.ping_monitor(
hook_config,
'config.yaml',
state=module.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)

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',
'>', '>',
@ -234,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,
@ -267,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',
@ -292,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

@ -7,7 +7,11 @@ def test_ping_monitor_ignores_start_state():
flexmock(module.requests).should_receive('post').never() flexmock(module.requests).should_receive('post').never()
module.ping_monitor( module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False {'integration_key': 'abc123'},
'config.yaml',
module.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
) )
@ -15,7 +19,11 @@ def test_ping_monitor_ignores_finish_state():
flexmock(module.requests).should_receive('post').never() flexmock(module.requests).should_receive('post').never()
module.ping_monitor( module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False {'integration_key': 'abc123'},
'config.yaml',
module.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
) )
@ -23,7 +31,11 @@ def test_ping_monitor_calls_api_for_fail_state():
flexmock(module.requests).should_receive('post') flexmock(module.requests).should_receive('post')
module.ping_monitor( module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False {'integration_key': 'abc123'},
'config.yaml',
module.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
) )
@ -31,5 +43,24 @@ def test_ping_monitor_dry_run_does_not_call_api():
flexmock(module.requests).should_receive('post').never() flexmock(module.requests).should_receive('post').never()
module.ping_monitor( module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=True {'integration_key': 'abc123'},
'config.yaml',
module.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_with_connection_error_does_not_raise():
flexmock(module.requests).should_receive('post').and_raise(
module.requests.exceptions.ConnectionError
)
flexmock(module.logger).should_receive('warning')
module.ping_monitor(
{'integration_key': 'abc123'},
'config.yaml',
module.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
) )

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