Compare commits

..
158 Commits
Author SHA1 Message Date
Dan Helfman 37cc229749 Fix duplicate logging to Healthchecks and send "after_*" hooks output to Healthchecks (#328). 2020-06-23 11:01:03 -07:00
Dan Helfman 17c2d109e5 Add tests for pass-through of BORG_* environment variables. 2020-06-21 14:41:22 -07:00
Dan Helfman c8d5de2179 Fix broken pass-through of BORG_* environment variables to Borg (#327).
Reviewed-on: https://projects.torsion.org/witten/borgmatic/pulls/327
2020-06-21 21:29:59 +00:00
Dan Helfman 32e15dc905 Add a few more mocks to PostgreSQL SSL tests. 2020-06-20 14:39:16 -07:00
Dan Helfman f5ebca4907 Add SSL support to PostgreSQL database configuration (#331).
Reviewed-on: https://projects.torsion.org/witten/borgmatic/pulls/331
2020-06-20 21:24:14 +00:00
Edward Shornock 01db676d68 Change the example for the ssl_mode parameter 2020-06-20 23:32:24 +03:00
Edward Shornock d2d92b1f1a Add tests for the PostgreSQL SSL options 2020-06-20 23:32:24 +03:00
Dan Helfman 27cbe9dfc0 Fix for potential data loss (data not getting backed up) when borgmatic omitted configured source directories in certain situations (#333). 2020-06-19 20:16:38 -07:00
Edward Shornock 8fb830099f Re-add the ilbpq-ssl documentation URL to the schema
It's been moved from describing `ssl_mode` to the general
postgresql_database description key.
2020-06-19 13:22:39 +03:00
Edward Shornock 463a133a63 Ensure schema lines are less than 80 characters in length 2020-06-19 13:22:39 +03:00
Edward Shornock a16fed8887 Rename PostgreSQL SSL config variables
e.g. s/sslmode/ssl_mode/g to conform with borgmatic naming conventions.
2020-06-19 13:20:14 +03:00
Edward Shornock 33113890f5 Reduce duplication with a common function 2020-06-19 12:32:36 +03:00
Edward Shornock abd47fc14e Add SSL support to PostgreSQL hooks 2020-06-19 02:19:17 +03:00
Dan Helfman 7fb4061759 Improve configuration reference documentation readability via more aggressive word-wrapping in configuration schema descriptions. 2020-06-17 23:15:12 -07:00
Dan Helfman b320e74ad5 Update documentation code fragments theme to better match the rest of the page. 2020-06-17 16:02:57 -07:00
Dan Helfman 0ed8f67b9d Documentation feedback: Clarify that a Borg manual install is required, separate from installing borgmatic. 2020-06-17 11:42:40 -07:00
Ralph Heinkel a12a1121b6 Use values from BORG_* env variables if they are not specified in config.yaml 2020-06-15 19:50:11 +02:00
Dan Helfman 795e18773b Bump version for release. 2020-06-06 15:01:56 -07:00
Dan Helfman aa14449857 Add "borgmatic extract --strip-components" flag to remove leading path components when extracting an archive (#324). 2020-06-06 14:57:14 -07:00
Dan Helfman ed7b1cd3d7 Add some no-cover pragmas on functions that don't need tests. 2020-06-06 14:33:06 -07:00
Dan Helfman a155eefa23 Fix for certain configuration options like ssh_command impacting Borg invocations for separate configuration files (#323). 2020-06-06 14:30:04 -07:00
Dan Helfman 398665be9e Allow before_backup and similiar hooks to exit with a soft failure without altering the monitoring status (#292). 2020-06-02 14:33:41 -07:00
Dan Helfman 6db232d4ac Link to Borgmacator GNOME AppIndicator from monitoring documentation. 2020-06-02 12:53:08 -07:00
Dan Helfman d7277893fb Fix hang when a stale database dump named pipe from an aborted borgmatic run remains on disk (#316). 2020-06-02 12:40:32 -07:00
Dan Helfman 00033bf0a8 Tweak comment indentation in generated configuration file for clarity. 2020-06-02 11:37:13 -07:00
Dan Helfman adda33dc4e Bump version for release. 2020-05-26 13:15:01 -07:00
Dan Helfman 097a09578a Fix enabled database hooks to implicitly set one_file_system configuration option to true to prevent Borg hang. (#315). 2020-05-26 09:20:14 -07:00
Dan Helfman 65472c8de2 Fix error message when there are no MySQL databases to dump for "all" databases (#319). 2020-05-26 08:59:04 -07:00
Dan Helfman 602ad9e7ee Add note about indirect dbus dependency. 2020-05-21 19:56:32 -07:00
Dan Helfman 96df52ec50 Fix hang when streaming a database dump to Borg with implicit duplicate source directories by deduplicating them first (#316). 2020-05-20 13:33:53 -07:00
Dan Helfman 244dc35bae Global install documentation. 2020-05-19 14:19:39 -07:00
Dan Helfman d9c9d7d2ee Improve documentation around the installation process. Specifically, making borgmatic commands runnable via the system PATH. 2020-05-18 20:38:43 -07:00
Dan Helfman 89cb5eb76d Fix regression in support for PostgreSQL's "directory" dump format (#314). 2020-05-18 11:31:29 -07:00
Dan Helfman 6d3802335e Adding docs note about upgrading to get --files flag. 2020-05-18 08:43:32 -07:00
Dan Helfman c1d6232b79 Fix documentation to mention new "--files" flag. 2020-05-15 10:45:58 -07:00
Dan Helfman 048a9ebb52 Add an additional end-to-end database test. 2020-05-15 10:12:49 -07:00
Dan Helfman de478f6ff7 Clarify wording in changelog. 2020-05-14 23:23:21 -07:00
Dan Helfman 3e5a19d95a Add missing test coverage. 2020-05-14 23:21:43 -07:00
Dan Helfman 2ddf38f99c Fix error handling when executing commands to handle more edge cases. 2020-05-14 23:04:01 -07:00
Dan Helfman d88f321cef Fix legitimate database dump command errors (exit code 1) not being treated as errors by borgmatic (#310). 2020-05-14 22:38:38 -07:00
Dan Helfman 74adac6c70 Bump version for release. 2020-05-12 21:03:09 -07:00
Dan Helfman 15ea70a71b Flesh out missing test coverage for execute.py. 2020-05-12 15:49:49 -07:00
Dan Helfman 8b91c01a4c Add some missing test coverage. 2020-05-11 11:34:14 -07:00
Dan Helfman 3bcef72050 Add some missing test mocks that were masking lack of unit coverage. 2020-05-11 11:17:24 -07:00
Dan Helfman 695c764a01 Merge log output functions into one. 2020-05-11 10:55:50 -07:00
Dan Helfman f7c93ea2e8 Wait for process to finish before trying to check exit status. 2020-05-09 23:09:48 -07:00
Dan Helfman 1ea047dd94 Remove "borgmatic restore --progress" flag, as it now conflicts with streaming database restores. 2020-05-09 21:53:16 -07:00
Dan Helfman 4b523f9e2c Make database restore output only show at verbosity 2. 2020-05-08 19:38:33 -07:00
Dan Helfman 6a61070d85 Use shell redirection rather than the --file flag to sidestep synchronization issues when pg_dump/pg_dumpall tries to write to a named pipe. 2020-05-08 19:11:26 -07:00
Dan Helfman f36082938e Additional test coverage. 2020-05-08 09:48:04 -07:00
Dan Helfman 1ba996ad93 Additional test coverage. 2020-05-07 12:14:27 -07:00
Dan Helfman a23fdf946d Stream database dumps and restores directly to/from Borg without using any additional filesystem space (#258). 2020-05-07 11:44:04 -07:00
Dan Helfman 12cf6913ef Remove unused function parameter. 2020-05-03 14:07:34 -07:00
Dan Helfman a4eef383c3 Spell out repository consistency check options in more detail. 2020-04-29 11:08:41 -07:00
Dan Helfman ac124612ad Documentation on macOS launchd permissions issues with work-around for Full Disk Access (#293). 2020-04-26 16:10:52 -07:00
Dan Helfman 95a479a86e Fix path in release script. 2020-04-24 16:05:50 -07:00
Dan Helfman e4eff0e3dc Bump version for release. 2020-04-24 15:56:56 -07:00
Dan HelfmanandGitHub dce1928dc4 Fix PostgreSQL restore error on "all" database dump. 2020-04-24 15:50:33 -07:00
Nathan Beals 3c8dc4929f Added test_restore_all_database_dump unit test.
Updated the other unit tests, as I had to re-arrange argument order
Added an 'all' test for the postgres end-to-end test.

Ran black formatter on it all.
2020-04-24 18:32:53 -04:00
Dan Helfman e511014a28 Fix MySQL restore error on "all" database dump by excluding system tables (#301). 2020-04-22 12:17:22 -07:00
Dan Helfman bae5f88824 Upgrade test database versions. 2020-04-21 10:01:26 -07:00
Dan Helfman 41ad98653a https://github.com/docker/compose/issues/2127 2020-04-21 09:39:20 -07:00
Dan Helfman 6a138aeb6e Move root vs. non-root instructions. 2020-04-18 13:14:35 -07:00
Dan Helfman f0ce37801b Add root vs. non-root to set up guide. 2020-04-17 20:30:10 -07:00
Dan Helfman 35f6aba365 Clarify that borgmatic should be run with sudo after a root --user install. 2020-04-17 09:46:50 -07:00
Nathan Beals f6407bafcb Remove the --create flag, was causing an error 2020-04-10 11:24:13 -04:00
Nathan Beals d5e9f67cec Finished. Now uses 'psql' to run the plain-text scripts that pg_dumpall creates 2020-04-10 10:55:53 -04:00
Nathan Beals b14f371c05 First attempt at fixing this pg_dumpall/restoring issue 2020-04-10 09:20:00 -04:00
Dan Helfman 31a5d1b9c4 Docs feedback: Clarify PagerDuty integration instructions. 2020-03-26 14:14:53 -07:00
Dan Helfman fb4305a953 Add link for Alpine packages of borgmatic to README. 2020-03-10 21:10:02 -07:00
Dan Helfman eab872823c Clarify license version. 2020-03-09 15:50:54 -07:00
Dan Helfman 3332750243 More documentation examples of a la carte actions. 2020-03-09 11:20:18 -07:00
Dan Helfman 4942b7ce4d Feedback on PagerDuty hook documentation. 2020-02-13 13:11:25 -08:00
Dan Helfman a2af77f363 Maybe fix release signing. 2020-02-03 09:57:34 -08:00
Dan Helfman a7490b56d1 Bump version for release. 2020-02-03 09:45:10 -08:00
Dan Helfman 66eb18d5ea Upgrade pip and tox in tests. 2020-02-02 14:30:26 -08:00
Dan Helfman 46486138b6 Fix PagerDuty link. 2020-02-01 13:43:37 -08:00
Dan Helfman d6562c4b1e Fix the "--stats" and "--files" flags so that they yield output at verbosity 0 (#290). 2020-01-31 10:23:36 -08:00
Dan Helfman 1ddde0910c Add security policy, such as it is. 2020-01-30 15:42:48 -08:00
Dan Helfman 79f3b84ca2 Documentation for "--archive latest" (#289). 2020-01-29 17:08:03 -08:00
Dan Helfman 55141bda67 Specify "--archive latest" to all actions that accept an archive (#289). 2020-01-29 16:59:02 -08:00
Dan Helfman bc02c123e6 Monitor backups with PagerDuty hook integration (#245). 2020-01-27 15:32:09 -08:00
Dan Helfman e76d5ad988 Fix tests. 2020-01-27 12:56:12 -08:00
Dan Helfman 8ad8a9c422 Add per-action hooks: "before_prune", "after_prune", "before_check", and "after_check" (#255). 2020-01-27 11:07:07 -08:00
Dan Helfman b15c9b7dab Add missing "how to" text. 2020-01-24 21:02:56 -08:00
Dan Helfman 2405e97c38 Backup to a removable drive or intermittent server via "soft failure" feature (#284). 2020-01-24 20:52:48 -08:00
Dan Helfman fdbb2ee905 View consistency check progress via "--progress" flag for "check" action (#287). 2020-01-24 11:27:16 -08:00
Dan Helfman 94b9ef56be Change "exclude_if_present" option to support multiple filenames, rather than just a single filename (#280). 2020-01-23 13:41:37 -08:00
Dan Helfman 952168ce25 Fix unwanted console log messages with "list --json" and "info --json". 2020-01-23 13:40:54 -08:00
Dan Helfman 5273037a94 For "list" and "info" actions, show repository names even at verbosity 0. 2020-01-23 11:17:39 -08:00
Dan Helfman 53e6ff9524 No longer list files or show stats by default at verbosity 2. 2020-01-22 15:23:49 -08:00
Dan Helfman f66fd1caaa Customize Healthchecks log level via borgmatic "--monitoring-verbosity" flag (#277). 2020-01-22 15:10:47 -08:00
Dan Helfman d93fdbc5ad Support "--files" and "--stats" flags at verbosity level 0. 2020-01-22 13:28:24 -08:00
Dan HelfmanandGitea 58e0439daf Disable per-file logging by default at verbosity 1; opt-in via new --files option. 2020-01-22 20:36:59 +00:00
palto42 75b5e7254e changes as per comments in PR #283 2020-01-22 19:03:26 +01:00
Dan Helfman 39550a7fe9 Add ~/.config/borgmatic.d as another configuration directory default (#274). 2020-01-22 09:26:58 -08:00
palto42 5f0c084bee Merge 'upstream/master' into list-files 2020-01-22 18:12:26 +01:00
Dan Helfman 88f06f7921 Revert "Use absolute paths in systemd commands."
This reverts commit 24e1516ec5.
2020-01-21 16:03:24 -08:00
Dan Helfman 8d12079386 Bump version. 2020-01-21 10:47:29 -08:00
Dan Helfman 7824a034ca Add test for database dump directory removal. 2020-01-21 10:34:46 -08:00
Dan HelfmanandGitHub 8ef0ba2fae After a backup of a database dump in directory format, properly remove the dump directory. 2020-01-21 10:29:40 -08:00
Dan Helfman cc384f4324 Second ticket for --json color bug. 2020-01-21 08:33:41 -08:00
Ronan Dunklau 8a91c79fb0 Support directory format dump cleanup.
Previously, only deleting a dump in a single-file format was supported.
This led to errors when performing a PostgreSQL directory format backup.
2020-01-19 15:15:47 +01:00
Dan Helfman ac1d63bb0d Use more realistic repository examples in README. 2020-01-18 20:00:18 -08:00
palto42 83632448be updated NEWS for mod. --stats & new --files opt. 2020-01-18 14:57:50 +01:00
palto42 e108526bab disable --stats by default 2020-01-18 14:38:59 +01:00
palto42 e27ba0d08a less detail at v1 + option "--files" for details 2020-01-11 16:38:07 +01:00
Dan Helfman 5afe0e3d63 Disable colored output when "--json" flag is used, so as to produce valid JSON ouput (#276). 2020-01-04 15:50:41 -08:00
Dan Helfman c52f82f9ce Documentation: Enable and start borgmatic with a single systemctl command. 2020-01-04 13:37:56 -08:00
Dan Helfman d0c533555e In "borgmatic --help", don't expand $HOME in listing of default "--config" paths. 2020-01-02 10:37:31 -08:00
Dan Helfman 1995c80e60 Add comment about old versions of systemd and option compatibility (#275). 2020-01-02 10:05:32 -08:00
Dan Helfman 24e1516ec5 Use absolute paths in systemd commands. 2020-01-01 17:14:55 -08:00
Dan Helfman 5b1beda82b Add logrotate documentation suggestion. 2019-12-31 15:06:53 -08:00
Dan Helfman e4f1094569 Bump version for release. 2019-12-20 14:04:49 -08:00
Dan Helfman 911668f0c8 Only trigger "on_error" hooks and monitoring failures for "prune", "create", and "check" actions, and not for other actions (#270). 2019-12-20 13:58:02 -08:00
Dan Helfman 6bfa0783b9 Clarify that the documentation suggestion form is only for documentation. 2019-12-17 20:16:13 -08:00
Dan Helfman d64bcd5e83 When pruning with verbosity level 1, list pruned and kept archives. 2019-12-17 20:12:41 -08:00
Dan Helfman ed2ca9f476 Sign release files. 2019-12-17 20:06:25 -08:00
Dan Helfman f787dfe809 Override particular configuration options from the command-line via "--override" flag (#268). 2019-12-17 11:46:27 -08:00
Dan Helfman afaabd14a8 Clarify documentation on how /etc/borgmatic.d/ configuration files are interpreted. 2019-12-13 11:42:17 -08:00
Dan Helfman e009bfeaa2 Update Healthchecks/Cronitor/Cronhub monitoring integrations to fire for "check" and "prune" actions, not just "create" (#249). 2019-12-12 22:54:45 -08:00
Dan Helfman f1358d52aa Add "borgmatic init" repository probing fix to NEWS. 2019-12-12 21:50:24 -08:00
Dan HelfmanandGitHub b04b333466 Use --remote-path, --debug and --info when checking for repo existence. 2019-12-13 05:47:47 +00:00
Matthew Daley dd16504329 Use --remote-path, --debug and --info when checking for repo existence
These are currently not being used in the call to `borg info` performed
as part of the borgmatic init command to check whether or not the repo
already exists.
2019-12-13 15:45:12 +13:00
Dan Helfman c6cb21a748 Switch to read-only container filesystem to avoid *.pyc getting created with busted permissions. 2019-12-11 21:24:37 -08:00
Dan Helfman 78aa4626fa Remove user switch in container due to CI permission issue. 2019-12-11 16:58:08 -08:00
Dan Helfman d2df224da8 Use busybox short option to su. 2019-12-11 16:46:24 -08:00
Dan Helfman 464ff2fe96 Run end-to-end tests on developer machines with Docker Compose for approximate parity with continuous integration tests. 2019-12-11 16:43:01 -08:00
Dan Helfman 0cc711173a Merge branch 'master' into end-to-end-database-tests 2019-12-11 12:27:14 -08:00
Dan Helfman 14e5cfc8f8 Support piping "borgmatic list" output to grep. Retain colored output when piping/redirecting (#271). 2019-12-11 12:12:25 -08:00
Dan Helfman b8b888090d Select Postgres service to work with particular client version. 2019-12-10 21:41:15 -08:00
Dan Helfman 68281339b7 Black. 2019-12-10 16:57:12 -08:00
Dan Helfman 2e5be3d3f1 Add missing psql. 2019-12-10 16:52:59 -08:00
Dan Helfman abd31a94fb Ports fix? 2019-12-10 16:47:09 -08:00
Dan Helfman 01e2cf08d1 Fix Drone CI services syntax. 2019-12-10 16:43:43 -08:00
Dan Helfman 9f821862b7 End-to-end tests for database dump and restore. 2019-12-10 16:41:01 -08:00
Dan Helfman 8660af745e Optionally change the internal database dump path via "borgmatic_source_directory" option in location configuration section (#259). 2019-12-10 16:04:34 -08:00
Dan Helfman 826e4352d1 Filter listed paths via "borgmatic list --path" flag (#269). 2019-12-08 14:07:02 -08:00
Dan Helfman b94999bba4 Fix "borgmatic umount" so it only runs Borg once instead of once per repository / configuration file. 2019-12-07 21:36:51 -08:00
Dan Helfman 65cc4c9429 Fix "--repository" flag to accept relative paths. 2019-12-06 16:29:41 -08:00
Dan Helfman df2be9620b Mount whole repositories via "borgmatic mount" without any "--archive" flag (#253). 2019-12-06 15:58:54 -08:00
Dan Helfman 2ab9daaa0f Attempt to repair any inconsistencies found during a consistency check via "borgmatic check --repair" flag (#266). 2019-12-04 16:07:00 -08:00
Dan Helfman 0c6c61a272 Pass extra options directly to particular Borg commands, handy for Borg options that borgmatic does not yet support natively (#235). 2019-12-04 15:48:10 -08:00
Dan Helfman 00f62ca023 Fix for "before_backup" hook not triggering an error when the command contains "borg" and has an exit code of 1 (#256). 2019-11-30 16:55:05 -08:00
Dan Helfman 9b2ca15de6 Fix for garbled Borg file listing when using "borgmatic create --progress" with verbosity level 1 or 2 (#257). 2019-11-30 15:31:36 -08:00
Dan Helfman c4aa34bf5c Fix for missing Healthchecks monitoring payload or HTTP 500 due to incorrect unicode encoding (#260). 2019-11-30 14:51:32 -08:00
Dan Helfman 4385f2a36a Merge branch 'master' of github.com:witten/borgmatic 2019-11-25 15:28:21 -08:00
Dan HelfmanandGitHub ed6a9dadf8 Fix for database dump removal incorrectly skipping some database dumps. 2019-11-25 23:28:15 +00:00
Dan Helfman d978a2d190 Fix for database dump removal incorrectly skipping some database dumps. 2019-11-25 15:27:59 -08:00
Dan Helfman 375036e409 Support for mounting an archive as a FUSE filesystem via "borgmatic mount" action, and unmounting via "borgmatic umount" (#123). 2019-11-25 14:56:20 -08:00
Raphael Heinrich 99168c1035 Fix error on cleanup multiple database dumps 2019-11-25 22:07:42 +01:00
Dan Helfman f4a231420f Show summary log errors regardless of verbosity. Log the "summary:" header with level based on the contained logs. 2019-11-25 10:31:09 -08:00
Dan Helfman 55ebfdda39 Show full error logs at "--verbosity 0" so you can see command output without upping the verbosity level. 2019-11-19 10:09:25 -08:00
Dan Helfman e63e2e0852 Rephrasing verbosity levels. 2019-11-17 22:52:26 -08:00
Dan Helfman edc4b9e60e Update the command-line help description. 2019-11-17 22:49:16 -08:00
Dan Helfman 78ff734e6c Add note about error logs only flowing to Healthchecks for the "create" action. 2019-11-17 19:10:11 -08:00
Dan Helfman 2cc743cf47 With "borgmatic check", consider Borg warnings as errors (#247). 2019-11-17 19:06:28 -08:00
Dan Helfman d99e6d1994 Remove obnoxious log message when Healthchecks hook sends failure for check action. 2019-11-17 18:55:40 -08:00
91 changed files with 5420 additions and 1463 deletions
+65 -5
View File
@@ -2,52 +2,112 @@
kind: pipeline
name: python-3-5-alpine-3-10
services:
- name: postgresql
image: postgres:11.6-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
- name: mysql
image: mariadb:10.3
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
steps:
- name: build
image: python:3.5-alpine3.10
pull: always
commands:
- scripts/run-tests
- scripts/run-full-tests
---
kind: pipeline
name: python-3-6-alpine-3-10
services:
- name: postgresql
image: postgres:11.6-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
- name: mysql
image: mariadb:10.3
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
steps:
- name: build
image: python:3.6-alpine3.10
pull: always
commands:
- scripts/run-tests
- scripts/run-full-tests
---
kind: pipeline
name: python-3-7-alpine-3-10
services:
- name: postgresql
image: postgres:11.6-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
- name: mysql
image: mariadb:10.3
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
steps:
- name: build
image: python:3.7-alpine3.10
pull: always
commands:
- scripts/run-tests
- scripts/run-full-tests
---
kind: pipeline
name: python-3-7-alpine-3-7
services:
- name: postgresql
image: postgres:10.11-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
- name: mysql
image: mariadb:10.1
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
steps:
- name: build
image: python:3.7-alpine3.7
pull: always
commands:
- scripts/run-tests
- scripts/run-full-tests
---
kind: pipeline
name: python-3-8-alpine-3-10
services:
- name: postgresql
image: postgres:11.6-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
- name: mysql
image: mariadb:10.3
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
steps:
- name: build
image: python:3.8-alpine3.10
pull: always
commands:
- scripts/run-tests
- scripts/run-full-tests
---
kind: pipeline
name: documentation
+150
View File
@@ -1,3 +1,153 @@
1.5.7.dev0
* #327: Fix broken pass-through of BORG_* environment variables to Borg.
* #328: Fix duplicate logging to Healthchecks and send "after_*" hooks output to Healthchecks.
* #331: Add SSL support to PostgreSQL database configuration.
* #333: Fix for potential data loss (data not getting backed up) when borgmatic omitted configured
source directories in certain situations. Specifically, this occurred when two source directories
on different filesystems were related by parentage (e.g. "/foo" and "/foo/bar/baz") and the
one_file_system option was enabled.
* Update documentation code fragments theme to better match the rest of the page.
* Improve configuration reference documentation readability via more aggressive word-wrapping in
configuration schema descriptions.
1.5.6
* #292: Allow before_backup and similiar hooks to exit with a soft failure without altering the
monitoring status on Healthchecks or other providers. Support this by waiting to ping monitoring
services with a "start" status until after before_* hooks finish. Failures in before_* hooks
still trigger a monitoring "fail" status.
* #316: Fix hang when a stale database dump named pipe from an aborted borgmatic run remains on
disk.
* #323: Fix for certain configuration options like ssh_command impacting Borg invocations for
separate configuration files.
* #324: Add "borgmatic extract --strip-components" flag to remove leading path components when
extracting an archive.
* Tweak comment indentation in generated configuration file for clarity.
* Link to Borgmacator GNOME AppIndicator from monitoring documentation.
1.5.5
* #314: Fix regression in support for PostgreSQL's "directory" dump format. Unlike other dump
formats, the "directory" dump format does not stream directly to/from Borg.
* #315: Fix enabled database hooks to implicitly set one_file_system configuration option to true.
This prevents Borg from reading devices like /dev/zero and hanging.
* #316: Fix hang when streaming a database dump to Borg with implicit duplicate source directories
by deduplicating them first.
* #319: Fix error message when there are no MySQL databases to dump for "all" databases.
* Improve documentation around the installation process. Specifically, making borgmatic commands
runnable via the system PATH and offering a global install option.
1.5.4
* #310: Fix legitimate database dump command errors (exit code 1) not being treated as errors by
borgmatic.
* For database dumps, replace the named pipe on every borgmatic run. This prevent hangs on stale
pipes left over from previous runs.
* Fix error handling to handle more edge cases when executing commands.
1.5.3
* #258: Stream database dumps and restores directly to/from Borg without using any additional
filesystem space. This feature is automatic, and works even on restores from archives made with
previous versions of borgmatic.
* #293: Documentation on macOS launchd permissions issues with work-around for Full Disk Access.
* Remove "borgmatic restore --progress" flag, as it now conflicts with streaming database restores.
1.5.2
* #301: Fix MySQL restore error on "all" database dump by excluding system tables.
* Fix PostgreSQL restore error on "all" database dump by using "psql" for the restore instead of
"pg_restore".
1.5.1
* #289: Tired of looking up the latest successful archive name in order to pass it to borgmatic
actions? Me too. Now you can specify "--archive latest" to all actions that accept an archive
flag.
* #290: Fix the "--stats" and "--files" flags so that they yield output at verbosity 0.
* Reduce the default verbosity of borgmatic logs sent to Healthchecks monitoring hook. Now, it's
warnings and errors only. You can increase the verbosity via the "--monitoring-verbosity" flag.
* Add security policy documentation in SECURITY.md.
1.5.0
* #245: Monitor backups with PagerDuty hook integration. See the documentation for more
information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook
* #255: Add per-action hooks: "before_prune", "after_prune", "before_check", and "after_check".
* #274: Add ~/.config/borgmatic.d as another configuration directory default.
* #277: Customize Healthchecks log level via borgmatic "--monitoring-verbosity" flag.
* #280: Change "exclude_if_present" option to support multiple filenames that indicate a directory
should be excluded from backups, rather than just a single filename.
* #284: Backup to a removable drive or intermittent server via "soft failure" feature. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/
* #287: View consistency check progress via "--progress" flag for "check" action.
* For "create" and "prune" actions, no longer list files or show detailed stats at any verbosities
by default. You can opt back in with "--files" or "--stats" flags.
* For "list" and "info" actions, show repository names even at verbosity 0.
1.4.22
* #276, #285: Disable colored output when "--json" flag is used, so as to produce valid JSON ouput.
* After a backup of a database dump in directory format, properly remove the dump directory.
* In "borgmatic --help", don't expand $HOME in listing of default "--config" paths.
1.4.21
* #268: Override particular configuration options from the command-line via "--override" flag. See
the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides
* #270: Only trigger "on_error" hooks and monitoring failures for "prune", "create", and "check"
actions, and not for other actions.
* When pruning with verbosity level 1, list pruned and kept archives. Previously, this information
was only shown at verbosity level 2.
1.4.20
* Fix repository probing during "borgmatic init" to respect verbosity flag and remote_path option.
* #249: Update Healthchecks/Cronitor/Cronhub monitoring integrations to fire for "check" and
"prune" actions, not just "create".
1.4.19
* #259: Optionally change the internal database dump path via "borgmatic_source_directory" option
in location configuration section.
* #271: Support piping "borgmatic list" output to grep by logging certain log levels to console
stdout and others to stderr.
* Retain colored output when piping or redirecting in an interactive terminal.
* Add end-to-end tests for database dump and restore. These are run on developer machines with
Docker Compose for approximate parity with continuous integration tests.
1.4.18
* Fix "--repository" flag to accept relative paths.
* Fix "borgmatic umount" so it only runs Borg once instead of once per repository / configuration
file.
* #253: Mount whole repositories via "borgmatic mount" without any "--archive" flag.
* #269: Filter listed paths via "borgmatic list --path" flag.
1.4.17
* #235: Pass extra options directly to particular Borg commands, handy for Borg options that
borgmatic does not yet support natively. Use "extra_borg_options" in the storage configuration
section.
* #266: Attempt to repair any inconsistencies found during a consistency check via
"borgmatic check --repair" flag.
1.4.16
* #256: Fix for "before_backup" hook not triggering an error when the command contains "borg" and
has an exit code of 1.
* #257: Fix for garbled Borg file listing when using "borgmatic create --progress" with
verbosity level 1 or 2.
* #260: Fix for missing Healthchecks monitoring payload or HTTP 500 due to incorrect unicode
encoding.
1.4.15
* Fix for database dump removal incorrectly skipping some database dumps.
* #123: Support for mounting an archive as a FUSE filesystem via "borgmatic mount" action, and
unmounting via "borgmatic umount". See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#mount-a-filesystem
1.4.14
* Show summary log errors regardless of verbosity level, and log the "summary:" header with a log
level based on the contained summary logs.
1.4.13
* Show full error logs at "--verbosity 0" so you can see command output without upping the
verbosity level.
1.4.12
* #247: With "borgmatic check", consider Borg warnings as errors.
* Dial back the display of inline error logs a bit, so failed command output doesn't appear
multiple times in the logs (well, except for the summary).
1.4.11
* #241: When using the Healthchecks monitoring hook, include borgmatic logs in the payloads for
completion and failure pings.
+17 -5
View File
@@ -20,9 +20,11 @@ location:
- /home
- /etc
# Paths to local or remote repositories.
# Paths of local or remote repositories to backup to.
repositories:
- user@backupserver:sourcehostname.borg
- 1234@usw-s001.rsync.net:backups.borg
- k8pDxu32@k8pDxu32.repo.borgbase.com:repo
- /var/lib/backups/local.borg
retention:
# Retention policy for how many backups to keep.
@@ -64,6 +66,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
<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://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.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;
@@ -78,6 +81,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
* [Extract a backup](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/)
* [Backup your databases](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/)
* [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/)
* [Backup to a removable drive or an intermittent server](https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/)
* [Upgrade borgmatic](https://torsion.org/borgmatic/docs/how-to/upgrade/)
* [Develop on borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/)
@@ -115,14 +119,22 @@ If you'd like to chat with borgmatic developers or users, head on over to the
href="https://webchat.freenode.net/?channels=borgmatic">web chat</a> or a
native <a href="irc://chat.freenode.net:6697">IRC client</a>.
Other questions or comments? Contact <mailto:witten@torsion.org>.
Also see the [security
policy](https://torsion.org/borgmatic/docs/security-policy/) for any security
issues.
Other questions or comments? Contact
[witten@torsion.org](mailto:witten@torsion.org).
### Contributing
borgmatic is hosted at <https://torsion.org/borgmatic> with [source code
available](https://projects.torsion.org/witten/borgmatic). It's also mirrored
on [GitHub](https://github.com/witten/borgmatic) for convenience.
available](https://projects.torsion.org/witten/borgmatic), and is also
mirrored on [GitHub](https://github.com/witten/borgmatic) for convenience.
borgmatic is licensed under the GNU General Public License version 3 or any
later version.
If you'd like to contribute to borgmatic development, please feel free to
submit a [Pull Request](https://projects.torsion.org/witten/borgmatic/pulls)
+19
View File
@@ -0,0 +1,19 @@
---
title: Security policy
permalink: security-policy/index.html
---
## Supported versions
While we want to hear about security vulnerabilities in all versions of
borgmatic, security fixes will only be made to the most recently released
version. It's not practical for our small volunteer effort to maintain
multiple different release branches and put out separate security patches for
each.
## Reporting a vulnerability
If you find a security vulnerability, please [file a
ticket](https://torsion.org/borgmatic/#issues) or [send email
directly](mailto:witten@torsion.org) as appropriate. You should expect to hear
back within a few days at most, and generally sooner.
+18 -8
View File
@@ -1,7 +1,7 @@
import logging
from borgmatic.borg import extract
from borgmatic.execute import execute_command
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
DEFAULT_CHECKS = ('repository', 'archives')
DEFAULT_PREFIX = '{hostname}-'
@@ -91,23 +91,25 @@ def check_archives(
consistency_config,
local_path='borg',
remote_path=None,
progress=None,
repair=None,
only_checks=None,
):
'''
Given a local or remote repository path, a storage config dict, a consistency config dict,
local/remote commands to run, and an optional list of checks to use instead of configured
checks, check the contained Borg archives for consistency.
local/remote commands to run, whether to include progress information, whether to attempt a
repair, and an optional list of checks to use instead of configured checks, check the contained
Borg archives for consistency.
If there are no consistency checks to run, skip running them.
'''
checks = _parse_checks(consistency_config, only_checks)
check_last = consistency_config.get('check_last', None)
lock_wait = None
extra_borg_options = storage_config.get('extra_borg_options', {}).get('check', '')
if set(checks).intersection(set(DEFAULT_CHECKS + ('data',))):
remote_path_flags = ('--remote-path', remote_path) if remote_path else ()
lock_wait = storage_config.get('lock_wait', None)
lock_wait_flags = ('--lock-wait', str(lock_wait)) if lock_wait else ()
verbosity_flags = ()
if logger.isEnabledFor(logging.INFO):
@@ -119,14 +121,22 @@ def check_archives(
full_command = (
(local_path, 'check')
+ (('--repair',) if repair else ())
+ _make_check_flags(checks, check_last, prefix)
+ remote_path_flags
+ lock_wait_flags
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags
+ (('--progress',) if progress else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ (repository,)
)
execute_command(full_command)
# The Borg repair option trigger an interactive prompt, which won't work when output is
# captured. And progress messes with the terminal directly.
if repair or progress:
execute_command(full_command, output_file=DO_NOT_CAPTURE)
else:
execute_command(full_command)
if 'extract' in checks:
extract.extract_last_archive_dry_run(repository, lock_wait, local_path, remote_path)
+103 -27
View File
@@ -2,9 +2,10 @@ import glob
import itertools
import logging
import os
import pathlib
import tempfile
from borgmatic.execute import execute_command, execute_command_without_capture
from borgmatic.execute import DO_NOT_CAPTURE, execute_command, execute_command_with_processes
logger = logging.getLogger(__name__)
@@ -43,6 +44,53 @@ def _expand_home_directories(directories):
return tuple(os.path.expanduser(directory) for directory in directories)
def map_directories_to_devices(directories): # pragma: no cover
'''
Given a sequence of directories, return a map from directory to an identifier for the device on
which that directory resides. This is handy for determining whether two different directories
are on the same filesystem (have the same device identifier).
'''
return {directory: os.stat(directory).st_dev for directory in directories}
def deduplicate_directories(directory_devices):
'''
Given a map from directory to the identifier for the device on which that directory resides,
return the directories as a sorted tuple with all duplicate child directories removed. For
instance, if paths is ('/foo', '/foo/bar'), return just: ('/foo',)
The one exception to this rule is if two paths are on different filesystems (devices). In that
case, they won't get de-duplicated in case they both need to be passed to Borg (e.g. the
location.one_file_system option is true).
The idea is that if Borg is given a parent directory, then it doesn't also need to be given
child directories, because it will naturally spider the contents of the parent directory. And
there are cases where Borg coming across the same file twice will result in duplicate reads and
even hangs, e.g. when a database hook is using a named pipe for streaming database dumps to
Borg.
'''
deduplicated = set()
directories = sorted(directory_devices.keys())
for directory in directories:
deduplicated.add(directory)
parents = pathlib.PurePath(directory).parents
# If another directory in the given list is a parent of current directory (even n levels
# up) and both are on the same filesystem, then the current directory is a duplicate.
for other_directory in directories:
for parent in parents:
if (
pathlib.PurePath(other_directory) == parent
and directory_devices[other_directory] == directory_devices[directory]
):
if directory in deduplicated:
deduplicated.remove(directory)
break
return tuple(sorted(deduplicated))
def _write_pattern_file(patterns=None):
'''
Given a sequence of patterns, write them to a named temporary file and return it. Return None
@@ -88,8 +136,12 @@ def _make_exclude_flags(location_config, exclude_filename=None):
)
)
caches_flag = ('--exclude-caches',) if location_config.get('exclude_caches') else ()
if_present = location_config.get('exclude_if_present')
if_present_flags = ('--exclude-if-present', if_present) if if_present else ()
if_present_flags = tuple(
itertools.chain.from_iterable(
('--exclude-if-present', if_present)
for if_present in location_config.get('exclude_if_present', ())
)
)
keep_exclude_tags_flags = (
('--keep-exclude-tags',) if location_config.get('keep_exclude_tags') else ()
)
@@ -104,20 +156,26 @@ def _make_exclude_flags(location_config, exclude_filename=None):
)
BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic'
DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic'
def borgmatic_source_directories():
def borgmatic_source_directories(borgmatic_source_directory):
'''
Return a list of borgmatic-specific source directories used for state like database backups.
'''
if not borgmatic_source_directory:
borgmatic_source_directory = DEFAULT_BORGMATIC_SOURCE_DIRECTORY
return (
[BORGMATIC_SOURCE_DIRECTORY]
if os.path.exists(os.path.expanduser(BORGMATIC_SOURCE_DIRECTORY))
[borgmatic_source_directory]
if os.path.exists(os.path.expanduser(borgmatic_source_directory))
else []
)
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
def create_archive(
dry_run,
repository,
@@ -128,13 +186,23 @@ def create_archive(
progress=False,
stats=False,
json=False,
files=False,
stream_processes=None,
):
'''
Given vebosity/dry-run flags, a local or remote repository path, a location config dict, and a
storage config dict, create a Borg archive and return Borg's JSON output (if any).
If a sequence of stream processes is given (instances of subprocess.Popen), then execute the
create command while also triggering the given processes to produce output.
'''
sources = _expand_directories(
location_config['source_directories'] + borgmatic_source_directories()
sources = deduplicate_directories(
map_directories_to_devices(
_expand_directories(
location_config['source_directories']
+ borgmatic_source_directories(location_config.get('borgmatic_source_directory'))
)
)
)
pattern_file = _write_pattern_file(location_config.get('patterns'))
@@ -148,8 +216,8 @@ def create_archive(
umask = storage_config.get('umask', None)
lock_wait = storage_config.get('lock_wait', None)
files_cache = location_config.get('files_cache')
default_archive_name_format = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
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', '')
full_command = (
(local_path, 'create')
@@ -159,28 +227,29 @@ def create_archive(
+ (('--chunker-params', chunker_params) if chunker_params else ())
+ (('--compression', compression) if compression else ())
+ (('--remote-ratelimit', str(remote_rate_limit)) if remote_rate_limit else ())
+ (('--one-file-system',) if location_config.get('one_file_system') else ())
+ (
('--one-file-system',)
if location_config.get('one_file_system') or stream_processes
else ()
)
+ (('--numeric-owner',) if location_config.get('numeric_owner') else ())
+ (('--noatime',) if location_config.get('atime') is False else ())
+ (('--noctime',) if location_config.get('ctime') is False else ())
+ (('--nobirthtime',) if location_config.get('birthtime') is False else ())
+ (('--read-special',) if location_config.get('read_special') else ())
+ (('--read-special',) if (location_config.get('read_special') or stream_processes) else ())
+ (('--nobsdflags',) if location_config.get('bsd_flags') is False else ())
+ (('--files-cache', files_cache) if files_cache else ())
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--list', '--filter', 'AME-') if logger.isEnabledFor(logging.INFO) and not json else ())
+ (('--list', '--filter', 'AME-') if files and not json and not progress else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ())
+ (
('--stats',)
if not dry_run and (logger.isEnabledFor(logging.INFO) or stats) and not json
else ()
)
+ (('--stats',) if stats and not json and not dry_run else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ())
+ (('--dry-run',) if dry_run else ())
+ (('--progress',) if progress else ())
+ (('--json',) if json else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ (
'{repository}::{archive_name_format}'.format(
repository=repository, archive_name_format=archive_name_format
@@ -189,17 +258,24 @@ def create_archive(
+ sources
)
# The progress output isn't compatible with captured and logged output, as progress messes with
# the terminal directly.
if progress:
execute_command_without_capture(full_command)
return
if json:
output_log_level = None
elif stats:
elif (stats or files) and logger.getEffectiveLevel() == logging.WARNING:
output_log_level = logging.WARNING
else:
output_log_level = logging.INFO
return execute_command(full_command, output_log_level)
# The progress output isn't compatible with captured and logged output, as progress messes with
# the terminal directly.
output_file = DO_NOT_CAPTURE if progress else None
if stream_processes:
return execute_command_with_processes(
full_command,
stream_processes,
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)
+7 -1
View File
@@ -19,9 +19,15 @@ DEFAULT_BOOL_OPTION_TO_ENVIRONMENT_VARIABLE = {
def initialize(storage_config):
for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items():
value = storage_config.get(option_name)
# Options from borgmatic configuration take precedence over already set BORG_* environment
# variables.
value = storage_config.get(option_name) or os.environ.get(environment_variable_name)
if value:
os.environ[environment_variable_name] = value
else:
os.environ.pop(environment_variable_name, None)
for (
option_name,
+30 -12
View File
@@ -1,7 +1,8 @@
import logging
import os
import subprocess
from borgmatic.execute import execute_command, execute_command_without_capture
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
@@ -27,7 +28,9 @@ def extract_last_archive_dry_run(repository, lock_wait=None, local_path='borg',
+ (repository,)
)
list_output = execute_command(full_list_command, output_log_level=None)
list_output = execute_command(
full_list_command, output_log_level=None, borg_local_path=local_path
)
try:
last_archive_name = list_output.strip().splitlines()[-1]
@@ -48,7 +51,7 @@ def extract_last_archive_dry_run(repository, lock_wait=None, local_path='borg',
)
)
execute_command(full_extract_command, working_directory=None, error_on_warnings=True)
execute_command(full_extract_command, working_directory=None)
def extract_archive(
@@ -61,18 +64,25 @@ def extract_archive(
local_path='borg',
remote_path=None,
destination_path=None,
strip_components=None,
progress=False,
error_on_warnings=True,
extract_to_stdout=False,
):
'''
Given a dry-run flag, a local or remote repository path, an archive name, zero or more paths to
restore from the archive, location/storage configuration dicts, optional local and remote Borg
paths, and an optional destination path to extract to, extract the archive into the current
directory.
If extract to stdout is True, then start the extraction streaming to stdout, and return that
extract process as an instance of subprocess.Popen.
'''
umask = storage_config.get('umask', None)
lock_wait = storage_config.get('lock_wait', None)
if progress and extract_to_stdout:
raise ValueError('progress and extract_to_stdout cannot both be set')
full_command = (
(local_path, 'extract')
+ (('--remote-path', remote_path) if remote_path else ())
@@ -82,7 +92,9 @@ def extract_archive(
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--dry-run',) if dry_run else ())
+ (('--strip-components', str(strip_components)) if strip_components else ())
+ (('--progress',) if progress else ())
+ (('--stdout',) if extract_to_stdout else ())
+ ('::'.join((repository if ':' in repository else os.path.abspath(repository), archive)),)
+ (tuple(paths) if paths else ())
)
@@ -90,13 +102,19 @@ def extract_archive(
# The progress output isn't compatible with captured and logged output, as progress messes with
# the terminal directly.
if progress:
execute_command_without_capture(
full_command, working_directory=destination_path, error_on_warnings=error_on_warnings
return execute_command(
full_command, output_file=DO_NOT_CAPTURE, working_directory=destination_path
)
return
return None
# Error on warnings by default, as Borg only gives a warning if the restore paths don't exist in
# the archive!
execute_command(
full_command, working_directory=destination_path, error_on_warnings=error_on_warnings
)
if extract_to_stdout:
return execute_command(
full_command,
output_file=subprocess.PIPE,
working_directory=destination_path,
run_to_completion=False,
)
# Don't give Borg local path, so as to error on warnings, as Borg only gives a warning if the
# restore paths don't exist in the archive!
execute_command(full_command, working_directory=destination_path)
+3 -1
View File
@@ -39,5 +39,7 @@ def display_archives_info(
)
return execute_command(
full_command, output_log_level=None if info_arguments.json else logging.WARNING
full_command,
output_log_level=None if info_arguments.json else logging.WARNING,
borg_local_path=local_path,
)
+17 -7
View File
@@ -1,7 +1,7 @@
import logging
import subprocess
from borgmatic.execute import execute_command, execute_command_without_capture
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
@@ -11,6 +11,7 @@ INFO_REPOSITORY_NOT_FOUND_EXIT_CODE = 2
def initialize_repository(
repository,
storage_config,
encryption_mode,
append_only=None,
storage_quota=None,
@@ -18,11 +19,17 @@ def initialize_repository(
remote_path=None,
):
'''
Given a local or remote repository path, a Borg encryption mode, 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.
Given a local or remote repository path, a storage configuration dict, a Borg encryption mode,
whether the repository should be append-only, and the storage quota to use, initialize the
repository. If the repository already exists, then log and skip initialization.
'''
info_command = (local_path, 'info', repository)
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:
@@ -33,6 +40,8 @@ def initialize_repository(
if error.returncode != INFO_REPOSITORY_NOT_FOUND_EXIT_CODE:
raise
extra_borg_options = storage_config.get('extra_borg_options', {}).get('init', '')
init_command = (
(local_path, 'init')
+ (('--encryption', encryption_mode) if encryption_mode else ())
@@ -41,8 +50,9 @@ def initialize_repository(
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug',) if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--remote-path', remote_path) if remote_path else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ (repository,)
)
# Don't use execute_command() here because it doesn't support interactive prompts.
execute_command_without_capture(init_command)
# Do not capture output here, so as to support interactive prompts.
execute_command(init_command, output_file=DO_NOT_CAPTURE, borg_local_path=local_path)
+41 -2
View File
@@ -11,6 +11,42 @@ logger = logging.getLogger(__name__)
BORG_EXCLUDE_CHECKPOINTS_GLOB = '*[0123456789]'
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
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)
archive, and return its name.
Raise ValueError if "latest" is given but there are no archives in the repository.
'''
if archive != "latest":
return archive
lock_wait = storage_config.get('lock_wait', None)
full_command = (
(local_path, 'list')
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ make_flags('remote-path', remote_path)
+ make_flags('lock-wait', lock_wait)
+ make_flags('glob-archives', BORG_EXCLUDE_CHECKPOINTS_GLOB)
+ make_flags('last', 1)
+ ('--short', repository)
)
output = execute_command(full_command, output_log_level=None, borg_local_path=local_path)
try:
latest_archive = output.strip().splitlines()[-1]
except IndexError:
raise ValueError('No archives found in the repository')
logger.debug('{}: Latest archive is {}'.format(repository, latest_archive))
return latest_archive
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, and the arguments to the list
@@ -36,15 +72,18 @@ def list_archives(repository, storage_config, list_arguments, local_path='borg',
+ make_flags('remote-path', remote_path)
+ make_flags('lock-wait', lock_wait)
+ make_flags_from_arguments(
list_arguments, excludes=('repository', 'archive', 'successful')
list_arguments, excludes=('repository', 'archive', 'paths', 'successful')
)
+ (
'::'.join((repository, list_arguments.archive))
if list_arguments.archive
else repository,
)
+ (tuple(list_arguments.paths) if list_arguments.paths else ())
)
return execute_command(
full_command, output_log_level=None if list_arguments.json else logging.WARNING
full_command,
output_log_level=None if list_arguments.json else logging.WARNING,
borg_local_path=local_path,
)
+46
View File
@@ -0,0 +1,46 @@
import logging
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
def mount_archive(
repository,
archive,
mount_point,
paths,
foreground,
options,
storage_config,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, an optional archive name, a filesystem mount point,
zero or more paths to mount from the archive, extra Borg mount options, a storage configuration
dict, and optional local and remote Borg paths, mount the archive onto the mount point.
'''
umask = storage_config.get('umask', None)
lock_wait = storage_config.get('lock_wait', None)
full_command = (
(local_path, 'mount')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--foreground',) if foreground else ())
+ (('-o', options) if options else ())
+ (('::'.join((repository, archive)),) if archive else (repository,))
+ (mount_point,)
+ (tuple(paths) if paths else ())
)
# Don't capture the output when foreground mode is used so that ctrl-C can work properly.
if foreground:
execute_command(full_command, output_file=DO_NOT_CAPTURE, borg_local_path=local_path)
return
execute_command(full_command, borg_local_path=local_path)
+12 -4
View File
@@ -41,6 +41,7 @@ def prune_archives(
local_path='borg',
remote_path=None,
stats=False,
files=False,
):
'''
Given dry-run flag, a local or remote repository path, a storage config dict, and a
@@ -49,6 +50,7 @@ def prune_archives(
'''
umask = storage_config.get('umask', None)
lock_wait = storage_config.get('lock_wait', None)
extra_borg_options = storage_config.get('extra_borg_options', {}).get('prune', '')
full_command = (
(local_path, 'prune')
@@ -56,12 +58,18 @@ def prune_archives(
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--stats',) if not dry_run and logger.isEnabledFor(logging.INFO) else ())
+ (('--stats',) if stats and not dry_run else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--list',) if files else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--dry-run',) if dry_run else ())
+ (('--stats',) if stats else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ (repository,)
)
execute_command(full_command, output_log_level=logging.WARNING if stats else logging.INFO)
if (stats or files) and logger.getEffectiveLevel() == logging.WARNING:
output_log_level = logging.WARNING
else:
output_log_level = logging.INFO
execute_command(full_command, output_log_level=output_log_level, borg_local_path=local_path)
+20
View File
@@ -0,0 +1,20 @@
import logging
from borgmatic.execute import execute_command
logger = logging.getLogger(__name__)
def unmount_archive(mount_point, local_path='borg'):
'''
Given a mounted filesystem mount point, and an optional local Borg paths, umount the filesystem
from the mount point.
'''
full_command = (
(local_path, 'umount')
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (mount_point,)
)
execute_command(full_command)
+123 -21
View File
@@ -9,6 +9,8 @@ SUBPARSER_ALIASES = {
'create': ['--create', '-C'],
'check': ['--check', '-k'],
'extract': ['--extract', '-x'],
'mount': ['--mount', '-m'],
'umount': ['--umount', '-u'],
'restore': ['--restore', '-r'],
'list': ['--list', '-l'],
'info': ['--info', '-i'],
@@ -104,7 +106,8 @@ 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.
'''
config_paths = collect.get_default_config_paths()
config_paths = collect.get_default_config_paths(expand_home=True)
unexpanded_config_paths = collect.get_default_config_paths(expand_home=False)
global_parser = ArgumentParser(add_help=False)
global_group = global_parser.add_argument_group('global arguments')
@@ -116,7 +119,7 @@ def parse_arguments(*unparsed_arguments):
dest='config_paths',
default=config_paths,
help='Configuration filenames or directories, defaults to: {}'.format(
' '.join(config_paths)
' '.join(unexpanded_config_paths)
),
)
global_group.add_argument(
@@ -140,21 +143,28 @@ def parse_arguments(*unparsed_arguments):
type=int,
choices=range(-1, 3),
default=0,
help='Display verbose progress to the console (from none to lots: 0, 1, or 2) or only errors (-1)',
help='Display verbose progress to the console (from only errors to very verbose: -1, 0, 1, or 2)',
)
global_group.add_argument(
'--syslog-verbosity',
type=int,
choices=range(-1, 3),
default=0,
help='Log verbose progress to syslog (from none to lots: 0, 1, or 2) or only errors (-1). Ignored when console is interactive or --log-file is given',
help='Log verbose progress to syslog (from only errors to very verbose: -1, 0, 1, or 2). Ignored when console is interactive or --log-file is given',
)
global_group.add_argument(
'--log-file-verbosity',
type=int,
choices=range(-1, 3),
default=0,
help='Log verbose progress to log file (from none to lots: 0, 1, or 2) or only errors (-1). Only used when --log-file is given',
help='Log verbose progress to log file (from only errors to very verbose: -1, 0, 1, or 2). Only used when --log-file is given',
)
global_group.add_argument(
'--monitoring-verbosity',
type=int,
choices=range(-1, 3),
default=0,
help='Log verbose progress to monitoring integrations that support logging (from only errors to very verbose: -1, 0, 1, or 2)',
)
global_group.add_argument(
'--log-file',
@@ -162,6 +172,13 @@ def parse_arguments(*unparsed_arguments):
default=None,
help='Write log messages to this file instead of syslog',
)
global_group.add_argument(
'--override',
metavar='SECTION.OPTION=VALUE',
nargs='+',
dest='overrides',
help='One or more configuration file options to override with specified values',
)
global_group.add_argument(
'--version',
dest='version',
@@ -172,9 +189,9 @@ def parse_arguments(*unparsed_arguments):
top_level_parser = ArgumentParser(
description='''
A simple wrapper script for the Borg backup software that creates and prunes backups.
If none of the action options are given, then borgmatic defaults to: prune, create, and
check archives.
Simple, configuration-driven backup software for servers and workstations. If none of
the action options are given, then borgmatic defaults to: prune, create, and check
archives.
''',
parents=[global_parser],
)
@@ -227,6 +244,9 @@ def parse_arguments(*unparsed_arguments):
action='store_true',
help='Display statistics of archive',
)
prune_group.add_argument(
'--files', dest='files', default=False, action='store_true', help='Show per-file details'
)
prune_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
create_parser = subparsers.add_parser(
@@ -242,7 +262,7 @@ def parse_arguments(*unparsed_arguments):
dest='progress',
default=False,
action='store_true',
help='Display progress for each file as it is processed',
help='Display progress for each file as it is backed up',
)
create_group.add_argument(
'--stats',
@@ -251,6 +271,9 @@ def parse_arguments(*unparsed_arguments):
action='store_true',
help='Display statistics of archive',
)
create_group.add_argument(
'--files', dest='files', default=False, action='store_true', help='Show per-file details'
)
create_group.add_argument(
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
)
@@ -264,6 +287,20 @@ def parse_arguments(*unparsed_arguments):
add_help=False,
)
check_group = check_parser.add_argument_group('check arguments')
check_group.add_argument(
'--progress',
dest='progress',
default=False,
action='store_true',
help='Display progress for each file as it is checked',
)
check_group.add_argument(
'--repair',
dest='repair',
default=False,
action='store_true',
help='Attempt to repair any inconsistencies found (experimental and only for interactive use)',
)
check_group.add_argument(
'--only',
metavar='CHECK',
@@ -286,7 +323,9 @@ def parse_arguments(*unparsed_arguments):
'--repository',
help='Path of repository to extract, defaults to the configured repository if there is only one',
)
extract_group.add_argument('--archive', help='Name of archive to extract', required=True)
extract_group.add_argument(
'--archive', help='Name of archive to extract (or "latest")', required=True
)
extract_group.add_argument(
'--path',
'--restore-path',
@@ -301,17 +340,78 @@ def parse_arguments(*unparsed_arguments):
dest='destination',
help='Directory to extract files into, defaults to the current directory',
)
extract_group.add_argument(
'--strip-components',
type=int,
metavar='NUMBER',
dest='strip_components',
help='Number of leading path components to remove from each extracted path. Skip paths with fewer elements',
)
extract_group.add_argument(
'--progress',
dest='progress',
default=False,
action='store_true',
help='Display progress for each file as it is processed',
help='Display progress for each file as it is extracted',
)
extract_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
)
mount_parser = subparsers.add_parser(
'mount',
aliases=SUBPARSER_ALIASES['mount'],
help='Mount files from a named archive as a FUSE filesystem',
description='Mount a named archive as a FUSE filesystem',
add_help=False,
)
mount_group = mount_parser.add_argument_group('mount arguments')
mount_group.add_argument(
'--repository',
help='Path of repository to use, defaults to the configured repository if there is only one',
)
mount_group.add_argument('--archive', help='Name of archive to mount (or "latest")')
mount_group.add_argument(
'--mount-point',
metavar='PATH',
dest='mount_point',
help='Path where filesystem is to be mounted',
required=True,
)
mount_group.add_argument(
'--path',
metavar='PATH',
nargs='+',
dest='paths',
help='Paths to mount from archive, defaults to the entire archive',
)
mount_group.add_argument(
'--foreground',
dest='foreground',
default=False,
action='store_true',
help='Stay in foreground until ctrl-C is pressed',
)
mount_group.add_argument('--options', dest='options', help='Extra Borg mount options')
mount_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
umount_parser = subparsers.add_parser(
'umount',
aliases=SUBPARSER_ALIASES['umount'],
help='Unmount a FUSE filesystem that was mounted with "borgmatic mount"',
description='Unmount a mounted FUSE filesystem',
add_help=False,
)
umount_group = umount_parser.add_argument_group('umount arguments')
umount_group.add_argument(
'--mount-point',
metavar='PATH',
dest='mount_point',
help='Path of filesystem to unmount',
required=True,
)
umount_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
restore_parser = subparsers.add_parser(
'restore',
aliases=SUBPARSER_ALIASES['restore'],
@@ -324,7 +424,9 @@ def parse_arguments(*unparsed_arguments):
'--repository',
help='Path of repository to restore from, defaults to the configured repository if there is only one',
)
restore_group.add_argument('--archive', help='Name of archive to restore from', required=True)
restore_group.add_argument(
'--archive', help='Name of archive to restore from (or "latest")', required=True
)
restore_group.add_argument(
'--database',
metavar='NAME',
@@ -332,13 +434,6 @@ def parse_arguments(*unparsed_arguments):
dest='databases',
help='Names of databases to restore from archive, defaults to all databases. Note that any databases to restore must be defined in borgmatic\'s configuration',
)
restore_group.add_argument(
'--progress',
dest='progress',
default=False,
action='store_true',
help='Display progress for each database dump file as it is extracted from archive',
)
restore_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
)
@@ -355,7 +450,14 @@ def parse_arguments(*unparsed_arguments):
'--repository',
help='Path of repository to list, defaults to the configured repository if there is only one',
)
list_group.add_argument('--archive', help='Name of archive to list')
list_group.add_argument('--archive', help='Name of archive to list (or "latest")')
list_group.add_argument(
'--path',
metavar='PATH',
nargs='+',
dest='paths',
help='Paths to list from archive, defaults to the entire archive',
)
list_group.add_argument(
'--short', default=False, action='store_true', help='Output only archive or path names'
)
@@ -410,7 +512,7 @@ def parse_arguments(*unparsed_arguments):
'--repository',
help='Path of repository to show info for, defaults to the configured repository if there is only one',
)
info_group.add_argument('--archive', help='Name of archive to show info for')
info_group.add_argument('--archive', help='Name of archive to show info for (or "latest")')
info_group.add_argument(
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
)
+311 -104
View File
@@ -1,4 +1,5 @@
import collections
import copy
import json
import logging
import os
@@ -15,7 +16,9 @@ from borgmatic.borg import extract as borg_extract
from borgmatic.borg import info as borg_info
from borgmatic.borg import init as borg_init
from borgmatic.borg import list as borg_list
from borgmatic.borg import mount as borg_mount
from borgmatic.borg import prune as borg_prune
from borgmatic.borg import umount as borg_umount
from borgmatic.commands.arguments import parse_arguments
from borgmatic.config import checks, collect, convert, validate
from borgmatic.hooks import command, dispatch, dump, monitor
@@ -50,17 +53,28 @@ def run_configuration(config_filename, config, arguments):
borg_environment.initialize(storage)
encountered_error = None
error_repository = ''
prune_create_or_check = {'prune', 'create', 'check'}.intersection(arguments)
monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
if 'create' in arguments:
try:
try:
if prune_create_or_check:
dispatch.call_hooks(
'ping_monitor',
'initialize_monitor',
hooks,
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.START,
monitoring_log_level,
global_arguments.dry_run,
)
if 'prune' in arguments:
command.execute_hook(
hooks.get('before_prune'),
hooks.get('umask'),
config_filename,
'pre-prune',
global_arguments.dry_run,
)
if 'create' in arguments:
command.execute_hook(
hooks.get('before_backup'),
hooks.get('umask'),
@@ -68,18 +82,32 @@ def run_configuration(config_filename, config, arguments):
'pre-backup',
global_arguments.dry_run,
)
dispatch.call_hooks(
'dump_databases',
hooks,
if 'check' in arguments:
command.execute_hook(
hooks.get('before_check'),
hooks.get('umask'),
config_filename,
dump.DATABASE_HOOK_NAMES,
'pre-check',
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
encountered_error = error
yield from make_error_log_records(
'{}: Error running pre-backup hook'.format(config_filename), error
if prune_create_or_check:
dispatch.call_hooks(
'ping_monitor',
hooks,
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.START,
monitoring_log_level,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if command.considered_soft_failure(config_filename, error):
return
encountered_error = error
yield from make_error_log_records(
'{}: Error running pre hook'.format(config_filename), error
)
if not encountered_error:
for repository_path in location['repositories']:
@@ -102,37 +130,68 @@ def run_configuration(config_filename, config, arguments):
'{}: Error running actions for repository'.format(repository_path), error
)
if 'create' in arguments and not encountered_error:
if not encountered_error:
try:
dispatch.call_hooks(
'remove_database_dumps',
hooks,
config_filename,
dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
)
command.execute_hook(
hooks.get('after_backup'),
hooks.get('umask'),
config_filename,
'post-backup',
global_arguments.dry_run,
)
dispatch.call_hooks(
'ping_monitor',
hooks,
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.FINISH,
global_arguments.dry_run,
)
if 'prune' in arguments:
command.execute_hook(
hooks.get('after_prune'),
hooks.get('umask'),
config_filename,
'post-prune',
global_arguments.dry_run,
)
if 'create' in arguments:
dispatch.call_hooks(
'remove_database_dumps',
hooks,
config_filename,
dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
command.execute_hook(
hooks.get('after_backup'),
hooks.get('umask'),
config_filename,
'post-backup',
global_arguments.dry_run,
)
if 'check' in arguments:
command.execute_hook(
hooks.get('after_check'),
hooks.get('umask'),
config_filename,
'post-check',
global_arguments.dry_run,
)
if prune_create_or_check:
dispatch.call_hooks(
'ping_monitor',
hooks,
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.FINISH,
monitoring_log_level,
global_arguments.dry_run,
)
dispatch.call_hooks(
'destroy_monitor',
hooks,
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitoring_log_level,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if command.considered_soft_failure(config_filename, error):
return
encountered_error = error
yield from make_error_log_records(
'{}: Error running post-backup hook'.format(config_filename), error
'{}: Error running post hook'.format(config_filename), error
)
if encountered_error:
if encountered_error and prune_create_or_check:
try:
command.execute_hook(
hooks.get('on_error'),
@@ -150,9 +209,21 @@ def run_configuration(config_filename, config, arguments):
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.FAIL,
monitoring_log_level,
global_arguments.dry_run,
)
dispatch.call_hooks(
'destroy_monitor',
hooks,
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitoring_log_level,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if command.considered_soft_failure(config_filename, error):
return
yield from make_error_log_records(
'{}: Error running on-error hook'.format(config_filename), error
)
@@ -187,6 +258,7 @@ def run_actions(
logger.info('{}: Initializing repository'.format(repository))
borg_init.initialize_repository(
repository,
storage,
arguments['init'].encryption_mode,
arguments['init'].append_only,
arguments['init'].storage_quota,
@@ -203,9 +275,28 @@ def run_actions(
local_path=local_path,
remote_path=remote_path,
stats=arguments['prune'].stats,
files=arguments['prune'].files,
)
if 'create' in arguments:
logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
dispatch.call_hooks(
'remove_database_dumps',
hooks,
repository,
dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
active_dumps = dispatch.call_hooks(
'dump_databases',
hooks,
repository,
dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
stream_processes = [process for processes in active_dumps.values() for process in processes]
json_output = borg_create.create_archive(
global_arguments.dry_run,
repository,
@@ -216,9 +307,12 @@ def run_actions(
progress=arguments['create'].progress,
stats=arguments['create'].stats,
json=arguments['create'].json,
files=arguments['create'].files,
stream_processes=stream_processes,
)
if json_output:
yield json.loads(json_output)
if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency):
logger.info('{}: Running consistency checks'.format(repository))
borg_check.check_archives(
@@ -227,102 +321,182 @@ def run_actions(
consistency,
local_path=local_path,
remote_path=remote_path,
progress=arguments['check'].progress,
repair=arguments['check'].repair,
only_checks=arguments['check'].only,
)
if 'extract' in arguments:
if arguments['extract'].repository is None or repository == arguments['extract'].repository:
if arguments['extract'].repository is None or validate.repositories_match(
repository, arguments['extract'].repository
):
logger.info(
'{}: Extracting archive {}'.format(repository, arguments['extract'].archive)
)
borg_extract.extract_archive(
global_arguments.dry_run,
repository,
arguments['extract'].archive,
borg_list.resolve_archive_name(
repository, arguments['extract'].archive, storage, local_path, remote_path
),
arguments['extract'].paths,
location,
storage,
local_path=local_path,
remote_path=remote_path,
destination_path=arguments['extract'].destination,
strip_components=arguments['extract'].strip_components,
progress=arguments['extract'].progress,
)
if 'mount' in arguments:
if arguments['mount'].repository is None or validate.repositories_match(
repository, arguments['mount'].repository
):
if arguments['mount'].archive:
logger.info(
'{}: Mounting archive {}'.format(repository, arguments['mount'].archive)
)
else:
logger.info('{}: Mounting repository'.format(repository))
borg_mount.mount_archive(
repository,
borg_list.resolve_archive_name(
repository, arguments['mount'].archive, storage, local_path, remote_path
),
arguments['mount'].mount_point,
arguments['mount'].paths,
arguments['mount'].foreground,
arguments['mount'].options,
storage,
local_path=local_path,
remote_path=remote_path,
)
if 'restore' in arguments:
if arguments['restore'].repository is None or repository == arguments['restore'].repository:
if arguments['restore'].repository is None or validate.repositories_match(
repository, arguments['restore'].repository
):
logger.info(
'{}: Restoring databases from archive {}'.format(
repository, arguments['restore'].archive
)
)
dispatch.call_hooks(
'remove_database_dumps',
hooks,
repository,
dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
restore_names = arguments['restore'].databases or []
if 'all' in restore_names:
restore_names = []
# Extract dumps for the named databases from the archive.
dump_patterns = dispatch.call_hooks(
'make_database_dump_patterns',
archive_name = borg_list.resolve_archive_name(
repository, arguments['restore'].archive, storage, local_path, remote_path
)
found_names = set()
for hook_name, per_hook_restore_databases in hooks.items():
if hook_name not in dump.DATABASE_HOOK_NAMES:
continue
for restore_database in per_hook_restore_databases:
database_name = restore_database['name']
if restore_names and database_name not in restore_names:
continue
found_names.add(database_name)
dump_pattern = dispatch.call_hooks(
'make_database_dump_pattern',
hooks,
repository,
dump.DATABASE_HOOK_NAMES,
location,
database_name,
)[hook_name]
# Kick off a single database extract to stdout.
extract_process = borg_extract.extract_archive(
dry_run=global_arguments.dry_run,
repository=repository,
archive=archive_name,
paths=dump.convert_glob_patterns_to_borg_patterns([dump_pattern]),
location_config=location,
storage_config=storage,
local_path=local_path,
remote_path=remote_path,
destination_path='/',
# A directory format dump isn't a single file, and therefore can't extract
# to stdout. In this case, the extract_process return value is None.
extract_to_stdout=bool(restore_database.get('format') != 'directory'),
)
# Run a single database restore, consuming the extract stdout (if any).
dispatch.call_hooks(
'restore_database_dump',
{hook_name: [restore_database]},
repository,
dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
extract_process,
)
dispatch.call_hooks(
'remove_database_dumps',
hooks,
repository,
dump.DATABASE_HOOK_NAMES,
restore_names,
)
borg_extract.extract_archive(
global_arguments.dry_run,
repository,
arguments['restore'].archive,
dump.convert_glob_patterns_to_borg_patterns(
dump.flatten_dump_patterns(dump_patterns, restore_names)
),
location,
storage,
local_path=local_path,
remote_path=remote_path,
destination_path='/',
progress=arguments['restore'].progress,
# We don't want glob patterns that don't match to error.
error_on_warnings=False,
)
# Map the restore names or detected dumps to the corresponding database configurations.
restore_databases = dump.get_per_hook_database_configurations(
hooks, restore_names, dump_patterns
)
# Finally, restore the databases and cleanup the dumps.
dispatch.call_hooks(
'restore_database_dumps',
restore_databases,
repository,
dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
)
dispatch.call_hooks(
'remove_database_dumps',
restore_databases,
repository,
dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
)
if not restore_names and not found_names:
raise ValueError('No databases were found to restore')
missing_names = sorted(set(restore_names) - found_names)
if missing_names:
raise ValueError(
'Cannot restore database(s) {} missing from borgmatic\'s configuration'.format(
', '.join(missing_names)
)
)
if 'list' in arguments:
if arguments['list'].repository is None or repository == arguments['list'].repository:
logger.info('{}: Listing archives'.format(repository))
if arguments['list'].repository is None or validate.repositories_match(
repository, arguments['list'].repository
):
list_arguments = copy.copy(arguments['list'])
if not list_arguments.json:
logger.warning('{}: Listing archives'.format(repository))
list_arguments.archive = borg_list.resolve_archive_name(
repository, list_arguments.archive, storage, local_path, remote_path
)
json_output = borg_list.list_archives(
repository,
storage,
list_arguments=arguments['list'],
list_arguments=list_arguments,
local_path=local_path,
remote_path=remote_path,
)
if json_output:
yield json.loads(json_output)
if 'info' in arguments:
if arguments['info'].repository is None or repository == arguments['info'].repository:
logger.info('{}: Displaying summary info for archives'.format(repository))
if arguments['info'].repository is None or validate.repositories_match(
repository, arguments['info'].repository
):
info_arguments = copy.copy(arguments['info'])
if not info_arguments.json:
logger.warning('{}: Displaying summary info for archives'.format(repository))
info_arguments.archive = borg_list.resolve_archive_name(
repository, info_arguments.archive, storage, local_path, remote_path
)
json_output = borg_info.display_archives_info(
repository,
storage,
info_arguments=arguments['info'],
info_arguments=info_arguments,
local_path=local_path,
remote_path=remote_path,
)
@@ -330,7 +504,7 @@ def run_actions(
yield json.loads(json_output)
def load_configurations(config_filenames):
def load_configurations(config_filenames, overrides=None):
'''
Given a sequence of configuration filenames, load and validate each configuration file. Return
the results as a tuple of: dict of configuration filename to corresponding parsed configuration,
@@ -344,7 +518,7 @@ def load_configurations(config_filenames):
for config_filename in config_filenames:
try:
configs[config_filename] = validate.parse_configuration(
config_filename, validate.schema_filename()
config_filename, validate.schema_filename(), overrides
)
except (ValueError, OSError, validate.Validation_error) as error:
logs.extend(
@@ -365,14 +539,16 @@ def load_configurations(config_filenames):
return (configs, logs)
def log_record(**kwargs):
def log_record(suppress_log=False, **kwargs):
'''
Create a log record based on the given makeLogRecord() arguments, one of which must be
named "levelno". Log the record and return it.
named "levelno". Log the record (unless suppress log is set) and return it.
'''
record = logging.makeLogRecord(kwargs)
logger.handle(record)
if suppress_log:
return record
logger.handle(record)
return record
@@ -390,7 +566,10 @@ def make_error_log_records(message, error=None):
except CalledProcessError as error:
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
if error.output:
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error.output)
# Suppress these logs for now and save full error output for the log summary at the end.
yield log_record(
levelno=logging.CRITICAL, levelname='CRITICAL', msg=error.output, suppress_log=True
)
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
except (ValueError, OSError) as error:
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
@@ -401,6 +580,14 @@ def make_error_log_records(message, error=None):
pass
def get_local_path(configs):
'''
Arbitrarily return the local path from the first configuration dict. Default to "borg" if not
set.
'''
return next(iter(configs.values())).get('location', {}).get('local_path', 'borg')
def collect_configuration_run_summary_logs(configs, arguments):
'''
Given a dict of configuration filename to corresponding parsed configuration, and parsed
@@ -416,6 +603,8 @@ def collect_configuration_run_summary_logs(configs, arguments):
repository = arguments['extract'].repository
elif 'list' in arguments and arguments['list'].archive:
repository = arguments['list'].repository
elif 'mount' in arguments:
repository = arguments['mount'].repository
else:
repository = None
@@ -469,6 +658,15 @@ def collect_configuration_run_summary_logs(configs, arguments):
if results:
json_results.extend(results)
if 'umount' in arguments:
logger.info('Unmounting mount point {}'.format(arguments['umount'].mount_point))
try:
borg_umount.unmount_archive(
mount_point=arguments['umount'].mount_point, local_path=get_local_path(configs)
)
except (CalledProcessError, OSError) as error:
yield from make_error_log_records('Error unmounting mount point', error)
if json_results:
sys.stdout.write(json.dumps(json_results))
@@ -518,14 +716,21 @@ def main(): # pragma: no cover
sys.exit(0)
config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
configs, parse_logs = load_configurations(config_filenames)
configs, parse_logs = load_configurations(config_filenames, global_arguments.overrides)
colorama.init(autoreset=True, strip=not should_do_markup(global_arguments.no_color, configs))
any_json_flags = any(
getattr(sub_arguments, 'json', False) for sub_arguments in arguments.values()
)
colorama.init(
autoreset=True,
strip=not should_do_markup(global_arguments.no_color or any_json_flags, configs),
)
try:
configure_logging(
verbosity_to_log_level(global_arguments.verbosity),
verbosity_to_log_level(global_arguments.syslog_verbosity),
verbosity_to_log_level(global_arguments.log_file_verbosity),
verbosity_to_log_level(global_arguments.monitoring_verbosity),
global_arguments.log_file,
)
except (FileNotFoundError, PermissionError) as error:
@@ -536,16 +741,18 @@ def main(): # pragma: no cover
logger.debug('Ensuring legacy configuration is upgraded')
convert.guard_configuration_upgraded(LEGACY_CONFIG_PATH, config_filenames)
summary_logs = list(collect_configuration_run_summary_logs(configs, arguments))
summary_logs = parse_logs + list(collect_configuration_run_summary_logs(configs, arguments))
summary_logs_max_level = max(log.levelno for log in summary_logs)
if logger.getEffectiveLevel() < logging.WARNING:
logger.info('')
logger.info('summary:')
[
logger.handle(log)
for log in parse_logs + summary_logs
if log.levelno >= logger.getEffectiveLevel()
]
for message in ('', 'summary:'):
log_record(
levelno=summary_logs_max_level,
levelname=logging.getLevelName(summary_logs_max_level),
msg=message,
)
if any(log.levelno == logging.CRITICAL for log in summary_logs):
for log in summary_logs:
logger.handle(log)
if summary_logs_max_level >= logging.CRITICAL:
exit_with_help_link()
+7 -4
View File
@@ -1,20 +1,23 @@
import os
def get_default_config_paths():
def get_default_config_paths(expand_home=True):
'''
Based on the value of the XDG_CONFIG_HOME and HOME environment variables, return a list of
default configuration paths. This includes both system-wide configuration and configuration in
the current user's home directory.
Don't expand the home directory ($HOME) if the expand home flag is False.
'''
user_config_directory = os.getenv('XDG_CONFIG_HOME') or os.path.expandvars(
os.path.join('$HOME', '.config')
)
user_config_directory = os.getenv('XDG_CONFIG_HOME') or os.path.join('$HOME', '.config')
if expand_home:
user_config_directory = os.path.expandvars(user_config_directory)
return [
'/etc/borgmatic/config.yaml',
'/etc/borgmatic.d',
'%s/borgmatic/config.yaml' % user_config_directory,
'%s/borgmatic.d' % user_config_directory,
]
+4 -6
View File
@@ -37,9 +37,7 @@ def _schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
for item_schema in schema['seq']
]
)
add_comments_to_configuration_sequence(
config, schema, indent=(level * INDENT) + SEQUENCE_INDENT
)
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
elif 'map' in schema:
config = yaml.comments.CommentedMap(
[
@@ -86,8 +84,8 @@ def _comment_out_optional_configuration(rendered_config):
optional = False
for line in rendered_config.split('\n'):
# Upon encountering an optional configuration option, commenting out lines until the next
# blank line.
# Upon encountering an optional configuration option, comment out lines until the next blank
# line.
if line.strip().startswith('# {}'.format(COMMENTED_OUT_SENTINEL)):
optional = True
continue
@@ -142,7 +140,7 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
```
things:
# First key description. Added by this function.
# First key description. Added by this function.
- key: foo
# Second key description. Added by add_comments_to_configuration_map().
other: bar
+10
View File
@@ -0,0 +1,10 @@
def normalize(config):
'''
Given a configuration dict, apply particular hard-coded rules to normalize its contents to
adhere to the configuration schema.
'''
exclude_if_present = config.get('location', {}).get('exclude_if_present')
# "Upgrade" exclude_if_present from a string to a list.
if isinstance(exclude_if_present, str):
config['location']['exclude_if_present'] = [exclude_if_present]
+71
View File
@@ -0,0 +1,71 @@
import io
import ruamel.yaml
def set_values(config, keys, value):
'''
Given a hierarchy of configuration dicts, a sequence of parsed key strings, and a string value,
descend into the hierarchy based on the keys to set the value into the right place.
'''
if not keys:
return
first_key = keys[0]
if len(keys) == 1:
config[first_key] = value
return
if first_key not in config:
config[first_key] = {}
set_values(config[first_key], keys[1:], value)
def convert_value_type(value):
'''
Given a string value, determine its logical type (string, boolean, integer, etc.), and return it
converted to that type.
'''
return ruamel.yaml.YAML(typ='safe').load(io.StringIO(value))
def parse_overrides(raw_overrides):
'''
Given a sequence of configuration file override strings in the form of "section.option=value",
parse and return a sequence of tuples (keys, values), where keys is a sequence of strings. For
instance, given the following raw overrides:
['section.my_option=value1', 'section.other_option=value2']
... return this:
(
(('section', 'my_option'), 'value1'),
(('section', 'other_option'), 'value2'),
)
Raise ValueError if an override can't be parsed.
'''
if not raw_overrides:
return ()
try:
return tuple(
(tuple(raw_keys.split('.')), convert_value_type(value))
for raw_override in raw_overrides
for raw_keys, value in (raw_override.split('=', 1),)
)
except ValueError:
raise ValueError('Invalid override. Make sure you use the form: SECTION.OPTION=VALUE')
def apply_overrides(config, raw_overrides):
'''
Given a sequence of configuration file override strings in the form of "section.option=value"
and a configuration dict, parse each override and set it the configuration dict.
'''
overrides = parse_overrides(raw_overrides)
for (keys, value) in overrides:
set_values(config, keys, value)
+352 -163
View File
@@ -3,9 +3,10 @@ version: 1
map:
location:
desc: |
Where to look for files to backup, and where to store those backups. See
https://borgbackup.readthedocs.io/en/stable/quickstart.html and
https://borgbackup.readthedocs.io/en/stable/usage.html#borg-create for details.
Where to look for files to backup, and where to store those backups.
See https://borgbackup.readthedocs.io/en/stable/quickstart.html and
https://borgbackup.readthedocs.io/en/stable/usage/create.html
for details.
required: true
map:
source_directories:
@@ -13,7 +14,8 @@ map:
seq:
- type: str
desc: |
List of source directories to backup (required). Globs and tildes are expanded.
List of source directories to backup (required). Globs and
tildes are expanded.
example:
- /home
- /etc
@@ -23,18 +25,25 @@ map:
seq:
- type: str
desc: |
Paths to local or remote repositories (required). Tildes are expanded. Multiple
repositories are backed up to in sequence. See ssh_command for SSH options like
identity file or port.
Paths to local or remote repositories (required). Tildes are
expanded. Multiple repositories are backed up to in
sequence. See ssh_command for SSH options like identity file
or port.
example:
- user@backupserver:sourcehostname.borg
one_file_system:
type: bool
desc: Stay in same file system (do not cross mount points). Defaults to false.
desc: |
Stay in same file system (do not cross mount points).
Defaults to false. But when a database hook is used, the
setting here is ignored and one_file_system is considered
true.
example: true
numeric_owner:
type: bool
desc: Only store/extract numeric user and group identifiers. Defaults to false.
desc: |
Only store/extract numeric user and group identifiers.
Defaults to false.
example: true
atime:
type: bool
@@ -46,25 +55,32 @@ map:
example: false
birthtime:
type: bool
desc: Store birthtime (creation date) into archive. Defaults to true.
desc: |
Store birthtime (creation date) into archive. Defaults to
true.
example: false
read_special:
type: bool
desc: |
Use Borg's --read-special flag to allow backup of block and other special
devices. Use with caution, as it will lead to problems if used when
backing up special devices such as /dev/zero. Defaults to false.
Use Borg's --read-special flag to allow backup of block and
other special devices. Use with caution, as it will lead to
problems if used when backing up special devices such as
/dev/zero. Defaults to false. But when a database hook is
used, the setting here is ignored and read_special is
considered true.
example: false
bsd_flags:
type: bool
desc: Record bsdflags (e.g. NODUMP, IMMUTABLE) in archive. Defaults to true.
desc: |
Record bsdflags (e.g. NODUMP, IMMUTABLE) in archive.
Defaults to true.
example: true
files_cache:
type: str
desc: |
Mode in which to operate the files cache. See
https://borgbackup.readthedocs.io/en/stable/usage/create.html#description for
details. Defaults to "ctime,size,inode".
http://borgbackup.readthedocs.io/en/stable/usage/create.html
for details. Defaults to "ctime,size,inode".
example: ctime,size,inode
local_path:
type: str
@@ -78,9 +94,10 @@ map:
seq:
- type: str
desc: |
Any paths matching these patterns are included/excluded from backups. Globs are
expanded. (Tildes are not.) Note that Borg considers this option experimental.
See the output of "borg help patterns" for more details. Quote any value if it
Any paths matching these patterns are included/excluded from
backups. Globs are expanded. (Tildes are not.) Note that
Borg considers this option experimental. See the output of
"borg help patterns" for more details. Quote any value if it
contains leading punctuation, so it parses correctly.
example:
- 'R /'
@@ -91,17 +108,19 @@ map:
seq:
- type: str
desc: |
Read include/exclude patterns from one or more separate named files, one pattern
per line. Note that Borg considers this option experimental. See the output of
"borg help patterns" for more details.
Read include/exclude patterns from one or more separate
named files, one pattern per line. Note that Borg considers
this option experimental. See the output of "borg help
patterns" for more details.
example:
- /etc/borgmatic/patterns
exclude_patterns:
seq:
- type: str
desc: |
Any paths matching these patterns are excluded from backups. Globs and tildes
are expanded. See the output of "borg help patterns" for more details.
Any paths matching these patterns are excluded from backups.
Globs and tildes are expanded. See the output of "borg help
patterns" for more details.
example:
- '*.pyc'
- ~/*/.cache
@@ -110,112 +129,138 @@ map:
seq:
- type: str
desc: |
Read exclude patterns from one or more separate named files, one pattern per
line. See the output of "borg help patterns" for more details.
Read exclude patterns from one or more separate named files,
one pattern per line. See the output of "borg help patterns"
for more details.
example:
- /etc/borgmatic/excludes
exclude_caches:
type: bool
desc: |
Exclude directories that contain a CACHEDIR.TAG file. See
http://www.brynosaurus.com/cachedir/spec.html for details. Defaults to false.
http://www.brynosaurus.com/cachedir/spec.html for details.
Defaults to false.
example: true
exclude_if_present:
type: str
seq:
- type: str
desc: |
Exclude directories that contain a file with the given filename. Defaults to not
set.
example: .nobackup
Exclude directories that contain a file with the given
filenames. Defaults to not set.
example:
- .nobackup
keep_exclude_tags:
type: bool
desc: |
If true, the exclude_if_present filename is included in backups. Defaults to
false, meaning that the exclude_if_present filename is omitted from backups.
If true, the exclude_if_present filename is included in
backups. Defaults to false, meaning that the
exclude_if_present filename is omitted from backups.
example: true
exclude_nodump:
type: bool
desc: |
Exclude files with the NODUMP flag. Defaults to false.
example: true
borgmatic_source_directory:
type: str
desc: |
Path for additional source files used for temporary internal
state like borgmatic database dumps. Note that changing this
path prevents "borgmatic restore" from finding any database
dumps created before the change. Defaults to ~/.borgmatic
example: /tmp/borgmatic
storage:
desc: |
Repository storage options. See
https://borgbackup.readthedocs.io/en/stable/usage.html#borg-create and
https://borgbackup.readthedocs.io/en/stable/usage/general.html#environment-variables for
https://borgbackup.readthedocs.io/en/stable/usage/create.html and
https://borgbackup.readthedocs.io/en/stable/usage/general.html for
details.
map:
encryption_passcommand:
type: str
desc: |
The standard output of this command is used to unlock the encryption key. Only
use on repositories that were initialized with passcommand/repokey encryption.
Note that if both encryption_passcommand and encryption_passphrase are set,
then encryption_passphrase takes precedence. Defaults to not set.
The standard output of this command is used to unlock the
encryption key. Only use on repositories that were
initialized with passcommand/repokey encryption. Note that
if both encryption_passcommand and encryption_passphrase are
set, then encryption_passphrase takes precedence. Defaults
to not set.
example: "secret-tool lookup borg-repository repo-name"
encryption_passphrase:
type: str
desc: |
Passphrase to unlock the encryption key with. Only use on repositories that were
initialized with passphrase/repokey encryption. Quote the value if it contains
punctuation, so it parses correctly. And backslash any quote or backslash
Passphrase to unlock the encryption key with. Only use on
repositories that were initialized with passphrase/repokey
encryption. Quote the value if it contains punctuation, so
it parses correctly. And backslash any quote or backslash
literals as well. Defaults to not set.
example: "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
checkpoint_interval:
type: int
desc: |
Number of seconds between each checkpoint during a long-running backup. See
https://borgbackup.readthedocs.io/en/stable/faq.html#if-a-backup-stops-mid-way-does-the-already-backed-up-data-stay-there
for details. Defaults to checkpoints every 1800 seconds (30 minutes).
Number of seconds between each checkpoint during a
long-running backup. See
https://borgbackup.readthedocs.io/en/stable/faq.html
for details. Defaults to checkpoints every 1800 seconds (30
minutes).
example: 1800
chunker_params:
type: str
desc: |
Specify the parameters passed to then chunker (CHUNK_MIN_EXP, CHUNK_MAX_EXP,
HASH_MASK_BITS, HASH_WINDOW_SIZE). See https://borgbackup.readthedocs.io/en/stable/internals.html
Specify the parameters passed to then chunker
(CHUNK_MIN_EXP, CHUNK_MAX_EXP, HASH_MASK_BITS,
HASH_WINDOW_SIZE). See
https://borgbackup.readthedocs.io/en/stable/internals.html
for details. Defaults to "19,23,21,4095".
example: 19,23,21,4095
compression:
type: str
desc: |
Type of compression to use when creating archives. See
https://borgbackup.readthedocs.org/en/stable/usage.html#borg-create for details.
Defaults to "lz4".
http://borgbackup.readthedocs.io/en/stable/usage/create.html
for details. Defaults to "lz4".
example: lz4
remote_rate_limit:
type: int
desc: Remote network upload rate limit in kiBytes/second. Defaults to unlimited.
desc: |
Remote network upload rate limit in kiBytes/second. Defaults
to unlimited.
example: 100
ssh_command:
type: str
desc: |
Command to use instead of "ssh". This can be used to specify ssh options.
Defaults to not set.
Command to use instead of "ssh". This can be used to specify
ssh options. Defaults to not set.
example: ssh -i /path/to/private/key
borg_base_directory:
type: str
desc: |
Base path used for various Borg directories. Defaults to $HOME, ~$USER, or ~.
See https://borgbackup.readthedocs.io/en/stable/usage/general.html#environment-variables for details.
Base path used for various Borg directories. Defaults to
$HOME, ~$USER, or ~.
example: /path/to/base
borg_config_directory:
type: str
desc: |
Path for Borg configuration files. Defaults to $borg_base_directory/.config/borg
Path for Borg configuration files. Defaults to
$borg_base_directory/.config/borg
example: /path/to/base/config
borg_cache_directory:
type: str
desc: |
Path for Borg cache files. Defaults to $borg_base_directory/.cache/borg
Path for Borg cache files. Defaults to
$borg_base_directory/.cache/borg
example: /path/to/base/cache
borg_security_directory:
type: str
desc: |
Path for Borg security and encryption nonce files. Defaults to $borg_base_directory/.config/borg/security
Path for Borg security and encryption nonce files. Defaults
to $borg_base_directory/.config/borg/security
example: /path/to/base/config/security
borg_keys_directory:
type: str
desc: |
Path for Borg encryption key files. Defaults to $borg_base_directory/.config/borg/keys
Path for Borg encryption key files. Defaults to
$borg_base_directory/.config/borg/keys
example: /path/to/base/config/keys
umask:
type: scalar
@@ -223,35 +268,69 @@ map:
example: 0077
lock_wait:
type: int
desc: Maximum seconds to wait for acquiring a repository/cache lock. Defaults to 1.
desc: |
Maximum seconds to wait for acquiring a repository/cache
lock. Defaults to 1.
example: 5
archive_name_format:
type: str
desc: |
Name of the archive. Borg placeholders can be used. See the output of
"borg help placeholders" for details. Defaults to
"{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}". If you specify this option, you must
also specify a prefix in the retention section to avoid accidental pruning of
archives with a different archive name format. And you should also specify a
Name of the archive. Borg placeholders can be used. See the
output of "borg help placeholders" for details. Defaults to
"{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}". If you specify this
option, you must also specify a prefix in the retention
section to avoid accidental pruning of archives with a
different archive name format. And you should also specify a
prefix in the consistency section as well.
example: "{hostname}-documents-{now}"
relocated_repo_access_is_ok:
type: bool
desc: Bypass Borg error about a repository that has been moved. Defaults to false.
desc: |
Bypass Borg error about a repository that has been moved.
Defaults to false.
example: true
unknown_unencrypted_repo_access_is_ok:
type: bool
desc: |
Bypass Borg error about a previously unknown unencrypted repository. Defaults to
false.
Bypass Borg error about a previously unknown unencrypted
repository. Defaults to false.
example: true
extra_borg_options:
map:
init:
type: str
desc: |
Extra command-line options to pass to "borg init".
example: "--make-parent-dirs"
prune:
type: str
desc: |
Extra command-line options to pass to "borg prune".
example: "--save-space"
create:
type: str
desc: |
Extra command-line options to pass to "borg create".
example: "--no-files-cache"
check:
type: str
desc: |
Extra command-line options to pass to "borg check".
example: "--save-space"
desc: |
Additional options to pass directly to particular Borg
commands, handy for Borg options that borgmatic does not yet
support natively. Note that borgmatic does not perform any
validation on these options. Running borgmatic with
"--verbosity 2" shows the exact Borg command-line
invocation.
retention:
desc: |
Retention policy for how many backups to keep in each category. See
https://borgbackup.readthedocs.org/en/stable/usage.html#borg-prune for details.
At least one of the "keep" options is required for pruning to work. See
https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/
if you'd like to skip pruning entirely.
https://borgbackup.readthedocs.io/en/stable/usage/prune.html for
details. At least one of the "keep" options is required for pruning
to work. See borgmatic documentation if you'd like to skip pruning
entirely.
map:
keep_within:
type: str
@@ -288,27 +367,37 @@ map:
prefix:
type: str
desc: |
When pruning, only consider archive names starting with this prefix.
Borg placeholders can be used. See the output of "borg help placeholders" for
details. Defaults to "{hostname}-". Use an empty value to disable the default.
When pruning, only consider archive names starting with this
prefix. Borg placeholders can be used. See the output of
"borg help placeholders" for details. Defaults to
"{hostname}-". Use an empty value to disable the default.
example: sourcehostname
consistency:
desc: |
Consistency checks to run after backups. See
https://borgbackup.readthedocs.org/en/stable/usage.html#borg-check and
https://borgbackup.readthedocs.org/en/stable/usage.html#borg-extract for details.
https://borgbackup.readthedocs.io/en/stable/usage/check.html and
https://borgbackup.readthedocs.io/en/stable/usage/extract.html for
details.
map:
checks:
seq:
- type: str
enum: ['repository', 'archives', 'data', 'extract', 'disabled']
enum: [
'repository',
'archives',
'data',
'extract',
'disabled'
]
unique: true
desc: |
List of one or more consistency checks to run: "repository", "archives", "data",
and/or "extract". Defaults to "repository" and "archives". 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
List of one or more consistency checks to run: "repository",
"archives", "data", and/or "extract". Defaults to
"repository" and "archives". 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
@@ -317,24 +406,29 @@ map:
seq:
- type: str
desc: |
Paths to a subset of the repositories in the location section on which to run
consistency checks. Handy in case some of your repositories are very large, and
so running consistency checks on them would take too long. Defaults to running
consistency checks on all repositories configured in the location section.
Paths to a subset of the repositories in the location
section on which to run consistency checks. Handy in case
some of your repositories are very large, and so running
consistency checks on them would take too long. Defaults to
running consistency checks on all repositories configured in
the location section.
example:
- user@backupserver:sourcehostname.borg
check_last:
type: int
desc: Restrict the number of checked archives to the last n. Applies only to the
"archives" check. Defaults to checking all archives.
desc: |
Restrict the number of checked archives to the last n.
Applies only to the "archives" check. Defaults to checking
all archives.
example: 3
prefix:
type: str
desc: |
When performing the "archives" check, only consider archive names starting with
this prefix. Borg placeholders can be used. See the output of
"borg help placeholders" for details. Defaults to "{hostname}-". Use an empty
value to disable the default.
When performing the "archives" check, only consider archive
names starting with this prefix. Borg placeholders can be
used. See the output of "borg help placeholders" for
details. Defaults to "{hostname}-". Use an empty value to
disable the default.
example: sourcehostname
output:
desc: |
@@ -343,41 +437,75 @@ map:
color:
type: bool
desc: |
Apply color to console output. Can be overridden with --no-color command-line
flag. Defaults to true.
Apply color to console output. Can be overridden with
--no-color command-line flag. Defaults to true.
example: false
hooks:
desc: |
Shell commands, scripts, or integrations to execute at various points during a borgmatic
run. IMPORTANT: All provided commands and scripts are executed with user permissions of
borgmatic. Do not forget to set secure permissions on this configuration file (chmod
0600) as well as on any script called from a hook (chmod 0700) to prevent potential
shell injection or privilege escalation.
Shell commands, scripts, or integrations to execute at various
points during a borgmatic run. IMPORTANT: All provided commands and
scripts are executed with user permissions of borgmatic. Do not
forget to set secure permissions on this configuration file (chmod
0600) as well as on any script called from a hook (chmod 0700) to
prevent potential shell injection or privilege escalation.
map:
before_backup:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute before creating a
backup, run once per configuration file.
List of one or more shell commands or scripts to execute
before creating a backup, run once per configuration file.
example:
- echo "Starting a backup."
before_prune:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute
before pruning, run once per configuration file.
example:
- echo "Starting pruning."
before_check:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute
before consistency checks, run once per configuration file.
example:
- echo "Starting checks."
after_backup:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute after creating a
backup, run once per configuration file.
List of one or more shell commands or scripts to execute
after creating a backup, run once per configuration file.
example:
- echo "Created a backup."
- echo "Finished a backup."
after_prune:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute
after pruning, run once per configuration file.
example:
- echo "Finished pruning."
after_check:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute
after consistency checks, run once per configuration file.
example:
- echo "Finished checks."
on_error:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute when an exception
occurs during a backup or when running a before_backup or after_backup hook.
List of one or more shell commands or scripts to execute
when an exception occurs during a "prune", "create", or
"check" action or an associated before/after hook.
example:
- echo "Error while creating a backup or running a backup hook."
- echo "Error during prune/create/check."
postgresql_databases:
seq:
- map:
@@ -385,14 +513,17 @@ map:
required: true
type: str
desc: |
Database name (required if using this hook). Or "all" to dump all
databases on the host.
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: str
desc: |
Database hostname to connect to. Defaults to connecting via local
Unix socket.
Database hostname to connect to. Defaults to
connecting via local Unix socket.
example: database.example.org
port:
type: int
@@ -401,39 +532,78 @@ map:
username:
type: str
desc: |
Username with which to connect to the database. Defaults to the
username of the current user. You probably want to specify the
"postgres" superuser here when the database name is "all".
Username with which to connect to the database.
Defaults to the username of the current user.
You probably want to specify the "postgres"
superuser here when the database name is "all".
example: dbuser
password:
type: str
desc: |
Password with which to connect to the database. Omitting a password
will only work if PostgreSQL is configured to trust the configured
username without a password, or you create a ~/.pgpass file.
Password with which to connect to the database.
Omitting a password will only work if PostgreSQL
is configured to trust the configured username
without a password, or you create a ~/.pgpass
file.
example: trustsome1
format:
type: str
enum: ['plain', 'custom', 'directory', 'tar']
desc: |
Database dump output format. One of "plain", "custom", "directory",
or "tar". Defaults to "custom" (unlike raw pg_dump). See
https://www.postgresql.org/docs/current/app-pgdump.html for details.
Note that format is ignored when the database name is "all".
Database dump output format. One of "plain",
"custom", "directory", or "tar". Defaults to
"custom" (unlike raw pg_dump). See pg_dump
documentation for details. Note that format is
ignored when the database name is "all".
example: directory
ssl_mode:
type: str
enum: ['disable', 'allow', 'prefer',
'require', 'verify-ca', 'verify-full']
desc: |
SSL mode to use to connect to the database
server. One of "disable", "allow", "prefer",
"require", "verify-ca" or "verify-full".
Defaults to "disable".
example: require
ssl_cert:
type: str
desc: |
Path to a client certificate.
example: "/root/.postgresql/postgresql.crt"
ssl_key:
type: str
desc: |
Path to a private client key.
example: "/root/.postgresql/postgresql.key"
ssl_root_cert:
type: str
desc: |
Path to a root certificate containing a list of
trusted certificate authorities.
example: "/root/.postgresql/root.crt"
ssl_crl:
type: str
desc: |
Path to a certificate revocation list.
example: "/root/.postgresql/root.crl"
options:
type: str
desc: |
Additional pg_dump/pg_dumpall options to pass directly to the dump
command, without performing any validation on them. See
https://www.postgresql.org/docs/current/app-pgdump.html for details.
Additional pg_dump/pg_dumpall options to pass
directly to the dump command, without performing
any validation on them. See pg_dump
documentation for details.
example: --role=someone
desc: |
List of one or more PostgreSQL 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 then removed afterwards. Requires
List of one or more PostgreSQL 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
pg_dump/pg_dumpall/pg_restore commands. See
https://www.postgresql.org/docs/current/app-pgdump.html for details.
https://www.postgresql.org/docs/current/app-pgdump.html and
https://www.postgresql.org/docs/current/libpq-ssl.html for
details.
mysql_databases:
seq:
- map:
@@ -441,14 +611,17 @@ map:
required: true
type: str
desc: |
Database name (required if using this hook). Or "all" to dump all
databases on the host.
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: str
desc: |
Database hostname to connect to. Defaults to connecting via local
Unix socket.
Database hostname to connect to. Defaults to
connecting via local Unix socket.
example: database.example.org
port:
type: int
@@ -457,76 +630,92 @@ map:
username:
type: str
desc: |
Username with which to connect to the database. Defaults to the
username of the current user.
Username with which to connect to the database.
Defaults to the username of the current user.
example: dbuser
password:
type: str
desc: |
Password with which to connect to the database. Omitting a password
will only work if MySQL is configured to trust the configured
username without a password.
Password with which to connect to the database.
Omitting a password will only work if MySQL is
configured to trust the configured username
without a password.
example: trustsome1
options:
type: str
desc: |
Additional mysqldump options to pass directly to the dump command,
without performing any validation on them. See
https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html or
https://mariadb.com/kb/en/library/mysqldump/ for details.
Additional mysqldump options to pass directly to
the dump command, without performing any
validation on them. See mysqldump documentation
for details.
example: --skip-comments
desc: |
List of one or more MySQL/MariaDB 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 then removed afterwards. Requires
List of one or more MySQL/MariaDB 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
mysqldump/mysql commands (from either MySQL or MariaDB). See
https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html or
https://mariadb.com/kb/en/library/mysqldump/ for details.
healthchecks:
type: str
desc: |
Healthchecks ping URL or UUID to notify when a backup begins, ends, or errors.
Create an account at https://healthchecks.io if you'd like to use this service.
See
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook
for details.
Healthchecks ping URL or UUID to notify when a backup
begins, ends, or errors. Create an account at
https://healthchecks.io if you'd like to use this service.
See borgmatic monitoring documentation for details.
example:
https://hc-ping.com/your-uuid-here
cronitor:
type: str
desc: |
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
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook
for details.
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: str
desc: |
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.
example:
a177cad45bd374409f78906a810a3074
cronhub:
type: str
desc: |
Cronhub ping URL to notify when a backup begins, ends, or errors. Create an
account at https://cronhub.io if you'd like to use this service. See
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook for
details.
Cronhub ping URL to notify when a backup begins, ends, or
errors. Create an account at https://cronhub.io if you'd
like to use this service. See borgmatic monitoring
documentation for details.
example:
https://cronhub.io/start/1f5e3410-254c-11e8-b61d-55875966d031
https://cronhub.io/start/1f5e3410-254c-11e8-b61d-55875966d01
before_everything:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute before running all
actions (if one of them is "create"), run once before all configuration files.
List of one or more shell commands or scripts to execute
before running all actions (if one of them is "create").
These are collected from all configuration files and then
run once before all of them (prior to all actions).
example:
- echo "Starting actions."
after_everything:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute after running all
actions (if one of them is "create"), run once after all configuration files.
List of one or more shell commands or scripts to execute
after running all actions (if one of them is "create").
These are collected from all configuration files and then
run once before all of them (prior to all actions).
example:
- echo "Completed actions."
umask:
type: scalar
desc: Umask used when executing hooks. Defaults to the umask that borgmatic is run with.
desc: |
Umask used when executing hooks. Defaults to the umask that
borgmatic is run with.
example: 0077
+30 -9
View File
@@ -1,11 +1,12 @@
import logging
import os
import pkg_resources
import pykwalify.core
import pykwalify.errors
import ruamel.yaml
from borgmatic.config import load
from borgmatic.config import load, normalize, override
def schema_filename():
@@ -81,11 +82,12 @@ def remove_examples(schema):
return schema
def parse_configuration(config_filename, schema_filename):
def parse_configuration(config_filename, schema_filename, overrides=None):
'''
Given the path to a config filename in YAML format and the path to a schema filename in
pykwalify YAML schema format, return the parsed configuration as a data structure of nested
dicts and lists corresponding to the schema. Example return value:
Given the path to a config filename in YAML format, the path to a schema filename in pykwalify
YAML schema format, a sequence of configuration file override strings in the form of
"section.option=value", return the parsed configuration as a data structure of nested dicts and
lists corresponding to the schema. Example return value:
{'location': {'source_directories': ['/home', '/etc'], 'repository': 'hostname.borg'},
'retention': {'keep_daily': 7}, 'consistency': {'checks': ['repository', 'archives']}}
@@ -101,6 +103,9 @@ def parse_configuration(config_filename, schema_filename):
except (ruamel.yaml.error.YAMLError, RecursionError) as error:
raise Validation_error(config_filename, (str(error),))
override.apply_overrides(config, overrides)
normalize.normalize(config)
validator = pykwalify.core.Core(source_data=config, schema_data=remove_examples(schema))
parsed_result = validator.validate(raise_exception=False)
@@ -112,6 +117,24 @@ def parse_configuration(config_filename, schema_filename):
return parsed_result
def normalize_repository_path(repository):
'''
Given a repository path, return the absolute path of it (for local repositories).
'''
# A colon in the repository indicates it's a remote repository. Bail.
if ':' in repository:
return repository
return os.path.abspath(repository)
def repositories_match(first, second):
'''
Given two repository paths (relative and/or absolute), return whether they match.
'''
return normalize_repository_path(first) == normalize_repository_path(second)
def guard_configuration_contains_repository(repository, configurations):
'''
Given a repository path and a dict mapping from config filename to corresponding parsed config
@@ -133,9 +156,7 @@ def guard_configuration_contains_repository(repository, configurations):
if count > 1:
raise ValueError(
'Can\'t determine which repository to use. Use --repository option to disambiguate'.format(
repository
)
'Can\'t determine which repository to use. Use --repository option to disambiguate'
)
return
@@ -145,7 +166,7 @@ def guard_configuration_contains_repository(repository, configurations):
config_repository
for config in configurations.values()
for config_repository in config['location']['repositories']
if repository == config_repository
if repositories_match(repository, config_repository)
)
)
+201 -72
View File
@@ -1,5 +1,7 @@
import collections
import logging
import os
import select
import subprocess
logger = logging.getLogger(__name__)
@@ -9,52 +11,135 @@ ERROR_OUTPUT_MAX_LINE_COUNT = 25
BORG_ERROR_EXIT_CODE = 2
def exit_code_indicates_error(command, exit_code, error_on_warnings=False):
def exit_code_indicates_error(process, exit_code, borg_local_path=None):
'''
Return True if the given exit code from running the command corresponds to an error.
Return True if the given exit code from running a command corresponds to an error. If a Borg
local path is given and matches the process' command, then treat exit code 1 as a warning
instead of an error.
'''
# If we're running something other than Borg, treat all non-zero exit codes as errors.
if 'borg' in command[0] and not error_on_warnings:
if exit_code is None:
return False
command = process.args.split(' ') if isinstance(process.args, str) else process.args
if borg_local_path and command[0] == borg_local_path:
return bool(exit_code >= BORG_ERROR_EXIT_CODE)
return bool(exit_code != 0)
def log_output(command, process, output_buffer, output_log_level, error_on_warnings):
def command_for_process(process):
'''
Given a command already executed, its process opened by subprocess.Popen(), and the process'
relevant output buffer (stderr or stdout), log its output with the requested log level.
Additionally, raise a CalledProcessException if the process exits with an error (or a warning,
if error on warnings is True).
Given a process as an instance of subprocess.Popen, return the command string that was used to
invoke it.
'''
last_lines = []
return process.args if isinstance(process.args, str) else ' '.join(process.args)
while process.poll() is None:
line = output_buffer.readline().rstrip().decode()
if not line:
def output_buffer_for_process(process, exclude_stdouts):
'''
Given a process as an instance of subprocess.Popen and a sequence of stdouts to exclude, return
either the process's stdout or stderr. The idea is that if stdout is excluded for a process, we
still have stderr to log.
'''
return process.stderr if process.stdout in exclude_stdouts else process.stdout
def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
'''
Given a sequence of subprocess.Popen() instances for multiple processes, log the output for each
process with the requested log level. Additionally, raise a CalledProcessError if a process
exits with an error (or a warning for exit code 1, if that process matches the Borg local path).
For simplicity, it's assumed that the output buffer for each process is its stdout. But if any
stdouts are given to exclude, then for any matching processes, log from their stderr instead.
Note that stdout for a process can be None if output is intentionally not captured. In which
case it won't be logged.
'''
# Map from output buffer to sequence of last lines.
buffer_last_lines = collections.defaultdict(list)
output_buffers = [
output_buffer_for_process(process, exclude_stdouts)
for process in processes
if process.stdout or process.stderr
]
# Log output for each process until they all exit.
while True:
if output_buffers:
(ready_buffers, _, _) = select.select(output_buffers, [], [])
for ready_buffer in ready_buffers:
line = ready_buffer.readline().rstrip().decode()
if not line:
continue
# Keep the last few lines of output in case the process errors, and we need the output for
# the exception below.
last_lines = buffer_last_lines[ready_buffer]
last_lines.append(line)
if len(last_lines) > ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.pop(0)
logger.log(output_log_level, line)
for process in processes:
exit_code = process.poll() if output_buffers else process.wait()
# If any process errors, then raise accordingly.
if exit_code_indicates_error(process, exit_code, borg_local_path):
# If an error occurs, include its output in the raised exception so that we don't
# inadvertently hide error output.
output_buffer = output_buffer_for_process(process, exclude_stdouts)
last_lines = buffer_last_lines[output_buffer] if output_buffer else []
if len(last_lines) == ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.insert(0, '...')
# Something has gone wrong. So vent each process' output buffer to prevent it from
# hanging. And then kill the process.
for other_process in processes:
if other_process.poll() is None:
other_process.stdout.read(0)
other_process.kill()
raise subprocess.CalledProcessError(
exit_code, command_for_process(process), '\n'.join(last_lines)
)
if all(process.poll() is not None for process in processes):
break
# Consume any remaining output that we missed (if any).
for process in processes:
output_buffer = output_buffer_for_process(process, exclude_stdouts)
if not output_buffer:
continue
# Keep the last few lines of output in case the command errors, and we need the output for
# the exception below.
last_lines.append(line)
if len(last_lines) > ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.pop(0)
remaining_output = output_buffer.read().rstrip().decode()
logger.log(output_log_level, line)
if remaining_output: # pragma: no cover
logger.log(output_log_level, remaining_output)
remaining_output = output_buffer.read().rstrip().decode()
if remaining_output: # pragma: no cover
logger.log(output_log_level, remaining_output)
exit_code = process.poll()
def log_command(full_command, input_file, output_file):
'''
Log the given command (a sequence of command/argument strings), along with its input/output file
paths.
'''
logger.debug(
' '.join(full_command)
+ (' < {}'.format(getattr(input_file, 'name', '')) if input_file else '')
+ (' > {}'.format(getattr(output_file, 'name', '')) if output_file else '')
)
if exit_code_indicates_error(command, exit_code, error_on_warnings):
# If an error occurs, include its output in the raised exception so that we don't
# inadvertently hide error output.
if len(last_lines) == ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.insert(0, '...')
raise subprocess.CalledProcessError(exit_code, ' '.join(command), '\n'.join(last_lines))
# An sentinel passed as an output file to execute_command() to indicate that the command's output
# should be allowed to flow through to stdout without being captured for logging. Useful for
# commands with interactive prompts or those that mess directly with the console.
DO_NOT_CAPTURE = object()
def execute_command(
@@ -65,64 +150,108 @@ def execute_command(
shell=False,
extra_environment=None,
working_directory=None,
error_on_warnings=False,
borg_local_path=None,
run_to_completion=True,
):
'''
Execute the given command (a sequence of command/argument strings) and log its output at the
given log level. If output log level is None, instead capture and return the output. If an
open output file object is given, then write stdout to the file and only log stderr (but only
if an output log level is set). If an open input file object is given, then read stdin from the
file. If shell is True, execute the command within a shell. If an extra environment dict is
given, then use it to augment the current environment, and pass the result into the command. If
a working directory is given, use that as the present working directory when running the
command.
given log level. If output log level is None, instead capture and return the output. (Implies
run_to_completion.) If an open output file object is given, then write stdout to the file and
only log stderr (but only if an output log level is set). If an open input file object is given,
then read stdin from the file. If shell is True, execute the command within a shell. If an extra
environment dict is given, then use it to augment the current environment, and pass the result
into the command. If a working directory is given, use that as the present working directory
when running the command. If a Borg local path is given, and the command matches it (regardless
of arguments), treat exit code 1 as a warning instead of an error. If run to completion is
False, then return the process for the command without executing it to completion.
Raise subprocesses.CalledProcessError if an error occurs while running the command.
'''
logger.debug(
' '.join(full_command)
+ (' < {}'.format(input_file.name) if input_file else '')
+ (' > {}'.format(output_file.name) if output_file else '')
)
log_command(full_command, input_file, output_file)
environment = {**os.environ, **extra_environment} if extra_environment else None
do_not_capture = bool(output_file is DO_NOT_CAPTURE)
command = ' '.join(full_command) if shell else full_command
if output_log_level is None:
output = subprocess.check_output(
full_command, shell=shell, env=environment, cwd=working_directory
command, shell=shell, env=environment, cwd=working_directory
)
return output.decode() if output is not None else None
else:
process = subprocess.Popen(
full_command,
process = subprocess.Popen(
command,
stdin=input_file,
stdout=None if do_not_capture else (output_file or subprocess.PIPE),
stderr=None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT),
shell=shell,
env=environment,
cwd=working_directory,
)
if not run_to_completion:
return process
log_outputs(
(process,), (input_file, output_file), output_log_level, borg_local_path=borg_local_path
)
def execute_command_with_processes(
full_command,
processes,
output_log_level=logging.INFO,
output_file=None,
input_file=None,
shell=False,
extra_environment=None,
working_directory=None,
borg_local_path=None,
):
'''
Execute the given command (a sequence of command/argument strings) and log its output at the
given log level. Simultaneously, continue to poll one or more active processes so that they
run as well. This is useful, for instance, for processes that are streaming output to a named
pipe that the given command is consuming from.
If an open output file object is given, then write stdout to the file and only log stderr (but
only if an output log level is set). If an open input file object is given, then read stdin from
the file. If shell is True, execute the command within a shell. If an extra environment dict is
given, then use it to augment the current environment, and pass the result into the command. If
a working directory is given, use that as the present working directory when running the
command. If a Borg local path is given, then for any matching command or process (regardless of
arguments), treat exit code 1 as a warning instead of an error.
Raise subprocesses.CalledProcessError if an error occurs while running the command or in the
upstream process.
'''
log_command(full_command, input_file, output_file)
environment = {**os.environ, **extra_environment} if extra_environment else None
do_not_capture = bool(output_file is DO_NOT_CAPTURE)
command = ' '.join(full_command) if shell else full_command
try:
command_process = subprocess.Popen(
command,
stdin=input_file,
stdout=output_file or subprocess.PIPE,
stderr=subprocess.PIPE if output_file else subprocess.STDOUT,
stdout=None if do_not_capture else (output_file or subprocess.PIPE),
stderr=None
if do_not_capture
else (subprocess.PIPE if output_file else subprocess.STDOUT),
shell=shell,
env=environment,
cwd=working_directory,
)
log_output(
full_command,
process,
process.stderr if output_file else process.stdout,
output_log_level,
error_on_warnings,
)
except (subprocess.CalledProcessError, OSError):
# Something has gone wrong. So vent each process' output buffer to prevent it from hanging.
# And then kill the process.
for process in processes:
if process.poll() is None:
process.stdout.read(0)
process.kill()
raise
def execute_command_without_capture(full_command, working_directory=None, error_on_warnings=False):
'''
Execute the given command (a sequence of command/argument strings), but don't capture or log its
output in any way. This is necessary for commands that monkey with the terminal (e.g. progress
display) or provide interactive prompts.
If a working directory is given, use that as the present working directory when running the
command.
'''
logger.debug(' '.join(full_command))
try:
subprocess.check_call(full_command, cwd=working_directory)
except subprocess.CalledProcessError as error:
if exit_code_indicates_error(full_command, error.returncode, error_on_warnings):
raise
log_outputs(
tuple(processes) + (command_process,),
(input_file, output_file),
output_log_level,
borg_local_path=borg_local_path,
)
+24
View File
@@ -6,6 +6,9 @@ from borgmatic import execute
logger = logging.getLogger(__name__)
SOFT_FAIL_EXIT_CODE = 75
def interpolate_context(command, context):
'''
Given a single hook command and a dict of context names/values, interpolate the values by
@@ -69,3 +72,24 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
finally:
if original_umask:
os.umask(original_umask)
def considered_soft_failure(config_filename, error):
'''
Given a configuration filename and an exception object, return whether the exception object
represents a subprocess.CalledProcessError with a return code of SOFT_FAIL_EXIT_CODE. If so,
that indicates that the error is a "soft failure", and should not result in an error.
'''
exit_code = getattr(error, 'returncode', None)
if exit_code is None:
return False
if exit_code == SOFT_FAIL_EXIT_CODE:
logger.info(
'{}: Command hook exited with soft failure exit code ({}); skipping remaining actions'.format(
config_filename, SOFT_FAIL_EXIT_CODE
)
)
return True
return False
+10 -1
View File
@@ -13,7 +13,16 @@ MONITOR_STATE_TO_CRONHUB = {
}
def ping_monitor(ping_url, config_filename, state, dry_run):
def initialize_monitor(
ping_url, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No initialization is necessary for this monitor.
'''
pass
def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run):
'''
Ping the given 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.
+10 -1
View File
@@ -13,7 +13,16 @@ MONITOR_STATE_TO_CRONITOR = {
}
def ping_monitor(ping_url, config_filename, state, dry_run):
def initialize_monitor(
ping_url, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No initialization is necessary for this monitor.
'''
pass
def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run):
'''
Ping the given 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.
+2 -1
View File
@@ -1,6 +1,6 @@
import logging
from borgmatic.hooks import cronhub, cronitor, healthchecks, mysql, postgresql
from borgmatic.hooks import cronhub, cronitor, healthchecks, mysql, pagerduty, postgresql
logger = logging.getLogger(__name__)
@@ -8,6 +8,7 @@ HOOK_NAME_TO_MODULE = {
'healthchecks': healthchecks,
'cronitor': cronitor,
'cronhub': cronhub,
'pagerduty': pagerduty,
'postgresql_databases': postgresql,
'mysql_databases': mysql,
}
+32 -118
View File
@@ -1,12 +1,25 @@
import glob
import logging
import os
import shutil
from borgmatic.borg.create import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
logger = logging.getLogger(__name__)
DATABASE_HOOK_NAMES = ('postgresql_databases', 'mysql_databases')
def make_database_dump_path(borgmatic_source_directory, database_hook_name):
'''
Given a borgmatic source directory (or None) and a database hook name, construct a database dump
path.
'''
if not borgmatic_source_directory:
borgmatic_source_directory = DEFAULT_BORGMATIC_SOURCE_DIRECTORY
return os.path.join(borgmatic_source_directory, database_hook_name)
def make_database_dump_filename(dump_path, name, hostname=None):
'''
Based on the given dump directory path, database name, and hostname, return a filename to use
@@ -20,61 +33,39 @@ def make_database_dump_filename(dump_path, name, hostname=None):
return os.path.join(os.path.expanduser(dump_path), hostname or 'localhost', name)
def flatten_dump_patterns(dump_patterns, names):
def create_parent_directory_for_dump(dump_path):
'''
Given a dict from a database hook name to glob patterns matching the dumps for the named
databases, flatten out all the glob patterns into a single sequence, and return it.
Raise ValueError if there are no resulting glob patterns, which indicates that databases are not
configured in borgmatic's configuration.
Create a directory to contain the given dump path.
'''
flattened = [pattern for patterns in dump_patterns.values() for pattern in patterns]
if not flattened:
raise ValueError(
'Cannot restore database(s) {} missing from borgmatic\'s configuration'.format(
', '.join(names) or '"all"'
)
)
return flattened
os.makedirs(os.path.dirname(dump_path), mode=0o700, exist_ok=True)
def remove_database_dumps(dump_path, databases, database_type_name, log_prefix, dry_run):
def create_named_pipe_for_dump(dump_path):
'''
Remove the database dumps for the given databases in the dump directory path. The databases are
supplied as a sequence of dicts, one dict describing each database as per the configuration
schema. Use the name of the database type and the log prefix in any log entries. If this is a
dry run, then don't actually remove anything.
Create a named pipe at the given dump path.
'''
if not databases:
logger.debug('{}: No {} databases configured'.format(log_prefix, database_type_name))
return
create_parent_directory_for_dump(dump_path)
os.mkfifo(dump_path, mode=0o600)
def remove_database_dumps(dump_path, database_type_name, log_prefix, dry_run):
'''
Remove all database dumps in the given dump directory path (including the directory itself). If
this is a dry run, then don't actually remove anything.
'''
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
logger.info(
'{}: Removing {} database dumps{}'.format(log_prefix, database_type_name, dry_run_label)
)
for database in databases:
dump_filename = make_database_dump_filename(
dump_path, database['name'], database.get('hostname')
)
expanded_path = os.path.expanduser(dump_path)
logger.debug(
'{}: Removing {} database dump {} from {}{}'.format(
log_prefix, database_type_name, database['name'], dump_filename, dry_run_label
)
)
if dry_run:
continue
if dry_run:
return
os.remove(dump_filename)
dump_path = os.path.dirname(dump_filename)
if len(os.listdir(dump_path)) == 0:
os.rmdir(dump_path)
if os.path.exists(expanded_path):
shutil.rmtree(expanded_path)
def convert_glob_patterns_to_borg_patterns(patterns):
@@ -83,80 +74,3 @@ def convert_glob_patterns_to_borg_patterns(patterns):
patterns like "sh:etc/*".
'''
return ['sh:{}'.format(pattern.lstrip(os.path.sep)) for pattern in patterns]
def get_database_names_from_dumps(patterns):
'''
Given a sequence of database dump patterns, find the corresponding database dumps on disk and
return the database names from their filenames.
'''
return [os.path.basename(dump_path) for pattern in patterns for dump_path in glob.glob(pattern)]
def get_database_configurations(databases, names):
'''
Given the full database configuration dicts as per the configuration schema, and a sequence of
database names, filter down and yield the configuration for just the named databases.
Additionally, if a database configuration is named "all", project out that configuration for
each named database.
'''
named_databases = {database['name']: database for database in databases}
for name in names:
database = named_databases.get(name)
if database:
yield database
continue
if 'all' in named_databases:
yield {**named_databases['all'], **{'name': name}}
continue
def get_per_hook_database_configurations(hooks, names, dump_patterns):
'''
Given the hooks configuration dict as per the configuration schema, a sequence of database
names to restore, and a dict from database hook name to glob patterns for matching dumps,
filter down the configuration for just the named databases.
If there are no named databases given, then find the corresponding database dumps on disk and
use the database names from their filenames. Additionally, if a database configuration is named
"all", project out that configuration for each named database.
Return the results as a dict from database hook name to a sequence of database configuration
dicts for that database type.
Raise ValueError if one of the database names cannot be matched to a database in borgmatic's
database configuration.
'''
hook_databases = {
hook_name: list(
get_database_configurations(
hooks.get(hook_name),
names or get_database_names_from_dumps(dump_patterns[hook_name]),
)
)
for hook_name in DATABASE_HOOK_NAMES
if hook_name in hooks
}
if not names or 'all' in names:
if not any(hook_databases.values()):
raise ValueError(
'Cannot restore database "all", as there are no database dumps in the archive'
)
return hook_databases
found_names = {
database['name'] for databases in hook_databases.values() for database in databases
}
missing_names = sorted(set(names) - found_names)
if missing_names:
raise ValueError(
'Cannot restore database(s) {} missing from borgmatic\'s configuration'.format(
', '.join(missing_names)
)
)
return hook_databases
+34 -14
View File
@@ -22,13 +22,14 @@ class Forgetful_buffering_handler(logging.Handler):
first) once a particular capacity in bytes is reached.
'''
def __init__(self, byte_capacity):
def __init__(self, byte_capacity, log_level):
super().__init__()
self.byte_capacity = byte_capacity
self.byte_count = 0
self.buffer = []
self.forgot = False
self.setLevel(log_level)
def emit(self, record):
message = record.getMessage() + '\n'
@@ -53,9 +54,8 @@ def format_buffered_logs_for_payload():
if isinstance(handler, Forgetful_buffering_handler)
)
except StopIteration:
payload = 'Cannot find the log handler for sending logs to Healthchecks'
logger.warning(payload)
return payload
# No handler means no payload.
return ''
payload = ''.join(message for message in buffering_handler.buffer)
@@ -65,18 +65,24 @@ def format_buffered_logs_for_payload():
return payload
def ping_monitor(ping_url_or_uuid, config_filename, state, dry_run):
def initialize_monitor(
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
way, we can send them all to Healthchecks upon a finish or failure state.
'''
logging.getLogger().addHandler(
Forgetful_buffering_handler(PAYLOAD_LIMIT_BYTES, monitoring_log_level)
)
def ping_monitor(ping_url_or_uuid, config_filename, state, monitoring_log_level, dry_run):
'''
Ping the given Healthchecks URL or UUID, 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.
configuration filename in any log entries, and log to Healthchecks with the giving log level.
If this is a dry run, then don't actually ping anything.
'''
if state is monitor.State.START:
# Add a handler to the root logger that stores in memory the most recent logs emitted. That
# way, we can send them all to Healthchecks upon a finish or failure state.
logging.getLogger().addHandler(Forgetful_buffering_handler(PAYLOAD_LIMIT_BYTES))
payload = ''
ping_url = (
ping_url_or_uuid
if ping_url_or_uuid.startswith('http')
@@ -95,7 +101,21 @@ def ping_monitor(ping_url_or_uuid, config_filename, state, dry_run):
if state in (monitor.State.FINISH, monitor.State.FAIL):
payload = format_buffered_logs_for_payload()
else:
payload = ''
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.post(ping_url, data=payload)
requests.post(ping_url, data=payload.encode('utf-8'))
def destroy_monitor(ping_url_or_uuid, config_filename, monitoring_log_level, dry_run):
'''
Remove the monitor handler that was added to the root logger. This prevents the handler from
getting reused by other instances of this monitor.
'''
logger = logging.getLogger()
for handler in tuple(logger.handlers):
if isinstance(handler, Forgetful_buffering_handler):
logger.removeHandler(handler)
+1 -1
View File
@@ -1,6 +1,6 @@
from enum import Enum
MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub')
MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty')
class State(Enum):
+133 -52
View File
@@ -1,94 +1,175 @@
import logging
import os
from borgmatic.execute import execute_command
from borgmatic.execute import execute_command, execute_command_with_processes
from borgmatic.hooks import dump
DUMP_PATH = '~/.borgmatic/mysql_databases'
logger = logging.getLogger(__name__)
def dump_databases(databases, log_prefix, dry_run):
def make_dump_path(location_config): # pragma: no cover
'''
Dump the given MySQL/MariaDB databases to disk. 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. If this is a dry run, then don't actually dump anything.
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'), 'mysql_databases'
)
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
def database_names_to_dump(database, extra_environment, log_prefix, dry_run_label):
'''
Given a requested database name, return the corresponding sequence of database names to dump.
In the case of "all", query for the names of databases on the configured host and return them,
excluding any system databases that will cause problems during restore.
'''
requested_name = database['name']
if requested_name != 'all':
return (requested_name,)
show_command = (
('mysql',)
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
+ ('--skip-column-names', '--batch')
+ ('--execute', 'show schemas')
)
logger.debug(
'{}: Querying for "all" MySQL databases to dump{}'.format(log_prefix, dry_run_label)
)
show_output = execute_command(
show_command, output_log_level=None, extra_environment=extra_environment
)
return tuple(
show_name
for show_name in show_output.strip().splitlines()
if show_name not in SYSTEM_DATABASE_NAMES
)
def dump_databases(databases, log_prefix, location_config, dry_run):
'''
Dump the given MySQL/MariaDB databases to a named pipe. The databases are supplied as a sequence
of dicts, one dict describing each database as per the configuration schema. Use the given 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 ''
processes = []
logger.info('{}: Dumping MySQL databases{}'.format(log_prefix, dry_run_label))
for database in databases:
name = database['name']
dump_filename = dump.make_database_dump_filename(DUMP_PATH, name, database.get('hostname'))
command = (
('mysqldump', '--add-drop-database')
requested_name = database['name']
dump_filename = dump.make_database_dump_filename(
make_dump_path(location_config), requested_name, database.get('hostname')
)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
dump_database_names = database_names_to_dump(
database, extra_environment, log_prefix, dry_run_label
)
if not dump_database_names:
raise ValueError('Cannot find any MySQL databases to dump.')
dump_command = (
('mysqldump',)
+ ('--add-drop-database',)
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
+ (('--all-databases',) if name == 'all' else ('--databases', name))
+ ('--databases',)
+ dump_database_names
# Use shell redirection rather than execute_command(output_file=open(...)) to prevent
# the open() call on a named pipe from hanging the main borgmatic process.
+ ('>', dump_filename)
)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
logger.debug(
'{}: Dumping MySQL database {} to {}{}'.format(
log_prefix, name, dump_filename, dry_run_label
log_prefix, requested_name, dump_filename, dry_run_label
)
)
if not dry_run:
os.makedirs(os.path.dirname(dump_filename), mode=0o700, exist_ok=True)
if dry_run:
continue
dump.create_named_pipe_for_dump(dump_filename)
processes.append(
execute_command(
command, output_file=open(dump_filename, 'w'), extra_environment=extra_environment
dump_command,
shell=True,
extra_environment=extra_environment,
run_to_completion=False,
)
)
return processes
def remove_database_dumps(databases, log_prefix, dry_run): # pragma: no cover
def remove_database_dumps(databases, log_prefix, location_config, dry_run): # pragma: no cover
'''
Remove the database dumps for the given databases. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the log prefix in
any log entries. If this is a dry run, then don't actually remove anything.
Remove all database dump files for this hook regardless of the given databases. Use the log
prefix in any log entries. Use the given location configuration dict to construct the
destination path. If this is a dry run, then don't actually remove anything.
'''
dump.remove_database_dumps(DUMP_PATH, databases, 'MySQL', log_prefix, dry_run)
dump.remove_database_dumps(make_dump_path(location_config), 'MySQL', log_prefix, dry_run)
def make_database_dump_patterns(databases, log_prefix, names):
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, and a sequence of database
names to match, return the corresponding glob patterns to match the database dumps in an
archive. An empty sequence of names indicates that the patterns should match all dumps.
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(DUMP_PATH, name, hostname='*') for name in (names or ['*'])
]
return dump.make_database_dump_filename(make_dump_path(location_config), name, hostname='*')
def restore_database_dumps(databases, log_prefix, dry_run):
def restore_database_dump(database_config, log_prefix, location_config, dry_run, extract_process):
'''
Restore the given MySQL/MariaDB databases from disk. 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. If this is a dry run, then don't actually restore anything.
Restore the given MySQL/MariaDB 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.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
for database in databases:
dump_filename = dump.make_database_dump_filename(
DUMP_PATH, database['name'], database.get('hostname')
)
restore_command = (
('mysql', '--batch')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
if len(database_config) != 1:
raise ValueError('The database configuration value is invalid')
logger.debug(
'{}: Restoring MySQL database {}{}'.format(log_prefix, database['name'], dry_run_label)
)
if not dry_run:
execute_command(
restore_command, input_file=open(dump_filename), extra_environment=extra_environment
)
database = database_config[0]
restore_command = (
('mysql', '--batch', '--verbose')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
logger.debug(
'{}: Restoring MySQL database {}{}'.format(log_prefix, database['name'], dry_run_label)
)
if dry_run:
return
execute_command_with_processes(
restore_command,
[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment=extra_environment,
borg_local_path=location_config.get('local_path', 'borg'),
)
+71
View File
@@ -0,0 +1,71 @@
import datetime
import json
import logging
import platform
import requests
from borgmatic.hooks import monitor
logger = logging.getLogger(__name__)
EVENTS_API_URL = 'https://events.pagerduty.com/v2/enqueue'
def initialize_monitor(
integration_key, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No initialization is necessary for this monitor.
'''
pass
def ping_monitor(integration_key, 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
given configuration filename in any log entries. If this is a dry run, then don't actually
create an event.
'''
if state != monitor.State.FAIL:
logger.debug(
'{}: Ignoring unsupported monitoring {} in PagerDuty hook'.format(
config_filename, state.name.lower()
)
)
return
dry_run_label = ' (dry run; not actually sending)' if dry_run else ''
logger.info('{}: Sending failure event to PagerDuty {}'.format(config_filename, dry_run_label))
if dry_run:
return
hostname = platform.node()
local_timestamp = (
datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).astimezone().isoformat()
)
payload = json.dumps(
{
'routing_key': integration_key,
'event_action': 'trigger',
'payload': {
'summary': 'backup failed on {}'.format(hostname),
'severity': 'error',
'source': hostname,
'timestamp': local_timestamp,
'component': 'borgmatic',
'group': 'backups',
'class': 'backup failure',
'custom_details': {
'hostname': hostname,
'configuration filename': config_filename,
'server time': local_timestamp,
},
},
}
)
logger.debug('{}: Using PagerDuty payload: {}'.format(config_filename, payload))
logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.post(EVENTS_API_URL, data=payload.encode('utf-8'))
+134 -59
View File
@@ -1,104 +1,179 @@
import logging
import os
from borgmatic.execute import execute_command
from borgmatic.execute import execute_command, execute_command_with_processes
from borgmatic.hooks import dump
DUMP_PATH = '~/.borgmatic/postgresql_databases'
logger = logging.getLogger(__name__)
def dump_databases(databases, log_prefix, dry_run):
def make_dump_path(location_config): # pragma: no cover
'''
Dump the given PostgreSQL databases to disk. 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. If this is a dry run, then don't actually dump anything.
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'), 'postgresql_databases'
)
def make_extra_environment(database):
'''
Make the extra_environment dict from the given database configuration.
'''
extra = dict()
if 'password' in database:
extra['PGPASSWORD'] = database['password']
extra['PGSSLMODE'] = database.get('ssl_mode', 'disable')
if 'ssl_cert' in database:
extra['PGSSLCERT'] = database['ssl_cert']
if 'ssl_key' in database:
extra['PGSSLKEY'] = database['ssl_key']
if 'ssl_root_cert' in database:
extra['PGSSLROOTCERT'] = database['ssl_root_cert']
if 'ssl_crl' in database:
extra['PGSSLCRL'] = database['ssl_crl']
return extra
def dump_databases(databases, log_prefix, location_config, dry_run):
'''
Dump the given PostgreSQL databases to a named pipe. The databases are supplied as a sequence of
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 ''
processes = []
logger.info('{}: Dumping PostgreSQL databases{}'.format(log_prefix, dry_run_label))
for database in databases:
name = database['name']
dump_filename = dump.make_database_dump_filename(DUMP_PATH, name, database.get('hostname'))
dump_filename = dump.make_database_dump_filename(
make_dump_path(location_config), name, database.get('hostname')
)
all_databases = bool(name == 'all')
dump_format = database.get('format', 'custom')
command = (
('pg_dumpall' if all_databases else 'pg_dump', '--no-password', '--clean')
+ ('--file', dump_filename)
(
'pg_dumpall' if all_databases else 'pg_dump',
'--no-password',
'--clean',
'--if-exists',
)
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--username', database['username']) if 'username' in database else ())
+ (() if all_databases else ('--format', database.get('format', 'custom')))
+ (() if all_databases else ('--format', dump_format))
+ (('--file', dump_filename) if dump_format == 'directory' else ())
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
+ (() if all_databases else (name,))
# Use shell redirection rather than the --file flag to sidestep synchronization issues
# when pg_dump/pg_dumpall tries to write to a named pipe. But for the directory dump
# format in a particular, a named destination is required, and redirection doesn't work.
+ (('>', dump_filename) if dump_format != 'directory' else ())
)
extra_environment = {'PGPASSWORD': database['password']} if 'password' in database else None
extra_environment = make_extra_environment(database)
logger.debug(
'{}: Dumping PostgreSQL database {} to {}{}'.format(
log_prefix, name, dump_filename, dry_run_label
)
)
if not dry_run:
os.makedirs(os.path.dirname(dump_filename), mode=0o700, exist_ok=True)
execute_command(command, extra_environment=extra_environment)
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)
processes.append(
execute_command(
command, shell=True, extra_environment=extra_environment, run_to_completion=False
)
)
return processes
def remove_database_dumps(databases, log_prefix, dry_run): # pragma: no cover
def remove_database_dumps(databases, log_prefix, location_config, dry_run): # pragma: no cover
'''
Remove the database dumps for the given databases. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the log prefix in
any log entries. If this is a dry run, then don't actually remove anything.
Remove all database dump files for this hook regardless of the given databases. Use the log
prefix in any log entries. Use the given location configuration dict to construct the
destination path. If this is a dry run, then don't actually remove anything.
'''
dump.remove_database_dumps(DUMP_PATH, databases, 'PostgreSQL', log_prefix, dry_run)
dump.remove_database_dumps(make_dump_path(location_config), 'PostgreSQL', log_prefix, dry_run)
def make_database_dump_patterns(databases, log_prefix, names):
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, and a sequence of database
names to match, return the corresponding glob patterns to match the database dumps in an
archive. An empty sequence of names indicates that the patterns should match all dumps.
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(DUMP_PATH, name, hostname='*') for name in (names or ['*'])
]
return dump.make_database_dump_filename(make_dump_path(location_config), name, hostname='*')
def restore_database_dumps(databases, log_prefix, dry_run):
def restore_database_dump(database_config, log_prefix, location_config, dry_run, extract_process):
'''
Restore the given PostgreSQL databases from disk. 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. If this is a dry run, then don't actually restore anything.
Restore the given PostgreSQL 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 ''
for database in databases:
dump_filename = dump.make_database_dump_filename(
DUMP_PATH, database['name'], database.get('hostname')
)
restore_command = (
('pg_restore', '--no-password', '--clean', '--if-exists', '--exit-on-error')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--username', database['username']) if 'username' in database else ())
+ ('--dbname', database['name'])
+ (dump_filename,)
)
extra_environment = {'PGPASSWORD': database['password']} if 'password' in database else None
analyze_command = (
('psql', '--no-password', '--quiet')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--username', database['username']) if 'username' in database else ())
+ ('--dbname', database['name'])
+ ('--command', 'ANALYZE')
)
if len(database_config) != 1:
raise ValueError('The database configuration value is invalid')
logger.debug(
'{}: Restoring PostgreSQL database {}{}'.format(
log_prefix, database['name'], dry_run_label
)
database = database_config[0]
all_databases = bool(database['name'] == 'all')
dump_filename = dump.make_database_dump_filename(
make_dump_path(location_config), database['name'], database.get('hostname')
)
analyze_command = (
('psql', '--no-password', '--quiet')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--username', database['username']) if 'username' in database else ())
+ (('--dbname', database['name']) if not all_databases else ())
+ ('--command', 'ANALYZE')
)
restore_command = (
('psql' if all_databases else 'pg_restore', '--no-password')
+ (
('--if-exists', '--exit-on-error', '--clean', '--dbname', database['name'])
if not all_databases
else ()
)
if not dry_run:
execute_command(restore_command, extra_environment=extra_environment)
execute_command(analyze_command, extra_environment=extra_environment)
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--username', database['username']) if 'username' in database else ())
+ (() if extract_process else (dump_filename,))
)
extra_environment = make_extra_environment(database)
logger.debug(
'{}: Restoring PostgreSQL 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,
extra_environment=extra_environment,
borg_local_path=location_config.get('local_path', 'borg'),
)
execute_command(analyze_command, extra_environment=extra_environment)
+59 -4
View File
@@ -26,7 +26,7 @@ def interactive_console():
Return whether the current console is "interactive". Meaning: Capable of
user input and not just something like a cron job.
'''
return sys.stdout.isatty() and os.environ.get('TERM') != 'dumb'
return sys.stderr.isatty() and os.environ.get('TERM') != 'dumb'
def should_do_markup(no_color, configs):
@@ -48,6 +48,42 @@ def should_do_markup(no_color, configs):
return interactive_console()
class Multi_stream_handler(logging.Handler):
'''
A logging handler that dispatches each log record to one of multiple stream handlers depending
on the record's log level.
'''
def __init__(self, log_level_to_stream_handler):
super(Multi_stream_handler, self).__init__()
self.log_level_to_handler = log_level_to_stream_handler
self.handlers = set(self.log_level_to_handler.values())
def flush(self): # pragma: no cover
super(Multi_stream_handler, self).flush()
for handler in self.handlers:
handler.flush()
def emit(self, record):
'''
Dispatch the log record to the approriate stream handler for the record's log level.
'''
self.log_level_to_handler[record.levelno].emit(record)
def setFormatter(self, formatter): # pragma: no cover
super(Multi_stream_handler, self).setFormatter(formatter)
for handler in self.handlers:
handler.setFormatter(formatter)
def setLevel(self, level): # pragma: no cover
super(Multi_stream_handler, self).setLevel(level)
for handler in self.handlers:
handler.setLevel(level)
LOG_LEVEL_TO_COLOR = {
logging.CRITICAL: colorama.Fore.RED,
logging.ERROR: colorama.Fore.RED,
@@ -74,7 +110,11 @@ def color_text(color, message):
def configure_logging(
console_log_level, syslog_log_level=None, log_file_log_level=None, log_file=None
console_log_level,
syslog_log_level=None,
log_file_log_level=None,
monitoring_log_level=None,
log_file=None,
):
'''
Configure logging to go to both the console and (syslog or log file). Use the given log levels,
@@ -86,8 +126,22 @@ def configure_logging(
syslog_log_level = console_log_level
if log_file_log_level is None:
log_file_log_level = console_log_level
if monitoring_log_level is None:
monitoring_log_level = console_log_level
console_handler = logging.StreamHandler()
# Log certain log levels to console stderr and others to stdout. This supports use cases like
# grepping (non-error) output.
console_error_handler = logging.StreamHandler(sys.stderr)
console_standard_handler = logging.StreamHandler(sys.stdout)
console_handler = Multi_stream_handler(
{
logging.CRITICAL: console_error_handler,
logging.ERROR: console_error_handler,
logging.WARN: console_standard_handler,
logging.INFO: console_standard_handler,
logging.DEBUG: console_standard_handler,
}
)
console_handler.setFormatter(Console_color_formatter())
console_handler.setLevel(console_log_level)
@@ -112,5 +166,6 @@ def configure_logging(
handlers = (console_handler,)
logging.basicConfig(
level=min(console_log_level, syslog_log_level, log_file_log_level), handlers=handlers
level=min(console_log_level, syslog_log_level, log_file_log_level, monitoring_log_level),
handlers=handlers,
)
+3 -3
View File
@@ -1,13 +1,13 @@
FROM python:3.7.4-alpine3.10 as borgmatic
FROM python:3.8.1-alpine3.11 as borgmatic
COPY . /app
RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
RUN borgmatic --help > /command-line.txt \
&& for action in init prune create check extract restore list info; do \
&& for action in init prune create check extract mount umount restore list info; do \
echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
&& borgmatic "$action" --help >> /command-line.txt; done
FROM node:12.10.0-alpine as html
FROM node:13.7.0-alpine as html
ARG ENVIRONMENT=production
+19
View File
@@ -0,0 +1,19 @@
---
title: Security policy
permalink: security-policy/index.html
---
## Supported versions
While we want to hear about security vulnerabilities in all versions of
borgmatic, security fixes will only be made to the most recently released
version. It's not practical for our small volunteer effort to maintain
multiple different release branches and put out separate security patches for
each.
## Reporting a vulnerability
If you find a security vulnerability, please [file a
ticket](https://torsion.org/borgmatic/#issues) or [send email
directly](mailto:witten@torsion.org) as appropriate. You should expect to hear
back within a few days at most, and generally sooner.
@@ -1,12 +1,12 @@
<h2>Improve this documentation</h2>
<p>Have an idea on how to make this documentation even better? Send your
feedback below! (But if you need help installing or using borgmatic, please
use our <a href="https://torsion.org/borgmatic/#issues">issue tracker</a>
instead.)</p>
feedback below! But if you need help with borgmatic, or have an idea for a
borgmatic feature, please use our <a href="https://torsion.org/borgmatic/#issues">issue
tracker</a> instead.</p>
<form id="suggestion-form">
<div><label for="suggestion">Suggestion</label></div>
<div><label for="suggestion">Documentation suggestion</label></div>
<textarea id="suggestion" rows="8" cols="60" name="suggestion"></textarea>
<div data-sk-error="suggestion" class="form-error"></div>
<input id="_page" type="hidden" name="_page">
+2 -2
View File
@@ -181,7 +181,7 @@ pre {
padding: .5em;
margin: 1em -.5em 2em -.5em;
overflow-x: auto;
background-color: #eee;
background-color: #fafafa;
font-size: 0.75em; /* 12px /16 */
}
pre,
@@ -194,7 +194,7 @@ code {
-webkit-hyphens: manual;
-moz-hyphens: manual;
hyphens: manual;
background-color: #efefef;
background-color: #fafafa;
}
pre + pre[class*="language-"] {
margin-top: 1em;
+18 -13
View File
@@ -3,9 +3,12 @@
* Based on dabblet (http://dabblet.com)
* @author Lea Verou
*/
/*
* Modified with an approximation of the One Light syntax highlighting theme.
*/
code[class*="language-"],
pre[class*="language-"] {
color: #ABB2BF;
color: #494b53;
background: none;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
@@ -26,13 +29,15 @@ pre[class*="language-"] {
pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection,
code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection {
text-shadow: none;
background: #383e49;
color: #232324;
background: #dbdbdc;
}
pre[class*="language-"]::selection, pre[class*="language-"] ::selection,
code[class*="language-"]::selection, code[class*="language-"] ::selection {
text-shadow: none;
background: #9aa2b1;
color: #232324;
background: #dbdbdc;
}
@media print {
@@ -50,7 +55,7 @@ pre[class*="language-"] {
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #282c34;
background: #fafafa;
}
/* Inline code */
@@ -64,16 +69,16 @@ pre[class*="language-"] {
.token.prolog,
.token.doctype,
.token.cdata {
color: #5C6370;
color: #505157;
}
.token.punctuation {
color: #abb2bf;
color: #526fff;
}
.token.selector,
.token.tag {
color: #e06c75;
color: none;
}
.token.property,
@@ -83,7 +88,7 @@ pre[class*="language-"] {
.token.symbol,
.token.attr-name,
.token.deleted {
color: #d19a66;
color: #986801;
}
.token.string,
@@ -91,7 +96,7 @@ pre[class*="language-"] {
.token.attr-value,
.token.builtin,
.token.inserted {
color: #98c379;
color: #50a14f;
}
.token.operator,
@@ -99,22 +104,22 @@ pre[class*="language-"] {
.token.url,
.language-css .token.string,
.style .token.string {
color: #56b6c2;
color: #526fff;
}
.token.atrule,
.token.keyword {
color: #e06c75;
color: #e45649;
}
.token.function {
color: #61afef;
color: #4078f2;
}
.token.regex,
.token.important,
.token.variable {
color: #c678dd;
color: #e45649;
}
.token.important,
@@ -29,6 +29,10 @@ configuration file, right before the `create` action. `after_backup` hooks run
afterwards, but not if an error occurs in a previous hook or in the backups
themselves.
There are additional hooks for the `prune` and `check` actions as well.
`before_prune` and `after_prune` run if there are any `prune` actions, while
`before_check` and `after_check` run if there are any `check` actions.
You can also use `before_everything` and `after_everything` hooks to perform
global setup or cleanup:
@@ -0,0 +1,107 @@
---
title: How to backup to a removable drive or an intermittent server
---
## Occasional backups
A common situation is backing up to a repository that's only sometimes online.
For instance, you might send most of your backups to the cloud, but
occasionally you want to plug in an external hard drive or backup to your
buddy's sometimes-online server for that extra level of redundancy.
But if you run borgmatic and your hard drive isn't plugged in, or your buddy's
server is offline, then you'll get an annoying error message and the overall
borgmatic run will fail (even if individual repositories still complete).
So what if you want borgmatic to swallow the error of a missing drive
or an offline server, and continue trucking along? That's where the concept of
"soft failure" come in.
## Soft failure command hooks
This feature leverages [borgmatic command
hooks](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/),
so first familiarize yourself with them. The idea is that you write a simple
test in the form of a borgmatic hook to see if backups should proceed or not.
The way the test works is that if any of your hook commands return a special
exit status of 75, that indicates to borgmatic that it's a temporary failure,
and borgmatic should skip all subsequent actions for that configuration file.
If you return any other status, then it's a standard success or error. (Zero is
success; anything else other than 75 is an error).
So for instance, if you have an external drive that's only sometimes mounted,
declare its repository in its own [separate configuration
file](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/),
say at `/etc/borgmatic.d/removable.yaml`:
```yaml
location:
source_directories:
- /home
repositories:
- /mnt/removable/backup.borg
```
Then, write a `before_backup` hook in that same configuration file that uses
the external `findmnt` utility to see whether the drive is mounted before
proceeding.
```yaml
hooks:
before_backup:
- findmnt /mnt/removable > /dev/null || exit 75
```
What this does is check if the `findmnt` command errors when probing for a
particular mount point. If it does error, then it returns exit code 75 to
borgmatic. borgmatic logs the soft failure, skips all further actions in that
configurable file, and proceeds onward to any other borgmatic configuration
files you may have.
You can imagine a similar check for the sometimes-online server case:
```yaml
location:
source_directories:
- /home
repositories:
- me@buddys-server.org:backup.borg
hooks:
before_backup:
- ping -q -c 1 buddys-server.org > /dev/null || exit 75
```
## Caveats and details
There are some caveats you should be aware of with this feature.
* You'll generally want to put a soft failure command in the `before_backup`
hook, so as to gate whether the backup action occurs. While a soft failure is
also supported in the `after_backup` hook, returning a soft failure there
won't prevent any actions from occuring, because they've already occurred!
Similiarly, you can return a soft failure from an `on_error` hook, but at
that point it's too late to prevent the error.
* Returning a soft failure does prevent further commands in the same hook from
executing. So, like a standard error, it is an "early out". Unlike a standard
error, borgmatic does not display it in angry red text or consider it a
failure.
* The soft failure only applies to the scope of a single borgmatic
configuration file. So put anything that you don't want soft-failed, like
always-online cloud backups, in separate configuration files from your
soft-failing repositories.
* The soft failure doesn't have to apply to a repository. You can even perform
a test to make sure that individual source directories are mounted and
available. Use your imagination!
* The soft failure feature also works for `before_prune`, `after_prune`,
`before_check`, and `after_check` hooks. But it is not implemented for
`before_everything` or `after_everything`.
## Related documentation
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
* [Make per-application backups](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/)
* [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/)
* [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
+18 -3
View File
@@ -22,9 +22,18 @@ hooks:
- name: posts
```
Prior to each backup, borgmatic dumps each configured database to a file
(located in `~/.borgmatic/`) and includes it in the backup. After the backup
completes, borgmatic removes the database dump files to recover disk space.
As part of each backup, borgmatic streams a database dump for each configured
database directly to Borg, so it's included in the backup without consuming
additional disk space. (The one exception is PostgreSQL's "directory" dump
format, which can't stream and therefore does consume temporary disk space.)
To support this, borgmatic creates temporary named pipes in `~/.borgmatic` by
default. To customize this path, set the `borgmatic_source_directory` option
in the `location` section of borgmatic's configuration.
Also note that using a database hook implicitly enables both the
`read_special` and `one_file_system` configuration settings (even if they're
disabled in your configuration) to support this dump and restore streaming.
Here's a more involved example that connects to remote databases:
@@ -108,6 +117,12 @@ borgmatic restore --archive host-2019-01-02T04:06:07.080910
(No borgmatic `restore` action? Upgrade borgmatic!)
With newer versions of borgmatic, you can simplify this to:
```bash
borgmatic restore --archive latest
```
The `--archive` value is the name of the archive to restore from. This
restores all databases dumps that borgmatic originally backed up to that
archive.
+22 -4
View File
@@ -7,7 +7,7 @@ Borg itself is great for efficiently de-duplicating data across successive
backup archives, even when dealing with very large repositories. But you may
find that while borgmatic's default mode of "prune, create, and check" works
well on small repositories, it's not so great on larger ones. That's because
running the default consistency checks takes a long time on large
running the default pruning and consistency checks take a long time on large
repositories.
### A la carte actions
@@ -27,9 +27,18 @@ borgmatic check
You can run with only one of these actions provided, or you can mix and match
any number of them in a single borgmatic run. This supports approaches like
making backups with `create` on a frequent schedule, while only running
expensive consistency checks with `check` on a much less frequent basis from
a separate cron job.
skipping certain actions while running others. For instance, this skips
`prune` and only runs `create` and `check`:
```bash
borgmatic create check
```
Or, you can make backups with `create` on a frequent schedule (e.g. with
`borgmatic create` called from one cron job), while only running expensive
consistency checks with `check` on a much less frequent basis (e.g. with
`borgmatic check` called from a separate cron job).
### Consistency check configuration
@@ -47,6 +56,15 @@ consistency:
- repository
```
Here are the available checks from fastest to slowest:
* `repository`: Checks the consistency of the repository itself.
* `archives`: Checks all of the archives in the repository.
* `extract`: Performs an extraction dry-run of the most recent archive.
* `data`: Verifies the data integrity of all archives contents, decrypting and decompressing all data (implies `archives` as well).
See [Borg's check documentation](https://borgbackup.readthedocs.io/en/stable/usage/check.html) for more information.
If that's still too slow, you can disable consistency checks entirely,
either for a single repository or for all repositories.
+12 -4
View File
@@ -75,14 +75,22 @@ tox -e isort
### End-to-end tests
borgmatic additionally includes some end-to-end tests that integration test
with Borg for a few representative scenarios. These tests don't run by default
because they're relatively slow and depend on Borg. If you would like to run
them:
with Borg and supported databases for a few representative scenarios. These
tests don't run by default when running `tox`, because they're relatively slow
and depend on Docker containers for runtime dependencies. These tests tests do
run on the continuous integration (CI) server, and running them on your
developer machine is the closest thing to CI test parity.
If you would like to run the full test suite, first install Docker and [Docker
Compose](https://docs.docker.com/compose/install/). Then run:
```bash
tox -e end-to-end
scripts/run-full-dev-tests
```
Note that this scripts assumes you have permission to run Docker. If you
don't, then you may need to run with `sudo`.
## Code style
Start with [PEP 8](https://www.python.org/dev/peps/pep-0008/). But then, apply
+47
View File
@@ -31,6 +31,12 @@ borgmatic extract --archive host-2019-01-02T04:06:07.080910
(No borgmatic `extract` action? Try the old-style `--extract`, or upgrade
borgmatic!)
With newer versions of borgmatic, you can simplify this to:
```bash
borgmatic extract --archive latest
```
The `--archive` value is the name of the archive to extract. This extracts the
entire contents of the archive to the current directory, so make sure you're
in the right place before running the command.
@@ -87,6 +93,47 @@ so that you can extract files from your archive without impacting your live
databases.
## Mount a filesystem
If instead of extracting files, you'd like to explore the files from an
archive as a [FUSE](https://en.wikipedia.org/wiki/Filesystem_in_Userspace)
filesystem, you can use the `borgmatic mount` action. Here's an example:
```bash
borgmatic mount --archive host-2019-... --mount-point /mnt
```
This mounts the entire archive on the given mount point `/mnt`, so that you
can look in there for your files.
Omit the `--archive` flag to mount all archives (lazy-loaded):
```bash
borgmatic mount --mount-point /mnt
```
Or use the "latest" value for the archive to mount the latest successful archive:
```bash
borgmatic mount --archive latest --mount-point /mnt
```
If you'd like to restrict the mounted filesystem to only particular paths from
your archive, use the `--path` flag, similar to the `extract` action above.
For instance:
```bash
borgmatic mount --archive host-2019-... --mount-point /mnt --path var/lib
```
When you're all done exploring your files, unmount your mount point. No
`--archive` flag is needed:
```bash
borgmatic umount --mount-point /mnt
```
## Related documentation
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
+2 -1
View File
@@ -95,7 +95,8 @@ borgmatic --log-file /path/to/file.log
```
Note that if you use the `--log-file` flag, you are responsible for rotating
the log file so it doesn't grow too large. Also, there is a
the log file so it doesn't grow too large, for example with
[logrotate](https://wiki.archlinux.org/index.php/Logrotate). Also, there is a
`--log-file-verbosity` flag to customize the log file's log level.
+43 -3
View File
@@ -22,9 +22,15 @@ When you set up multiple configuration files like this, borgmatic will run
each one in turn from a single borgmatic invocation. This includes, by
default, the traditional `/etc/borgmatic/config.yaml` as well.
And if you need even more customizability, you can specify alternate
configuration paths on the command-line with borgmatic's `--config` option.
See `borgmatic --help` for more information.
Each configuration file is interpreted independently, as if you ran borgmatic
for each configuration file one at a time. In other words, borgmatic does not
perform any merging of configuration files by default. If you'd like borgmatic
to merge your configuration files, see below about configuration includes.
Additionally, the `~/.config/borgmatic.d/` directory works the same way as
`/etc/borgmatic.d`. If you need even more customizability, you can specify
alternate configuration paths on the command-line with borgmatic's `--config`
flag. See `borgmatic --help` for more information.
## Configuration includes
@@ -110,6 +116,40 @@ Note that this `<<` include merging syntax is only for merging in mappings
directly, please see the section above about standard includes.
## Configuration overrides
In more complex multi-application setups, you may want to override particular
borgmatic configuration file options at the time you run borgmatic. For
instance, you could reuse a common configuration file for multiple
applications, but then set the repository for each application at runtime. Or
you might want to try a variant of an option for testing purposes without
actually touching your configuration file.
Whatever the reason, you can override borgmatic configuration options at the
command-line via the `--override` flag. Here's an example:
```bash
borgmatic create --override location.remote_path=borg1
```
What this does is load your configuration files, and for each one, disregard
the configured value for the `remote_path` option in the `location` section,
and use the value of `borg1` instead.
Note that the value is parsed as an actual YAML string, so you can even set
list values by using brackets. For instance:
```bash
borgmatic create --override location.repositories=[test1.borg,test2.borg]
```
There is not currently a way to override a single element of a list without
replacing the whole list.
Be sure to quote your overrides if they contain spaces or other characters
that your shell may interpret.
## Related documentation
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
+94 -30
View File
@@ -28,19 +28,21 @@ hooks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#error-hoo
below for how to configure this.
4. **borgmatic monitoring hooks**: This feature integrates with monitoring
services like [Healthchecks](https://healthchecks.io/),
[Cronitor](https://cronitor.io), and [Cronhub](https://cronhub.io), and pings
these services whenever borgmatic runs. That way, you'll receive an alert when
something goes wrong or the service doesn't hear from borgmatic for a
configured interval. See
[Healthchecks
[Cronitor](https://cronitor.io), [Cronhub](https://cronhub.io), and
[PagerDuty](https://www.pagerduty.com/) and pings these services whenever
borgmatic runs. That way, you'll receive an alert when something goes wrong or
(for certain hooks) the service doesn't hear from borgmatic for a configured
interval. See [Healthchecks
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook), [Cronitor
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook), and [Cronhub
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook)
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook), [Cronhub
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook), and
[PagerDuty hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook)
below for how to configure this.
3. **Third-party monitoring software**: You can use traditional monitoring
software to consume borgmatic JSON output and track when the last
successful backup occurred. See [scripting
borgmatic](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#scripting-borgmatic)
and [related software](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#related-software)
below for how to configure this.
5. **Borg hosting providers**: Most [Borg hosting
providers](https://torsion.org/borgmatic/#hosting-providers) include
@@ -57,10 +59,10 @@ tests](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/).
## Error hooks
When an error occurs during a backup, borgmatic can run configurable shell
commands to fire off custom error notifications or take other actions, so you
can get alerted as soon as something goes wrong. Here's a not-so-useful
example:
When an error occurs during a `prune`, `create`, or `check` action, borgmatic
can run configurable shell commands to fire off custom error notifications or
take other actions, so you can get alerted as soon as something goes wrong.
Here's a not-so-useful example:
```yaml
hooks:
@@ -91,7 +93,9 @@ here:
* `output`: output of the command that failed (may be blank if an error
occurred without running a command)
Note that borgmatic does not run `on_error` hooks if an error occurs within a
Note that borgmatic runs the `on_error` hooks only for `prune`, `create`, or
`check` actions or hooks in which an error occurs, and not other actions.
borgmatic does not run `on_error` hooks if an error occurs within a
`before_everything` or `after_everything` hook. For more about hooks, see the
[borgmatic hooks
documentation](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/),
@@ -113,22 +117,25 @@ hooks:
```
With this hook in place, borgmatic pings your Healthchecks project when a
backup begins, ends, or errors. Specifically, before the <a
backup begins, ends, or errors. Specifically, after the <a
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
hooks</a> run, borgmatic lets Healthchecks know that a backup has started.
hooks</a> run, borgmatic lets Healthchecks know that it has started if any of
the `prune`, `create`, or `check` actions are run.
Then, if the backup completes 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 payload data sent to Healthchecks. This means that borgmatic logs show up
in the Healthchecks UI, although be aware that Healthchecks currently has a
10-kilobyte limit for the logs in each ping.
If an error occurs during the backup, borgmatic notifies Healthchecks after
the `on_error` hooks run, also tacking on logs including the error itself.
If an error occurs during any action or hook, borgmatic notifies Healthchecks
after the `on_error` hooks run, also tacking on logs including the error
itself. But the logs are only included for errors that occur when a `prune`,
`create`, or `check` action is run.
Note that borgmatic sends logs to Healthchecks by applying the maximum of any
other borgmatic verbosity level (`--verbosity`, `--syslog-verbosity`, etc.),
as there is not currently a dedicated Healthchecks verbosity setting.
You can customize the verbosity of the logs that are sent to Healthchecks with
borgmatic's `--monitoring-verbosity` flag. The `--files` and `--stats` flags
may also be of use. See `borgmatic --help` for more information.
You can configure Healthchecks to notify you by a [variety of
mechanisms](https://healthchecks.io/#welcome-integrations) when backups fail
@@ -150,12 +157,13 @@ hooks:
```
With this hook in place, borgmatic pings your Cronitor monitor when a backup
begins, ends, or errors. Specifically, before the <a
begins, ends, or errors. Specifically, after the <a
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
hooks</a> run, borgmatic lets Cronitor know that a backup has started. Then,
if the backup completes successfully, borgmatic notifies Cronitor of the
success after the `after_backup` hooks run. And if an error occurs during the
backup, borgmatic notifies Cronitor after the `on_error` hooks run.
hooks</a> run, borgmatic lets Cronitor know that it has started if any of the
`prune`, `create`, or `check` actions are run. Then, if the actions complete
successfully, borgmatic notifies Cronitor of the success after the
`after_backup` hooks run. And if an error occurs during any action or hook,
borgmatic notifies Cronitor after the `on_error` hooks run.
You can configure Cronitor to notify you by a [variety of
mechanisms](https://cronitor.io/docs/cron-job-notifications) when backups fail
@@ -177,12 +185,13 @@ hooks:
```
With this hook in place, borgmatic pings your Cronhub monitor when a backup
begins, ends, or errors. Specifically, before the <a
begins, ends, or errors. Specifically, after the <a
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
hooks</a> run, borgmatic lets Cronhub know that a backup has started. Then,
if the backup completes successfully, borgmatic notifies Cronhub of the
success after the `after_backup` hooks run. And if an error occurs during the
backup, borgmatic notifies Cronhub after the `on_error` hooks run.
hooks</a> run, borgmatic lets Cronhub know that it has started if any of the
`prune`, `create`, or `check` actions are run. Then, if the actions complete
successfully, borgmatic notifies Cronhub of the success after the
`after_backup` hooks run. And if an error occurs during any action or hook,
borgmatic notifies Cronhub after the `on_error` hooks run.
Note that even though you configure borgmatic with the "start" variant of the
ping URL, borgmatic substitutes the correct state into the URL when pinging
@@ -193,6 +202,44 @@ mechanisms](https://docs.cronhub.io/integrations.html) when backups fail
or it doesn't hear from borgmatic for a certain period of time.
## PagerDuty hook
In case you're new here: [borgmatic](https://torsion.org/borgmatic/) is
simple, configuration-driven backup software for servers and workstations,
powered by [Borg Backup](https://www.borgbackup.org/).
[PagerDuty](https://www.pagerduty.com/) provides incident monitoring and
alerting. borgmatic has built-in integration that can notify you via PagerDuty
as soon as a backup fails, so you can make sure your backups keep working.
First, create a PagerDuty account and <a
href="https://support.pagerduty.com/docs/services-and-integrations">service</a>
on their site. On the service, add an integration and set the Integration Type
to "borgmatic".
Then, configure borgmatic with the unique "Integration Key" for your service.
Here's an example:
```yaml
hooks:
pagerduty: a177cad45bd374409f78906a810a3074
```
With this hook in place, borgmatic creates a PagerDuty event for your service
whenever backups fail. Specifically, if an error occurs during a `create`,
`prune`, or `check` action, borgmatic sends an event to PagerDuty before the
`on_error` hooks run. Note that borgmatic does not contact PagerDuty when a
backup starts or ends without error.
You can configure PagerDuty to notify you by a [variety of
mechanisms](https://support.pagerduty.com/docs/notifications) when backups
fail.
If you have any issues with the integration, [please contact
us](https://torsion.org/borgmatic/#support-and-contributing).
## Scripting borgmatic
To consume the output of borgmatic in other software, you can include an
@@ -204,6 +251,11 @@ suppressed so as not to interfere with the captured JSON. Also note that JSON
output only shows up at the console, and not in syslog.
## Related software
* [Borgmacator GNOME AppIndicator](https://github.com/N-Coder/borgmacator/)
### Successful backups
`borgmatic list` includes support for a `--successful` flag that only lists
@@ -227,6 +279,18 @@ 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
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
without having to first run "borgmatic list" manually, which can be handy in
automated scripts. Here's an example:
```bash
borgmatic info --archive latest
```
## Related documentation
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
+93 -37
View File
@@ -3,38 +3,76 @@ title: How to set up backups with borgmatic
---
## Installation
To get up and running, first [install
Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at
least version 1.1.
Many users need to backup system files that require privileged access, so
these instructions install and run borgmatic as root. If you don't need to
backup such files, then you are welcome to install and run borgmatic as a
non-root user.
By default, borgmatic looks for its configuration files in `/etc/borgmatic/`
and `/etc/borgmatic.d/`, where the root user typically has read access.
First, manually [install
Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at least
version 1.1. borgmatic does not install Borg automatically so as to avoid
conflicts with existing Borg installations.
So, to download and install borgmatic as the root user, run the following
commands:
Then, download and install borgmatic as a [user site
installation](https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site)
by running the following command:
```bash
sudo pip3 install --user --upgrade borgmatic
```
This is a [recommended user site
installation](https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site).
You will need to ensure that `/root/.local/bin` is available on your `$PATH`
so
that the borgmatic executable is available. For instance, adding this to
root's `~/.profile` or `~/.bash_profile` may do the trick:
This installs borgmatic and its commands at the `/root/.local/bin` path.
Your pip binary may have a different name than "pip3". Make sure you're using
Python 3, as borgmatic does not support Python 2.
The next step is to ensure that borgmatic's commands available are on your
system `PATH`, so that you can run borgmatic:
```bash
export PATH="$PATH:~/.local/bin"
echo export 'PATH="$PATH:/root/.local/bin"' >> ~/.bashrc
source ~/.bashrc
```
Note that your pip binary may have a different name than "pip3". Make sure
you're using Python 3, as borgmatic does not support Python 2.
This adds `/root/.local/bin` to your non-root user's system `PATH`.
If you're using a command shell other than Bash, you may need to use different
commands here.
You can check whether all of this worked with:
```bash
sudo borgmatic --version
```
If borgmatic is properly installed, that should output your borgmatic version.
### Global install option
If you try the user site installation above, and have problems making
borgmatic commands runnable on your system `PATH`, an alternate approach is to
install borgmatic globally.
The following uninstalls borgmatic, and then reinstalls it such that borgmatic
commands are on the default system `PATH`:
```bash
sudo pip3 uninstall borgmatic
sudo pip3 install --upgrade borgmatic
```
The main downside of a global install is that borgmatic is less cleanly
separated from the rest of your Python software, and there's the theoretical
possibility of libary conflicts. But if you're okay with that, for instance
on a relatively dedicated system, then a global install can work out just
fine.
### Other ways to install
Along with the above process, you have several other options for installing
borgmatic:
Besides the approaches described above, there are several other options for
installing borgmatic:
* [Docker image with scheduled backups](https://hub.docker.com/r/b3vis/borgmatic/)
* [Docker base image](https://hub.docker.com/r/monachus/borgmatic/)
@@ -43,6 +81,7 @@ borgmatic:
* [Fedora official](https://bodhi.fedoraproject.org/updates/?search=borgmatic)
* [Fedora unofficial](https://copr.fedorainfracloud.org/coprs/heffer/borgmatic/)
* [Arch Linux](https://www.archlinux.org/packages/community/any/borgmatic/)
* [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=borgmatic)
* [OpenBSD](http://ports.su/sysutils/borgmatic)
* [openSUSE](https://software.opensuse.org/package/borgmatic)
* [stand-alone binary](https://github.com/cmarquardt/borgmatic-binary)
@@ -72,10 +111,13 @@ sudo generate-borgmatic-config
If that command is not found, then it may be installed in a location that's
not in your system `PATH` (see above). Try looking in `~/.local/bin/`.
This generates a sample configuration file at /etc/borgmatic/config.yaml (by
default). You should edit the file to suit your needs, as the values are
representative. All options are optional except where indicated, so feel free
to ignore anything you don't need.
This generates a sample configuration file at `/etc/borgmatic/config.yaml` by
default. If you'd like to use another path, use the `--destination` flag, for
instance: `--destination ~/.config/borgmatic/config.yaml`.
You should edit the configuration file to suit your needs, as the generated
values are only representative. All options are optional except where
indicated, so feel free to ignore anything you don't need.
Note that the configuration file is organized into distinct sections, each
with a section name like `location:` or `storage:`. So take care that if you
@@ -83,12 +125,11 @@ uncomment a particular option, also uncomment its containing section name, or
else borgmatic won't recognize the option. Also be sure to use spaces rather
than tabs for indentation; YAML does not allow tabs.
You can also get the same sample configuration file from the [configuration
You can get the same sample configuration file from the [configuration
reference](https://torsion.org/borgmatic/docs/reference/configuration/), the
authoritative set of all configuration options. This is handy if borgmatic has
added new options
since you originally created your configuration file. Also check out how to
[upgrade your
added new options since you originally created your configuration file. Also
check out how to [upgrade your
configuration](https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-your-configuration).
@@ -134,7 +175,7 @@ this step if you already have a Borg repository.) To create a repository, run
a command like the following:
```bash
borgmatic init --encryption repokey
sudo borgmatic init --encryption repokey
```
(No borgmatic `init` action? Try the old-style `--init` flag, or upgrade
@@ -166,16 +207,23 @@ good idea to test that borgmatic is working. So to run borgmatic and start a
backup, you can invoke it like this:
```bash
borgmatic --verbosity 1
sudo borgmatic --verbosity 1 --files
```
(No borgmatic `--files` flag? It's only present in newer versions of
borgmatic. So try leaving it out, or upgrade borgmatic!)
By default, this will also prune any old backups as per the configured
retention policy, and check backups for consistency problems due to things
like file damage.
The verbosity flag makes borgmatic list the files that it's archiving, which
are those that are new or changed since the last backup. Eyeball the list and
see if it matches your expectations based on the configuration.
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.
Eyeball the list and see if it matches your expectations based on the
configuration.
If you'd like to specify an alternate configuration file path, use the
`--config` flag. See `borgmatic --help` for more information.
## Autopilot
@@ -208,20 +256,28 @@ Then, from the directory where you downloaded them:
```bash
sudo mv borgmatic.service borgmatic.timer /etc/systemd/system/
sudo systemctl enable borgmatic.timer
sudo systemctl start borgmatic.timer
sudo systemctl enable --now borgmatic.timer
```
Feel free to modify the timer file based on how frequently you'd like
borgmatic to run.
### launchd in macOS
If you run borgmatic in macOS with launchd, you may encounter permissions
issues when reading files to backup. If that happens to you, you may be
interested in an [unofficial work-around for Full Disk
Access](https://projects.torsion.org/witten/borgmatic/issues/293).
## Colored output
Borgmatic produces colored terminal output by default. It is disabled when a
non-interactive terminal is detected (like a cron job). Otherwise, you can
disable it by passing the `--no-color` flag, setting the environment variable
`PY_COLORS=False`, or setting the `color` option to `false` in the `output`
section of configuration.
non-interactive terminal is detected (like a cron job), or when you use the
`--json` flag. Otherwise, you can disable it by passing the `--no-color` flag,
setting the environment variable `PY_COLORS=False`, or setting the `color`
option to `false` in the `output` section of configuration.
## Troubleshooting
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+4 -1
View File
@@ -15,8 +15,11 @@ IOSchedulingPriority=7
IOWeight=100
Restart=no
# Prevent rate limiting of borgmatic log events. If you are using an older version of systemd that
# doesn't support this (pre-240 or so), you may have to remove this option.
LogRateLimitIntervalSec=0
# Delay start to prevent backups running during boot.
# Delay start to prevent backups running during boot. Note that systemd-inhibit requires dbus and
# dbus-user-session to be installed.
ExecStartPre=sleep 1m
ExecStart=systemd-inhibit --who="borgmatic" --why="Prevent interrupting scheduled backup" /root/.local/bin/borgmatic --syslog-verbosity 1
+5 -3
View File
@@ -23,10 +23,12 @@ git push github $version
rm -fr dist
python3 setup.py bdist_wheel
python3 setup.py sdist
twine upload -r pypi dist/borgmatic-*.tar.gz
twine upload -r pypi dist/borgmatic-*-py3-none-any.whl
gpg --detach-sign --armor dist/borgmatic-*.tar.gz
gpg --detach-sign --armor dist/borgmatic-*-py3-none-any.whl
twine upload -r pypi dist/borgmatic-*.tar.gz dist/borgmatic-*.tar.gz.asc
twine upload -r pypi dist/borgmatic-*-py3-none-any.whl dist/borgmatic-*-py3-none-any.whl.asc
# Set release changelogs on projects.evoworx.org and GitHub.
# Set release changelogs on projects.torsion.org and GitHub.
release_changelog="$(cat NEWS | sed '/^$/q' | grep -v '^\S')"
escaped_release_changelog="$(echo "$release_changelog" | sed -z 's/\n/\\n/g' | sed -z 's/\"/\\"/g')"
curl --silent --request POST \
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# This script is for running all tests, including end-to-end tests, on a developer machine. It sets
# up database containers to run tests against, runs the tests, and then tears down the containers.
#
# Run this script from the root directory of the borgmatic source.
#
# For more information, see:
# https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/
set -e
docker-compose --file tests/end-to-end/docker-compose.yaml up --force-recreate \
--renew-anon-volumes --abort-on-container-exit
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# This script installs test dependencies and runs all tests, including end-to-end tests. It
# is designed to run inside a test container, and presumes that other test infrastructure like
# databases are already running. Therefore, on a developer machine, you should not run this script
# directly. Instead, run scripts/run-full-dev-tests
#
# For more information, see:
# https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/
set -e
python -m pip install --upgrade pip==20.0.2
pip install tox==3.14.3
export COVERAGE_FILE=/tmp/.coverage
tox --workdir /tmp/.tox
apk add --no-cache borgbackup postgresql-client mariadb-client
tox --workdir /tmp/.tox -e end-to-end
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# This script is intended to be run from the continuous integration build
# server, and not on a developer machine. For that, see:
# https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/
set -e
python -m pip install --upgrade pip==19.3.1
pip install tox==3.14.0
tox
apk add --no-cache borgbackup
tox -e end-to-end
+1 -1
View File
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup
VERSION = '1.4.11'
VERSION = '1.5.7.dev0'
setup(
+25
View File
@@ -0,0 +1,25 @@
version: '3'
services:
postgresql:
image: postgres:12.2-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
mysql:
image: mariadb:10.5
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
tests:
image: python:3.8-alpine3.11
volumes:
- "../..:/app:ro"
tmpfs:
- "/app/borgmatic.egg-info"
tty: true
working_dir: /app
command:
- /app/scripts/run-full-tests
depends_on:
- postgresql
- mysql
+5 -5
View File
@@ -44,13 +44,13 @@ def test_borgmatic_command():
generate_configuration(config_path, repository_path)
subprocess.check_call(
'borgmatic -v 2 --config {} --init --encryption repokey'.format(config_path).split(' ')
'borgmatic -v 2 --config {} init --encryption repokey'.format(config_path).split(' ')
)
# Run borgmatic to generate a backup archive, and then list it to make sure it exists.
subprocess.check_call('borgmatic --config {}'.format(config_path).split(' '))
output = subprocess.check_output(
'borgmatic --config {} --list --json'.format(config_path).split(' ')
'borgmatic --config {} list --json'.format(config_path).split(' ')
).decode(sys.stdout.encoding)
parsed_output = json.loads(output)
@@ -61,16 +61,16 @@ def test_borgmatic_command():
# Extract the created archive into the current (temporary) directory, and confirm that the
# extracted file looks right.
output = subprocess.check_output(
'borgmatic --config {} --extract --archive {}'.format(config_path, archive_name).split(
'borgmatic --config {} extract --archive {}'.format(config_path, archive_name).split(
' '
)
).decode(sys.stdout.encoding)
extracted_config_path = os.path.join(extract_path, config_path)
assert open(extracted_config_path).read() == open(config_path).read()
# Exercise the info flag.
# Exercise the info action.
output = subprocess.check_output(
'borgmatic --config {} --info --json'.format(config_path).split(' ')
'borgmatic --config {} info --json'.format(config_path).split(' ')
).decode(sys.stdout.encoding)
parsed_output = json.loads(output)
+164
View File
@@ -0,0 +1,164 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
import pytest
def write_configuration(
config_path, repository_path, borgmatic_source_directory, postgresql_dump_format='custom'
):
'''
Write out borgmatic configuration into a file at the config path. Set the options so as to work
for testing. This includes injecting the given repository path, borgmatic source directory for
storing database dumps, dump format (for PostgreSQL), and encryption passphrase.
'''
config = '''
location:
source_directories:
- {}
repositories:
- {}
borgmatic_source_directory: {}
storage:
encryption_passphrase: "test"
hooks:
postgresql_databases:
- name: test
hostname: postgresql
username: postgres
password: test
format: {}
- name: all
hostname: postgresql
username: postgres
password: test
mysql_databases:
- name: test
hostname: mysql
username: root
password: test
- name: all
hostname: mysql
username: root
password: test
'''.format(
config_path, repository_path, borgmatic_source_directory, postgresql_dump_format
)
config_file = open(config_path, 'w')
config_file.write(config)
config_file.close()
def test_database_dump_and_restore():
# Create a Borg repository.
temporary_directory = tempfile.mkdtemp()
repository_path = os.path.join(temporary_directory, 'test.borg')
borgmatic_source_directory = os.path.join(temporary_directory, '.borgmatic')
original_working_directory = os.getcwd()
try:
config_path = os.path.join(temporary_directory, 'test.yaml')
write_configuration(config_path, repository_path, borgmatic_source_directory)
subprocess.check_call(
'borgmatic -v 2 --config {} init --encryption repokey'.format(config_path).split(' ')
)
# Run borgmatic to generate a backup archive including a database dump.
subprocess.check_call('borgmatic create --config {} -v 2'.format(config_path).split(' '))
# Get the created archive name.
output = subprocess.check_output(
'borgmatic --config {} list --json'.format(config_path).split(' ')
).decode(sys.stdout.encoding)
parsed_output = json.loads(output)
assert len(parsed_output) == 1
assert len(parsed_output[0]['archives']) == 1
archive_name = parsed_output[0]['archives'][0]['archive']
# Restore the database from the archive.
subprocess.check_call(
'borgmatic --config {} restore --archive {}'.format(config_path, archive_name).split(
' '
)
)
finally:
os.chdir(original_working_directory)
shutil.rmtree(temporary_directory)
def test_database_dump_and_restore_with_directory_format():
# Create a Borg repository.
temporary_directory = tempfile.mkdtemp()
repository_path = os.path.join(temporary_directory, 'test.borg')
borgmatic_source_directory = os.path.join(temporary_directory, '.borgmatic')
original_working_directory = os.getcwd()
try:
config_path = os.path.join(temporary_directory, 'test.yaml')
write_configuration(
config_path,
repository_path,
borgmatic_source_directory,
postgresql_dump_format='directory',
)
subprocess.check_call(
'borgmatic -v 2 --config {} init --encryption repokey'.format(config_path).split(' ')
)
# Run borgmatic to generate a backup archive including a database dump.
subprocess.check_call('borgmatic create --config {} -v 2'.format(config_path).split(' '))
# Restore the database from the archive.
subprocess.check_call(
'borgmatic --config {} restore --archive latest'.format(config_path).split(' ')
)
finally:
os.chdir(original_working_directory)
shutil.rmtree(temporary_directory)
def test_database_dump_with_error_causes_borgmatic_to_exit():
# Create a Borg repository.
temporary_directory = tempfile.mkdtemp()
repository_path = os.path.join(temporary_directory, 'test.borg')
borgmatic_source_directory = os.path.join(temporary_directory, '.borgmatic')
original_working_directory = os.getcwd()
try:
config_path = os.path.join(temporary_directory, 'test.yaml')
write_configuration(config_path, repository_path, borgmatic_source_directory)
subprocess.check_call(
'borgmatic -v 2 --config {} init --encryption repokey'.format(config_path).split(' ')
)
# Run borgmatic with a config override such that the database dump fails.
with pytest.raises(subprocess.CalledProcessError):
subprocess.check_call(
[
'borgmatic',
'create',
'--config',
config_path,
'-v',
'2',
'--override',
"hooks.postgresql_databases=[{'name': 'nope'}]",
]
)
finally:
os.chdir(original_working_directory)
shutil.rmtree(temporary_directory)
+63 -10
View File
@@ -98,12 +98,14 @@ def test_parse_arguments_with_no_actions_defaults_to_all_actions_enabled():
def test_parse_arguments_with_no_actions_passes_argument_to_relevant_actions():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
arguments = module.parse_arguments('--stats')
arguments = module.parse_arguments('--stats', '--files')
assert 'prune' in arguments
assert arguments['prune'].stats
assert arguments['prune'].files
assert 'create' in arguments
assert arguments['create'].stats
assert arguments['create'].files
assert 'check' in arguments
@@ -256,7 +258,7 @@ def test_parse_arguments_disallows_glob_archives_with_successful():
)
def test_parse_arguments_disallows_repository_without_extract_or_list():
def test_parse_arguments_disallows_repository_unless_action_consumes_it():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit):
@@ -271,20 +273,36 @@ def test_parse_arguments_allows_repository_with_extract():
)
def test_parse_arguments_allows_repository_with_mount():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
module.parse_arguments(
'--config',
'myconfig',
'mount',
'--repository',
'test.borg',
'--archive',
'test',
'--mount-point',
'/mnt',
)
def test_parse_arguments_allows_repository_with_list():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
module.parse_arguments('--config', 'myconfig', 'list', '--repository', 'test.borg')
def test_parse_arguments_disallows_archive_without_extract_restore_or_list():
def test_parse_arguments_disallows_archive_unless_action_consumes_it():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit):
module.parse_arguments('--config', 'myconfig', '--archive', 'test')
def test_parse_arguments_disallows_paths_without_extract():
def test_parse_arguments_disallows_paths_unless_action_consumes_it():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit):
@@ -297,6 +315,14 @@ def test_parse_arguments_allows_archive_with_extract():
module.parse_arguments('--config', 'myconfig', 'extract', '--archive', 'test')
def test_parse_arguments_allows_archive_with_mount():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
module.parse_arguments(
'--config', 'myconfig', 'mount', '--archive', 'test', '--mount-point', '/mnt'
)
def test_parse_arguments_allows_archive_with_dashed_extract():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
@@ -335,6 +361,20 @@ def test_parse_arguments_requires_archive_with_restore():
module.parse_arguments('--config', 'myconfig', 'restore')
def test_parse_arguments_requires_mount_point_with_mount():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit):
module.parse_arguments('--config', 'myconfig', 'mount', '--archive', 'test')
def test_parse_arguments_requires_mount_point_with_umount():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit):
module.parse_arguments('--config', 'myconfig', 'umount')
def test_parse_arguments_allows_progress_before_create():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
@@ -353,12 +393,6 @@ def test_parse_arguments_allows_progress_and_extract():
module.parse_arguments('--progress', 'extract', '--archive', 'test', 'list')
def test_parse_arguments_allows_progress_and_restore():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
module.parse_arguments('--progress', 'restore', '--archive', 'test', 'list')
def test_parse_arguments_disallows_progress_without_create():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
@@ -385,6 +419,25 @@ def test_parse_arguments_with_stats_flag_but_no_create_or_prune_flag_raises_valu
module.parse_arguments('--stats', 'list')
def test_parse_arguments_with_files_and_create_flags_does_not_raise():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
module.parse_arguments('--files', 'create', 'list')
def test_parse_arguments_with_files_and_prune_flags_does_not_raise():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
module.parse_arguments('--files', 'prune', 'list')
def test_parse_arguments_with_files_flag_but_no_create_or_prune_or_restore_flag_raises_value_error():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit):
module.parse_arguments('--files', 'list')
def test_parse_arguments_allows_json_with_list_or_info():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
+40
View File
@@ -0,0 +1,40 @@
import pytest
from borgmatic.config import override as module
@pytest.mark.parametrize(
'value,expected_result',
(
('thing', 'thing'),
('33', 33),
('33b', '33b'),
('true', True),
('false', False),
('[foo]', ['foo']),
('[foo, bar]', ['foo', 'bar']),
),
)
def test_convert_value_type_coerces_values(value, expected_result):
assert module.convert_value_type(value) == expected_result
def test_apply_overrides_updates_config():
raw_overrides = [
'section.key=value1',
'other_section.thing=value2',
'section.nested.key=value3',
'new.foo=bar',
]
config = {
'section': {'key': 'value', 'other': 'other_value'},
'other_section': {'thing': 'thing_value'},
}
module.apply_overrides(config, raw_overrides)
assert config == {
'section': {'key': 'value1', 'other': 'other_value', 'nested': {'key': 'value3'}},
'other_section': {'thing': 'value2'},
'new': {'foo': 'bar'},
}
+8
View File
@@ -0,0 +1,8 @@
MAXIMUM_LINE_LENGTH = 80
def test_schema_line_length_stays_under_limit():
schema_file = open('borgmatic/config/schema.yaml')
for line in schema_file.readlines():
assert len(line.rstrip('\n')) <= MAXIMUM_LINE_LENGTH
+52
View File
@@ -212,3 +212,55 @@ def test_parse_configuration_raises_for_validation_error():
with pytest.raises(module.Validation_error):
module.parse_configuration('config.yaml', 'schema.yaml')
def test_parse_configuration_applies_overrides():
mock_config_and_schema(
'''
location:
source_directories:
- /home
repositories:
- hostname.borg
local_path: borg1
'''
)
result = module.parse_configuration(
'config.yaml', 'schema.yaml', overrides=['location.local_path=borg2']
)
assert result == {
'location': {
'source_directories': ['/home'],
'repositories': ['hostname.borg'],
'local_path': 'borg2',
}
}
def test_parse_configuration_applies_normalization():
mock_config_and_schema(
'''
location:
source_directories:
- /home
repositories:
- hostname.borg
exclude_if_present: .nobackup
'''
)
result = module.parse_configuration('config.yaml', 'schema.yaml')
assert result == {
'location': {
'source_directories': ['/home'],
'repositories': ['hostname.borg'],
'exclude_if_present': ['.nobackup'],
}
}
@@ -0,0 +1,24 @@
import logging
from flexmock import flexmock
from borgmatic.hooks import healthchecks as module
def test_destroy_monitor_removes_healthchecks_handler():
logger = logging.getLogger()
original_handlers = list(logger.handlers)
logger.addHandler(module.Forgetful_buffering_handler(byte_capacity=100, log_level=1))
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock())
assert logger.handlers == original_handlers
def test_destroy_monitor_without_healthchecks_handler_does_not_raise():
logger = logging.getLogger()
original_handlers = list(logger.handlers)
module.destroy_monitor(flexmock(), flexmock(), flexmock(), flexmock())
assert logger.handlers == original_handlers
+94 -29
View File
@@ -7,74 +7,139 @@ from flexmock import flexmock
from borgmatic import execute as module
def test_log_output_logs_each_line_separately():
def test_log_outputs_logs_each_line_separately():
flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'hi').once()
flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'there').once()
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
hi_process = subprocess.Popen(['echo', 'hi'], stdout=subprocess.PIPE)
module.log_output(
['echo', 'hi'],
hi_process,
hi_process.stdout,
output_log_level=logging.INFO,
error_on_warnings=False,
)
flexmock(module).should_receive('output_buffer_for_process').with_args(
hi_process, ()
).and_return(hi_process.stdout)
there_process = subprocess.Popen(['echo', 'there'], stdout=subprocess.PIPE)
module.log_output(
['echo', 'there'],
there_process,
there_process.stdout,
flexmock(module).should_receive('output_buffer_for_process').with_args(
there_process, ()
).and_return(there_process.stdout)
module.log_outputs(
(hi_process, there_process),
exclude_stdouts=(),
output_log_level=logging.INFO,
error_on_warnings=False,
borg_local_path='borg',
)
def test_log_output_includes_error_output_in_exception():
def test_log_outputs_skips_logs_for_process_with_none_stdout():
flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'hi').never()
flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'there').once()
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
hi_process = subprocess.Popen(['echo', 'hi'], stdout=None)
flexmock(module).should_receive('output_buffer_for_process').with_args(
hi_process, ()
).and_return(hi_process.stdout)
there_process = subprocess.Popen(['echo', 'there'], stdout=subprocess.PIPE)
flexmock(module).should_receive('output_buffer_for_process').with_args(
there_process, ()
).and_return(there_process.stdout)
module.log_outputs(
(hi_process, there_process),
exclude_stdouts=(),
output_log_level=logging.INFO,
borg_local_path='borg',
)
def test_log_outputs_includes_error_output_in_exception():
flexmock(module.logger).should_receive('log')
flexmock(module).should_receive('exit_code_indicates_error').and_return(True)
flexmock(module).should_receive('command_for_process').and_return('grep')
process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout)
with pytest.raises(subprocess.CalledProcessError) as error:
module.log_output(
['grep'],
process,
process.stdout,
module.log_outputs(
(process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg'
)
assert error.value.output
def test_log_outputs_skips_error_output_in_exception_for_process_with_none_stdout():
flexmock(module.logger).should_receive('log')
flexmock(module).should_receive('exit_code_indicates_error').and_return(True)
flexmock(module).should_receive('command_for_process').and_return('grep')
process = subprocess.Popen(['grep'], stdout=None)
flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout)
with pytest.raises(subprocess.CalledProcessError) as error:
module.log_outputs(
(process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg'
)
assert error.value.returncode == 2
assert not error.value.output
def test_log_outputs_kills_other_processes_when_one_errors():
flexmock(module.logger).should_receive('log')
flexmock(module).should_receive('exit_code_indicates_error').and_return(True)
flexmock(module).should_receive('command_for_process').and_return('grep')
process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
other_process = subprocess.Popen(
['watch', 'true'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
flexmock(module).should_receive('output_buffer_for_process').with_args(process, ()).and_return(
process.stdout
)
flexmock(module).should_receive('output_buffer_for_process').with_args(
other_process, ()
).and_return(other_process.stdout)
flexmock(other_process).should_receive('kill').once()
with pytest.raises(subprocess.CalledProcessError) as error:
module.log_outputs(
(process, other_process),
exclude_stdouts=(),
output_log_level=logging.INFO,
error_on_warnings=False,
borg_local_path='borg',
)
assert error.value.returncode == 2
assert error.value.output
def test_log_output_truncates_long_error_output():
def test_log_outputs_truncates_long_error_output():
flexmock(module).ERROR_OUTPUT_MAX_LINE_COUNT = 0
flexmock(module.logger).should_receive('log')
flexmock(module).should_receive('exit_code_indicates_error').and_return(True)
flexmock(module).should_receive('command_for_process').and_return('grep')
process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout)
with pytest.raises(subprocess.CalledProcessError) as error:
module.log_output(
['grep'],
process,
process.stdout,
output_log_level=logging.INFO,
error_on_warnings=False,
module.log_outputs(
(process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg'
)
assert error.value.returncode == 2
assert error.value.output.startswith('...')
def test_log_output_with_no_output_logs_nothing():
def test_log_outputs_with_no_output_logs_nothing():
flexmock(module.logger).should_receive('log').never()
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
process = subprocess.Popen(['true'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
module.log_output(
['true'], process, process.stdout, output_log_level=logging.INFO, error_on_warnings=False
flexmock(module).should_receive('output_buffer_for_process').and_return(process.stdout)
module.log_outputs(
(process,), exclude_stdouts=(), output_log_level=logging.INFO, borg_local_path='borg'
)
+44
View File
@@ -156,6 +156,36 @@ def test_make_check_flags_with_default_checks_and_prefix_includes_prefix_flag():
assert flags == ('--prefix', 'foo-')
def test_check_archives_with_progress_calls_borg_with_progress_parameter():
checks = ('repository',)
consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').and_return(())
flexmock(module).should_receive('execute_command').never()
flexmock(module).should_receive('execute_command').with_args(
('borg', 'check', '--progress', 'repo'), output_file=module.DO_NOT_CAPTURE
).once()
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config, progress=True
)
def test_check_archives_with_repair_calls_borg_with_repair_parameter():
checks = ('repository',)
consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').and_return(())
flexmock(module).should_receive('execute_command').never()
flexmock(module).should_receive('execute_command').with_args(
('borg', 'check', '--repair', 'repo'), output_file=module.DO_NOT_CAPTURE
).once()
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config, repair=True
)
@pytest.mark.parametrize(
'checks',
(
@@ -294,3 +324,17 @@ def test_check_archives_with_retention_prefix():
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config
)
def test_check_archives_with_extra_borg_options_calls_borg_with_extra_options():
checks = ('repository',)
consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').and_return(())
insert_execute_command_mock(('borg', 'check', '--extra', '--options', 'repo'))
module.check_archives(
repository='repo',
storage_config={'extra_borg_options': {'check': '--extra --options'}},
consistency_config=consistency_config,
)
+458 -92
View File
@@ -60,6 +60,31 @@ def test_expand_home_directories_considers_none_as_no_directories():
assert paths == ()
@pytest.mark.parametrize(
'directories,expected_directories',
(
({'/': 1, '/root': 1}, ('/',)),
({'/': 1, '/root/': 1}, ('/',)),
({'/': 1, '/root': 2}, ('/', '/root')),
({'/root': 1, '/': 1}, ('/',)),
({'/root': 1, '/root/foo': 1}, ('/root',)),
({'/root/': 1, '/root/foo': 1}, ('/root/',)),
({'/root': 1, '/root/foo/': 1}, ('/root',)),
({'/root': 1, '/root/foo': 2}, ('/root', '/root/foo')),
({'/root/foo': 1, '/root': 1}, ('/root',)),
({'/root': 1, '/etc': 1, '/root/foo/bar': 1}, ('/etc', '/root')),
({'/root': 1, '/root/foo': 1, '/root/foo/bar': 1}, ('/root',)),
({'/dup': 1, '/dup': 1}, ('/dup',)),
({'/foo': 1, '/bar': 1}, ('/bar', '/foo')),
({'/foo': 1, '/bar': 2}, ('/bar', '/foo')),
),
)
def test_deduplicate_directories_removes_child_paths_on_the_same_filesystem(
directories, expected_directories
):
assert module.deduplicate_directories(directories) == expected_directories
def test_write_pattern_file_does_not_raise():
temporary_file = flexmock(name='filename', write=lambda mode: None, flush=lambda: None)
flexmock(module.tempfile).should_receive('NamedTemporaryFile').and_return(temporary_file)
@@ -145,9 +170,16 @@ def test_make_exclude_flags_does_not_include_exclude_caches_when_false_in_config
def test_make_exclude_flags_includes_exclude_if_present_when_in_config():
exclude_flags = module._make_exclude_flags(location_config={'exclude_if_present': 'exclude_me'})
exclude_flags = module._make_exclude_flags(
location_config={'exclude_if_present': ['exclude_me', 'also_me']}
)
assert exclude_flags == ('--exclude-if-present', 'exclude_me')
assert exclude_flags == (
'--exclude-if-present',
'exclude_me',
'--exclude-if-present',
'also_me',
)
def test_make_exclude_flags_includes_keep_exclude_tags_when_true_in_config():
@@ -184,14 +216,21 @@ def test_borgmatic_source_directories_set_when_directory_exists():
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os.path).should_receive('expanduser')
assert module.borgmatic_source_directories() == [module.BORGMATIC_SOURCE_DIRECTORY]
assert module.borgmatic_source_directories('/tmp') == ['/tmp']
def test_borgmatic_source_directories_empty_when_directory_does_not_exist():
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.os.path).should_receive('expanduser')
assert module.borgmatic_source_directories() == []
assert module.borgmatic_source_directories('/tmp') == []
def test_borgmatic_source_directories_defaults_when_directory_not_given():
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os.path).should_receive('expanduser')
assert module.borgmatic_source_directories(None) == [module.DEFAULT_BORGMATIC_SOURCE_DIRECTORY]
DEFAULT_ARCHIVE_NAME = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
@@ -200,13 +239,18 @@ ARCHIVE_WITH_PATHS = ('repo::{}'.format(DEFAULT_ARCHIVE_NAME), 'foo', 'bar')
def test_create_archive_calls_borg_with_parameters():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg', 'create') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -224,7 +268,9 @@ def test_create_archive_calls_borg_with_parameters():
def test_create_archive_with_patterns_calls_borg_with_patterns():
pattern_flags = ('--patterns-from', 'patterns')
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(
flexmock(name='/tmp/patterns')
@@ -232,7 +278,10 @@ def test_create_archive_with_patterns_calls_borg_with_patterns():
flexmock(module).should_receive('_make_pattern_flags').and_return(pattern_flags)
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create') + pattern_flags + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg', 'create') + pattern_flags + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -250,7 +299,9 @@ def test_create_archive_with_patterns_calls_borg_with_patterns():
def test_create_archive_with_exclude_patterns_calls_borg_with_excludes():
exclude_flags = ('--exclude-from', 'excludes')
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(('exclude',))
flexmock(module).should_receive('_write_pattern_file').and_return(None).and_return(
flexmock(name='/tmp/excludes')
@@ -258,7 +309,10 @@ def test_create_archive_with_exclude_patterns_calls_borg_with_excludes():
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(exclude_flags)
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create') + exclude_flags + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg', 'create') + exclude_flags + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -275,15 +329,19 @@ def test_create_archive_with_exclude_patterns_calls_borg_with_excludes():
def test_create_archive_with_log_info_calls_borg_with_info_parameter():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-', '--info', '--stats') + ARCHIVE_WITH_PATHS,
('borg', 'create', '--info') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
insert_logging_mock(logging.INFO)
@@ -301,14 +359,19 @@ def test_create_archive_with_log_info_calls_borg_with_info_parameter():
def test_create_archive_with_log_info_and_json_suppresses_most_borg_output():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--json') + ARCHIVE_WITH_PATHS, output_log_level=None
('borg', 'create', '--json') + ARCHIVE_WITH_PATHS,
output_log_level=None,
output_file=None,
borg_local_path='borg',
)
insert_logging_mock(logging.INFO)
@@ -327,15 +390,18 @@ def test_create_archive_with_log_info_and_json_suppresses_most_borg_output():
def test_create_archive_with_log_debug_calls_borg_with_debug_parameter():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-', '--stats', '--debug', '--show-rc')
+ ARCHIVE_WITH_PATHS,
('borg', 'create', '--debug', '--show-rc') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
insert_logging_mock(logging.DEBUG)
@@ -353,13 +419,18 @@ def test_create_archive_with_log_debug_calls_borg_with_debug_parameter():
def test_create_archive_with_log_debug_and_json_suppresses_most_borg_output():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--json') + ARCHIVE_WITH_PATHS, output_log_level=None
('borg', 'create', '--json') + ARCHIVE_WITH_PATHS,
output_log_level=None,
output_file=None,
borg_local_path='borg',
)
insert_logging_mock(logging.DEBUG)
@@ -378,14 +449,19 @@ def test_create_archive_with_log_debug_and_json_suppresses_most_borg_output():
def test_create_archive_with_dry_run_calls_borg_with_dry_run_parameter():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--dry-run') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg', 'create', '--dry-run') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -400,20 +476,23 @@ def test_create_archive_with_dry_run_calls_borg_with_dry_run_parameter():
)
def test_create_archive_with_dry_run_and_log_info_calls_borg_without_stats_parameter():
def test_create_archive_with_stats_and_dry_run_calls_borg_without_stats_parameter():
# --dry-run and --stats are mutually exclusive, see:
# https://borgbackup.readthedocs.io/en/stable/usage/create.html#description
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-', '--info', '--dry-run')
+ ARCHIVE_WITH_PATHS,
('borg', 'create', '--info', '--dry-run') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
insert_logging_mock(logging.INFO)
@@ -426,41 +505,15 @@ def test_create_archive_with_dry_run_and_log_info_calls_borg_without_stats_param
'exclude_patterns': None,
},
storage_config={},
)
def test_create_archive_with_dry_run_and_log_debug_calls_borg_without_stats_parameter():
# --dry-run and --stats are mutually exclusive, see:
# https://borgbackup.readthedocs.io/en/stable/usage/create.html#description
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-', '--debug', '--show-rc', '--dry-run')
+ ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
)
insert_logging_mock(logging.DEBUG)
module.create_archive(
dry_run=True,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
stats=True,
)
def test_create_archive_with_checkpoint_interval_calls_borg_with_checkpoint_interval_parameters():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -468,6 +521,8 @@ def test_create_archive_with_checkpoint_interval_calls_borg_with_checkpoint_inte
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--checkpoint-interval', '600') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -484,7 +539,9 @@ def test_create_archive_with_checkpoint_interval_calls_borg_with_checkpoint_inte
def test_create_archive_with_chunker_params_calls_borg_with_chunker_params_parameters():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -492,6 +549,8 @@ def test_create_archive_with_chunker_params_calls_borg_with_chunker_params_param
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--chunker-params', '1,2,3,4') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -508,7 +567,9 @@ def test_create_archive_with_chunker_params_calls_borg_with_chunker_params_param
def test_create_archive_with_compression_calls_borg_with_compression_parameters():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -516,6 +577,8 @@ def test_create_archive_with_compression_calls_borg_with_compression_parameters(
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--compression', 'rle') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -532,7 +595,9 @@ def test_create_archive_with_compression_calls_borg_with_compression_parameters(
def test_create_archive_with_remote_rate_limit_calls_borg_with_remote_ratelimit_parameters():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -540,6 +605,8 @@ def test_create_archive_with_remote_rate_limit_calls_borg_with_remote_ratelimit_
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--remote-ratelimit', '100') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -556,13 +623,18 @@ def test_create_archive_with_remote_rate_limit_calls_borg_with_remote_ratelimit_
def test_create_archive_with_one_file_system_calls_borg_with_one_file_system_parameter():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--one-file-system') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg', 'create', '--one-file-system') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -580,13 +652,18 @@ def test_create_archive_with_one_file_system_calls_borg_with_one_file_system_par
def test_create_archive_with_numeric_owner_calls_borg_with_numeric_owner_parameter():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--numeric-owner') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg', 'create', '--numeric-owner') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -604,13 +681,18 @@ def test_create_archive_with_numeric_owner_calls_borg_with_numeric_owner_paramet
def test_create_archive_with_read_special_calls_borg_with_read_special_parameter():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--read-special') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg', 'create', '--read-special') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -629,13 +711,18 @@ def test_create_archive_with_read_special_calls_borg_with_read_special_parameter
@pytest.mark.parametrize('option_name', ('atime', 'ctime', 'birthtime', 'bsd_flags'))
def test_create_archive_with_option_true_calls_borg_without_corresponding_parameter(option_name):
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg', 'create') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -654,7 +741,9 @@ def test_create_archive_with_option_true_calls_borg_without_corresponding_parame
@pytest.mark.parametrize('option_name', ('atime', 'ctime', 'birthtime', 'bsd_flags'))
def test_create_archive_with_option_false_calls_borg_with_corresponding_parameter(option_name):
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -662,6 +751,8 @@ def test_create_archive_with_option_false_calls_borg_with_corresponding_paramete
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--no' + option_name.replace('_', '')) + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -679,7 +770,9 @@ def test_create_archive_with_option_false_calls_borg_with_corresponding_paramete
def test_create_archive_with_files_cache_calls_borg_with_files_cache_parameters():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -687,6 +780,8 @@ def test_create_archive_with_files_cache_calls_borg_with_files_cache_parameters(
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--files-cache', 'ctime,size') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -704,13 +799,18 @@ def test_create_archive_with_files_cache_calls_borg_with_files_cache_parameters(
def test_create_archive_with_local_path_calls_borg_via_local_path():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg1', 'create') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg1', 'create') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg1',
)
module.create_archive(
@@ -728,7 +828,9 @@ def test_create_archive_with_local_path_calls_borg_via_local_path():
def test_create_archive_with_remote_path_calls_borg_with_remote_path_parameters():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -736,6 +838,8 @@ def test_create_archive_with_remote_path_calls_borg_with_remote_path_parameters(
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--remote-path', 'borg1') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -753,13 +857,18 @@ def test_create_archive_with_remote_path_calls_borg_with_remote_path_parameters(
def test_create_archive_with_umask_calls_borg_with_umask_parameters():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--umask', '740') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg', 'create', '--umask', '740') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -776,13 +885,18 @@ def test_create_archive_with_umask_calls_borg_with_umask_parameters():
def test_create_archive_with_lock_wait_calls_borg_with_lock_wait_parameters():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--lock-wait', '5') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO
('borg', 'create', '--lock-wait', '5') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -797,15 +911,20 @@ def test_create_archive_with_lock_wait_calls_borg_with_lock_wait_parameters():
)
def test_create_archive_with_stats_calls_borg_with_stats_parameter():
def test_create_archive_with_stats_calls_borg_with_stats_parameter_and_warning_output_log_level():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--stats') + ARCHIVE_WITH_PATHS, output_log_level=logging.WARNING
('borg', 'create', '--stats') + ARCHIVE_WITH_PATHS,
output_log_level=logging.WARNING,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -821,15 +940,139 @@ def test_create_archive_with_stats_calls_borg_with_stats_parameter():
)
def test_create_archive_with_progress_calls_borg_with_progress_parameter():
def test_create_archive_with_stats_and_log_info_calls_borg_with_stats_parameter_and_info_output_log_level():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command_without_capture').with_args(
('borg', 'create', '--progress') + ARCHIVE_WITH_PATHS
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--info', '--stats') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
insert_logging_mock(logging.INFO)
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
stats=True,
)
def test_create_archive_with_files_calls_borg_with_list_parameter_and_warning_output_log_level():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-') + ARCHIVE_WITH_PATHS,
output_log_level=logging.WARNING,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
files=True,
)
def test_create_archive_with_files_and_log_info_calls_borg_with_list_parameter_and_info_output_log_level():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-', '--info') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
insert_logging_mock(logging.INFO)
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
files=True,
)
def test_create_archive_with_progress_and_log_info_calls_borg_with_progress_parameter_and_no_list():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--info', '--progress') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=module.DO_NOT_CAPTURE,
borg_local_path='borg',
)
insert_logging_mock(logging.INFO)
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
progress=True,
)
def test_create_archive_with_progress_calls_borg_with_progress_parameter():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--progress') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=module.DO_NOT_CAPTURE,
borg_local_path='borg',
)
module.create_archive(
@@ -845,15 +1088,53 @@ def test_create_archive_with_progress_calls_borg_with_progress_parameter():
)
def test_create_archive_with_progress_and_stream_processes_calls_borg_with_progress_parameter():
processes = flexmock()
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command_with_processes').with_args(
('borg', 'create', '--one-file-system', '--read-special', '--progress')
+ ARCHIVE_WITH_PATHS,
processes=processes,
output_log_level=logging.INFO,
output_file=module.DO_NOT_CAPTURE,
borg_local_path='borg',
)
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
progress=True,
stream_processes=processes,
)
def test_create_archive_with_json_calls_borg_with_json_parameter():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--json') + ARCHIVE_WITH_PATHS, output_log_level=None
('borg', 'create', '--json') + ARCHIVE_WITH_PATHS,
output_log_level=None,
output_file=None,
borg_local_path='borg',
).and_return('[]')
json_output = module.create_archive(
@@ -873,13 +1154,18 @@ def test_create_archive_with_json_calls_borg_with_json_parameter():
def test_create_archive_with_stats_and_json_calls_borg_without_stats_parameter():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--json') + ARCHIVE_WITH_PATHS, output_log_level=None
('borg', 'create', '--json') + ARCHIVE_WITH_PATHS,
output_log_level=None,
output_file=None,
borg_local_path='borg',
).and_return('[]')
json_output = module.create_archive(
@@ -900,7 +1186,9 @@ def test_create_archive_with_stats_and_json_calls_borg_without_stats_parameter()
def test_create_archive_with_source_directories_glob_expands():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'food'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'food'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -908,6 +1196,8 @@ def test_create_archive_with_source_directories_glob_expands():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', 'repo::{}'.format(DEFAULT_ARCHIVE_NAME), 'foo', 'food'),
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
flexmock(module.glob).should_receive('glob').with_args('foo*').and_return(['foo', 'food'])
@@ -925,7 +1215,9 @@ def test_create_archive_with_source_directories_glob_expands():
def test_create_archive_with_non_matching_source_directories_glob_passes_through():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo*',))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo*',))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -933,6 +1225,8 @@ def test_create_archive_with_non_matching_source_directories_glob_passes_through
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', 'repo::{}'.format(DEFAULT_ARCHIVE_NAME), 'foo*'),
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
flexmock(module.glob).should_receive('glob').with_args('foo*').and_return([])
@@ -950,7 +1244,9 @@ def test_create_archive_with_non_matching_source_directories_glob_passes_through
def test_create_archive_with_glob_calls_borg_with_expanded_directories():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'food'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'food'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -958,6 +1254,8 @@ def test_create_archive_with_glob_calls_borg_with_expanded_directories():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', 'repo::{}'.format(DEFAULT_ARCHIVE_NAME), 'foo', 'food'),
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -974,13 +1272,18 @@ def test_create_archive_with_glob_calls_borg_with_expanded_directories():
def test_create_archive_with_archive_name_format_calls_borg_with_archive_name():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', 'repo::ARCHIVE_NAME', 'foo', 'bar'), output_log_level=logging.INFO
('borg', 'create', 'repo::ARCHIVE_NAME', 'foo', 'bar'),
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -997,7 +1300,9 @@ def test_create_archive_with_archive_name_format_calls_borg_with_archive_name():
def test_create_archive_with_archive_name_format_accepts_borg_placeholders():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
@@ -1005,6 +1310,8 @@ def test_create_archive_with_archive_name_format_accepts_borg_placeholders():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', 'repo::Documents_{hostname}-{now}', 'foo', 'bar'),
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
@@ -1017,3 +1324,62 @@ def test_create_archive_with_archive_name_format_accepts_borg_placeholders():
},
storage_config={'archive_name_format': 'Documents_{hostname}-{now}'},
)
def test_create_archive_with_extra_borg_options_calls_borg_with_extra_options():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--extra', '--options') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={'extra_borg_options': {'create': '--extra --options'}},
)
def test_create_archive_with_stream_processes_calls_borg_with_processes():
processes = flexmock()
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('_expand_directories').and_return(())
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command_with_processes').with_args(
('borg', 'create', '--one-file-system', '--read-special') + ARCHIVE_WITH_PATHS,
processes=processes,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
)
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
stream_processes=processes,
)
+22
View File
@@ -60,3 +60,25 @@ def test_initialize_with_relocated_repo_access_should_override_default():
assert os.environ.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'yes'
finally:
os.environ = orig_environ
def test_initialize_prefers_configuration_option_over_borg_environment_variable():
orig_environ = os.environ
try:
os.environ = {'BORG_SSH': 'mosh'}
module.initialize({'ssh_command': 'ssh -C'})
assert os.environ.get('BORG_RSH') == 'ssh -C'
finally:
os.environ = orig_environ
def test_initialize_passes_through_existing_borg_environment_variable():
orig_environ = os.environ
try:
os.environ = {'BORG_PASSPHRASE': 'pass'}
module.initialize({'ssh_command': 'ssh -C'})
assert os.environ.get('BORG_PASSPHRASE') == 'pass'
finally:
os.environ = orig_environ
+63 -7
View File
@@ -1,5 +1,6 @@
import logging
import pytest
from flexmock import flexmock
from borgmatic.borg import extract as module
@@ -7,15 +8,15 @@ from borgmatic.borg import extract as module
from ..test_verbosity import insert_logging_mock
def insert_execute_command_mock(command, working_directory=None, error_on_warnings=True):
def insert_execute_command_mock(command, working_directory=None):
flexmock(module).should_receive('execute_command').with_args(
command, working_directory=working_directory, error_on_warnings=error_on_warnings
command, working_directory=working_directory
).once()
def insert_execute_command_output_mock(command, result):
flexmock(module).should_receive('execute_command').with_args(
command, output_log_level=None
command, output_log_level=None, borg_local_path=command[0]
).and_return(result).once()
@@ -87,7 +88,7 @@ def test_extract_last_archive_dry_run_calls_borg_with_lock_wait_parameters():
module.extract_last_archive_dry_run(repository='repo', lock_wait=5)
def test_extract_archive_calls_borg_with_restore_path_parameters():
def test_extract_archive_calls_borg_with_path_parameters():
flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', 'repo::archive', 'path1', 'path2'))
@@ -219,12 +220,27 @@ def test_extract_archive_calls_borg_with_destination_path():
)
def test_extract_archive_calls_borg_with_strip_components():
flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(('borg', 'extract', '--strip-components', '5', 'repo::archive'))
module.extract_archive(
dry_run=False,
repository='repo',
archive='archive',
paths=None,
location_config={},
storage_config={},
strip_components=5,
)
def test_extract_archive_calls_borg_with_progress_parameter():
flexmock(module.os.path).should_receive('abspath').and_return('repo')
flexmock(module).should_receive('execute_command_without_capture').with_args(
flexmock(module).should_receive('execute_command').with_args(
('borg', 'extract', '--progress', 'repo::archive'),
output_file=module.DO_NOT_CAPTURE,
working_directory=None,
error_on_warnings=True,
).once()
module.extract_archive(
@@ -238,10 +254,50 @@ def test_extract_archive_calls_borg_with_progress_parameter():
)
def test_extract_archive_with_progress_and_extract_to_stdout_raises():
flexmock(module).should_receive('execute_command').never()
with pytest.raises(ValueError):
module.extract_archive(
dry_run=False,
repository='repo',
archive='archive',
paths=None,
location_config={},
storage_config={},
progress=True,
extract_to_stdout=True,
)
def test_extract_archive_calls_borg_with_stdout_parameter_and_returns_process():
flexmock(module.os.path).should_receive('abspath').and_return('repo')
process = flexmock()
flexmock(module).should_receive('execute_command').with_args(
('borg', 'extract', '--stdout', 'repo::archive'),
output_file=module.subprocess.PIPE,
working_directory=None,
run_to_completion=False,
).and_return(process).once()
assert (
module.extract_archive(
dry_run=False,
repository='repo',
archive='archive',
paths=None,
location_config={},
storage_config={},
extract_to_stdout=True,
)
== process
)
def test_extract_archive_skips_abspath_for_remote_repository():
flexmock(module.os.path).should_receive('abspath').never()
flexmock(module).should_receive('execute_command').with_args(
('borg', 'extract', 'server:repo::archive'), working_directory=None, error_on_warnings=True
('borg', 'extract', 'server:repo::archive'), working_directory=None
).once()
module.extract_archive(
+17 -10
View File
@@ -10,7 +10,7 @@ from ..test_verbosity import insert_logging_mock
def test_display_archives_info_calls_borg_with_parameters():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', 'repo'), output_log_level=logging.WARNING
('borg', 'info', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg'
)
module.display_archives_info(
@@ -20,7 +20,7 @@ def test_display_archives_info_calls_borg_with_parameters():
def test_display_archives_info_with_log_info_calls_borg_with_info_parameter():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--info', 'repo'), output_log_level=logging.WARNING
('borg', 'info', '--info', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg'
)
insert_logging_mock(logging.INFO)
module.display_archives_info(
@@ -30,7 +30,7 @@ def test_display_archives_info_with_log_info_calls_borg_with_info_parameter():
def test_display_archives_info_with_log_info_and_json_suppresses_most_borg_output():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--json', 'repo'), output_log_level=None
('borg', 'info', '--json', 'repo'), output_log_level=None, borg_local_path='borg'
).and_return('[]')
insert_logging_mock(logging.INFO)
@@ -43,7 +43,9 @@ def test_display_archives_info_with_log_info_and_json_suppresses_most_borg_outpu
def test_display_archives_info_with_log_debug_calls_borg_with_debug_parameter():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--debug', '--show-rc', 'repo'), output_log_level=logging.WARNING
('borg', 'info', '--debug', '--show-rc', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
insert_logging_mock(logging.DEBUG)
@@ -54,7 +56,7 @@ def test_display_archives_info_with_log_debug_calls_borg_with_debug_parameter():
def test_display_archives_info_with_log_debug_and_json_suppresses_most_borg_output():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--json', 'repo'), output_log_level=None
('borg', 'info', '--json', 'repo'), output_log_level=None, borg_local_path='borg'
).and_return('[]')
insert_logging_mock(logging.DEBUG)
@@ -67,7 +69,7 @@ def test_display_archives_info_with_log_debug_and_json_suppresses_most_borg_outp
def test_display_archives_info_with_json_calls_borg_with_json_parameter():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--json', 'repo'), output_log_level=None
('borg', 'info', '--json', 'repo'), output_log_level=None, borg_local_path='borg'
).and_return('[]')
json_output = module.display_archives_info(
@@ -79,7 +81,7 @@ def test_display_archives_info_with_json_calls_borg_with_json_parameter():
def test_display_archives_info_with_archive_calls_borg_with_archive_parameter():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', 'repo::archive'), output_log_level=logging.WARNING
('borg', 'info', 'repo::archive'), output_log_level=logging.WARNING, borg_local_path='borg'
)
module.display_archives_info(
@@ -89,7 +91,7 @@ def test_display_archives_info_with_archive_calls_borg_with_archive_parameter():
def test_display_archives_info_with_local_path_calls_borg_via_local_path():
flexmock(module).should_receive('execute_command').with_args(
('borg1', 'info', 'repo'), output_log_level=logging.WARNING
('borg1', 'info', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg1'
)
module.display_archives_info(
@@ -102,7 +104,9 @@ def test_display_archives_info_with_local_path_calls_borg_via_local_path():
def test_display_archives_info_with_remote_path_calls_borg_with_remote_path_parameters():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--remote-path', 'borg1', 'repo'), output_log_level=logging.WARNING
('borg', 'info', '--remote-path', 'borg1', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
module.display_archives_info(
@@ -116,7 +120,9 @@ def test_display_archives_info_with_remote_path_calls_borg_with_remote_path_para
def test_display_archives_info_with_lock_wait_calls_borg_with_lock_wait_parameters():
storage_config = {'lock_wait': 5}
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--lock-wait', '5', 'repo'), output_log_level=logging.WARNING
('borg', 'info', '--lock-wait', '5', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
module.display_archives_info(
@@ -131,6 +137,7 @@ def test_display_archives_info_passes_through_arguments_to_borg(argument_name):
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--' + argument_name.replace('_', '-'), 'value', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
module.display_archives_info(
+37 -15
View File
@@ -23,8 +23,8 @@ def insert_info_command_not_found_mock():
def insert_init_command_mock(init_command, **kwargs):
flexmock(module).should_receive('execute_command_without_capture').with_args(
init_command
flexmock(module).should_receive('execute_command').with_args(
init_command, output_file=module.DO_NOT_CAPTURE, borg_local_path=init_command[0]
).once()
@@ -32,24 +32,25 @@ def test_initialize_repository_calls_borg_with_parameters():
insert_info_command_not_found_mock()
insert_init_command_mock(INIT_COMMAND + ('repo',))
module.initialize_repository(repository='repo', encryption_mode='repokey')
module.initialize_repository(repository='repo', storage_config={}, encryption_mode='repokey')
def test_initialize_repository_raises_for_borg_init_error():
insert_info_command_not_found_mock()
flexmock(module).should_receive('execute_command_without_capture').and_raise(
flexmock(module).should_receive('execute_command').and_raise(
module.subprocess.CalledProcessError(2, 'borg init')
)
with pytest.raises(subprocess.CalledProcessError):
module.initialize_repository(repository='repo', encryption_mode='repokey')
module.initialize_repository(
repository='repo', storage_config={}, encryption_mode='repokey'
)
def test_initialize_repository_skips_initialization_when_repository_already_exists():
insert_info_command_found_mock()
flexmock(module).should_receive('execute_command_without_capture').never()
flexmock(module).should_receive('execute_command').once()
module.initialize_repository(repository='repo', encryption_mode='repokey')
module.initialize_repository(repository='repo', storage_config={}, encryption_mode='repokey')
def test_initialize_repository_raises_for_unknown_info_command_error():
@@ -58,21 +59,27 @@ def test_initialize_repository_raises_for_unknown_info_command_error():
)
with pytest.raises(subprocess.CalledProcessError):
module.initialize_repository(repository='repo', encryption_mode='repokey')
module.initialize_repository(
repository='repo', storage_config={}, encryption_mode='repokey'
)
def test_initialize_repository_with_append_only_calls_borg_with_append_only_parameter():
insert_info_command_not_found_mock()
insert_init_command_mock(INIT_COMMAND + ('--append-only', 'repo'))
module.initialize_repository(repository='repo', encryption_mode='repokey', append_only=True)
module.initialize_repository(
repository='repo', storage_config={}, encryption_mode='repokey', append_only=True
)
def test_initialize_repository_with_storage_quota_calls_borg_with_storage_quota_parameter():
insert_info_command_not_found_mock()
insert_init_command_mock(INIT_COMMAND + ('--storage-quota', '5G', 'repo'))
module.initialize_repository(repository='repo', encryption_mode='repokey', storage_quota='5G')
module.initialize_repository(
repository='repo', storage_config={}, encryption_mode='repokey', storage_quota='5G'
)
def test_initialize_repository_with_log_info_calls_borg_with_info_parameter():
@@ -80,7 +87,7 @@ def test_initialize_repository_with_log_info_calls_borg_with_info_parameter():
insert_init_command_mock(INIT_COMMAND + ('--info', 'repo'))
insert_logging_mock(logging.INFO)
module.initialize_repository(repository='repo', encryption_mode='repokey')
module.initialize_repository(repository='repo', storage_config={}, encryption_mode='repokey')
def test_initialize_repository_with_log_debug_calls_borg_with_debug_parameter():
@@ -88,18 +95,33 @@ def test_initialize_repository_with_log_debug_calls_borg_with_debug_parameter():
insert_init_command_mock(INIT_COMMAND + ('--debug', 'repo'))
insert_logging_mock(logging.DEBUG)
module.initialize_repository(repository='repo', encryption_mode='repokey')
module.initialize_repository(repository='repo', storage_config={}, encryption_mode='repokey')
def test_initialize_repository_with_local_path_calls_borg_via_local_path():
insert_info_command_not_found_mock()
insert_init_command_mock(('borg1',) + INIT_COMMAND[1:] + ('repo',))
module.initialize_repository(repository='repo', encryption_mode='repokey', local_path='borg1')
module.initialize_repository(
repository='repo', storage_config={}, encryption_mode='repokey', local_path='borg1'
)
def test_initialize_repository_with_remote_path_calls_borg_with_remote_path_parameter():
insert_info_command_not_found_mock()
insert_init_command_mock(INIT_COMMAND + ('--remote-path', 'borg1', 'repo'))
module.initialize_repository(repository='repo', encryption_mode='repokey', remote_path='borg1')
module.initialize_repository(
repository='repo', storage_config={}, encryption_mode='repokey', remote_path='borg1'
)
def test_initialize_repository_with_extra_borg_options_calls_borg_with_extra_options():
insert_info_command_not_found_mock()
insert_init_command_mock(INIT_COMMAND + ('--extra', '--options', 'repo'))
module.initialize_repository(
repository='repo',
storage_config={'extra_borg_options': {'init': '--extra --options'}},
encryption_mode='repokey',
)
+149 -24
View File
@@ -7,132 +7,255 @@ from borgmatic.borg import list as module
from ..test_verbosity import insert_logging_mock
BORG_LIST_LATEST_ARGUMENTS = (
'--glob-archives',
module.BORG_EXCLUDE_CHECKPOINTS_GLOB,
'--last',
'1',
'--short',
'repo',
)
def test_resolve_archive_name_passes_through_non_latest_archive_name():
archive = 'myhost-2030-01-01T14:41:17.647620'
assert module.resolve_archive_name('repo', archive, storage_config={}) == archive
def test_resolve_archive_name_calls_borg_with_parameters():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, output_log_level=None, borg_local_path='borg'
).and_return(expected_archive + '\n')
assert module.resolve_archive_name('repo', 'latest', storage_config={}) == expected_archive
def test_resolve_archive_name_with_log_info_calls_borg_with_info_parameter():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--info') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
borg_local_path='borg',
).and_return(expected_archive + '\n')
insert_logging_mock(logging.INFO)
assert module.resolve_archive_name('repo', 'latest', storage_config={}) == expected_archive
def test_resolve_archive_name_with_log_debug_calls_borg_with_debug_parameter():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--debug', '--show-rc') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
borg_local_path='borg',
).and_return(expected_archive + '\n')
insert_logging_mock(logging.DEBUG)
assert module.resolve_archive_name('repo', 'latest', storage_config={}) == expected_archive
def test_resolve_archive_name_with_local_path_calls_borg_via_local_path():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg1', 'list') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
borg_local_path='borg1',
).and_return(expected_archive + '\n')
assert (
module.resolve_archive_name('repo', 'latest', storage_config={}, local_path='borg1')
== expected_archive
)
def test_resolve_archive_name_with_remote_path_calls_borg_with_remote_path_parameters():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--remote-path', 'borg1') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
borg_local_path='borg',
).and_return(expected_archive + '\n')
assert (
module.resolve_archive_name('repo', 'latest', storage_config={}, remote_path='borg1')
== expected_archive
)
def test_resolve_archive_name_without_archives_raises():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS, output_log_level=None, borg_local_path='borg'
).and_return('')
with pytest.raises(ValueError):
module.resolve_archive_name('repo', 'latest', storage_config={})
def test_resolve_archive_name_with_lock_wait_calls_borg_with_lock_wait_parameters():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--lock-wait', 'okay') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
borg_local_path='borg',
).and_return(expected_archive + '\n')
assert (
module.resolve_archive_name('repo', 'latest', storage_config={'lock_wait': 'okay'})
== expected_archive
)
def test_list_archives_calls_borg_with_parameters():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', 'repo'), output_log_level=logging.WARNING
('borg', 'list', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg'
)
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=False),
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', 'list', '--info', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg'
)
insert_logging_mock(logging.INFO)
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=False),
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False),
)
def test_list_archives_with_log_info_and_json_suppresses_most_borg_output():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--json', 'repo'), output_log_level=None
('borg', 'list', '--json', 'repo'), output_log_level=None, borg_local_path='borg'
)
insert_logging_mock(logging.INFO)
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=True, successful=False),
list_arguments=flexmock(archive=None, paths=None, json=True, successful=False),
)
def test_list_archives_with_log_debug_calls_borg_with_debug_parameter():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--debug', '--show-rc', 'repo'), output_log_level=logging.WARNING
('borg', 'list', '--debug', '--show-rc', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
insert_logging_mock(logging.DEBUG)
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=False),
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False),
)
def test_list_archives_with_log_debug_and_json_suppresses_most_borg_output():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--json', 'repo'), output_log_level=None
('borg', 'list', '--json', 'repo'), output_log_level=None, borg_local_path='borg'
)
insert_logging_mock(logging.DEBUG)
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=True, successful=False),
list_arguments=flexmock(archive=None, paths=None, json=True, successful=False),
)
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', '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, json=False, successful=False),
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False),
)
def test_list_archives_with_archive_calls_borg_with_archive_parameter():
storage_config = {}
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', 'repo::archive'), output_log_level=logging.WARNING
('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', json=False, successful=False),
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
('borg1', 'list', 'repo'), output_log_level=logging.WARNING, borg_local_path='borg1'
)
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=False),
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False),
local_path='borg1',
)
def test_list_archives_with_remote_path_calls_borg_with_remote_path_parameters():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--remote-path', 'borg1', 'repo'), output_log_level=logging.WARNING
('borg', 'list', '--remote-path', 'borg1', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=False),
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False),
remote_path='borg1',
)
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', 'list', '--short', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
).and_return('[]')
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=False, short=True),
list_arguments=flexmock(archive=None, paths=None, json=False, successful=False, short=True),
)
@@ -154,13 +277,14 @@ def test_list_archives_passes_through_arguments_to_borg(argument_name):
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--' + argument_name.replace('_', '-'), 'value', 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
).and_return('[]')
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(
archive=None, json=False, successful=False, **{argument_name: 'value'}
archive=None, paths=None, json=False, successful=False, **{argument_name: 'value'}
),
)
@@ -169,24 +293,25 @@ def test_list_archives_with_successful_calls_borg_to_exclude_checkpoints():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--glob-archives', module.BORG_EXCLUDE_CHECKPOINTS_GLOB, 'repo'),
output_log_level=logging.WARNING,
borg_local_path='borg',
).and_return('[]')
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=True),
list_arguments=flexmock(archive=None, paths=None, json=False, successful=True),
)
def test_list_archives_with_json_calls_borg_with_json_parameter():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--json', 'repo'), output_log_level=None
('borg', 'list', '--json', 'repo'), output_log_level=None, borg_local_path='borg'
).and_return('[]')
json_output = module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=True, successful=False),
list_arguments=flexmock(archive=None, paths=None, json=True, successful=False),
)
assert json_output == '[]'
+148
View File
@@ -0,0 +1,148 @@
import logging
from flexmock import flexmock
from borgmatic.borg import mount as module
from ..test_verbosity import insert_logging_mock
def insert_execute_command_mock(command):
flexmock(module).should_receive('execute_command').with_args(
command, borg_local_path='borg'
).once()
def test_mount_archive_calls_borg_with_required_parameters():
insert_execute_command_mock(('borg', 'mount', 'repo::archive', '/mnt'))
module.mount_archive(
repository='repo',
archive='archive',
mount_point='/mnt',
paths=None,
foreground=False,
options=None,
storage_config={},
)
def test_mount_archive_calls_borg_with_path_parameters():
insert_execute_command_mock(('borg', 'mount', 'repo::archive', '/mnt', 'path1', 'path2'))
module.mount_archive(
repository='repo',
archive='archive',
mount_point='/mnt',
paths=['path1', 'path2'],
foreground=False,
options=None,
storage_config={},
)
def test_mount_archive_calls_borg_with_remote_path_parameters():
insert_execute_command_mock(
('borg', 'mount', '--remote-path', 'borg1', 'repo::archive', '/mnt')
)
module.mount_archive(
repository='repo',
archive='archive',
mount_point='/mnt',
paths=None,
foreground=False,
options=None,
storage_config={},
remote_path='borg1',
)
def test_mount_archive_calls_borg_with_umask_parameters():
insert_execute_command_mock(('borg', 'mount', '--umask', '0770', 'repo::archive', '/mnt'))
module.mount_archive(
repository='repo',
archive='archive',
mount_point='/mnt',
paths=None,
foreground=False,
options=None,
storage_config={'umask': '0770'},
)
def test_mount_archive_calls_borg_with_lock_wait_parameters():
insert_execute_command_mock(('borg', 'mount', '--lock-wait', '5', 'repo::archive', '/mnt'))
module.mount_archive(
repository='repo',
archive='archive',
mount_point='/mnt',
paths=None,
foreground=False,
options=None,
storage_config={'lock_wait': '5'},
)
def test_mount_archive_with_log_info_calls_borg_with_info_parameter():
insert_execute_command_mock(('borg', 'mount', '--info', 'repo::archive', '/mnt'))
insert_logging_mock(logging.INFO)
module.mount_archive(
repository='repo',
archive='archive',
mount_point='/mnt',
paths=None,
foreground=False,
options=None,
storage_config={},
)
def test_mount_archive_with_log_debug_calls_borg_with_debug_parameters():
insert_execute_command_mock(('borg', 'mount', '--debug', '--show-rc', 'repo::archive', '/mnt'))
insert_logging_mock(logging.DEBUG)
module.mount_archive(
repository='repo',
archive='archive',
mount_point='/mnt',
paths=None,
foreground=False,
options=None,
storage_config={},
)
def test_mount_archive_calls_borg_with_foreground_parameter():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'mount', '--foreground', 'repo::archive', '/mnt'),
output_file=module.DO_NOT_CAPTURE,
borg_local_path='borg',
).once()
module.mount_archive(
repository='repo',
archive='archive',
mount_point='/mnt',
paths=None,
foreground=True,
options=None,
storage_config={},
)
def test_mount_archive_calls_borg_with_options_parameters():
insert_execute_command_mock(('borg', 'mount', '-o', 'super_mount', 'repo::archive', '/mnt'))
module.mount_archive(
repository='repo',
archive='archive',
mount_point='/mnt',
paths=None,
foreground=False,
options='super_mount',
storage_config={},
)
+69 -6
View File
@@ -10,7 +10,7 @@ from ..test_verbosity import insert_logging_mock
def insert_execute_command_mock(prune_command, output_log_level):
flexmock(module).should_receive('execute_command').with_args(
prune_command, output_log_level=output_log_level
prune_command, output_log_level=output_log_level, borg_local_path=prune_command[0]
).once()
@@ -75,7 +75,7 @@ def test_prune_archives_with_log_info_calls_borg_with_info_parameter():
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_execute_command_mock(PRUNE_COMMAND + ('--stats', '--info', 'repo'), logging.INFO)
insert_execute_command_mock(PRUNE_COMMAND + ('--info', 'repo'), logging.INFO)
insert_logging_mock(logging.INFO)
module.prune_archives(
@@ -88,9 +88,7 @@ def test_prune_archives_with_log_debug_calls_borg_with_debug_parameter():
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_execute_command_mock(
PRUNE_COMMAND + ('--stats', '--debug', '--list', '--show-rc', 'repo'), logging.INFO
)
insert_execute_command_mock(PRUNE_COMMAND + ('--debug', '--show-rc', 'repo'), logging.INFO)
insert_logging_mock(logging.DEBUG)
module.prune_archives(
@@ -142,7 +140,7 @@ def test_prune_archives_with_remote_path_calls_borg_with_remote_path_parameters(
)
def test_prune_archives_with_stats_calls_borg_with_stats_parameter():
def test_prune_archives_with_stats_calls_borg_with_stats_parameter_and_warning_output_log_level():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
@@ -158,6 +156,56 @@ def test_prune_archives_with_stats_calls_borg_with_stats_parameter():
)
def test_prune_archives_with_stats_and_log_info_calls_borg_with_stats_parameter_and_info_output_log_level():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_logging_mock(logging.INFO)
insert_execute_command_mock(PRUNE_COMMAND + ('--stats', '--info', 'repo'), logging.INFO)
module.prune_archives(
dry_run=False,
repository='repo',
storage_config={},
retention_config=retention_config,
stats=True,
)
def test_prune_archives_with_files_calls_borg_with_list_parameter_and_warning_output_log_level():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_execute_command_mock(PRUNE_COMMAND + ('--list', 'repo'), logging.WARNING)
module.prune_archives(
dry_run=False,
repository='repo',
storage_config={},
retention_config=retention_config,
files=True,
)
def test_prune_archives_with_files_and_log_info_calls_borg_with_list_parameter_and_info_output_log_level():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_logging_mock(logging.INFO)
insert_execute_command_mock(PRUNE_COMMAND + ('--info', '--list', 'repo'), logging.INFO)
module.prune_archives(
dry_run=False,
repository='repo',
storage_config={},
retention_config=retention_config,
files=True,
)
def test_prune_archives_with_umask_calls_borg_with_umask_parameters():
storage_config = {'umask': '077'}
retention_config = flexmock()
@@ -188,3 +236,18 @@ def test_prune_archives_with_lock_wait_calls_borg_with_lock_wait_parameters():
storage_config=storage_config,
retention_config=retention_config,
)
def test_prune_archives_with_extra_borg_options_calls_borg_with_extra_options():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_execute_command_mock(PRUNE_COMMAND + ('--extra', '--options', 'repo'), logging.INFO)
module.prune_archives(
dry_run=False,
repository='repo',
storage_config={'extra_borg_options': {'prune': '--extra --options'}},
retention_config=retention_config,
)
+31
View File
@@ -0,0 +1,31 @@
import logging
from flexmock import flexmock
from borgmatic.borg import umount as module
from ..test_verbosity import insert_logging_mock
def insert_execute_command_mock(command):
flexmock(module).should_receive('execute_command').with_args(command).once()
def test_unmount_archive_calls_borg_with_required_parameters():
insert_execute_command_mock(('borg', 'umount', '/mnt'))
module.unmount_archive(mount_point='/mnt')
def test_unmount_archive_with_log_info_calls_borg_with_info_parameter():
insert_execute_command_mock(('borg', 'umount', '--info', '/mnt'))
insert_logging_mock(logging.INFO)
module.unmount_archive(mount_point='/mnt')
def test_unmount_archive_with_log_debug_calls_borg_with_debug_parameters():
insert_execute_command_mock(('borg', 'umount', '--debug', '--show-rc', '/mnt'))
insert_logging_mock(logging.DEBUG)
module.unmount_archive(mount_point='/mnt')
+150 -10
View File
@@ -3,6 +3,7 @@ import subprocess
from flexmock import flexmock
import borgmatic.hooks.command
from borgmatic.commands import borgmatic as module
@@ -13,20 +14,53 @@ def test_run_configuration_runs_actions_for_each_repository():
expected_results[1:]
)
config = {'location': {'repositories': ['foo', 'bar']}}
arguments = {'global': flexmock()}
arguments = {'global': flexmock(monitoring_verbosity=1)}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results
def test_run_configuration_executes_hooks_for_create_action():
def test_run_configuration_calls_hooks_for_prune_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(dry_run=False), 'create': flexmock()}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'prune': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_executes_and_calls_hooks_for_create_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), '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_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))
@@ -39,7 +73,7 @@ def test_run_configuration_logs_actions_error():
flexmock(module).should_receive('make_error_log_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_raise(OSError)
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(dry_run=False)}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False)}
results = list(module.run_configuration('test.yaml', config, arguments))
@@ -53,13 +87,27 @@ def test_run_configuration_logs_pre_hook_error():
flexmock(module).should_receive('make_error_log_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').never()
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(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))
assert results == expected_results
def test_run_configuration_bails_for_pre_hook_soft_failure():
flexmock(module.borg_environment).should_receive('initialize')
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).should_receive('make_error_log_records').never()
flexmock(module).should_receive('run_actions').never()
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_post_hook_error():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(
@@ -70,13 +118,30 @@ def test_run_configuration_logs_post_hook_error():
flexmock(module).should_receive('make_error_log_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(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))
assert results == expected_results
def test_run_configuration_bails_for_post_hook_soft_failure():
flexmock(module.borg_environment).should_receive('initialize')
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
).and_return(None)
flexmock(module.dispatch).should_receive('call_hooks')
flexmock(module).should_receive('make_error_log_records').never()
flexmock(module).should_receive('run_actions').and_return([])
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_on_error_hook_error():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').and_raise(OSError)
@@ -86,7 +151,22 @@ def test_run_configuration_logs_on_error_hook_error():
).and_return(expected_results[1:])
flexmock(module).should_receive('run_actions').and_raise(OSError)
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(dry_run=False)}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results
def test_run_configuration_bails_for_on_error_hook_soft_failure():
flexmock(module.borg_environment).should_receive('initialize')
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)
expected_results = [flexmock()]
flexmock(module).should_receive('make_error_log_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), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
@@ -119,6 +199,10 @@ def test_log_record_does_not_raise():
module.log_record(levelno=1, foo='bar', baz='quux')
def test_log_record_with_suppress_does_not_raise():
module.log_record(levelno=1, foo='bar', baz='quux', suppress_log=True)
def test_make_error_log_records_generates_output_logs_for_message_only():
flexmock(module).should_receive('log_record').replace_with(dict)
@@ -129,6 +213,7 @@ def test_make_error_log_records_generates_output_logs_for_message_only():
def test_make_error_log_records_generates_output_logs_for_called_process_error():
flexmock(module).should_receive('log_record').replace_with(dict)
flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING)
logs = tuple(
module.make_error_log_records(
@@ -164,6 +249,18 @@ def test_make_error_log_records_generates_nothing_for_other_error():
assert logs == ()
def test_get_local_path_uses_configuration_value():
assert module.get_local_path({'test.yaml': {'location': {'local_path': 'borg1'}}}) == 'borg1'
def test_get_local_path_without_location_defaults_to_borg():
assert module.get_local_path({'test.yaml': {}}) == 'borg'
def test_get_local_path_without_local_path_defaults_to_borg():
assert module.get_local_path({'test.yaml': {'location': {}}}) == 'borg'
def test_collect_configuration_run_summary_logs_info_for_success():
flexmock(module.command).should_receive('execute_hook').never()
flexmock(module).should_receive('run_configuration').and_return([])
@@ -178,7 +275,7 @@ def test_collect_configuration_run_summary_logs_info_for_success():
def test_collect_configuration_run_summary_executes_hooks_for_create():
flexmock(module).should_receive('run_configuration').and_return([])
arguments = {'create': flexmock(), 'global': flexmock(dry_run=False)}
arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)}
logs = tuple(
module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments)
@@ -214,6 +311,33 @@ def test_collect_configuration_run_summary_logs_extract_with_repository_error():
assert logs == expected_logs
def test_collect_configuration_run_summary_logs_info_for_success_with_mount():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module).should_receive('run_configuration').and_return([])
arguments = {'mount': flexmock(repository='repo')}
logs = tuple(
module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments)
)
assert {log.levelno for log in logs} == {logging.INFO}
def test_collect_configuration_run_summary_logs_mount_with_repository_error():
flexmock(module.validate).should_receive('guard_configuration_contains_repository').and_raise(
ValueError
)
expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs)
arguments = {'mount': flexmock(repository='repo')}
logs = tuple(
module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments)
)
assert logs == expected_logs
def test_collect_configuration_run_summary_logs_missing_configs_error():
arguments = {'global': flexmock(config_paths=[])}
expected_logs = (flexmock(),)
@@ -228,7 +352,7 @@ def test_collect_configuration_run_summary_logs_pre_hook_error():
flexmock(module.command).should_receive('execute_hook').and_raise(ValueError)
expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs)
arguments = {'create': flexmock(), 'global': flexmock(dry_run=False)}
arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)}
logs = tuple(
module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments)
@@ -242,7 +366,7 @@ def test_collect_configuration_run_summary_logs_post_hook_error():
flexmock(module).should_receive('run_configuration').and_return([])
expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs)
arguments = {'create': flexmock(), 'global': flexmock(dry_run=False)}
arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)}
logs = tuple(
module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments)
@@ -292,6 +416,22 @@ def test_collect_configuration_run_summary_logs_run_configuration_error():
assert {log.levelno for log in logs} == {logging.CRITICAL}
def test_collect_configuration_run_summary_logs_run_umount_error():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module).should_receive('run_configuration').and_return([])
flexmock(module.borg_umount).should_receive('unmount_archive').and_raise(OSError)
flexmock(module).should_receive('make_error_log_records').and_return(
[logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg='Error'))]
)
arguments = {'umount': flexmock(mount_point='/mnt')}
logs = tuple(
module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments)
)
assert {log.levelno for log in logs} == {logging.INFO, logging.CRITICAL}
def test_collect_configuration_run_summary_logs_outputs_merged_json_results():
flexmock(module).should_receive('run_configuration').and_return(['foo', 'bar']).and_return(
['baz']
+8
View File
@@ -21,6 +21,14 @@ def test_get_default_config_paths_prefers_xdg_config_home_for_user_config_path()
assert '/home/user/.etc/borgmatic/config.yaml' in config_paths
def test_get_default_config_paths_does_not_expand_home_when_false():
flexmock(module.os, environ={'HOME': '/home/user'})
config_paths = module.get_default_config_paths(expand_home=False)
assert '$HOME/.config/borgmatic/config.yaml' in config_paths
def test_collect_config_filenames_collects_given_files():
config_paths = ('config.yaml', 'other.yaml')
flexmock(module.os.path).should_receive('isdir').and_return(False)
+27
View File
@@ -0,0 +1,27 @@
import pytest
from borgmatic.config import normalize as module
@pytest.mark.parametrize(
'config,expected_config',
(
(
{'location': {'exclude_if_present': '.nobackup'}},
{'location': {'exclude_if_present': ['.nobackup']}},
),
(
{'location': {'exclude_if_present': ['.nobackup']}},
{'location': {'exclude_if_present': ['.nobackup']}},
),
(
{'location': {'source_directories': ['foo', 'bar']}},
{'location': {'source_directories': ['foo', 'bar']}},
),
({'storage': {'compression': 'yes_please'}}, {'storage': {'compression': 'yes_please'}}),
),
)
def test_normalize_applies_hard_coded_normalization_to_config(config, expected_config):
module.normalize(config)
assert config == expected_config
+82
View File
@@ -0,0 +1,82 @@
import pytest
from flexmock import flexmock
from borgmatic.config import override as module
def test_set_values_with_empty_keys_bails():
config = {}
module.set_values(config, keys=(), value='value')
assert config == {}
def test_set_values_with_one_key_sets_it_into_config():
config = {}
module.set_values(config, keys=('key',), value='value')
assert config == {'key': 'value'}
def test_set_values_with_one_key_overwrites_existing_key():
config = {'key': 'old_value', 'other': 'other_value'}
module.set_values(config, keys=('key',), value='value')
assert config == {'key': 'value', 'other': 'other_value'}
def test_set_values_with_multiple_keys_creates_hierarchy():
config = {}
module.set_values(config, ('section', 'key'), 'value')
assert config == {'section': {'key': 'value'}}
def test_set_values_with_multiple_keys_updates_hierarchy():
config = {'section': {'other': 'other_value'}}
module.set_values(config, ('section', 'key'), 'value')
assert config == {'section': {'key': 'value', 'other': 'other_value'}}
def test_parse_overrides_splits_keys_and_values():
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value)
raw_overrides = ['section.my_option=value1', 'section.other_option=value2']
expected_result = (
(('section', 'my_option'), 'value1'),
(('section', 'other_option'), 'value2'),
)
module.parse_overrides(raw_overrides) == expected_result
def test_parse_overrides_allows_value_with_equal_sign():
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value)
raw_overrides = ['section.option=this===value']
expected_result = ((('section', 'option'), 'this===value'),)
module.parse_overrides(raw_overrides) == expected_result
def test_parse_overrides_raises_on_missing_equal_sign():
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value)
raw_overrides = ['section.option']
with pytest.raises(ValueError):
module.parse_overrides(raw_overrides)
def test_parse_overrides_allows_value_with_single_key():
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value)
raw_overrides = ['option=value']
expected_result = ((('option',), 'value'),)
module.parse_overrides(raw_overrides) == expected_result
def test_parse_overrides_handles_empty_overrides():
module.parse_overrides(raw_overrides=None) == ()
+40
View File
@@ -1,4 +1,5 @@
import pytest
from flexmock import flexmock
from borgmatic.config import validate as module
@@ -95,7 +96,38 @@ def test_remove_examples_strips_examples_from_sequence_of_maps():
assert schema == {'seq': [{'map': {'foo': {'desc': 'thing'}}}]}
def test_normalize_repository_path_passes_through_remote_repository():
repository = 'example.org:test.borg'
module.normalize_repository_path(repository) == repository
def test_normalize_repository_path_passes_through_absolute_repository():
repository = '/foo/bar/test.borg'
flexmock(module.os.path).should_receive('abspath').and_return(repository)
module.normalize_repository_path(repository) == repository
def test_normalize_repository_path_resolves_relative_repository():
repository = 'test.borg'
absolute = '/foo/bar/test.borg'
flexmock(module.os.path).should_receive('abspath').and_return(absolute)
module.normalize_repository_path(repository) == absolute
def test_repositories_match_does_not_raise():
flexmock(module).should_receive('normalize_repository_path')
module.repositories_match('foo', 'bar')
def test_guard_configuration_contains_repository_does_not_raise_when_repository_in_config():
flexmock(module).should_receive('repositories_match').replace_with(
lambda first, second: first == second
)
module.guard_configuration_contains_repository(
repository='repo', configurations={'config.yaml': {'location': {'repositories': ['repo']}}}
)
@@ -116,6 +148,10 @@ def test_guard_configuration_contains_repository_errors_when_repository_assumed_
def test_guard_configuration_contains_repository_errors_when_repository_missing_from_config():
flexmock(module).should_receive('repositories_match').replace_with(
lambda first, second: first == second
)
with pytest.raises(ValueError):
module.guard_configuration_contains_repository(
repository='nope',
@@ -124,6 +160,10 @@ def test_guard_configuration_contains_repository_errors_when_repository_missing_
def test_guard_configuration_contains_repository_errors_when_repository_matches_config_twice():
flexmock(module).should_receive('repositories_match').replace_with(
lambda first, second: first == second
)
with pytest.raises(ValueError):
module.guard_configuration_contains_repository(
repository='repo',
+17
View File
@@ -1,4 +1,5 @@
import logging
import subprocess
from flexmock import flexmock
@@ -79,3 +80,19 @@ def test_execute_hook_on_error_logs_as_error():
).once()
module.execute_hook([':'], None, 'config.yaml', 'on-error', dry_run=False)
def test_considered_soft_failure_treats_soft_fail_exit_code_as_soft_fail():
error = subprocess.CalledProcessError(module.SOFT_FAIL_EXIT_CODE, 'try again')
assert module.considered_soft_failure('config.yaml', error)
def test_considered_soft_failure_does_not_treat_other_exit_code_as_soft_fail():
error = subprocess.CalledProcessError(1, 'error')
assert not module.considered_soft_failure('config.yaml', error)
def test_considered_soft_failure_does_not_treat_other_exception_type_as_soft_fail():
assert not module.considered_soft_failure('config.yaml', Exception())
+15 -5
View File
@@ -7,32 +7,42 @@ def test_ping_monitor_rewrites_ping_url_for_start_state():
ping_url = 'https://example.com/start/abcdef'
flexmock(module.requests).should_receive('get').with_args('https://example.com/start/abcdef')
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=False)
module.ping_monitor(
ping_url, '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():
ping_url = 'https://example.com/ping/abcdef'
flexmock(module.requests).should_receive('get').with_args('https://example.com/start/abcdef')
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=False)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_rewrites_ping_url_for_finish_state():
ping_url = 'https://example.com/start/abcdef'
flexmock(module.requests).should_receive('get').with_args('https://example.com/finish/abcdef')
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FINISH, dry_run=False)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_rewrites_ping_url_for_fail_state():
ping_url = 'https://example.com/start/abcdef'
flexmock(module.requests).should_receive('get').with_args('https://example.com/fail/abcdef')
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FAIL, dry_run=False)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_dry_run_does_not_hit_ping_url():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').never()
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=True)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True
)
+12 -4
View File
@@ -7,25 +7,33 @@ def test_ping_monitor_hits_ping_url_for_start_state():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'run'))
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=False)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_hits_ping_url_for_finish_state():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'complete'))
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FINISH, dry_run=False)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_hits_ping_url_for_fail_state():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'fail'))
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FAIL, dry_run=False)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_dry_run_does_not_hit_ping_url():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').never()
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=True)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True
)
+30 -126
View File
@@ -4,6 +4,14 @@ from flexmock import flexmock
from borgmatic.hooks import dump as module
def test_make_database_dump_path_joins_arguments():
assert module.make_database_dump_path('/tmp', 'super_databases') == '/tmp/super_databases'
def test_make_database_dump_path_defaults_without_source_directory():
assert module.make_database_dump_path(None, 'super_databases') == '~/.borgmatic/super_databases'
def test_make_database_dump_filename_uses_name_and_hostname():
flexmock(module.os.path).should_receive('expanduser').and_return('databases')
@@ -26,146 +34,42 @@ def test_make_database_dump_filename_with_invalid_name_raises():
module.make_database_dump_filename('databases', 'invalid/name')
def test_flatten_dump_patterns_produces_list_of_all_patterns():
dump_patterns = {'postgresql_databases': ['*/glob', 'glob/*'], 'mysql_databases': ['*/*/*']}
expected_patterns = sorted(
dump_patterns['postgresql_databases'] + dump_patterns['mysql_databases']
)
def test_create_parent_directory_for_dump_does_not_raise():
flexmock(module.os).should_receive('makedirs')
assert sorted(module.flatten_dump_patterns(dump_patterns, ('bob',))) == expected_patterns
module.create_parent_directory_for_dump('/path/to/parent')
def test_flatten_dump_patterns_with_no_patterns_errors():
dump_patterns = {'postgresql_databases': [], 'mysql_databases': []}
def test_create_named_pipe_for_dump_does_not_raise():
flexmock(module).should_receive('create_parent_directory_for_dump')
flexmock(module.os).should_receive('mkfifo')
with pytest.raises(ValueError):
assert module.flatten_dump_patterns(dump_patterns, ('bob',))
module.create_named_pipe_for_dump('/path/to/pipe')
def test_flatten_dump_patterns_with_no_hooks_errors():
dump_patterns = {}
def test_remove_database_dumps_removes_dump_path():
flexmock(module.os.path).should_receive('expanduser').and_return('databases/localhost')
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.shutil).should_receive('rmtree').with_args('databases/localhost').once()
with pytest.raises(ValueError):
assert module.flatten_dump_patterns(dump_patterns, ('bob',))
def test_remove_database_dumps_removes_dump_for_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.os).should_receive('listdir').and_return([])
flexmock(module.os).should_receive('rmdir')
for name in ('foo', 'bar'):
flexmock(module.os).should_receive('remove').with_args(
'databases/localhost/{}'.format(name)
).once()
module.remove_database_dumps('databases', databases, 'SuperDB', 'test.yaml', dry_run=False)
module.remove_database_dumps('databases', 'SuperDB', 'test.yaml', dry_run=False)
def test_remove_database_dumps_with_dry_run_skips_removal():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module.os).should_receive('rmdir').never()
flexmock(module.os).should_receive('remove').never()
flexmock(module.os.path).should_receive('expanduser').and_return('databases/localhost')
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_database_dumps('databases', databases, 'SuperDB', 'test.yaml', dry_run=True)
module.remove_database_dumps('databases', 'SuperDB', 'test.yaml', dry_run=True)
def test_remove_database_dumps_without_databases_does_not_raise():
module.remove_database_dumps('databases', [], 'SuperDB', 'test.yaml', dry_run=False)
def test_remove_database_dumps_without_dump_path_present_skips_removal():
flexmock(module.os.path).should_receive('expanduser').and_return('databases/localhost')
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_database_dumps('databases', 'SuperDB', 'test.yaml', dry_run=False)
def test_convert_glob_patterns_to_borg_patterns_removes_leading_slash():
assert module.convert_glob_patterns_to_borg_patterns(('/etc/foo/bar',)) == ['sh:etc/foo/bar']
def test_get_database_names_from_dumps_gets_names_from_filenames_matching_globs():
flexmock(module.glob).should_receive('glob').and_return(
('databases/localhost/foo',)
).and_return(('databases/localhost/bar',)).and_return(())
assert module.get_database_names_from_dumps(
('databases/*/foo', 'databases/*/bar', 'databases/*/baz')
) == ['foo', 'bar']
def test_get_database_configurations_only_produces_named_databases():
databases = [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'bar', 'hostname': 'example.com'},
{'name': 'baz', 'hostname': 'example.org'},
]
assert list(module.get_database_configurations(databases, ('foo', 'baz'))) == [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'baz', 'hostname': 'example.org'},
]
def test_get_database_configurations_matches_all_database():
databases = [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'all', 'hostname': 'example.com'},
]
assert list(module.get_database_configurations(databases, ('foo', 'bar', 'baz'))) == [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'bar', 'hostname': 'example.com'},
{'name': 'baz', 'hostname': 'example.com'},
]
def test_get_per_hook_database_configurations_partitions_by_hook():
hooks = {'postgresql_databases': [flexmock()]}
names = ('foo', 'bar')
dump_patterns = flexmock()
expected_config = {'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}]}
flexmock(module).should_receive('get_database_configurations').with_args(
hooks['postgresql_databases'], names
).and_return(expected_config['postgresql_databases'])
config = module.get_per_hook_database_configurations(hooks, names, dump_patterns)
assert config == expected_config
def test_get_per_hook_database_configurations_defaults_to_detected_database_names():
hooks = {'postgresql_databases': [flexmock()]}
names = ()
detected_names = flexmock()
dump_patterns = {'postgresql_databases': [flexmock()]}
expected_config = {'postgresql_databases': [flexmock()]}
flexmock(module).should_receive('get_database_names_from_dumps').and_return(detected_names)
flexmock(module).should_receive('get_database_configurations').with_args(
hooks['postgresql_databases'], detected_names
).and_return(expected_config['postgresql_databases'])
config = module.get_per_hook_database_configurations(hooks, names, dump_patterns)
assert config == expected_config
def test_get_per_hook_database_configurations_with_unknown_database_name_raises():
hooks = {'postgresql_databases': [flexmock()]}
names = ('foo', 'bar')
dump_patterns = flexmock()
flexmock(module).should_receive('get_database_configurations').with_args(
hooks['postgresql_databases'], names
).and_return([])
with pytest.raises(ValueError):
module.get_per_hook_database_configurations(hooks, names, dump_patterns)
def test_get_per_hook_database_configurations_with_all_and_no_archive_dumps_raises():
hooks = {'postgresql_databases': [flexmock()]}
names = ('foo', 'all')
dump_patterns = flexmock()
flexmock(module).should_receive('get_database_configurations').with_args(
hooks['postgresql_databases'], names
).and_return([])
with pytest.raises(ValueError):
module.get_per_hook_database_configurations(hooks, names, dump_patterns)
+50 -18
View File
@@ -4,7 +4,7 @@ from borgmatic.hooks import healthchecks as module
def test_forgetful_buffering_handler_emit_collects_log_records():
handler = module.Forgetful_buffering_handler(byte_capacity=100)
handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1)
handler.emit(flexmock(getMessage=lambda: 'foo'))
handler.emit(flexmock(getMessage=lambda: 'bar'))
@@ -13,7 +13,7 @@ def test_forgetful_buffering_handler_emit_collects_log_records():
def test_forgetful_buffering_handler_emit_forgets_log_records_when_capacity_reached():
handler = module.Forgetful_buffering_handler(byte_capacity=len('foo\nbar\n'))
handler = module.Forgetful_buffering_handler(byte_capacity=len('foo\nbar\n'), log_level=1)
handler.emit(flexmock(getMessage=lambda: 'foo'))
assert handler.buffer == ['foo\n']
handler.emit(flexmock(getMessage=lambda: 'bar'))
@@ -26,7 +26,7 @@ def test_forgetful_buffering_handler_emit_forgets_log_records_when_capacity_reac
def test_format_buffered_logs_for_payload_flattens_log_buffer():
handler = module.Forgetful_buffering_handler(byte_capacity=100)
handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1)
handler.buffer = ['foo\n', 'bar\n']
flexmock(module.logging).should_receive('getLogger').and_return(flexmock(handlers=[handler]))
@@ -36,7 +36,7 @@ def test_format_buffered_logs_for_payload_flattens_log_buffer():
def test_format_buffered_logs_for_payload_inserts_truncation_indicator_when_logs_forgotten():
handler = module.Forgetful_buffering_handler(byte_capacity=100)
handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1)
handler.buffer = ['foo\n', 'bar\n']
handler.forgot = True
flexmock(module.logging).should_receive('getLogger').and_return(flexmock(handlers=[handler]))
@@ -46,55 +46,81 @@ def test_format_buffered_logs_for_payload_inserts_truncation_indicator_when_logs
assert payload == '...\nfoo\nbar\n'
def test_format_buffered_logs_for_payload_without_handler_produces_payload_anyway():
def test_format_buffered_logs_for_payload_without_handler_produces_empty_payload():
flexmock(module.logging).should_receive('getLogger').and_return(
flexmock(handlers=[module.logging.Handler()])
)
payload = module.format_buffered_logs_for_payload()
assert payload
assert payload == ''
def test_ping_monitor_hits_ping_url_for_start_state():
flexmock(module).should_receive('Forgetful_buffering_handler')
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('post').with_args(
'{}/{}'.format(ping_url, 'start'), data=''
'{}/{}'.format(ping_url, 'start'), data=''.encode('utf-8')
)
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.START, dry_run=False)
module.ping_monitor(
ping_url,
'config.yaml',
state=module.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_hits_ping_url_for_finish_state():
ping_url = 'https://example.com'
payload = flexmock()
payload = 'data'
flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
flexmock(module.requests).should_receive('post').with_args(ping_url, data=payload)
flexmock(module.requests).should_receive('post').with_args(
ping_url, data=payload.encode('utf-8')
)
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.FINISH, dry_run=False)
module.ping_monitor(
ping_url,
'config.yaml',
state=module.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_hits_ping_url_for_fail_state():
ping_url = 'https://example.com'
payload = flexmock()
payload = 'data'
flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
'{}/{}'.format(ping_url, 'fail'), data=payload
'{}/{}'.format(ping_url, 'fail'), data=payload.encode('utf')
)
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.FAIL, dry_run=False)
module.ping_monitor(
ping_url,
'config.yaml',
state=module.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
ping_uuid = 'abcd-efgh-ijkl-mnop'
payload = flexmock()
payload = 'data'
flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
'https://hc-ping.com/{}'.format(ping_uuid), data=payload
'https://hc-ping.com/{}'.format(ping_uuid), data=payload.encode('utf-8')
)
module.ping_monitor(ping_uuid, 'config.yaml', state=module.monitor.State.FINISH, dry_run=False)
module.ping_monitor(
ping_uuid,
'config.yaml',
state=module.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_dry_run_does_not_hit_ping_url():
@@ -102,4 +128,10 @@ def test_ping_monitor_dry_run_does_not_hit_ping_url():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('post').never()
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.START, dry_run=True)
module.ping_monitor(
ping_url,
'config.yaml',
state=module.monitor.State.START,
monitoring_log_level=1,
dry_run=True,
)
+201 -109
View File
@@ -1,48 +1,94 @@
import sys
import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import mysql as module
def test_database_names_to_dump_passes_through_name():
extra_environment = flexmock()
log_prefix = ''
dry_run_label = ''
names = module.database_names_to_dump(
{'name': 'foo'}, extra_environment, log_prefix, dry_run_label
)
assert names == ('foo',)
def test_database_names_to_dump_queries_mysql_for_database_names():
extra_environment = flexmock()
log_prefix = ''
dry_run_label = ''
flexmock(module).should_receive('execute_command').with_args(
('mysql', '--skip-column-names', '--batch', '--execute', 'show schemas'),
output_log_level=None,
extra_environment=extra_environment,
).and_return('foo\nbar\nmysql\n').once()
names = module.database_names_to_dump(
{'name': 'all'}, extra_environment, log_prefix, dry_run_label
)
assert names == ('foo', 'bar')
def test_dump_databases_runs_mysqldump_for_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
output_file = flexmock()
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.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
for name in ('foo', 'bar'):
for name, process in zip(('foo', 'bar'), processes):
flexmock(module).should_receive('execute_command').with_args(
('mysqldump', '--add-drop-database', '--databases', name),
output_file=output_file,
(
'mysqldump',
'--add-drop-database',
'--databases',
name,
'>',
'databases/localhost/{}'.format(name),
),
shell=True,
extra_environment=None,
).once()
run_to_completion=False,
).and_return(process).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == processes
def test_dump_databases_with_dry_run_skips_mysqldump():
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.os).should_receive('makedirs').never()
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
flexmock(module).should_receive('execute_command').never()
module.dump_databases(databases, 'test.yaml', dry_run=True)
module.dump_databases(databases, 'test.yaml', {}, dry_run=True)
def test_dump_databases_runs_mysqldump_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
output_file = flexmock()
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.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
(
@@ -56,121 +102,154 @@ def test_dump_databases_runs_mysqldump_with_hostname_and_port():
'tcp',
'--databases',
'foo',
'>',
'databases/database.example.org/foo',
),
output_file=output_file,
shell=True,
extra_environment=None,
).once()
run_to_completion=False,
).and_return(process).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_mysqldump_with_username_and_password():
databases = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
output_file = flexmock()
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.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
('mysqldump', '--add-drop-database', '--user', 'root', '--databases', 'foo'),
output_file=output_file,
(
'mysqldump',
'--add-drop-database',
'--user',
'root',
'--databases',
'foo',
'>',
'databases/localhost/foo',
),
shell=True,
extra_environment={'MYSQL_PWD': 'trustsome1'},
).once()
run_to_completion=False,
).and_return(process).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_mysqldump_with_options():
databases = [{'name': 'foo', 'options': '--stuff=such'}]
output_file = flexmock()
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.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
('mysqldump', '--add-drop-database', '--stuff=such', '--databases', 'foo'),
output_file=output_file,
(
'mysqldump',
'--add-drop-database',
'--stuff=such',
'--databases',
'foo',
'>',
'databases/localhost/foo',
),
shell=True,
extra_environment=None,
).once()
run_to_completion=False,
).and_return(process).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_mysqldump_for_all_databases():
databases = [{'name': 'all'}]
output_file = flexmock()
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.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
flexmock(module).should_receive('execute_command').with_args(
('mysqldump', '--add-drop-database', '--all-databases'),
output_file=output_file,
extra_environment=None,
).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
def test_make_database_dump_patterns_converts_names_to_glob_paths():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/*/foo'
).and_return('databases/*/bar')
assert module.make_database_dump_patterns(flexmock(), flexmock(), ('foo', 'bar')) == [
'databases/*/foo',
'databases/*/bar',
]
def test_make_database_dump_patterns_treats_empty_names_as_matching_all_databases():
flexmock(module.dump).should_receive('make_database_dump_filename').with_args(
module.DUMP_PATH, '*', '*'
).and_return('databases/*/*')
assert module.make_database_dump_patterns(flexmock(), flexmock(), ()) == ['databases/*/*']
def test_restore_database_dumps_restores_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
for name in ('foo', 'bar'):
dump_filename = 'databases/localhost/{}'.format(name)
input_file = flexmock()
flexmock(sys.modules['builtins']).should_receive('open').with_args(
dump_filename
).and_return(input_file)
flexmock(module).should_receive('execute_command').with_args(
('mysql', '--batch'), input_file=input_file, extra_environment=None
).once()
module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
def test_restore_database_dumps_runs_mysql_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
dump_filename = 'databases/localhost/foo'
input_file = flexmock()
flexmock(sys.modules['builtins']).should_receive('open').with_args(dump_filename).and_return(
input_file
)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
(
'mysqldump',
'--add-drop-database',
'--databases',
'foo',
'bar',
'>',
'databases/localhost/all',
),
shell=True,
extra_environment=None,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_errors_for_missing_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).should_receive('database_names_to_dump').and_return(())
with pytest.raises(ValueError):
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_restore_database_dump_runs_mysql_to_restore():
database_config = [{'name': 'foo'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
('mysql', '--batch', '--verbose'),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment=None,
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('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_mysql_with_hostname_and_port():
database_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'mysql',
'--batch',
'--verbose',
'--host',
'database.example.org',
'--port',
@@ -178,28 +257,41 @@ def test_restore_database_dumps_runs_mysql_with_hostname_and_port():
'--protocol',
'tcp',
),
input_file=input_file,
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment=None,
borg_local_path='borg',
).once()
module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
def test_restore_database_dumps_runs_mysql_with_username_and_password():
databases = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
dump_filename = 'databases/localhost/foo'
input_file = flexmock()
flexmock(sys.modules['builtins']).should_receive('open').with_args(dump_filename).and_return(
input_file
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=False, extract_process=extract_process
)
flexmock(module).should_receive('execute_command').with_args(
('mysql', '--batch', '--user', 'root'),
input_file=input_file,
def test_restore_database_dump_runs_mysql_with_username_and_password():
database_config = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
('mysql', '--batch', '--verbose', '--user', 'root'),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment={'MYSQL_PWD': 'trustsome1'},
borg_local_path='borg',
).once()
module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
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('execute_command_with_processes').never()
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=True, extract_process=flexmock()
)
+35
View File
@@ -0,0 +1,35 @@
from flexmock import flexmock
from borgmatic.hooks import pagerduty as module
def test_ping_monitor_ignores_start_state():
flexmock(module.requests).should_receive('post').never()
module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_ignores_finish_state():
flexmock(module.requests).should_receive('post').never()
module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_calls_api_for_fail_state():
flexmock(module.requests).should_receive('post')
module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_dry_run_does_not_call_api():
flexmock(module.requests).should_receive('post').never()
module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=True
)
+266 -114
View File
@@ -1,3 +1,6 @@
import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import postgresql as module
@@ -5,54 +8,64 @@ from borgmatic.hooks import postgresql as module
def test_dump_databases_runs_pg_dump_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.os).should_receive('makedirs')
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
for name in ('foo', 'bar'):
for name, process in zip(('foo', 'bar'), processes):
flexmock(module).should_receive('execute_command').with_args(
(
'pg_dump',
'--no-password',
'--clean',
'--file',
'databases/localhost/{}'.format(name),
'--if-exists',
'--format',
'custom',
name,
'>',
'databases/localhost/{}'.format(name),
),
extra_environment=None,
).once()
shell=True,
extra_environment={'PGSSLMODE': 'disable'},
run_to_completion=False,
).and_return(process).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == processes
def test_dump_databases_with_dry_run_skips_pg_dump():
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.os).should_receive('makedirs').never()
flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('execute_command').never()
module.dump_databases(databases, 'test.yaml', dry_run=True)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=True) == []
def test_dump_databases_runs_pg_dump_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.os).should_receive('makedirs')
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('execute_command').with_args(
(
'pg_dump',
'--no-password',
'--clean',
'--file',
'databases/database.example.org/foo',
'--if-exists',
'--host',
'database.example.org',
'--port',
@@ -60,172 +73,230 @@ def test_dump_databases_runs_pg_dump_with_hostname_and_port():
'--format',
'custom',
'foo',
'>',
'databases/database.example.org/foo',
),
extra_environment=None,
).once()
shell=True,
extra_environment={'PGSSLMODE': 'disable'},
run_to_completion=False,
).and_return(process).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_pg_dump_with_username_and_password():
databases = [{'name': 'foo', 'username': 'postgres', 'password': 'trustsome1'}]
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.os).should_receive('makedirs')
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('make_extra_environment').and_return(
{'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}
)
flexmock(module).should_receive('execute_command').with_args(
(
'pg_dump',
'--no-password',
'--clean',
'--file',
'databases/localhost/foo',
'--if-exists',
'--username',
'postgres',
'--format',
'custom',
'foo',
'>',
'databases/localhost/foo',
),
extra_environment={'PGPASSWORD': 'trustsome1'},
).once()
shell=True,
extra_environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'},
run_to_completion=False,
).and_return(process).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_pg_dump_with_format():
databases = [{'name': 'foo', 'format': 'tar'}]
def test_make_extra_environment_maps_options_to_environment():
database = {
'name': 'foo',
'password': 'pass',
'ssl_mode': 'require',
'ssl_cert': 'cert.crt',
'ssl_key': 'key.key',
'ssl_root_cert': 'root.crt',
'ssl_crl': 'crl.crl',
}
expected = {
'PGPASSWORD': 'pass',
'PGSSLMODE': 'require',
'PGSSLCERT': 'cert.crt',
'PGSSLKEY': 'key.key',
'PGSSLROOTCERT': 'root.crt',
'PGSSLCRL': 'crl.crl',
}
extra_env = module.make_extra_environment(database)
assert extra_env == expected
def test_dump_databases_runs_pg_dump_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.os).should_receive('makedirs')
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('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('execute_command').with_args(
(
'pg_dump',
'--no-password',
'--clean',
'--if-exists',
'--format',
'directory',
'--file',
'databases/localhost/foo',
'--format',
'tar',
'foo',
),
extra_environment=None,
).once()
shell=True,
extra_environment={'PGSSLMODE': 'disable'},
run_to_completion=False,
).and_return(process).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_pg_dump_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.os).should_receive('makedirs')
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('execute_command').with_args(
(
'pg_dump',
'--no-password',
'--clean',
'--file',
'databases/localhost/foo',
'--if-exists',
'--format',
'custom',
'--stuff=such',
'foo',
'>',
'databases/localhost/foo',
),
extra_environment=None,
).once()
shell=True,
extra_environment={'PGSSLMODE': 'disable'},
run_to_completion=False,
).and_return(process).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_dump_databases_runs_pg_dumpall_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.os).should_receive('makedirs')
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('execute_command').with_args(
('pg_dumpall', '--no-password', '--clean', '--file', 'databases/localhost/all'),
extra_environment=None,
).once()
('pg_dumpall', '--no-password', '--clean', '--if-exists', '>', 'databases/localhost/all'),
shell=True,
extra_environment={'PGSSLMODE': 'disable'},
run_to_completion=False,
).and_return(process).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
def test_make_database_dump_patterns_converts_names_to_glob_paths():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/*/foo'
).and_return('databases/*/bar')
def test_restore_database_dump_runs_pg_restore():
database_config = [{'name': 'foo'}]
extract_process = flexmock(stdout=flexmock())
assert module.make_database_dump_patterns(flexmock(), flexmock(), ('foo', 'bar')) == [
'databases/*/foo',
'databases/*/bar',
]
def test_make_database_dump_patterns_treats_empty_names_as_matching_all_databases():
flexmock(module.dump).should_receive('make_database_dump_filename').with_args(
module.DUMP_PATH, '*', '*'
).and_return('databases/*/*')
assert module.make_database_dump_patterns(flexmock(), flexmock(), ()) == ['databases/*/*']
def test_restore_database_dumps_restores_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
for name in ('foo', 'bar'):
flexmock(module).should_receive('execute_command').with_args(
(
'pg_restore',
'--no-password',
'--clean',
'--if-exists',
'--exit-on-error',
'--dbname',
name,
'databases/localhost/{}'.format(name),
),
extra_environment=None,
).once()
flexmock(module).should_receive('execute_command').with_args(
('psql', '--no-password', '--quiet', '--dbname', name, '--command', 'ANALYZE'),
extra_environment=None,
).once()
module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
def test_restore_database_dumps_runs_pg_restore_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module).should_receive('execute_command').with_args(
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
'--no-password',
'--clean',
'--if-exists',
'--exit-on-error',
'--clean',
'--dbname',
'foo',
),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment={'PGSSLMODE': 'disable'},
borg_local_path='borg',
).once()
flexmock(module).should_receive('execute_command').with_args(
('psql', '--no-password', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE'),
extra_environment={'PGSSLMODE': 'disable'},
).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('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
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('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
'--no-password',
'--if-exists',
'--exit-on-error',
'--clean',
'--dbname',
'foo',
'--host',
'database.example.org',
'--port',
'5433',
'--dbname',
'foo',
'databases/localhost/foo',
),
extra_environment=None,
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment={'PGSSLMODE': 'disable'},
borg_local_path='borg',
).once()
flexmock(module).should_receive('execute_command').with_args(
(
@@ -241,32 +312,40 @@ def test_restore_database_dumps_runs_pg_restore_with_hostname_and_port():
'--command',
'ANALYZE',
),
extra_environment=None,
extra_environment={'PGSSLMODE': 'disable'},
).once()
module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
def test_restore_database_dumps_runs_pg_restore_with_username_and_password():
databases = [{'name': 'foo', 'username': 'postgres', 'password': 'trustsome1'}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=False, extract_process=extract_process
)
flexmock(module).should_receive('execute_command').with_args(
def test_restore_database_dump_runs_pg_restore_with_username_and_password():
database_config = [{'name': 'foo', 'username': 'postgres', 'password': 'trustsome1'}]
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('make_extra_environment').and_return(
{'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}
)
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
'--no-password',
'--clean',
'--if-exists',
'--exit-on-error',
'--username',
'postgres',
'--clean',
'--dbname',
'foo',
'databases/localhost/foo',
'--username',
'postgres',
),
extra_environment={'PGPASSWORD': 'trustsome1'},
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'},
borg_local_path='borg',
).once()
flexmock(module).should_receive('execute_command').with_args(
(
@@ -280,7 +359,80 @@ def test_restore_database_dumps_runs_pg_restore_with_username_and_password():
'--command',
'ANALYZE',
),
extra_environment={'PGPASSWORD': 'trustsome1'},
extra_environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'},
).once()
module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
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('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('execute_command_with_processes').with_args(
('psql', '--no-password'),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment={'PGSSLMODE': 'disable'},
borg_local_path='borg',
).once()
flexmock(module).should_receive('execute_command').with_args(
('psql', '--no-password', '--quiet', '--command', 'ANALYZE'),
extra_environment={'PGSSLMODE': 'disable'},
).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('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
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('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
'--no-password',
'--if-exists',
'--exit-on-error',
'--clean',
'--dbname',
'foo',
'/dump/path',
),
processes=[],
output_log_level=logging.DEBUG,
input_file=None,
extra_environment={'PGSSLMODE': 'disable'},
borg_local_path='borg',
).once()
flexmock(module).should_receive('execute_command').with_args(
('psql', '--no-password', '--quiet', '--dbname', 'foo', '--command', 'ANALYZE'),
extra_environment={'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
database_config, 'test.yaml', {}, dry_run=False, extract_process=None
)
+237 -49
View File
@@ -1,47 +1,65 @@
import subprocess
import pytest
from flexmock import flexmock
from borgmatic import execute as module
def test_exit_code_indicates_error_with_borg_error_is_true():
assert module.exit_code_indicates_error(('/usr/bin/borg1', 'init'), 2)
@pytest.mark.parametrize(
'process,exit_code,borg_local_path,expected_result',
(
(flexmock(args=['grep']), 2, None, True),
(flexmock(args=['grep']), 2, 'borg', True),
(flexmock(args=['borg']), 2, 'borg', True),
(flexmock(args=['borg1']), 2, 'borg1', True),
(flexmock(args=['grep']), 1, None, True),
(flexmock(args=['grep']), 1, 'borg', True),
(flexmock(args=['borg']), 1, 'borg', False),
(flexmock(args=['borg1']), 1, 'borg1', False),
(flexmock(args=['grep']), 0, None, False),
(flexmock(args=['grep']), 0, 'borg', False),
(flexmock(args=['borg']), 0, 'borg', False),
(flexmock(args=['borg1']), 0, 'borg1', False),
(flexmock(args=['borg']), None, None, False),
),
)
def test_exit_code_indicates_error_respects_exit_code_and_borg_local_path(
process, exit_code, borg_local_path, expected_result
):
assert module.exit_code_indicates_error(process, exit_code, borg_local_path) is expected_result
def test_exit_code_indicates_error_with_borg_warning_is_false():
assert not module.exit_code_indicates_error(('/usr/bin/borg1', 'init'), 1)
def test_command_for_process_converts_sequence_command_to_string():
process = flexmock(args=['foo', 'bar', 'baz'])
assert module.command_for_process(process) == 'foo bar baz'
def test_exit_code_indicates_error_with_borg_success_is_false():
assert not module.exit_code_indicates_error(('/usr/bin/borg1', 'init'), 0)
def test_command_for_process_passes_through_string_command():
process = flexmock(args='foo bar baz')
assert module.command_for_process(process) == 'foo bar baz'
def test_exit_code_indicates_error_with_borg_error_and_error_on_warnings_is_true():
assert module.exit_code_indicates_error(('/usr/bin/borg1', 'init'), 2, error_on_warnings=True)
def test_output_buffer_for_process_returns_stderr_when_stdout_excluded():
stdout = flexmock()
stderr = flexmock()
process = flexmock(stdout=stdout, stderr=stderr)
assert module.output_buffer_for_process(process, exclude_stdouts=[flexmock(), stdout]) == stderr
def test_exit_code_indicates_error_with_borg_warning_and_error_on_warnings_is_true():
assert module.exit_code_indicates_error(('/usr/bin/borg1', 'init'), 1, error_on_warnings=True)
def test_output_buffer_for_process_returns_stdout_when_not_excluded():
stdout = flexmock()
process = flexmock(stdout=stdout)
def test_exit_code_indicates_error_with_borg_success_and_error_on_warnings_is_false():
assert not module.exit_code_indicates_error(
('/usr/bin/borg1', 'init'), 0, error_on_warnings=True
assert (
module.output_buffer_for_process(process, exclude_stdouts=[flexmock(), flexmock()])
== stdout
)
def test_exit_code_indicates_error_with_non_borg_error_is_true():
assert module.exit_code_indicates_error(('/usr/bin/command',), 2)
def test_exit_code_indicates_error_with_non_borg_warning_is_true():
assert module.exit_code_indicates_error(('/usr/bin/command',), 1)
def test_exit_code_indicates_error_with_non_borg_success_is_false():
assert not module.exit_code_indicates_error(('/usr/bin/command',), 0)
def test_execute_command_calls_full_command():
full_command = ['foo', 'bar']
flexmock(module.os, environ={'a': 'b'})
@@ -54,7 +72,7 @@ def test_execute_command_calls_full_command():
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
flexmock(module).should_receive('log_outputs')
output = module.execute_command(full_command)
@@ -74,13 +92,27 @@ def test_execute_command_calls_full_command_with_output_file():
env=None,
cwd=None,
).and_return(flexmock(stderr=None)).once()
flexmock(module).should_receive('log_output')
flexmock(module).should_receive('log_outputs')
output = module.execute_command(full_command, output_file=output_file)
assert output is None
def test_execute_command_calls_full_command_without_capturing_output():
full_command = ['foo', 'bar']
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None
).and_return(flexmock(wait=lambda: 0)).once()
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
flexmock(module).should_receive('log_outputs')
output = module.execute_command(full_command, output_file=module.DO_NOT_CAPTURE)
assert output is None
def test_execute_command_calls_full_command_with_input_file():
full_command = ['foo', 'bar']
input_file = flexmock(name='test')
@@ -94,7 +126,7 @@ def test_execute_command_calls_full_command_with_input_file():
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
flexmock(module).should_receive('log_outputs')
output = module.execute_command(full_command, input_file=input_file)
@@ -105,7 +137,7 @@ def test_execute_command_calls_full_command_with_shell():
full_command = ['foo', 'bar']
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
' '.join(full_command),
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
@@ -113,7 +145,7 @@ def test_execute_command_calls_full_command_with_shell():
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
flexmock(module).should_receive('log_outputs')
output = module.execute_command(full_command, shell=True)
@@ -132,7 +164,7 @@ def test_execute_command_calls_full_command_with_extra_environment():
env={'a': 'b', 'c': 'd'},
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
flexmock(module).should_receive('log_outputs')
output = module.execute_command(full_command, extra_environment={'c': 'd'})
@@ -151,13 +183,31 @@ def test_execute_command_calls_full_command_with_working_directory():
env=None,
cwd='/working',
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
flexmock(module).should_receive('log_outputs')
output = module.execute_command(full_command, working_directory='/working')
assert output is None
def test_execute_command_without_run_to_completion_returns_process():
full_command = ['foo', 'bar']
process = flexmock()
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd=None,
).and_return(process).once()
flexmock(module).should_receive('log_outputs')
assert module.execute_command(full_command, run_to_completion=False) == process
def test_execute_command_captures_output():
full_command = ['foo', 'bar']
expected_output = '[]'
@@ -176,7 +226,7 @@ def test_execute_command_captures_output_with_shell():
expected_output = '[]'
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('check_output').with_args(
full_command, shell=True, env=None, cwd=None
'foo bar', shell=True, env=None, cwd=None
).and_return(flexmock(decode=lambda: expected_output)).once()
output = module.execute_command(full_command, output_log_level=None, shell=True)
@@ -214,28 +264,166 @@ def test_execute_command_captures_output_with_working_directory():
assert output == expected_output
def test_execute_command_without_capture_does_not_raise_on_success():
flexmock(module.subprocess).should_receive('check_call').and_raise(
module.subprocess.CalledProcessError(0, 'borg init')
)
def test_execute_command_with_processes_calls_full_command():
full_command = ['foo', 'bar']
processes = (flexmock(),)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_outputs')
module.execute_command_without_capture(('borg', 'init'))
output = module.execute_command_with_processes(full_command, processes)
assert output is None
def test_execute_command_without_capture_does_not_raise_on_warning():
def test_execute_command_with_processes_calls_full_command_with_output_file():
full_command = ['foo', 'bar']
processes = (flexmock(),)
output_file = flexmock(name='test')
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=output_file,
stderr=module.subprocess.PIPE,
shell=False,
env=None,
cwd=None,
).and_return(flexmock(stderr=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(full_command, processes, output_file=output_file)
assert output is None
def test_execute_command_with_processes_calls_full_command_without_capturing_output():
full_command = ['foo', 'bar']
processes = (flexmock(),)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None
).and_return(flexmock(wait=lambda: 0)).once()
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
flexmock(module.subprocess).should_receive('check_call').and_raise(
module.subprocess.CalledProcessError(1, 'borg init')
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(
full_command, processes, output_file=module.DO_NOT_CAPTURE
)
module.execute_command_without_capture(('borg', 'init'))
assert output is None
def test_execute_command_without_capture_raises_on_error():
flexmock(module).should_receive('exit_code_indicates_error').and_return(True)
flexmock(module.subprocess).should_receive('check_call').and_raise(
module.subprocess.CalledProcessError(2, 'borg init')
def test_execute_command_with_processes_calls_full_command_with_input_file():
full_command = ['foo', 'bar']
processes = (flexmock(),)
input_file = flexmock(name='test')
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=input_file,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(full_command, processes, input_file=input_file)
assert output is None
def test_execute_command_with_processes_calls_full_command_with_shell():
full_command = ['foo', 'bar']
processes = (flexmock(),)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
' '.join(full_command),
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=True,
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(full_command, processes, shell=True)
assert output is None
def test_execute_command_with_processes_calls_full_command_with_extra_environment():
full_command = ['foo', 'bar']
processes = (flexmock(),)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env={'a': 'b', 'c': 'd'},
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(
full_command, processes, extra_environment={'c': 'd'}
)
with pytest.raises(module.subprocess.CalledProcessError):
module.execute_command_without_capture(('borg', 'init'))
assert output is None
def test_execute_command_with_processes_calls_full_command_with_working_directory():
full_command = ['foo', 'bar']
processes = (flexmock(),)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd='/working',
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_outputs')
output = module.execute_command_with_processes(
full_command, processes, working_directory='/working'
)
assert output is None
def test_execute_command_with_processes_kills_processes_on_error():
full_command = ['foo', 'bar']
process = flexmock(stdout=flexmock(read=lambda count: None))
process.should_receive('poll')
process.should_receive('kill').once()
processes = (process,)
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd=None,
).and_raise(subprocess.CalledProcessError(1, full_command, 'error')).once()
flexmock(module).should_receive('log_outputs').never()
with pytest.raises(subprocess.CalledProcessError):
module.execute_command_with_processes(full_command, processes)
+37 -3
View File
@@ -22,14 +22,14 @@ def test_to_bool_passes_none_through():
def test_interactive_console_false_when_not_isatty(capsys):
with capsys.disabled():
flexmock(module.sys.stdout).should_receive('isatty').and_return(False)
flexmock(module.sys.stderr).should_receive('isatty').and_return(False)
assert module.interactive_console() is False
def test_interactive_console_false_when_TERM_is_dumb(capsys):
with capsys.disabled():
flexmock(module.sys.stdout).should_receive('isatty').and_return(True)
flexmock(module.sys.stderr).should_receive('isatty').and_return(True)
flexmock(module.os.environ).should_receive('get').with_args('TERM').and_return('dumb')
assert module.interactive_console() is False
@@ -37,7 +37,7 @@ def test_interactive_console_false_when_TERM_is_dumb(capsys):
def test_interactive_console_true_when_isatty_and_TERM_is_not_dumb(capsys):
with capsys.disabled():
flexmock(module.sys.stdout).should_receive('isatty').and_return(True)
flexmock(module.sys.stderr).should_receive('isatty').and_return(True)
flexmock(module.os.environ).should_receive('get').with_args('TERM').and_return('smart')
assert module.interactive_console() is True
@@ -113,6 +113,17 @@ def test_should_do_markup_prefers_PY_COLORS_to_interactive_console_value():
assert module.should_do_markup(no_color=False, configs={}) is True
def test_multi_stream_handler_logs_to_handler_for_log_level():
error_handler = flexmock()
error_handler.should_receive('emit').once()
info_handler = flexmock()
multi_handler = module.Multi_stream_handler(
{module.logging.ERROR: error_handler, module.logging.INFO: info_handler}
)
multi_handler.emit(flexmock(levelno=module.logging.ERROR))
def test_console_color_formatter_format_includes_log_message():
plain_message = 'uh oh'
record = flexmock(levelno=logging.CRITICAL, msg=plain_message)
@@ -132,6 +143,9 @@ def test_color_text_without_color_does_not_raise():
def test_configure_logging_probes_for_log_socket_on_linux():
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
@@ -147,6 +161,9 @@ def test_configure_logging_probes_for_log_socket_on_linux():
def test_configure_logging_probes_for_log_socket_on_macos():
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
@@ -163,6 +180,9 @@ def test_configure_logging_probes_for_log_socket_on_macos():
def test_configure_logging_sets_global_logger_to_most_verbose_log_level():
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.DEBUG, handlers=tuple
@@ -173,6 +193,9 @@ def test_configure_logging_sets_global_logger_to_most_verbose_log_level():
def test_configure_logging_skips_syslog_if_not_found():
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
@@ -184,6 +207,9 @@ def test_configure_logging_skips_syslog_if_not_found():
def test_configure_logging_skips_syslog_if_interactive_console():
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(True)
flexmock(module.logging).should_receive('basicConfig').with_args(
@@ -196,6 +222,10 @@ def test_configure_logging_skips_syslog_if_interactive_console():
def test_configure_logging_to_logfile_instead_of_syslog():
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
)
# syslog skipped in non-interactive console if --log-file argument provided
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
@@ -214,6 +244,10 @@ def test_configure_logging_to_logfile_instead_of_syslog():
def test_configure_logging_skips_logfile_if_argument_is_none():
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
)
# No WatchedFileHandler added if argument --log-file is None
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
+3 -3
View File
@@ -2,7 +2,7 @@
envlist = py35,py36,py37,py38
skip_missing_interpreters = True
skipsdist = True
minversion = 3.14.0
minversion = 3.14.1
[testenv]
usedevelop = True
@@ -10,8 +10,7 @@ deps = -rtest_requirements.txt
whitelist_externals =
find
sh
commands_pre =
find {toxinidir} -type f -not -path '{toxinidir}/.tox/*' -path '*/__pycache__/*' -name '*.py[c|o]' -delete
passenv = COVERAGE_FILE
commands =
pytest {posargs}
py36,py37,py38: black --check .
@@ -28,6 +27,7 @@ commands =
[testenv:end-to-end]
deps = -rtest_requirements.txt
passenv = COVERAGE_FILE
commands =
pytest {posargs} --no-cov tests/end-to-end