Compare commits

..
895 Commits
Author SHA1 Message Date
Dan Helfman 4985b805b4 Bump version for release. 2024-11-18 20:40:51 -08:00
Dan Helfman d09b4c72a9 Fix a few remaining Pushover issues from the PR. 2024-11-18 20:32:17 -08:00
Dan HelfmanandGitHub 9807549f88 Add a Pushover monitoring hook.
Merge pull request #86 from tony1661/pushover-branch.
2024-11-18 20:17:28 -08:00
Dan Helfman 30c821120e Fix borgmatic ignoring the "BORG_RELOCATED_REPO_ACCESS_IS_OK" and "BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK" environment variables (#939). 2024-11-18 09:46:52 -08:00
Dan Helfman 13884bd448 Apply the "umask" option to all relevant actions, not just some of them (#441). 2024-11-18 09:07:29 -08:00
Antonio Fernandez 6bce4c4a0d changed version release to 1.9.2 2024-11-18 07:56:00 -05:00
Antonio Fernandez 25572c98d7 better fuction name 2024-11-18 07:52:19 -05:00
Antonio Fernandez dab0dfcb32 added test for value error 2024-11-18 07:49:53 -05:00
Dan Helfman 851c454ef0 Remove the restriction that the "extract" and "mount" actions must match a single repository (#722). 2024-11-17 21:39:59 -08:00
Dan Helfman c7a0cebaf7 Add a documentation link to NEWS (#934). 2024-11-17 12:46:58 -08:00
Dan Helfman 76cfeda290 Update the logic that probes for the borgmatic runtime directory to support more platforms and use cases (#934).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/937
2024-11-17 19:57:09 +00:00
Dan Helfman afdf831c59 Fix broken restore/bootstrap when using Borg 1.2 and a randomly named temporary directory (#934). 2024-11-17 11:55:10 -08:00
Dan Helfman 9ac3087304 Before creating a temp file in a directory, make sure the directory exists (#934). 2024-11-17 10:30:17 -08:00
Dan Helfman 7cca83b698 Log the path of the borgmatic runtime directory used (#934). 2024-11-17 10:15:58 -08:00
Dan Helfman 4b5df7117a Fix documentation type (#934). 2024-11-17 09:01:58 -08:00
Dan Helfman 57decfa4db Document the fact that the config bootstrap feature writes to the runtime directory (#934). 2024-11-16 16:45:49 -08:00
Dan Helfman b80f60a731 Create the borgmatic runtime directory if it doesn't exist (#934). 2024-11-16 16:03:18 -08:00
Dan Helfman 8f5ea95348 Fix use of borgmatic runtime directory in the restore action (#934). 2024-11-16 12:19:20 -08:00
Dan Helfman b0cad58d6c Add a dash to the prefix of the randomly named temporary directory to improve readability (#937). 2024-11-16 11:26:24 -08:00
Dan Helfman 073d6bddf6 Fix outdated comment (#934). 2024-11-16 07:26:23 -08:00
Dan Helfman 810b65589f Documentation for runtime/state directory changes (#934). 2024-11-15 22:23:49 -08:00
Dan Helfman 295bfb0c57 Update the logic that probes for the borgmatic streaming database dump, bootstrap metadata, and check state directories to support more platforms and use cases (#934). 2024-11-15 18:15:32 -08:00
Antonio Fernandez 5f3d4f9b03 final fix for true/false and 1/0 2024-11-13 15:27:25 -05:00
Antonio Fernandez 5321301708 fix for true/false to 1/0 2024-11-13 15:23:47 -05:00
Antonio Fernandez a939a66fb4 raise ValueError on priprity 2 with retry or expire 2024-11-13 15:20:52 -05:00
Dan Helfman c0721a8cad Fix misleading example for user_runtime_directory. 2024-11-13 11:17:40 -08:00
Antonio Fernandez ea47704d86 added missing tests to get 100% test coverage 2024-11-13 12:09:25 -05:00
Antonio Fernandez 61e4eeff6c converted html to a boolean and updated documentation and schema 2024-11-13 11:52:08 -05:00
Antonio Fernandez 3ab4b45041 added test for early exit when state is not in config 2024-11-13 09:07:47 -05:00
Tony Fernandez 4e1f256b48 added dryrun test case with minimum config 2024-11-12 10:15:19 -05:00
Tony Fernandez 96bb402837 fix test 2 2024-11-12 10:10:13 -05:00
Tony Fernandez 97949266b3 fix test 2024-11-12 10:09:23 -05:00
Tony Fernandez e69d2385fc better name for test 2024-11-12 10:08:13 -05:00
Tony Fernandez 6d9340ebb2 better comment blocks 2024-11-12 10:05:57 -05:00
Tony Fernandez 0441e79b41 "fail" -> "fails" 2024-11-12 10:00:34 -05:00
Tony Fernandez b1af304125 better data dict creation 2024-11-12 09:59:02 -05:00
Tony Fernandez eb8f7e0329 better description for expire and retry 2024-11-12 09:55:07 -05:00
Dan Helfman bf978f2db4 Fix missing build backend setting in pyproject.toml to allow Fedora builds (#932). 2024-11-10 17:09:10 -08:00
Dan Helfman 22776b123d Bump version for release. 2024-11-10 08:13:18 -08:00
Tony Fernandez ef66349674 small fixed for some failing tests 2024-11-08 20:02:29 -05:00
Tony Fernandez 51b885e7db added global constant for priority 2024-11-08 19:49:23 -05:00
Tony Fernandez 1781787305 better schema description for retry and expire 2024-11-08 19:43:40 -05:00
Tony Fernandez 46ebb0cebb removed redundant code 2024-11-08 18:45:46 -05:00
Tony Fernandez 3e0fa57860 removed tests that are not needed 2024-11-08 17:44:27 -05:00
Tony Fernandez 59f8722e05 better spacing for comments 2024-11-08 17:42:46 -05:00
Tony Fernandez 4ba42e8905 better wording. Added 'by default' 2024-11-08 17:41:18 -05:00
Tony Fernandez 3b79482b24 better wording 2024-11-08 17:38:58 -05:00
Tony Fernandez 7eb19cb0a7 added period 2024-11-08 17:38:20 -05:00
Tony Fernandez a4fabb8521 fix version 2024-11-08 17:37:26 -05:00
Tony Fernandez 85ea8f4f45 fix 10min in seconds 2024-11-08 15:03:38 -05:00
Tony Fernandez 290559116d better logic for priority 2024-11-08 15:01:28 -05:00
Tony Fernandez 72b27b0858 better message description in schema 2024-11-08 14:57:44 -05:00
Tony Fernandez 0fdee067c7 double space fix 2024-11-08 14:45:36 -05:00
Tony Fernandez 0dca5eeafc fix title wordwrap 2024-11-08 14:09:03 -05:00
Tony Fernandez 02ce3ba190 fix url_title word wrap 2024-11-08 14:07:49 -05:00
Tony Fernandez dc78bf4d6b fix TTL wordwrap 2024-11-08 14:06:39 -05:00
Tony Fernandez 4b7fbce291 fix sound word wrap 2024-11-08 14:05:27 -05:00
Tony Fernandez 1817b9a9ea fix wordwrap for html 2024-11-08 13:50:22 -05:00
Tony Fernandez 009055c61a device description rewrap 2024-11-08 13:48:21 -05:00
Tony Fernandez 54884da8fa priority word wrap 2024-11-08 13:46:19 -05:00
Tony Fernandez 1177385e08 fix expire description 2024-11-08 13:44:12 -05:00
Tony Fernandez a45ba8553c removed duplicate type:object 2024-11-08 13:42:19 -05:00
Tony Fernandez d7d6e30178 moved checks from hook to schema 2024-11-08 13:40:23 -05:00
Dan Helfman 56304fdcad Add NEWS entry for multiple system credentials fix (#930). 2024-11-07 20:20:41 -08:00
Dan Helfman 3f75e9931f Only support a single systemd credential by default (#930).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/930
Reviewed-by: Dan Helfman <witten@torsion.org>
2024-11-08 04:18:56 +00:00
Dan Helfman 227f475e17 Fix an error when implicitly upgrading the check state directory across filesystems (#931). 2024-11-07 19:19:56 -08:00
cvlc12 467ddd0e93 creds: Only support single credential by default 2024-11-08 00:36:24 +01:00
Dan Helfman be08e889f0 Fix the user runtime directory location on macOS (and possibly Cygwin) (#928). 2024-11-03 21:44:11 -08:00
Dan Helfman 94c8a56373 Reorder NEWS items. 2024-11-03 13:58:04 -08:00
Dan Helfman cecf04aa69 Bump version for release. 2024-11-03 13:23:57 -08:00
Dan Helfman 814cdb4b87 Deprecate the "borgmatic_source_directory" option in favor of "user_runtime_directory" and "user_state_directory" (#562). Move the default borgmatic streaming database dump and bootstrap metadata location on disk (#562). With Borg 1.4+, store database dumps and bootstrap metadata in a "/borgmatic" directory within a backup archive (#838). Add "--local-path", "--remote-path", and "--user-runtime-directory" flags to the "config bootstrap" action. 2024-11-03 13:14:05 -08:00
Dan Helfman 13878be254 Move the default check state directory (#562, #638). Deprecate the "borgmatic_source_directory" option in favor of "borgmatic_runtime_directory" and "borgmatic_state_directory" (#562). 2024-10-30 22:36:43 -07:00
Antonio Fernandez 94db527500 finalized support for Pushover 2024-10-30 15:43:06 -04:00
Antonio Fernandez 2849f54932 initial pushover commit 2024-10-30 11:25:26 -04:00
Dan Helfman 129f3e753c Flesh out the Zabbix monitoring hook tests, add a logo to the documentation, etc. 2024-10-29 10:33:19 -07:00
Dan HelfmanandGitHub c85bf46ad9 Add Zabbix monitoring hook.
Merge pull request #85 from tony1661/zabbix-hook
2024-10-29 09:01:15 -07:00
Tony Fernandez fa6a4734d4 added auth tests with item id 2024-10-29 09:00:06 -04:00
Tony Fernandez e52e29444f added user/pass auth test 2024-10-29 08:54:25 -04:00
Tony Fernandez f713f1df7e better function names and comments 2024-10-29 08:21:52 -04:00
Tony Fernandez 87d824553d converted constants to capitals 2024-10-29 08:05:05 -04:00
Tony Fernandez 52fbf8cb24 capital 2024-10-28 09:54:07 -04:00
Tony Fernandez f758374772 adjustment in docs to reflect changes in 385ef2d 2024-10-28 09:52:20 -04:00
Tony Fernandez 60c5949c23 adjusted to accept integers and strings 2024-10-28 09:51:08 -04:00
Tony Fernandez d11c517f67 better schema descriptions 2024-10-28 09:41:34 -04:00
Tony Fernandez 237999cc81 fix word-wrap 2024-10-28 09:40:14 -04:00
Tony Fernandez d060f8d77a fix log for dry_run_label 2024-10-28 09:38:17 -04:00
Tony Fernandez 385ef2d012 fixes and first unit test attempt 2024-10-28 09:36:30 -04:00
Dan Helfman 02a2e77315 Add Documentation=... to sample systemd service files. 2024-10-27 16:31:08 -07:00
Dan Helfman 9b623a8a8e Move docs exporting to a separate script in the hopes it'll actually work there. 2024-10-27 14:48:17 -07:00
Dan Helfman e1f60e4b09 Fix podman create command to use correct image name. 2024-10-27 14:33:58 -07:00
Dan Helfman 269c00b240 Yet another attempt at making a docs tarball from a container image. 2024-10-27 14:26:00 -07:00
Dan Helfman 48f008d720 Fix image name of docs export. 2024-10-27 13:51:12 -07:00
Dan Helfman 53d9ffd1d3 Attempt to make a borgmatic docs tarball package in CI. 2024-10-27 13:26:41 -07:00
Dan Helfman d7323e08ac Fix isort invocation referring to gone setup.cfg file (#922). 2024-10-26 20:44:14 -07:00
Dan Helfman fc7a2852e0 Code formatting. 2024-10-26 20:34:48 -07:00
Dan Helfman f8c9d985e8 Add pyproject.toml transitin to NEWS. 2024-10-26 16:40:15 -07:00
Dan Helfman c7ca9bf844 Replace setup.py with pyproject.toml.
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/922
2024-10-26 23:38:34 +00:00
Dan Helfman 7a117d5cc9 Add support for Borg 2's "sftp://" repository URLs. 2024-10-26 11:58:26 -07:00
kaliko bd9751586c Merge blake8 conf to tox.ini 2024-10-26 20:11:39 +02:00
Dan Helfman fe0fe27c36 Adjust for Borg 2's change from "rclone://" repository URLs to just "rclone:". 2024-10-26 09:41:01 -07:00
Dan Helfman 1426859e1c Mention required borgmatic version in docs about Borg 2.x series changes. 2024-10-25 22:33:22 -07:00
Tony Fernandez ffb431e3ab minor improvement 2024-10-25 17:34:33 -04:00
Tony Fernandez 11fee81486 converted string to dictionary as requested 2024-10-25 17:28:02 -04:00
Dan Helfman 83bc737185 Borg 2 changes: Default the "archive_name_format" option to just "{hostname}". Update the "--match-archives"/"--archive" flags to support series names / archive hashes. Add a "--match-archives" flag to the "prune" action. 2024-10-25 14:26:31 -07:00
Tony Fernandez cda83310c8 fixed version number 2024-10-25 08:10:25 -04:00
Tony Fernandez 90ccbecf07 better documatation description 2024-10-25 08:08:38 -04:00
Tony Fernandez ccbf668bea fix for if logic 2024-10-24 09:01:10 -04:00
Tony Fernandez f18219a768 changes made with 'tox - e isort' 2024-10-24 08:00:14 -04:00
Tony Fernandez 9ba8ca24eb changes made with 'tox -e black' 2024-10-24 07:52:32 -04:00
Tony Fernandez b42793a2dc convert concat to fstring 2024-10-23 18:26:22 -04:00
Tony Fernandez 2877c1ad0d added spaces after commas 2024-10-23 18:10:58 -04:00
Tony Fernandez 1b9f95ca47 better description in schema for server 2024-10-23 18:04:28 -04:00
Tony Fernandez 7aff22536d better itemid description in schema 2024-10-23 17:53:07 -04:00
Tony Fernandez d6e1cc3e12 better wording 2024-10-23 17:43:54 -04:00
Tony Fernandez c1a08edca2 subheader formatting 2024-10-23 17:42:11 -04:00
Tony Fernandez 02a219fac2 it will 2024-10-23 17:40:24 -04:00
Tony Fernandez 78f81c7b73 changed verison to 1.9 - zabbix hook 2024-10-23 17:39:36 -04:00
Tony Fernandez 8edb40a8e9 added dryrun check 2024-10-23 17:38:24 -04:00
Tony Fernandez f2d7687ca3 removed unused variable 2024-10-23 17:34:21 -04:00
Tony Fernandez b5fb0c8247 added returns when expected values are not provided 2024-10-23 17:33:09 -04:00
Tony Fernandez 9b2ac961d7 capitalized Zabbix in logging 2024-10-23 17:30:28 -04:00
Tony Fernandez 38ce98771b removed spaces before logging 2024-10-23 17:28:54 -04:00
Tony Fernandez f616284ffb better description for server string 2024-10-23 17:24:20 -04:00
Tony Fernandez 5da898003f removed unnecessary parentheses here and below 2024-10-23 17:21:30 -04:00
Tony Fernandez 5a464b3186 better logic for run_states 2024-10-23 17:19:51 -04:00
Tony Fernandez 42fb8c38e0 better comments and logging 2024-10-23 17:15:55 -04:00
Tony Fernandez 80839566d6 better wording for API key 2024-10-23 17:12:18 -04:00
Tony Fernandez 702e55e6f7 formatting fix for key 2024-10-23 17:08:37 -04:00
Tony Fernandez 1a8e8835c1 added quotes and period for host description 2024-10-23 17:00:31 -04:00
Tony Fernandez 9abc5c60d4 its 2024-10-23 16:53:18 -04:00
Tony Fernandez fc87b74ab0 capital fixed 2024-10-23 16:52:45 -04:00
Dan Helfman ad21eb41ae Add support for Borg 2's "rclone://" repository URLs. 2024-10-23 11:10:40 -07:00
kaliko 601e393ec7 Fixed package discovery in pyproject.toml 2024-10-22 22:04:33 +02:00
Dan Helfman e391fd196d Add loading of systemd credentials to NEWS (#902). 2024-10-22 09:01:18 -07:00
Dan Helfman 5f387b3991 Fix documentation URL in YAML (#902). 2024-10-22 08:59:13 -07:00
Dan Helfman ed957a940a Load systemd encrypted credentials (#902).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/902
Reviewed-by: Dan Helfman <witten@torsion.org>
2024-10-22 15:58:41 +00:00
Dan Helfman bd4c672382 Apply the "working_directory" option to all actions, not just "create". Also fix the glob expansion of "source_directories" values to respect the "working_directory" option (#609). 2024-10-20 16:04:41 -07:00
Dan Helfman c71da46963 Fix change-passphrase to actually prompt for a new passphrase when an old one is configured (#911). 2024-10-19 09:58:32 -07:00
Tony Fernandez 3da7471fe6 added note about zabbix 7.0+ 2024-10-18 18:00:14 -04:00
Tony Fernandez b874e7e66f zabbix hook added 2024-10-18 17:57:37 -04:00
kaliko b029d1cb67 Add codespell to pyproject.toml 2024-10-15 12:52:05 +02:00
kaliko 33b1101ce1 Add isort to pyproject.toml 2024-10-15 12:51:33 +02:00
kaliko d96c5f79fb Add pytest to pyproject.toml 2024-10-14 18:30:39 +02:00
kaliko 9e29ce788f Fixed release script 2024-10-14 18:17:22 +02:00
kaliko 4658c5d1cb Moving to pyproject.toml 2024-10-14 18:17:14 +02:00
cvlc12 5280de86ff Load encrypted systemd credentials 2024-10-13 15:42:07 +02:00
Dan Helfman 29d5b36a78 Change soft failure command hooks to skip only the current repository (#921). 2024-10-06 17:39:02 -07:00
Dan Helfman 29f214a269 Change borgmatic 1.8.15 (unreleased) to 1.9.0 due to breaking changes. 2024-10-06 16:07:14 -07:00
Dan Helfman 6fdce2a4a6 Fix a confusing apparent hang when when the repository location changes, and instead show a helpful error message (#914). 2024-10-03 16:48:34 -07:00
Dan Helfman a4b65cf710 Update Borg 2 beta links. 2024-10-03 08:03:47 -07:00
Dan Helfman 79725d2ff7 Document a policy for versioning and breaking changes (#919). 2024-10-02 09:10:12 -07:00
Dan Helfman f7e8a2c1d1 Clarify the command-line help for the "--config" flag (#919). 2024-10-02 08:47:52 -07:00
Dan Helfman f54d566edc Fixes to make the inclusive language linter happy. 2024-10-01 13:37:31 -07:00
Dan Helfman b7efa0d3f0 When databases are configured, don't auto-enable the "one_file_system" option (#918). 2024-10-01 10:16:30 -07:00
Dan Helfman 88b945dcb9 Add missing test (#911). 2024-10-01 09:31:03 -07:00
Dan Helfman 34305d686c Revert "Add missing test (#911)" which accidentally included unrelated changes.
This reverts commit bf1e8bc44e.
2024-10-01 09:28:39 -07:00
Dan Helfman bf1e8bc44e Add missing test (#911). 2024-10-01 09:26:00 -07:00
Dan Helfman fd4f69f6c3 Rename repository actions for compatibility with recent Borg 2 changes (#915). 2024-09-09 10:05:32 -07:00
Dan Helfman 1fe6ae83a8 Add a "key change-passphrase" action to change the passphrase protecting a repository key (#911). 2024-09-01 11:13:39 -07:00
Dan Helfman 1197d6d0f6 Bump version for release. 2024-08-29 17:41:02 -07:00
Dan Helfman 288a4bf243 : Add documentation details for how to run custom database dump commands using binaries from running containers (#906). 2024-08-20 15:03:49 -07:00
Dan Helfman 1fb943e5f1 Documentation and config schema update about ~/.borgmatic volume mount (#906).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/907
2024-08-20 21:51:51 +00:00
Dan Helfman e0298685a1 Fix the "source_directories_must_exist" option to work with relative "source_directories" paths when a "working_directory" is set (#905). 2024-08-20 13:52:06 -07:00
Dan Helfman c5633227bf Add glob ("*") support to the "--repository" flag (#898). 2024-08-20 12:49:50 -07:00
Jonathan Brockhaus 6087c12e09 docs: typo 2024-08-16 14:36:28 +02:00
Jonathan Brockhaus 9d83f02e24 fix(config/schema.yaml): Add note about mounting .borgmatic folder inside container db backups 2024-08-16 14:25:11 +02:00
Jonathan Brockhaus b6ccde6757 fix(docs): Add note about mounting .borgmatic folder inside container db backups 2024-08-16 14:23:59 +02:00
Dan Helfman 548aceb3d5 Fix config validation broken by schema change (#904). 2024-08-12 23:09:55 -07:00
Dan Helfman 2bd63bbdd2 Clarify the configuration reference about the "spot" check options (#904). 2024-08-12 22:50:58 -07:00
Dan Helfman 9b4fed64a6 Upgrade run-full-tests dependencies. 2024-08-11 20:41:43 -07:00
Dan Helfman ff1001a5f5 Fix NEWS typo. 2024-08-11 20:38:38 -07:00
Dan Helfman e7f1c3eda5 Add "color" option regression fix to NEWS. 2024-08-11 20:35:54 -07:00
Dan HelfmanandGitHub 21e343a948 Fix broken color configuration option.
Merge pull request #83 from TheoBrigitte/main
2024-08-11 20:32:43 -07:00
Theo Brigitte 4c4fd92013 update logger unit tests
* update color config structure
  * add check for color: True in config
  * ensure interactive_console is called wherever it should
2024-08-09 00:07:46 +02:00
Theo Brigitte 37735e464c Fix color configuration directive 2024-08-05 16:38:49 +02:00
Dan Helfman 424cc6b66c Fix for a "bad character" Borg error in which the "spot" check fed Borg an invalid pattern (#899). 2024-07-25 11:34:09 -07:00
Dan Helfman e128a3e0a9 Add a ticket number onto the traceback fix in NEWS. 2024-07-19 22:22:19 -07:00
Dan Helfman 27e7ece2f5 More fix for a potential traceback (TypeError) during the handling of another error. 2024-07-15 14:45:21 -07:00
Dan Helfman d44dc93509 Fix for a potential traceback (TypeError) during the handling of another error. 2024-07-14 10:07:15 -07:00
Dan Helfman 31778fd3bf Fix error in borgmatic rcreate/init on empty repository directory with Borg 1.4 (#896). 2024-07-08 11:24:48 -07:00
Dan Helfman 4313f90dd8 Add a couple of more code style guidelines. 2024-07-07 20:49:27 -07:00
Dan Helfman 1f82ea2798 Attempt to fix documentation build. 2024-07-06 10:23:10 -07:00
Dan Helfman 148536d867 Upgrade certifi in test requirements. 2024-07-06 10:17:53 -07:00
Dan Helfman 197f0a521d Add a recent contributors section to the documentation. 2024-07-06 10:15:16 -07:00
Dan Helfman 0d8e352033 Update documentation link to Borg 1.4.0 exit codes. 2024-07-04 20:21:40 -07:00
Dan Helfman 20a3995977 Bump version for release. 2024-07-04 08:54:09 -07:00
Dan Helfman 66aa953371 Add additional tests (#298). 2024-07-04 08:42:05 -07:00
Dan Helfman ba053de8f7 Add delete and rdelete actions (#298).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/893
2024-07-04 06:07:30 +00:00
Dan Helfman 2f844d65d5 Automated tests complete (#298). 2024-07-02 23:11:58 -07:00
Dan Helfman 2dca5e1834 Initial automated tests for delete action (#298). 2024-07-02 20:26:29 -07:00
Dan Helfman 36197ce027 Dry run support for delete/rdelete actions (#298). 2024-06-28 20:56:42 -07:00
Dan Helfman e9a0226ee0 Initial work on delete/rdelete actions (#298). 2024-06-28 16:20:10 -07:00
Dan Helfman fc3b4a653e Add a development documentation note about using fully qualified imports. 2024-06-28 15:00:25 -07:00
Dan Helfman 3673abb01e Update documentation links that link to beta versions of Borg docs. 2024-06-27 14:42:53 -07:00
Dan Helfman ac4277d36c Add "new in version" label to Uptime Kuma documentation (#885). 2024-06-27 09:29:53 -07:00
Dan Helfman 21cbc99d9e Fix uptime_kuma monitor not being called after name change (#891).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/891
2024-06-27 16:27:07 +00:00
Paul Wilde d080bf2ae9 [bugfix] uptime_kuma monitor is not called after name change 2024-06-27 12:49:07 +01:00
Dan Helfman 2a1c790655 Minor reorganization of borgmatic source code reference. 2024-06-26 19:39:32 -07:00
Dan Helfman 410204a70d Formatting, whitespace, and minor fixes for Uptime Kuma hook (#885). 2024-06-26 16:09:14 -07:00
Dan Helfman 4a0c167c1c Add Uptime Kuma monitoring hook (#885).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/885
Reviewed-by: Dan Helfman <witten@torsion.org>
2024-06-26 22:50:11 +00:00
Dan Helfman 593c956d33 Add an "only_run_on" option to consistency checks so you can limit a check to running on particular days of the week (#785). 2024-06-26 14:57:59 -07:00
pswilde d18cb89493 Merge branch 'main' into main 2024-06-26 19:58:56 +00:00
Paul Wilde 067c79c606 renamed push_monitor back to ping_monitor in uptime kuma hook 2024-06-26 20:57:37 +01:00
Dan Helfman ebde88ccaa Fix the Healthchecks ping body size limit, restoring it to the documented 100,000 bytes (#889). 2024-06-25 12:45:44 -07:00
Dan Helfman cc402487d9 Minor development documentation clarifications. 2024-06-24 10:48:13 -07:00
Paul Wilde d108e6102b some minor corrections in how to uptime kuma docs 2024-06-24 12:00:22 +01:00
Paul Wilde 3e60043632 some minor corrections in how to uptime kuma docs 2024-06-24 11:57:12 +01:00
Paul Wilde a8d691169a some minor corrections in how to uptime kuma docs 2024-06-24 11:54:34 +01:00
Paul Wilde 939c2f6718 some minor corrections in how to uptime kuma docs 2024-06-24 11:54:12 +01:00
Paul Wilde 0837059e21 some minor corrections in how to uptime kuma docs 2024-06-24 11:51:41 +01:00
Paul Wilde 0ee166fdf0 added Uptime Kuma how-to docs 2024-06-24 11:46:38 +01:00
Paul Wilde b50996b864 change uptimekuma method names to 'push_*' instead of 'ping' 2024-06-24 11:07:09 +01:00
Paul Wilde 8f423c7293 black formatting on uptimekuma hook 2024-06-24 10:46:04 +01:00
Paul Wilde 14ce88e04b black formatting on test_uptimekuma.py 2024-06-24 10:37:44 +01:00
Paul Wilde f97968b72d variable renaming 2024-06-24 10:34:52 +01:00
Paul Wilde 612f867ea8 Merge remote-tracking branch 'upstream/main' 2024-06-24 10:28:02 +01:00
Paul Wilde 303d6609e4 removed unecessary tests for 'default urls' as these would never really be used anyway 2024-06-24 10:27:23 +01:00
Paul Wilde bf7b163ccd added early out for dry run 2024-06-24 10:25:08 +01:00
Paul Wilde 4bd798f0ad alpha ordered dispatch monitor hook names (including loki) 2024-06-24 10:22:27 +01:00
Paul Wilde 52aa7c5d21 switched to using full 'push_url' instead of separate 'server' and 'push_code' 2024-06-24 10:21:31 +01:00
Dan Helfman f5a1dd31c8 Fix PagerDuty hook traceback with Python < 3.10 (#886). 2024-06-23 18:28:41 -07:00
Dan Helfman c41000a4b1 Bump version for release. 2024-06-23 17:02:13 -07:00
Dan Helfman c3f8b05a68 Fix test warning in PagerDuty hook. 2024-06-23 17:01:54 -07:00
Dan Helfman f4fcf92bd6 Add an "upload_buffer_size" option to set the size of the upload buffer used in "create" action (#865). 2024-06-23 16:26:22 -07:00
Dan Helfman a2c139245d Add a "--max-duration" flag to the "check" action and a "max_duration" option to the repository check configuration (#817). 2024-06-22 16:19:06 -07:00
Paul Wilde a509cdedd5 Added Uptime Kuma tests 2024-06-22 10:46:17 +01:00
Paul Wilde dcbc30b164 WIP add uptime kuma tests 2024-06-22 10:19:34 +01:00
Paul Wilde 5ab99b4cc0 Added Uptime Kuma image to readme 2024-06-21 21:21:04 +01:00
Paul Wilde 27c90b7cf1 Added Uptime Kuma hook 2024-06-21 21:10:14 +01:00
Paul Wilde 6eb76454bb WIP added some schema info for uptime kuma 2024-06-21 17:00:44 +01:00
Paul Wilde 83bcea98dc WIP added some schema info for uptime kuma 2024-06-21 16:57:20 +01:00
Paul Wilde 4db09a73b3 WIP implement Uptime Kuma hook 2024-06-21 16:47:51 +01:00
Dan Helfman 45a9e3bfc3 Document that "borgmatic borg create" bypasses monitoring hooks (#882). 2024-06-20 14:25:20 -07:00
Dan Helfman bd40015e1c Added missing word to NEWS entry (#881). 2024-06-20 13:33:34 -07:00
Dan Helfman 7894600408 Fix "Unrecognized argument" error when the same value is with different command-line flags (#881). 2024-06-20 11:53:52 -07:00
Dan Helfman df4668754d Fix "Argument list too long" error in the "spot" check when checking 100k+ files (#866). 2024-06-09 22:53:56 -07:00
Dan Helfman 08d6f83b2e In the "spot" check, don't try to hash symlinked directories. 2024-06-09 15:58:16 -07:00
Dan Helfman c58f510054 Minor spot check documentation clarification (#868). 2024-06-09 15:28:28 -07:00
Dan Helfman c2879d054a Alpha ordering in docs (#874). 2024-06-05 14:58:43 -07:00
Dan Helfman f821d2c909 Calling interpolated variable "repository_label" instead of "label" for clarity (#874). 2024-06-05 14:56:21 -07:00
Dan Helfman 1ef2218919 Remove obsolute "version:" from Docker Compose files. 2024-06-05 14:50:52 -07:00
Dan Helfman 177c958572 Add configured repository "label" to the interpolated variables passed to command hooks (#874). 2024-06-05 14:47:37 -07:00
Dan Helfman b5ab1ff0cd Use (current) default action order whenever actions are mentioned (#873). 2024-06-05 11:21:51 -07:00
Dan Helfman 70a978b83d Upgrade test requirements. 2024-05-21 13:57:06 -07:00
Dan Helfman 2037810c6b Avoid requiring network in test_healthchecks.py (#869).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/869
2024-05-21 20:33:21 +00:00
Damien Cassou de304f83de Avoid requiring network in test_healthchecks.py
Some test environments (e.g., the one of the Nix build system) don't
allow network requests while building and testing.
2024-05-16 16:11:40 +02:00
Dan Helfman 5752373009 When color output is disabled (explicitly or implicitly), don't prefix each log line with the log level (#863). 2024-05-11 22:40:13 -07:00
Dan Helfman fecae39fcd To avoid duplicate install, update docs to uninstall borgmatic before re-installing with Apprise (#862). 2024-05-03 16:48:35 -07:00
Dan Helfman 38bc4fbfe2 Fix interaction between environment variable interpolation in constants and shell escaping (#860). 2024-04-30 09:36:26 -07:00
Dan Helfman 92ed7573d4 Fix NEWS formatting. 2024-04-29 09:39:40 -07:00
Dan Helfman 80f0e92462 Bump version for release. 2024-04-29 09:38:02 -07:00
Dan Helfman 5f10b1b2ca Clarify database limitations. 2024-04-28 16:55:24 -07:00
Dan Helfman 4f83b1e6b3 [Documentation] Add compression level explanation and example.
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/859
2024-04-28 16:50:09 +00:00
Codimp 15d5a687fb make parenthetical its own sentence 2024-04-28 18:41:05 +02:00
CodimpandCodimp eb1fce3787 documentation: add compression level explanation and example 2024-04-28 18:24:23 +02:00
Dan Helfman 7f735cbe59 Fix a traceback with "check --only spot" when the "spot" check is unconfigured (#857). 2024-04-24 16:12:58 -07:00
Dan Helfman a690ea4016 Add Healtchecks auto-provisioning to NEWS (#815). 2024-04-23 09:25:29 -07:00
Dan Helfman 7a110c7acd Add Healthchecks auto-provisionning (#815).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/852
Reviewed-by: Dan Helfman <witten@torsion.org>
2024-04-23 16:23:26 +00:00
estebanthilliez 407bb33359 Fix schema.yaml to comply with maximum line length 2024-04-22 20:47:03 +02:00
estebanthilliez 4b7f7bba04 Issue warning if using UUID URL scheme with create_slug 2024-04-22 20:45:36 +02:00
estebanthilliez cfdc0a1f2a Fix Healthchecks UUID regex 2024-04-22 20:44:31 +02:00
Dan Helfman f926055e67 Fix a traceback when the "data" consistency check is used (#854). 2024-04-21 14:55:02 -07:00
Dan Helfman 058af95d70 Document limitation about using database hooks and "one_file_system" (#853). 2024-04-20 14:53:41 -07:00
Dan Helfman 54facdc391 Clarify Apprise states configuration. 2024-04-20 08:26:06 -07:00
estebanthi 2e4c0cc7e7 Support for healthchecks auto provisionning 2024-04-19 10:43:45 +02:00
Dan Helfman cb2fd7c5e8 Fix lack of file extraction when using "extract --strip-components all" on a path with a leading slash (#851). 2024-04-17 16:50:09 -07:00
Dan Helfman 94133cc8b1 Add note about running spot check on a separate schedule (#656). 2024-04-16 10:57:34 -07:00
Dan Helfman dcec89be90 Wording tweak (#656). 2024-04-16 10:52:56 -07:00
Dan Helfman fefd5d1d0e Wording tweak (#656). 2024-04-16 10:50:37 -07:00
Dan Helfman 163c37d77f Bump version for release. 2024-04-16 10:43:35 -07:00
Dan Helfman b0e49ebce0 When "--match-archives *" is used with "check" action, don't skip Borg's orphaned objects check (#779). 2024-04-16 10:38:14 -07:00
Dan Helfman 7e51c41ebf Mask the password when logging a MongoDB dump or restore command (#848). 2024-04-16 10:20:15 -07:00
Dan Helfman f9182514d8 Add spot consistency check (#656).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/849
2024-04-15 21:25:50 +00:00
Dan Helfman 7700b87b60 Test requirements security upgrade. 2024-04-15 14:21:01 -07:00
Dan Helfman 75bdbe6087 Spot check documentation and edge case tweaks (#656). 2024-04-15 14:18:42 -07:00
Dan Helfman d243a8c836 Add spot check documentation (#656). 2024-04-15 12:51:07 -07:00
Dan Helfman 4c2eb2bfe3 Spot check basically complete other than docs (#656). 2024-04-15 11:02:05 -07:00
Dan Helfman 89ce060dbd Merge branch 'main' into spot-check 2024-04-05 12:25:50 -07:00
Dan Helfman ad7dcb4615 Fix "--json" error when Borg includes non-JSON warnings in JSON output (#847). 2024-04-05 12:23:50 -07:00
Dan Helfman 6680aece5a Split out (most of) command construction from create_archive() in preparation for reuse in spot check (#656). 2024-04-04 14:23:56 -07:00
Dan Helfman 57eb93760f Merge branch 'main' into spot-check 2024-03-22 11:27:24 -07:00
Dan Helfman f21a2c06e3 Add documentation link to Loki dashboard for borgmatic (#843). 2024-03-22 11:25:33 -07:00
Dan Helfman 2212539cb0 Merge branch 'main' into spot-check. 2024-03-20 12:01:52 -07:00
Dan Helfman 36d10fecb1 Upgrade black in test requirements. 2024-03-20 12:01:24 -07:00
Dan Helfman 3ecd0e731e Initial work on spot check schema and preparatory refactoring (#656). 2024-03-20 11:58:59 -07:00
Dan Helfman ecf5a7e294 When a command hook exits with a soft failure, ping the log and finish states for any configured monitoring hooks (#842). 2024-03-18 23:15:28 -07:00
Dan Helfman 893fbcf9ff Add documentation about backing up containerized databases by configuring borgmatic to exec into a container to run a dump command. 2024-03-14 18:00:52 -07:00
Dan Helfman f8f6560502 Fix handling of the NO_COLOR environment variable to ignore an empty value (#835). 2024-03-13 09:35:19 -07:00
Dan Helfman 8c301ba688 Bump version for release. 2024-03-11 13:27:08 -07:00
Dan Helfman 035e96156a Add an "access_token" option to the ntfy monitoring hook for authenticating without username/password (#811). 2024-03-11 12:48:58 -07:00
Dan Helfman a08c7fc77a When running the "rcreate" action and the repository already exists but with a different encryption mode than requested, error (#840). 2024-03-11 11:24:36 -07:00
Dan Helfman cf9e387811 Document a potentially breaking shell quoting edge case within error hooks (#839). 2024-03-11 10:42:51 -07:00
Dan Helfman e37224606a Clarify dev-CI parity. 2024-03-10 19:14:18 -07:00
Dan Helfman 9647301b99 Add log sending for the Apprise logging hook, enabled by default. 2024-03-10 16:18:49 -07:00
Dan Helfman a0e5dbff96 Remove list of command in Bash script. 2024-03-06 21:24:44 -08:00
Dan Helfman 86117edccf Remove build.torsion.org references from documentation. 2024-03-06 20:01:32 -08:00
Dan Helfman 440f3eeb63 Remove Drone configuration/tests. 2024-03-06 19:04:29 -08:00
Dan Helfman 181051eae1 Add new build server to NEWS. 2024-03-06 18:52:27 -08:00
Dan Helfman ec0ee971ed Attempt to use secrets. 2024-03-06 18:38:45 -08:00
Dan Helfman b83ffa0cf6 Attempt to fix trigger. 2024-03-06 16:53:41 -08:00
Dan Helfman cf88665d37 Fix typo. 2024-03-06 16:52:33 -08:00
Dan Helfman b233adba63 Fix build? 2024-03-06 16:51:49 -08:00
Dan Helfman 018f5e3315 Merge workflows, since Gitea doesn't yet support workflow_run. 2024-03-06 16:49:50 -08:00
Dan Helfman 284f26b49d Only run tests on pushes to main branch. 2024-03-06 16:40:39 -08:00
Dan Helfman 11b437794e Attempt to build documentation. 2024-03-06 16:38:34 -08:00
Dan Helfman 0665b50d57 Fixed debugging. 2024-03-06 16:17:12 -08:00
Dan Helfman 0586b80e5b More debugging. 2024-03-06 15:53:30 -08:00
Dan Helfman 272a7b4866 Actually kill other containers after tests finish. 2024-03-06 15:41:03 -08:00
Dan Helfman 98d4a59459 Another iteration. 2024-03-06 15:29:56 -08:00
Dan Helfman 744139cf97 Disable progress. 2024-03-06 15:21:45 -08:00
Dan Helfman 1339509e9b Flag order apparently matters to Docker Compose. 2024-03-06 14:55:55 -08:00
Dan Helfman e14f61415b Fix spew in test script. 2024-03-06 14:54:53 -08:00
Dan Helfman 98cf8f7e20 Another try at exiting tests properly. 2024-03-06 14:42:06 -08:00
Dan Helfman 5f16b64639 Attempt to exit test containers on tests exit while also showing test output. 2024-03-06 14:39:23 -08:00
Dan Helfman fe62a81151 Add missing service name to test scrits. 2024-03-06 14:32:26 -08:00
Dan Helfman 585b1573ae Attempt to make containers stop after tests run. 2024-03-06 14:30:49 -08:00
Dan Helfman 141ba2771d Attempt to fix and debug read-only filesystem issue at build. 2024-03-06 11:10:20 -08:00
Dan Helfman a527f76d08 Add back checkout now that NodeJS is installed on the host. 2024-03-06 08:49:53 -08:00
Dan Helfman a97c68b4c8 Debugging ls. 2024-03-06 08:35:50 -08:00
Dan Helfman ef07005a75 Remove duplicative(?) checkout step. 2024-03-06 08:35:05 -08:00
Dan Helfman 43c7c3b6be First attempt at using Gitea Actions to run tests. 2024-03-06 08:32:55 -08:00
Dan Helfman 2f6ad9d173 Add NO_COLOR support to NEWS (#835). 2024-03-04 13:49:54 -08:00
Dan HelfmanandGitHub 16bc0de3fb Support for NO_COLOR environment variable (#835).
Merge pull request #82 from shivansh02/feature/support-no-color-env-var
2024-03-04 13:46:09 -08:00
shivansh02 458d157e62 NO_COLOR set to any value returns false 2024-03-05 00:15:52 +05:30
shivansh02 40c3a28620 support for NO_COLOR env var 2024-03-04 18:21:28 +05:30
Dan Helfman 60107f1ee8 Add custom dump/restore command options for MySQL and MariaDB (#311). 2024-03-03 14:32:49 -08:00
Dan HelfmanandGitHub a1153a21fa Custom dump command options for MySQL and MariaDB.
Merge pull request #81 from shivansh02/feature/custom-dump-restore-commands-mysql
2024-03-03 14:27:14 -08:00
shivansh02 b6cb7da98e custom dump commands for mariadb 2024-03-04 00:24:22 +05:30
shivansh02 9e3d19a406 custom commands escaped 2024-03-03 23:31:02 +05:30
shivansh02 2b755d8ade custom show command for mysql and schema description 2024-03-03 23:15:07 +05:30
shivansh02 925f99cfef custom dump command for mysql 2024-03-03 03:47:02 +05:30
Dan Helfman c9f20eb260 Fix "--override" values containing deprecated section headers not actually overriding configuration options under deprecated section headers (#829). 2024-02-15 21:12:42 -08:00
Dan Helfman f4744826fe When the "--json" flag is given, suppress console escape codes so as not to interfere with JSON output (#827). 2024-02-11 17:44:43 -08:00
Dan Helfman 5586aab967 Clarify documentation about restoring a database: borgmatic does not create the database upon restore. 2024-02-09 15:35:29 -08:00
Dan Helfman 6fa5dff79b Fix broken escaping logic for "pg_dump_command" (#822) + bonus shell injection fixes. 2024-01-31 10:53:32 -08:00
Dan Helfman 75d11aa9cd Pass the PostgreSQL "PGSSLMODE" environment variable through to Borg (#370). 2024-01-25 14:18:01 -08:00
Dan Helfman ad1d104d65 Fix broken repository detection in the "rcreate" action with Borg 1.4 (#820). 2024-01-24 15:45:51 -08:00
Dan Helfman 009062128d Remove Python 3.8+ restriction, as only Python 3.8+ is supported. 2024-01-22 09:41:43 -08:00
Dan Helfman e9813d2539 Allow the "--repository" flag to match across multiple configuration files (#818). 2024-01-21 18:25:44 -08:00
Dan Helfman f9998b50e8 Rephrase documentation and link to docs on exit codes feature (#798). 2024-01-21 14:47:21 -08:00
Dan Helfman 5f921a7f80 Add documentation heading (#798). 2024-01-21 11:48:23 -08:00
Dan Helfman abf2b3a8c7 Elevate specific Borg warnings to errors or squash errors to warnings (#798). 2024-01-21 11:34:40 -08:00
Dan Helfman 34f3c2bb16 Clarify "--override" command-line help (#814) 2024-01-19 11:55:00 -08:00
Dan Helfman 4d79f582df Fix a traceback when providing an invalid "--override" value for a list option (#814). 2024-01-18 10:39:40 -08:00
Dan Helfman 63198088c4 Store included configuration files within each backup archive in support of the "config bootstrap" action (#736). 2024-01-09 13:47:20 -08:00
Dan Helfman 3c22a8ec16 Prevent various shell injection attacks (#810). 2024-01-07 10:21:49 -08:00
Dan Helfman ca49109ce7 Bump version for release. 2024-01-03 10:08:05 -08:00
Dan Helfman 6a7f71f92f Clarify prune action help concerning running compact afterwards (#808). 2024-01-03 10:03:35 -08:00
Dan Helfman 5f3dc1cfb0 Stream SQLite databases directly to Borg instead of dumping to an intermediate file (#807). 2023-12-31 11:07:59 -08:00
Dan Helfman f2023aed22 Fix typo. 2023-12-30 15:48:55 -08:00
Dan Helfman a03c2744e5 Update docs/how-to/provide-your-passwords.md (#805).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/805
Reviewed-by: Dan Helfman <witten@torsion.org>
2023-12-30 23:48:32 +00:00
axel simon 4176532317 Update docs/how-to/provide-your-passwords.md
Provide an explanation of encryption_passcommand.
Also, adjust headers for consistency.
2023-12-30 23:45:56 +00:00
Dan Helfman 9d6025e902 Validate the configured action names in the "skip_actions" option (#804). 2023-12-28 20:07:57 -08:00
Dan Helfman cf739bc997 The "check --force" flag now runs checks even if "check" is in "skip_actions" (#802). 2023-12-28 10:22:48 -08:00
Dan Helfman 84823dfb91 Clarify constants/placeholders interaction and improve examples (#763). 2023-12-24 11:18:17 -08:00
Dan Helfman 20cf0f7089 Add an "--ssh-command" flag to the "config bootstrap" action (#767). 2023-12-24 10:33:55 -08:00
Dan Helfman 67af0f5734 Document limitation with constant interpolation at the start of a value (#741). 2023-12-22 21:39:44 -08:00
Dan Helfman e80e0a253c Add configured repository labels to the JSON output for all actions (#800). 2023-12-20 09:17:41 -08:00
Dan Helfman 72587a3b72 Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2023-12-04 11:17:59 -08:00
Dan Helfman 8b49a59aff Fix a traceback when the "repositories" option contains both strings and key/value pairs (#794). 2023-12-04 11:17:13 -08:00
Dan Helfman e120dff9ff Add debug message that logs borg version for every config (#714).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/792
2023-11-25 03:59:40 +00:00
Tobias Hodapp 257678b66f Fixed borg -> Borg 2023-11-24 18:47:37 +01:00
Tobias Hodapp 422c5e32f4 Added debug message that logs borg version for every config 2023-11-23 11:46:10 +01:00
Dan Helfman c34ad7dde7 Update documentation about configuration includes and constants (#745). 2023-11-19 21:22:10 -08:00
Dan Helfman fdb353d358 Bump version for release. 2023-11-19 21:14:56 -08:00
Dan Helfman 3b99f7c75a Constants support includes and command-line overrides (#745, #782) 2023-11-19 21:13:35 -08:00
Dan Helfman 8b9abc6cf8 Documentation clarifications (#791). 2023-11-15 09:05:52 -08:00
Dan Helfman da034c316a Add another mention of "skip_actions" to the docs (#701). 2023-11-08 18:22:17 -08:00
Dan Helfman 08d01d8bcd Documentation formatting. 2023-11-08 17:57:31 -08:00
Dan Helfman eef69e23ee Document the possible units of times for a configured check frequency. 2023-11-08 17:53:59 -08:00
Dan Helfman 26bb54a9dd Remove broken link in documentation (#786). 2023-11-08 10:26:09 -08:00
Dan Helfman 715e2ac127 Add test support for Python 3.12. 2023-11-07 10:17:55 -08:00
Dan Helfman f39cea4abf Remove additional Python 3.7-isms (#784). 2023-11-07 10:17:16 -08:00
Dan Helfman 22101bdd49 Drop support for Python 3.7, which has been end-of-lifed (#784). 2023-11-07 10:11:29 -08:00
Dan Helfman 13cf863d89 Fix tests (#783). 2023-11-07 10:09:31 -08:00
Dan Helfman dcf25fa041 Upgrade ruamel.yaml dependency to support version 0.18.x (#783). 2023-11-07 10:00:13 -08:00
Dan Helfman 12b75f9075 Update documentation about logging changes from version 1.8.3 (#665). 2023-11-06 21:13:46 -08:00
Dan HelfmanandGitHub 9baf06a2f7 Fix typo.
Typo
2023-11-04 08:56:39 -07:00
tdltdc 56302e22cd Typo 2023-11-04 15:05:19 +01:00
Dan Helfman 6cc93c4eb9 Fix environment variable interpolation within configured repository paths (#782). 2023-11-03 21:16:04 -07:00
Dan Helfman 2da43239f6 Fix docs: minor typos (#781).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/781
2023-11-03 00:59:29 +00:00
debuglevel 4beef36d3c Update docs/how-to/inspect-your-backups.md 2023-11-02 23:14:31 +00:00
debuglevel eacfbd742b Typo 2023-11-02 23:13:45 +00:00
debuglevel 82a85986b6 Typo 2023-11-02 22:57:46 +00:00
Dan Helfman ef448e2dd1 Add a "skip_actions" option to skip running particular actions (#701). 2023-10-31 21:54:41 -07:00
Dan Helfman c3efe1b90e Only parse "--override" values as complex data types when they're for options of those types (#779). 2023-10-29 19:02:28 -07:00
Dan Helfman d85c1ee216 Correct changelog addition (#779). 2023-10-29 16:25:40 -07:00
Dan Helfman b47088067c Add a "--match-archives" flag to the "check" action (#779). 2023-10-29 16:22:39 -07:00
Dan Helfman c5732aa4fc Fix home page CSS layout to prevent overflow at certain window widths (#777). 2023-10-27 14:12:35 -07:00
Dan Helfman a0323d9d6c Bump version for release. 2023-10-26 22:20:26 -07:00
Dan Helfman 8ad7b473f1 When an archive filter causes no matching archives for the "rlist" or "info" actions, warn (#748). 2023-10-26 22:12:13 -07:00
Dan Helfman 895a0ccb3c Upgrade to tox 4. (Now a minimum requirement.) 2023-10-23 17:39:27 -07:00
Dan Helfman 257ab77bea Disallow the "--dry-run" flag with the "borg" action (#774). 2023-10-23 17:23:04 -07:00
Dan HelfmanandGitHub dccaa4014b Update systemd service example with better filesystem protection options.
Merge pull request #78 from Alphix/update-systemd-service
2023-10-15 08:59:39 -07:00
David Härdeman 2f3c0bec5b Update systemd .service example
First, ProtectSystem=strict will make the entire file system hierarchy (except
/dev, /proc/ and /sys) read-only, so separate ReadOnlyPaths= is not necessary.

Second, ProtectHome=tmpfs will not just mount an empty tmpfs on /root, but also
on /home and /run/user. As it's likely quite common to want to backup /home,
this seems like a footgun.

Finally, it's quite likely that borgbackup will want access to root's SSH keys
in order to connect to remote backup servers.

Note that all these options are commented out by default, so this is more of
a documentation change than any real change in functionality.
2023-10-15 11:30:11 +02:00
Dan Helfman 487d8ffd32 Fix normalization of deprecated sections to support empty sections without erroring (#771). 2023-10-14 13:04:18 -07:00
Dan Helfman 30523a7c89 Update home page example of Healthchecks configuration not to use deprecated config. 2023-10-11 12:56:21 -07:00
Dan Helfman 77b1907d03 Update Healthchecks deprecation warning message for clarity. 2023-10-11 12:17:57 -07:00
Dan Helfman 09594c85bf Be more explicit in documentation that you don't have to use an environment variable for passphrases. 2023-10-10 09:34:55 -07:00
Dan Helfman e07efdf68f Add documentation note about using includes for specifying passphrases (#769). 2023-10-10 09:16:58 -07:00
Dan Helfman 1fed44f905 Add documentation note about sudo and sudoers "secure_path" option (#757). 2023-10-09 14:15:54 -07:00
Dan Helfman c687dafdd2 Fix a traceback when an invalid command-line flag or action is used (#768). 2023-10-06 21:00:23 -07:00
Dan Helfman 3eff2c4248 Add Grafana Loki badge to integrations documentation. 2023-10-05 09:06:06 -07:00
Dan Helfman d94fdb6faf Add apprise logo to integrations in readme (#715).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/765
2023-10-05 15:51:04 +00:00
Pim Kunis a83282faf0 add apprise logo to integrations in readme 2023-10-05 15:38:32 +02:00
Dan Helfman e7169f6fb2 Upgrade certifi test dependency to fix security alert. 2023-10-04 22:59:15 -07:00
Dan Helfman 9587fc2366 Update Apprise documentation to use sudo for pipx install (#715). 2023-10-04 22:54:11 -07:00
Dan Helfman 5f06884d5a Fix Apprise/PyYAML end-to-end test breakage (#715). 2023-10-04 22:51:05 -07:00
Dan Helfman f011431463 Apprise hook documentation (#715). 2023-10-04 19:23:53 -07:00
Dan Helfman 9e14f209f1 Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2023-10-04 14:58:48 -07:00
Dan Helfman 9d34d2eec5 Support for Apprise (#759).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/759
2023-10-04 21:58:20 +00:00
Pim Kunis 7a9625cd44 fix PR comments 2023-10-04 13:19:40 +02:00
Pim Kunisandpizzapim 4763c323d0 add unit tests for apprise hook 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim eaa22be3db fix PR comments 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim a587e207f9 pin Apprise dependencies for test requirements 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim db8079b699 fix typo in setup.py
handle if apprise cannot be imported
2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim 5a989826a1 convert map to list for apprise function call
fix apprise config schema
remove apprise from required dependencies
2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim 21f4266273 incorporate PR review comments 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim e7252c7545 remove comments about tags 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim 86011c8418 default apprise notify type per borgmatic state 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim f3295ccb4a add support for apprise 2023-10-01 16:59:59 +00:00
Dan Helfman cacb81f086 Bump version for release. 2023-09-30 13:37:10 -07:00
Dan Helfman 06c2154e6a Build docs regardless of Drone "event" (push, etc.). 2023-09-29 19:49:09 -07:00
Dan Helfman ac1e1a9407 Simplify logging logic (#665). 2023-09-29 14:16:47 -07:00
Dan Helfman 10933fd55b Fix for borgmatic not stopping Borg immediately when the user presses ctrl-C (#761). 2023-09-27 08:52:00 -07:00
Dan Helfman af422ad705 Add documentation note about upgrading multiple pipx installations of borgmatic. 2023-09-18 13:46:41 -07:00
Dan Helfman d9d35491fb Fix tense typo. 2023-09-17 23:25:57 -07:00
Dan Helfman b540e63c0e Updated documentation so "sudo borgmatic" works for pipx borgmatic installations (#757). 2023-09-17 22:46:33 -07:00
Dan Helfman 5a56208922 Fix documentation typo. 2023-09-15 10:18:35 -07:00
Dan Helfman 5912769273 Fix error handling to log command output as one record per line (#754). 2023-09-14 21:10:52 -07:00
Dan Helfman bac2aabe66 Attempt to unbreak ticket filing. 2023-09-12 09:50:38 -07:00
Dan Helfman 9f3328781b When "archive_name_format" is not set, filter archives using the default archive name format (#753). 2023-09-06 23:13:40 -07:00
Dan Helfman 0205748db8 Update documentation to recommend installing/upgrading borgmatic with pipx instead of pip. 2023-09-04 16:25:10 -07:00
Dan Helfman d0a8251ad2 Add borgmatic version introducing Loki hook to docs (#743). 2023-08-27 20:30:13 -07:00
Dan Helfman 32019ea8f3 Add documentation for Grafana Loki hook (#743). 2023-08-25 10:52:00 -07:00
Dan Helfman fa9a061033 Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2023-08-25 09:29:02 -07:00
Dan Helfman b3d2560563 Added support for grafana loki (#743).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/747
2023-08-25 16:28:19 +00:00
Dan Helfman 4b4f56da42 Fix another database -> data source instance (#685). 2023-08-24 14:00:29 -07:00
Dan Helfman b96d1898f7 Prep work for eventual container-dumping hooks: Generalize internal database hook "API" (#685). 2023-08-24 13:50:10 -07:00
Tobias Hodapp 099a712e53 Added more documentation to the test
Split tests to integration tests
2023-08-24 13:17:42 +02:00
Tobias Hodapp 9e2674ea5a Added unit tests
Removed useless dry run check
2023-08-23 17:17:23 +02:00
Tobias Hodapp 7e419ec995 Fixed spelling errors
Added documentation
Added log messages for dry run
2023-08-22 23:03:14 +02:00
Tobias Hodapp a3edf757ee Added changes of formatting tools 2023-08-22 13:40:05 +02:00
Tobias Hodapp e576403b64 Added support for grafana loki 2023-08-22 03:13:39 +02:00
Dan Helfman 7313430178 Make warning about sections a little more explicit (#721). 2023-08-19 22:51:20 -07:00
Dan Helfman 962daaa8b9 Bump version for release. 2023-08-14 12:54:38 -07:00
Dan Helfman cd51e9c1ea Fix for database "restore" action not actually restore anything (#738). 2023-08-14 12:43:21 -07:00
Dan Helfman 6dca7c1c15 Add "key export" action to export a copy of the repository key (#345). 2023-08-07 12:28:39 -07:00
Dan Helfman fd8c56c6be Add brief source code reference documentation. 2023-08-06 23:44:31 -07:00
Dan HelfmanandGitHub 065057c966 Fix typos.
Merge pull request #77 from hop/main
2023-08-05 17:19:57 -07:00
Christoph Schindler c04517f843 Fix typos. 2023-08-06 02:16:31 +02:00
Dan Helfman 5d80c366fb Fix "borg create" flags/argument interleaving. 2023-08-04 20:02:09 -07:00
Dan Helfman 193dd93de2 Fork a MariaDB database hook from the MySQL database hook (#727). 2023-08-04 13:22:44 -07:00
Dan Helfman 8a94b9e2f1 Mention "store_config_files" in docs (#725). 2023-08-03 22:11:02 -07:00
Dan Helfman 3c8f6040e2 Bump version for release. 2023-08-03 21:56:26 -07:00
Dan Helfman 14e2a6b89d Omit "--glob-archives" or "--match-archives" Borg flag when its value would be "*" (#734). 2023-08-02 10:23:22 -07:00
Dan Helfman e607de7df1 Include multiple configuration files with a single "!include" (#732).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/737
2023-08-02 04:46:33 +00:00
Dan Helfman e9bd5f4e1d Add documentation and NEWS link (#732). 2023-08-01 21:12:49 -07:00
Dan Helfman 175003ff9b Additional test coverage (#732). 2023-08-01 19:45:01 -07:00
Dan Helfman b8d349d048 Additional test coverage (#732). 2023-08-01 16:27:53 -07:00
Dan Helfman f6f06551f0 Multiple configuration includes with a single "!include" (#732). 2023-08-01 14:12:35 -07:00
Dan Helfman 69771fe7ce Add "store_config_files" option to NEWS (#725). 2023-07-31 14:04:00 -07:00
Dan Helfman c5c3e2e0ce Code formatting (#725). 2023-07-31 14:01:20 -07:00
Dan HelfmanandGitHub 8491b2c416 Feat: optionally disable config bootstrap (#725).
Merge pull request #75 from diivi/feat/disable-config-bootstrap
2023-07-31 14:00:00 -07:00
Divyansh Singh 962267b3c5 add test for checking manifest creation 2023-08-01 02:26:36 +05:30
Divyansh Singh 562f4a281b add test 2023-08-01 02:01:15 +05:30
Divyansh Singh 6b09ca8022 exclude configs too and not just the manifest 2023-08-01 01:51:37 +05:30
Divyansh Singh f2ce2f387f feat: optionally disable config bootstrap 2023-08-01 01:32:06 +05:30
Dan Helfman 782a9bb70a Add new documentation to NEWS (#326, #697). 2023-07-31 12:19:19 -07:00
Dan HelfmanandGitHub 88adb5b3de Add docs for database restore params and config bootstrap (#326, #697).
Merge pull request #74 from diivi/docs/database-restore-params-and-config-bootstrap
2023-07-31 11:56:32 -07:00
Divyansh SinghandGitHub 59465b256d Apply suggestions from code review 2023-07-31 23:50:46 +05:30
Divyansh Singh adfb89ee65 improve docs - wittens suggestions 2023-07-29 00:16:04 +05:30
Dan Helfman c11dcdef0a Fix some "borgmatic" capitalization issues. 2023-07-26 23:38:08 -07:00
Dan Helfman 8a2514915c Fix for Borg's interactive prompt on the "check --repair" action automatically getting answered "NO" (#730). 2023-07-22 15:25:55 -07:00
Dan Helfman 4d7a2876a5 Fix for "prune" action error when using the "keep_exclude_tags" option in configuration (#728). 2023-07-22 10:26:52 -07:00
Dan Helfman 309f67e860 Fix documentation comma grammar issues. 2023-07-18 23:27:45 -07:00
Dan Helfman 0ad7b4f408 Bump version for release. 2023-07-18 22:50:09 -07:00
Divyansh Singh 1f415a24b8 add docs for database restore params and config bootstrap 2023-07-15 12:27:54 +05:30
Dan Helfman 5c941600a6 Clarify database version description. 2023-07-14 22:25:45 -07:00
Dan Helfman 5e0283b391 Remove broken markdown item. 2023-07-14 22:24:21 -07:00
Dan Helfman ac0b23901b Satisfy Gitea validation. 2023-07-14 22:23:41 -07:00
Dan Helfman cde50a75e9 Attempt to add some vertical whitespace for clarity. 2023-07-14 22:23:06 -07:00
Dan Helfman 521c0eb600 Fix text areas in issue templates. 2023-07-14 22:17:39 -07:00
Dan Helfman bc5dc2253c Convert issue templates from Markdown to YAML to take advantage of forms. 2023-07-14 22:16:04 -07:00
Dan Helfman 973ab01afb Disable submitting issues without a template. 2023-07-14 20:10:20 -07:00
Dan Helfman 9413a19eca Try out multiple Gitea templates. 2023-07-14 20:06:22 -07:00
Dan Helfman 0055de08a4 Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2023-07-13 20:21:42 -07:00
Dan Helfman 04fb351a11 Order documentation navigation sections. 2023-07-13 20:20:48 -07:00
Dan Helfman e913cddcd1 Remove configuration sections (#723).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/723
2023-07-14 03:10:51 +00:00
Dan Helfman da78929415 To prevent argument parsing errors on ambiguous commands, drop support for multiple consecutive flag values. 2023-07-13 19:25:14 -07:00
Dan Helfman 18b3b569d0 Fix broken bootstrap action (#721). 2023-07-12 09:39:45 -07:00
Dan Helfman 054bd52482 Fix incorrect merge from main that broke tests (#721). 2023-07-11 22:16:42 -07:00
Dan Helfman 2453ecad42 More documentation updates based on section removal (#721). 2023-07-11 22:10:57 -07:00
Dan Helfman cedf562a7e Merge branch 'main' into remove-sections 2023-07-11 20:05:16 -07:00
Dan Helfman 5b991b88dd Rewrite documentation navigation URLs when being run locally. 2023-07-11 19:58:49 -07:00
Dan Helfman d2fa205476 Update documentation for section removal (#721). 2023-07-11 19:42:14 -07:00
Dan Helfman ecd9e62147 Fix last end-to-end database test (#721). 2023-07-11 00:37:36 -07:00
Dan Helfman 003d4eac93 Remove extra argument (#721). 2023-07-10 11:41:43 -07:00
Dan Helfman e8dbca9d68 Truncate long command output without assuming an encoded byte string. 2023-07-10 11:16:18 -07:00
Dan Helfman bd9d592560 Truncate long command error output. 2023-07-10 10:20:51 -07:00
Dan Helfman 196a226a7e Add "check_i_know_what_i_am_doing" option to NEWS (#724). 2023-07-10 09:44:00 -07:00
Dan Helfman a3f47a6418 Remove some sections from tests (#721). 2023-07-10 09:38:28 -07:00
Dan Helfman d29667ca3c Add a config entry for BORG_CHECK_I_KNOW_WHAT_I_AM_DOING env var (#724).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/724
2023-07-10 16:38:09 +00:00
Yoann LaissusandYoann Laissus 7d18f59079 Add a config entry for BORG_CHECK_I_KNOW_WHAT_I_AM_DOING env var 2023-07-10 10:36:56 +02:00
Dan Helfman ab351548d2 Fix (some) end-to-end tests (#721). 2023-07-09 17:40:02 -07:00
Dan Helfman 48b6a1679a Remove sections (#721). 2023-07-08 23:14:30 -07:00
Dan Helfman 9cafc16052 For "borgmatic borg", pass the repository to Borg via a Borg-supported environment variable (#575). 2023-07-03 00:08:54 -07:00
Dan Helfman fbbfc684ce Add referral link for Hetzner. 2023-07-02 22:14:36 -07:00
Dan Helfman 13a37a1d9b Reddit is dead. 2023-06-30 22:55:47 -07:00
Dan Helfman 9cf27fa4ba Deprecated configuration options warning logging. 2023-06-29 10:03:36 -07:00
Dan Helfman e2c95327fb Fix an error when dumping a MySQL database and the "exclude_nodump" option is set (#720). 2023-06-28 09:15:11 -07:00
Dan Helfman f60e97d5bf When merging two configuration files, error gracefully if the two files do not adhere to the same format. 2023-06-26 16:46:09 -07:00
Dan Helfman 44f9ab95f9 Fix typos (#575). 2023-06-26 14:37:23 -07:00
Dan Helfman bb6004fc4f Revamp "borg" action to support REPOSITORY and ARCHIVE env vars instead of implicitly injecting repository/archive into the Borg command (#575). 2023-06-26 14:35:07 -07:00
Dan Helfman b242078f54 Fix an error when running "borg key export" through borgmatic (#719). 2023-06-26 09:30:46 -07:00
Dan Helfman c3004c6090 Some brief documentation on running only checks (#364). 2023-06-25 22:49:36 -07:00
Dan Helfman b9a11e860d Remove legacy configuration parsing code, no longer needed with upgrade-borgmatic-config gone (#529). 2023-06-25 15:36:25 -07:00
Dan Helfman 37a0a0c421 Bump version for release. 2023-06-24 22:23:01 -07:00
Dan Helfman 325b561296 Switch from "init" to "rcreate" for creating repos in end-to-end tests. 2023-06-24 15:52:20 -07:00
Dan Helfman b62017be4b Fix edge case in which "--config somepath.yaml" followed by an action alias (e.g. init for rcreate) wasn't parsed correctly (#716). 2023-06-24 15:35:10 -07:00
Dan Helfman 8debcbeaba Remove duplicated tests (#716). 2023-06-24 14:28:50 -07:00
Dan Helfman 35a11559ac Fix error parsing arguments with multiple verbosity flags (#716). 2023-06-24 14:10:47 -07:00
Dan Helfman e4e455ee45 Deprecate validate-borgmatic-config in favor of new "config validate" action (#529). 2023-06-23 10:11:41 -07:00
Dan Helfman 23809e9060 More Docker build fun (#326). 2023-06-22 15:11:49 -07:00
Dan Helfman bb0dd14f69 Attempt to fix CI test failures (#326). 2023-06-22 14:55:32 -07:00
Dan Helfman 308c96aeb5 Add comment describing need for dev-CI parity test. 2023-06-22 14:37:08 -07:00
Dan Helfman 62a2f5a1d0 Code formatting. 2023-06-22 14:25:26 -07:00
Dan Helfman e8c862659c Add missing services to build service configuration and add a test to catch this in the future (#326). 2023-06-22 14:20:42 -07:00
Dan Helfman 69611681e2 Add database restore overrides to NEWS, add a test, and move some tests (#326). 2023-06-22 12:40:57 -07:00
Dan Helfman 9e0df595c8 Merge branch 'main' of github.com:borgmatic-collective/borgmatic 2023-06-22 12:29:32 -07:00
Dan HelfmanandGitHub 68d90e1e40 feat: allow restoring to different port/host/username (#326).
Merge pull request #73 from diivi/feat/restore-with-different-hostname-port-username
2023-06-22 12:28:34 -07:00
Dan Helfman 248500c7be Accidentally a word. 2023-06-22 09:21:06 -07:00
Dan Helfman 3addb60fb8 Actually link to the most recent version. 2023-06-22 09:13:45 -07:00
Dan Helfman 01fffab898 Clarify that references docs are only for the most recent version of borgmatic. 2023-06-22 09:12:06 -07:00
Dan Helfman bc93401a70 Codespell fixes. 2023-06-21 13:14:54 -07:00
Dan Helfman 1b90da5bf1 Deprecate generate-borgmatic-config in favor if new "config generate" action (#529). 2023-06-21 12:19:49 -07:00
Dan Helfman 803fc25848 Add a test for another edge case (#712). 2023-06-21 10:47:53 -07:00
Dan Helfman 248f82d6f6 Fix for another subaction argument-parsing edge case (#712). 2023-06-21 10:41:32 -07:00
Divyansh Singh 87c6e5b349 make sure restore params in config aren't used when cli args are supplied 2023-06-21 00:03:07 +05:30
Dan Helfman 147516ae3f Remove additional upgrade-borgmatic-config code (#529). 2023-06-20 09:41:26 -07:00
Dan Helfman b10aee3070 Remove upgrade-borgmatic-config command for upgrading borgmatic 1.1.0 INI-style configuration (#529). 2023-06-19 23:17:59 -07:00
Dan Helfman 6098005f5d Fix an error when "data" check time files are accessed without getting upgraded first (#711, #713). 2023-06-19 23:07:57 -07:00
Dan Helfman 7b8be800a4 Refactor arguments parsing to fix bootstrap action CLI issues (#712). 2023-06-19 16:18:47 -07:00
Divyansh Singh 1a21eb03cd add tests for all databases 2023-06-20 00:52:01 +05:30
Divyansh Singh e2d82e9bba actually test port restores 2023-06-19 01:10:01 +05:30
Divyansh Singh 384182172a add unit tests for cases when cli/config restore args are used 2023-06-18 06:29:11 +05:30
Divyansh Singh 9016dcc418 all e2e tests 2023-06-18 05:47:35 +05:30
Divyansh Singh e53dd3da87 fix witten reported mysql error 2023-06-17 22:58:59 +05:30
Divyansh Singh 6c87608548 add tests for password logic 2023-06-17 00:47:15 +05:30
Dan Helfman ee2ebb79b8 Find sub-actions for an action without an isinstance() check. 2023-06-16 10:57:01 -07:00
Divyansh Singh 89602d1614 pass all existing tests (and formatting) 2023-06-16 15:14:00 +05:30
Dan Helfman c294e78715 Use absolute paths when storing configuration files in an archive for later bootstrapping (#697). 2023-06-15 21:45:43 -07:00
Dan Helfman 9152fed249 Add a documentation troubleshooting note for MySQL/MariaDB authentication errors (#399). 2023-06-15 14:55:57 -07:00
Divyansh Singh 8389851f2f fix bug where port becomes truthy when none is converted to str 2023-06-15 23:34:50 +05:30
Dan Helfman bbc7f0596c Fix Bash completion for sub-actions like "borgmatic config bootstrap" (#697 follow-on work). 2023-06-15 10:55:31 -07:00
Divyansh Singh 82d851d891 add argument for restore path 2023-06-15 23:05:53 +05:30
Divyansh Singh 62b6f13299 add restore-path support for sqlite 2023-06-15 23:02:09 +05:30
Divyansh Singh b7423c488e refactor password assignment logic 2023-06-15 22:54:06 +05:30
Dan Helfman 1d7c7eaaa7 Add sample systemd user serivce for running borgmatic as a non-root user (#669). 2023-06-14 14:57:57 -07:00
Divyansh Singh a9386b7a87 add mongodb support, and sqlite restore path (config option only) 2023-06-15 02:18:24 +05:30
Divyansh Singh 205e5b1524 mysql support 2023-06-15 01:47:46 +05:30
Divyansh Singh 67f4d43aec witten review 2023-06-15 01:37:18 +05:30
Dan Helfman e15bec30e6 Mention some hang edge cases in database limitations (#710). 2023-06-13 23:34:58 -07:00
Divyansh Singh 230cf6adc4 support command line args for hostname port username password 2023-06-14 00:11:19 +05:30
Divyansh Singh 8e8e64d920 add no-owner and refactor 2023-06-13 23:42:50 +05:30
Divyansh Singh f558cb3156 feat: allow restoring to different port/host/username 2023-06-12 21:54:39 +05:30
Dan Helfman 41924f2400 A little activism. 2023-06-11 09:50:57 -07:00
Dan Helfman 670bdffb3c Code formatting. 2023-06-10 19:25:49 -07:00
Dan Helfman 691d4f887a Fix incorrect log message (#697). 2023-06-10 16:02:03 -07:00
Dan Helfman beb899d6fb Make user-facing manifest loading error messages a little friendlier (#697). 2023-06-10 15:50:11 -07:00
Dan Helfman 0f9756e739 Fix failing test and add "bootstrap" action to CLI reference docs (#697). 2023-06-10 15:17:18 -07:00
Dan Helfman d84f1ec616 Add bootstrap action to NEWS and make post-PR tweaks (#697). 2023-06-10 14:52:00 -07:00
Dan HelfmanandGitHub ef409ad23c Store configs used to create an archive in the archive and add borgmatic bootstrap (#697).
Merge pull request #71 from diivi/feat/store-config-in-archive
2023-06-10 14:39:53 -07:00
Divyansh Singh d370ff958d mock expand directories thrice 2023-06-10 01:05:34 +05:30
Divyansh Singh 197920d9ef improve tests and some docstrings. 2023-06-09 17:31:57 +05:30
Divyansh Singh 425f260a22 test parser merging 2023-06-09 04:15:18 +05:30
Divyansh Singh 3315555d06 cleaner test 2023-06-09 00:21:41 +05:30
Divyansh Singh 6475345a8f attempt to test parse_subparser_arguments 2023-06-08 01:02:43 +05:30
Divyansh Singh f90d30e0e1 remove duplicate comments 2023-06-08 00:08:39 +05:30
Divyansh Singh 8384eaefb1 reformat 2023-06-08 00:07:36 +05:30
Divyansh Singh dcb90bba50 some tests remaining 2023-06-07 23:56:02 +05:30
Divyansh Singh dc56fd33a0 formatting 2023-06-07 01:47:16 +05:30
Divyansh Singh 2d761dd86b coverage at 100 2023-06-07 01:43:01 +05:30
Divyansh Singh f82631e3bb tests for arguments.py 2023-06-07 00:56:19 +05:30
Divyansh Singh 4b024daae0 pass all tests with wittens recommendation 2023-06-06 23:37:09 +05:30
Divyansh Singh 6a1d1a2e59 fix indentation error that caused too many test failures 2023-06-05 20:31:09 +05:30
Divyansh Singh 206a9c9607 edit schema comments and work on witten review 2023-06-05 20:05:10 +05:30
Dan Helfman a6425b8867 Fix moved Arch Linux borgmatic URL. 2023-06-04 22:21:16 -07:00
Dan Helfman b5d9398910 Stop uploading GPG signatures to pypi since it no longer supports them. 2023-06-03 22:37:46 -07:00
Dan Helfman a185eb73b0 Fix GitHub release script now that "master" has been renamed to "main". 2023-06-03 22:26:49 -07:00
Dan Helfman e80f27f922 Bump version for release. 2023-06-03 22:14:21 -07:00
Dan Helfman 1a5b3c9e4e Add Fedora schema loading fix to NEWS (#703). 2023-06-03 22:07:24 -07:00
Dan Helfman b3f70434df Fix error loading configuration schema on Fedora Linux (#703).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/702
2023-06-04 05:04:41 +00:00
Felix Kaechele c61d63b235 Use open() to test for file existance and readability
Signed-off-by: Felix Kaechele <felix@kaechele.ca>
2023-06-04 00:54:29 -04:00
Felix Kaechele ba0899660d Verify that schema path exists before returning it
Signed-off-by: Felix Kaechele <felix@kaechele.ca>
2023-06-03 23:42:20 -04:00
Felix Kaechele 15cabb93ca Drop importlib_metadata entirely
The fallback option using the dirname of the config module location
seems to be more robust in a number of cases.

Signed-off-by: Felix Kaechele <felix@kaechele.ca>
2023-06-03 23:42:20 -04:00
Felix Kaechele ce6daff12f Fix importlib.metadata.files workaround
Some distributions, such as Fedora, do not install the RECORDS file as
part of a package's dist-info. As a result importlib.metadata.files will
return None.

Use the workaround for these cases as well.

Signed-off-by: Felix Kaechele <felix@kaechele.ca>
2023-06-03 23:42:20 -04:00
Dan Helfman caf654366c Document work-around for colons in YAML strings (#708). 2023-06-03 10:19:34 -07:00
Divyansh Singh bb60b25399 merge subparsers and refactor 2023-06-02 02:04:35 +05:30
Divyansh Singh 74aa28e027 support more flags 2023-06-01 16:53:34 +05:30
Dan Helfman 4f49b345af NEWS wording fix for clarity (#706). 2023-05-30 23:21:55 -07:00
Dan Helfman 1784ca5910 Fix "check" action error when repository and archive checks are configured but the archive check gets skipped due to the configured frequency (#704). 2023-05-30 23:19:33 -07:00
Dan Helfman 8f4cce5fa5 Make dev docs message stand out a little more. 2023-05-30 22:30:06 -07:00
Dan Helfman 518aeabb2a Document verbosity levels (#484). 2023-05-30 22:25:27 -07:00
Dan Helfman 341bd4118d Fix "--archive latest" on "list" and "info" actions only working on the first of multiple configured repositories (#706). 2023-05-30 16:53:55 -07:00
Dan Helfman b222f6a60b Mention new verbosity level to NEWS (#484). 2023-05-30 15:52:49 -07:00
Dan Helfman c0aaba6891 Add option to disable syslog output (#484).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/675
2023-05-30 20:03:56 +00:00
Soumik Dutta a7f81d538d nit changes
- help strings in borgmatic commands
- test fixes in test_logger and test_borgmatic

Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-29 01:09:00 +05:30
Divyansh Singh 4c60bf84d7 extract config files 2023-05-28 01:36:32 +05:30
Divyansh Singh dbb778a4d6 finish parsing and add error for empty config subcommand 2023-05-26 22:44:31 +05:30
Divyansh SinghandGitHub f4a169fdf3 Merge pull request #2 from witten/feat/store-config-in-archive 2023-05-26 21:29:18 +05:30
Soumik Dutta 3d41ed3a34 add test to check that log_file is disabled
if logging is disabled

Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-26 09:58:53 +05:30
Soumik Dutta 0283f9ae2a fix help string
Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-26 08:57:12 +05:30
Soumik Dutta d556a23f97 update borgmatic tests
Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-26 08:57:12 +05:30
Soumik Dutta f98d07e8d8 fix logger test
Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-26 08:57:12 +05:30
Soumik Dutta 09f59ad97d disable monitoring hooks if monitoring_log_level is set to DISABLED
Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-26 08:57:12 +05:30
Soumik Dutta 24be6272ed add test for logger
Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-26 08:57:12 +05:30
Soumik Dutta 5a9bb4b97f update help strings
Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-26 08:57:12 +05:30
Soumik Dutta 6a2eb1f157 make value of disabled level higher
so that no other log has higher priority

Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-26 08:57:12 +05:30
Soumik Dutta 99473c30a8 disable sending logs in Healthchecks
Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-26 08:57:12 +05:30
Soumik Dutta f512d1e460 add verbosity level -2
Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-05-26 08:57:12 +05:30
Dan Helfman 96adee444b Potential fix for nested subparsers not parsing correctly. 2023-05-25 15:03:15 -07:00
Divyansh Singh 8b7996dfda removed parents and used reversed remaining_args 2023-05-26 01:07:11 +05:30
Divyansh Singh 2241de11c0 start work on borgmatic config bootstrap command 2023-05-26 00:26:13 +05:30
Dan Helfman 84c21b062f Fix incorrect argument ordering (#659). 2023-05-23 16:55:40 -07:00
Dan Helfman 76138faaf3 Add integration test for mount action (#659). 2023-05-23 14:49:04 -07:00
Dan Helfman 9299841a5b Add date-based matching flags to NEWS (#659). 2023-05-23 14:30:16 -07:00
Dan Helfman 35b5c62ca6 Add Borg 2 date-based matching flags for archive selection (#659).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/661
2023-05-23 21:26:17 +00:00
Dan Helfman 05b989347c Upgrade requests test requirement (security). 2023-05-23 08:43:45 -07:00
Chirag Aggarwal 00e9bb011a test should mock out make_flags_from_arguments
Signed-off-by: Chirag Aggarwal <thechiragaggarwal@gmail.com>
2023-05-20 09:23:09 -04:00
Dan Helfman 833796d1c4 Add archive check probing logic tweak to NEWS (#688). 2023-05-17 08:48:54 -07:00
Divyansh Singh ee32b076eb update tests and formatting 2023-05-16 23:17:35 +05:30
Dan Helfman e3425f48be Instead of taking the first check time found, take the maximum value (#688) 2023-05-16 10:20:52 -07:00
Dan Helfman 79b094d035 Bump version for release. 2023-05-16 09:59:09 -07:00
Dan Helfman b45e45f161 Partial conversion of showing repository labels in logs instead of paths (part of #635). 2023-05-16 09:36:50 -07:00
Divyansh Singh b10148844b change config_paths var name to used_config_paths to avoid collisions 2023-05-16 14:00:23 +05:30
Dan Helfman ba845d4008 Codespell saves the day. 2023-05-15 23:25:13 -07:00
Dan Helfman 645d29b040 Fix archive checks being skipped even when particular archives haven't been checked recently (#688). 2023-05-15 23:17:45 -07:00
Divyansh Singh 49b4d371ce create and add content to borgmatic-manifest.json 2023-05-16 00:24:19 +05:30
Divyansh Singh 1bc7bb4971 feat: store configs used to create an archive in the archive 2023-05-15 23:04:42 +05:30
Dan Helfman e66e449c3b Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2023-05-14 12:51:23 -07:00
Dan Helfman 8eb05b840a Log a warning when "borgmatic borg" is run with an action that borgmatic natively supports (#694). 2023-05-14 09:59:28 -07:00
Dan Helfman f0fc638284 Docs: add Gentoo Linux to other ways to install (#696).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/696
2023-05-13 16:33:11 +00:00
ennui c6126a9226 Docs: add Gentoo Linux to other ways to install 2023-05-13 11:22:47 +00:00
ennui 62b11ba16b Docs: add Gentoo Linux to other ways to install 2023-05-13 11:20:47 +00:00
Dan Helfman 403ae0f698 Clarify configuration comment about source_directories also accepting files (#693). 2023-05-09 10:14:03 -07:00
Dan Helfman 92a2230a07 Add support for logging each log line as a JSON object via global "--log-json" flag (#680). 2023-05-08 23:00:49 -07:00
Dan Helfman b3b08ee6d7 Fix error in "borgmatic restore" action when the configured repository path is relative (#691). 2023-05-07 21:21:35 -07:00
Dan Helfman 15ef37d89f Add test coverage for exact_options_completion() raising (#686). 2023-05-06 16:25:26 -07:00
Dan Helfman e84bac29e5 Remove value type for compatibility with Python 3.8 (#686). 2023-05-06 16:18:37 -07:00
Dan Helfman 1a956e8b05 Add fish shell completions to NEWS (#686). 2023-05-06 16:04:15 -07:00
Dan HelfmanandGitHub 4aae7968b8 Add fish shell completions support (#686).
Merge pull request #70 from isaec/feat/fish-completions
2023-05-06 16:00:25 -07:00
Isaac 66964f613c formatting! 2023-05-06 15:56:50 -07:00
Isaac 614c1bf2e4 rename test to make function under test clearer 2023-05-06 15:52:42 -07:00
Isaac aa770b98f9 follow unit test module convention 2023-05-06 15:50:37 -07:00
Isaac 453b78c852 drop messages 2023-05-06 15:49:07 -07:00
Isaac 0657106893 clarify dedent test name 2023-05-06 15:46:15 -07:00
Isaac 43c532bc57 add test for dedent strip 2023-05-06 11:51:35 -07:00
Isaac efb81fc2c1 rename last arg helper function to current arg for clarity 2023-05-06 11:42:32 -07:00
Isaac c8f4344f89 add more justification to checks 2023-05-06 11:39:02 -07:00
Isaac a047f856a1 tweak docstring, add comment 2023-05-06 11:37:38 -07:00
Isaac d732059979 fix rotted comments 2023-05-06 11:32:10 -07:00
Isaac ccfdd6806f test the value of completions 2023-05-06 11:29:14 -07:00
Isaac aa564ac5fe fix the error thrown, unit test for it, and add string explanations 2023-05-06 11:25:15 -07:00
Isaac 77dbb5c499 create way for test cases to be shared 2023-05-06 11:16:45 -07:00
Isaac e623f401b9 write more unit tests 2023-05-06 10:56:54 -07:00
Isaac 372622fbb1 add more doccomments, drop a check 2023-05-06 10:46:27 -07:00
Isaac 469e0ccace create doccomments, start writing unit tests 2023-05-06 10:42:06 -07:00
Isaac 59a6ce1462 replace double quotes with single quotes 2023-05-05 00:03:43 -07:00
Isaac 5a7a1747f2 add safety check to avoid infinite cat hang 2023-05-05 00:01:45 -07:00
Isaac b557d635fd async validity check 2023-05-04 23:57:37 -07:00
Isaac d59b9b817f support required actions 2023-05-04 23:44:54 -07:00
Isaac 16ac4824a5 handle typed without default params 2023-05-04 23:42:04 -07:00
Isaac 3592ec3ddf dont show deprecated options 2023-05-04 23:32:09 -07:00
Isaac 8f3039be23 handle the expanding filters better 2023-05-04 23:23:29 -07:00
Isaac b4a38d8be9 fix flag showing up for paths 2023-05-04 23:06:11 -07:00
Isaac d962376a9d refactor to only show specific options if possible 2023-05-04 21:58:30 -07:00
Isaac 193731a017 rename function 2023-05-04 21:14:48 -07:00
Isaac bbc3e9d717 show possible choices 2023-05-04 21:12:24 -07:00
Isaac 639e88262e create working file completion 2023-05-04 20:17:26 -07:00
Isaac f12a10d888 start work on conditional file completion 2023-05-04 19:50:49 -07:00
Isaac 28efc85660 rearrange to improve legability of the file 2023-05-04 18:11:13 -07:00
Isaac f1fd2e88dd drop blank completion 2023-05-04 13:49:29 -07:00
Isaac 700f8e9d9c replace .format with fstring 2023-05-04 13:39:48 -07:00
Isaac f04036e4a7 use fstring to produce completion lines 2023-05-04 13:33:21 -07:00
Isaac 062453af51 replace actionStr with action_name 2023-05-04 13:29:25 -07:00
Isaac b7fe2a5031 lowercase fish in docs 2023-05-04 13:27:57 -07:00
Isaac ca689505e5 add e2e fish test 2023-05-04 13:27:00 -07:00
Isaac 9ff5ea5240 add a unit test, fix isort and black 2023-05-04 13:22:09 -07:00
Dan Helfman 359afe5318 Error if --list is used with --json for create action (#680). 2023-05-03 17:16:36 -07:00
Dan Helfman 0b397a5bf9 Fix borgmatic error when not finding the configuration schema for certain "pip install --editable" development installs (#687). 2023-04-30 16:24:10 -07:00
Dan Helfman a60d7fd173 Run "borgmatic borg" action without capturing output so interactive prompts and flags like "--progress" still work. 2023-04-30 15:43:41 -07:00
Isaac f7e4024fca add to readme 2023-04-28 14:02:06 -07:00
Isaac 98e3a81fcf allow file completions as applicable 2023-04-28 12:42:26 -07:00
Isaac 9c77ebb016 continue deduping 2023-04-28 12:15:01 -07:00
Isaac 23f478ce74 use less completion lines 2023-04-28 12:13:08 -07:00
Isaac d265b6ed6f add comments in generated files 2023-04-28 11:57:16 -07:00
Dan Helfman 77c3161c77 Fix canonical home link in README. 2023-04-28 08:36:03 -07:00
Isaac 2e658cfa56 only allow one parser 2023-04-27 21:57:50 -07:00
Isaac 412d18f218 show sub options 2023-04-27 21:31:53 -07:00
Isaac 8060586d8b fix the script and drop unneeded options 2023-04-27 20:05:17 -07:00
Isaac 25b3db72a0 make more precise, fix the version check fn 2023-04-27 19:58:22 -07:00
Isaac 5678f3a96e basic working version 2023-04-27 19:44:11 -07:00
Isaac 28b152aedd make upgrade message a template 2023-04-27 19:31:42 -07:00
Isaac 0009471f67 start work on completion 2023-04-27 18:46:13 -07:00
jetchirag a62ac42cca Merge branch 'main' into borg2-archive-flags 2023-04-27 16:57:29 +00:00
Chirag Aggarwal 68ee9687f5 Added tests for all subcommands and used black formatter
Signed-off-by: Chirag Aggarwal <thechiragaggarwal@gmail.com>
2023-04-27 22:27:23 +05:30
Chirag Aggarwal 32395e47f9 Added duplicate flags test for prune
Signed-off-by: Chirag Aggarwal <thechiragaggarwal@gmail.com>
2023-04-24 20:49:41 +05:30
Chirag Aggarwal 8aaba9bb0a Added new flags to prune test for review
Signed-off-by: Chirag Aggarwal <thechiragaggarwal@gmail.com>
2023-04-24 20:43:34 +05:30
Chirag Aggarwal 96aca4f446 Updated existing tests to use new parameters
Signed-off-by: Chirag Aggarwal <thechiragaggarwal@gmail.com>
2023-04-24 20:24:41 +05:30
Dan Helfman 22b84a2fea Switch to Docker Compose for dev-docs script, so podman-docker is no longer needed for Podman users. 2023-04-22 10:07:40 -07:00
Dan Helfman 5962fd473e Another try. Backing out psql error changes (#678). 2023-04-21 10:34:50 -07:00
Dan Helfman 7e64f415ba Attempt to fix failing end-to-end database test that only fails in CI. 2023-04-21 10:03:29 -07:00
Dan Helfman ae12ccd8e6 And fixing again... 2023-04-21 09:31:37 -07:00
Dan Helfman 3cefeaa229 Fix end-to-end test command-line syntax. 2023-04-21 09:30:08 -07:00
Dan Helfman 71b75800cd Get more verbose in the end-to-end test restore. 2023-04-20 23:32:57 -07:00
Dan Helfman 9ca31530a0 Add missing test for check_all_source_directories_exist() raising. 2023-04-20 23:15:22 -07:00
Dan Helfman b555fcb956 Add "source_directories_must_exist" expansion fix to NEWS (#682). 2023-04-20 23:08:21 -07:00
Dan Helfman 5829196b70 Expand source directories when checking for existence (#682).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/683
2023-04-21 06:05:59 +00:00
Jesse Johnsonandholocronweaver a14870ce48 Expand source directories when checking for existence (#682). 2023-04-21 05:52:04 +00:00
Dan Helfman ee5c25f3bd Add additional tests for PostgreSQL hook fixes (#678). 2023-04-20 21:44:42 -07:00
Dan Helfman da0f5a34f2 Fix multiple bugs in PostgreSQL hook (#678).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/677
2023-04-21 04:05:22 +00:00
Dan Helfman 065be1d9d4 More inclusive language. 2023-04-20 14:28:04 -07:00
Dan Helfman f2f6fb537a !!! 2023-04-20 14:19:34 -07:00
Dan Helfman 7ff994a964 🤦 2023-04-20 13:56:12 -07:00
Dan Helfman 08edecacae WTF?! 2023-04-20 13:55:37 -07:00
Dan Helfman 1e03046d9a *Seriously?* 2023-04-20 13:50:26 -07:00
Dan Helfman c9bf52ee45 Sigh again. 2023-04-20 13:46:49 -07:00
Dan Helfman f947525fca ? 2023-04-20 13:45:26 -07:00
Dan Helfman 7f7b89d79c Trying a different approach: Ditching Podman-in-Podman. 2023-04-20 12:03:51 -07:00
Dan Helfman 499e42df35 😭 2023-04-20 11:58:06 -07:00
Dan Helfman 4302a07c9b WTF. 2023-04-20 11:53:52 -07:00
Dan Helfman 1721c05d2e Yet more. 2023-04-20 11:52:23 -07:00
Dan Helfman 8a31c27078 To see what sticks. 2023-04-20 11:50:25 -07:00
Dan Helfman d6e1cef356 Throwing stuff at the wall. 2023-04-20 11:49:43 -07:00
Dan Helfman f82bf619ff More. 2023-04-20 11:41:35 -07:00
Dan Helfman 02eeca1fc2 Hmm. 2023-04-20 11:36:30 -07:00
Dan Helfman 4e78cf1b95 ಠ_ಠ 2023-04-20 11:33:15 -07:00
Dan Helfman 9e9a7c50e5 😊🔫 2023-04-20 11:30:30 -07:00
Dan Helfman 51bc53e5ca Whee. 2023-04-20 11:24:59 -07:00
Dan Helfman b85538c54c Double sigh. 2023-04-20 11:11:49 -07:00
Dan Helfman bb5028e484 Sigh. 2023-04-20 11:11:08 -07:00
Dan Helfman 53ee0fcfad Another attempt at Podman-in-Podman incantations. 2023-04-20 11:06:15 -07:00
Dan Helfman 5f8c79dd16 Attempt to get Podman-in-Podman builds working. 2023-04-20 10:50:44 -07:00
Dan Helfman 0a6f5452f4 Fix broken Podman image name. 2023-04-19 23:16:15 -07:00
Dan Helfman 269fac074b Attempt to use Podman-in-Podman for building docs instead of Docker-in-Podman. 2023-04-19 23:14:51 -07:00
Dan Helfman 3b21ce4ce8 Rename "master" development branch to "main" to use more inclusive language (#684). 2023-04-19 21:43:08 -07:00
Dan Helfman 8bb7631f50 Fix missing mock in unit test. 2023-04-19 21:22:51 -07:00
Dan Helfman 9f5769f87b Make docs/schema a little more container agnostic / less Docker specific. 2023-04-16 15:41:17 -07:00
Dan Helfman 991e08f16d Add Unraid borgmatic installation link to docs. 2023-04-15 09:13:13 -07:00
Chirag Aggarwal 1ee56805f1 Merge remote-tracking branch 'upstream/master' into borg2-archive-flags 2023-04-15 17:29:20 +05:30
Dan Helfman 25506b8d2c Backing out upgrade of end-to-end test packages, because apparently we can't have nice things. 2023-04-14 23:47:51 -07:00
Dan Helfman 28e62d824b Upgrade end-to-end test packages. 2023-04-14 23:28:07 -07:00
Dan Helfman 7ee37a890e Fix broken end-to-end tests by no longer using an editable package there, a work-around for https://github.com/pypa/packaging-problems/issues/609 2023-04-14 23:22:07 -07:00
Dan Helfman 8cb5a42a9e Drop deprecated pkg_resources in favor of importlib.metadata and packaging. 2023-04-14 21:21:25 -07:00
Dan Helfman 5dbb71709c Upgrade test requirements and code style requirements. Auto-reformat code accordingly. 2023-04-14 19:35:24 -07:00
Dan Helfman 1c67db5d62 Add documentation for "borgmatic restore --schema" (#375). 2023-04-14 16:40:58 -07:00
Dan Helfman 96d4a8ee45 Add "borgmatic restore --schema" flag to NEWS (#375). 2023-04-14 16:33:06 -07:00
Dan HelfmanandGitHub 81e167959b feat: restore specific schemas (#375).
Merge pull request #67 from diivi/feat/restore-specific-schemas
2023-04-14 16:26:25 -07:00
Divyansh Singh f273e82d74 add tests 2023-04-15 02:57:51 +05:30
Jakub Jirutka 17f122bfe5 Use psql instead of pg_restore when format is "plain"
pg_restore: error: input file appears to be a text format dump. Please use psql.
2023-04-14 17:38:19 +02:00
Jakub Jirutka f0f43174c6 Swap if-else in restore_database_dump in postgresql hook for cleanliness 2023-04-14 17:38:19 +02:00
Jakub Jirutka dfccc1b94a Exit on error when restoring all PostgreSQL databases
"--set ON_ERROR_STOP=on" is equivalent to "--exit-on-error" in
pg_restore.
2023-04-14 17:38:18 +02:00
Jakub Jirutka 195024e505 Fix psql_command and pg_restore_command to accept command with arguments
These commands are executed without `shell=True`, so the subprocess
module treats e.g. "docker exec my_pg_container psql" as a single command
(resulting in Errno 2 "No such file or directory") instead of a command
with arguments.
2023-04-14 17:37:38 +02:00
Jakub Jirutka 19a00371f5 Run "psql" with "--no-psqlrc"
Some settings in user's .psqlrc, e.g. "linestyle unicode", may break the
CSV output. "--no-psqlrc" tells psql to not read startup file.

This is not necessary for the analyze_command and restore_command (with
all_databases), but it's generally recommended when running psql from a
script.
2023-04-14 17:37:37 +02:00
Jakub Jirutka 874fba7672 Fix PostgreSQL hook not using "psql_command" for list when dumping "all" 2023-04-14 15:13:49 +02:00
Dan Helfman 50b0a9ce38 Remove newline at end of file. 2023-04-13 19:13:50 -07:00
Dan Helfman 8802f6888e Fix "TypeError: 'module' object is not callable" in test_commands.py' (#676).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/676
2023-04-14 02:12:58 +00:00
polyzen ebe5c5e839 Fix "TypeError: 'module' object is not callable" in test_commands.py 2023-04-14 01:01:31 +00:00
Dan Helfman 613f6c602c Bump version for release. 2023-04-13 15:12:19 -07:00
Dan Helfman 4a94c2c9bf Selectively omit list values when including configuration files (#672). 2023-04-13 14:39:36 -07:00
Dan Helfman 08843d51d9 Replace "sequence" with "list" in docs for consistency. 2023-04-12 10:30:23 -07:00
Dan Helfman ea9213cb03 Spelling. 2023-04-11 22:12:57 -07:00
Dan Helfman 1ea4433aa9 Selectively shallow merge certain mappings or sequences when including configuration files (#672). 2023-04-11 21:49:10 -07:00
Divyansh Singh 2fea429d78 collection restore for mongodb 2023-04-12 09:34:19 +05:30
Divyansh Singh 264cebd2b1 complete psql multi schema backup 2023-04-11 23:19:49 +05:30
Dan Helfman 4c0e2cab78 View the results of configuration file merging via "validate-borgmatic-config --show" flag (#673). 2023-04-11 10:49:09 -07:00
Dan Helfman 31a2ac914a Add optional support for running end-to-end tests and building documentation with rootless Podman instead of Docker. 2023-04-10 14:26:54 -07:00
Dan Helfman d6ef0df50d Mention #670 being fixed in NEWS. 2023-04-09 10:01:08 -07:00
Dan Helfman cc60a71210 Clarify "log_file" NEWS (#413). 2023-04-06 14:12:12 -07:00
Dan Helfman 4cd7556a34 Add "log_file" command hook context to NEWS and docs (#413). 2023-04-06 13:58:37 -07:00
Dan HelfmanandGitHub b4b1fa939d feat: add logfile name to hook context for interpolation
Merge pull request #68 from diivi/feat/add-log-filename-to-hook-context
2023-04-06 13:46:45 -07:00
Divyansh Singh 16d7131fb7 refactor tests 2023-04-07 01:00:38 +05:30
Divyansh Singh 091d60c226 refactor and improve tests 2023-04-06 12:36:10 +05:30
Divyansh Singh 0fbdf8d860 feat: add logfile name to hook context for interpolation 2023-04-06 09:31:24 +05:30
Dan Helfman 192bfe46a9 Fix error when running the "prune" action with both "archive_name_format" and "prefix" options set (#668). 2023-04-05 14:58:05 -07:00
Dan Helfman 080c3afa0d Fix documentation referring to "archive_name_format" in wrong configuration section. 2023-04-05 14:00:21 -07:00
Divyansh Singh 9bc2322f9a feat: restore specific schemas 2023-04-06 02:10:36 +05:30
Dan Helfman a9a65ebe54 Fix integration tests to actually assert (#666). 2023-04-04 22:11:36 -07:00
Dan Helfman 616eb6b6da Fix error with "info --match-archives" and fix "--match-archives" overriding logic (#666). 2023-04-04 21:25:10 -07:00
Dan Helfman 00d1dea94e Bump version for release. 2023-04-03 16:11:25 -07:00
Dan HelfmanandGitHub 127ad1dd1f Add favicon to documentation.
Merge pull request #66 from diivi/add-favicon
2023-04-03 10:22:12 -07:00
Divyansh Singh fc58ba5763 add favicon to documentation 2023-04-03 17:36:24 +05:30
Dan Helfman 7e6bee84b0 Add "--log-file-format" flag for customizing the log message format (#658). 2023-04-02 23:06:36 -07:00
Dan Helfman 01811e03ba Tagged the auto-matching archive behavior as breaking in NEWS. 2023-04-02 14:38:35 -07:00
Dan Helfman 9712d00680 Add "match_archives" option (#588). 2023-04-01 23:57:55 -07:00
Dan Helfman 275e99d0b9 Add codespell link to documentation. 2023-04-01 14:38:52 -07:00
Dan Helfman b9328e6d42 Add spellchecking of source code to NEWS. 2023-04-01 14:09:48 -07:00
Dan Helfman 2934d0902c Code spell checking on every test run! 2023-04-01 11:03:59 -07:00
Dan HelfmanandGitHub 1ad43ad4b5 Fix: run typos to fix various typos in source code.
Merge pull request #65 from diivi/fix/run-typos
2023-04-01 10:44:11 -07:00
Divyansh Singh 32ab17fa46 merge 2023-04-01 22:12:41 +05:30
Divyansh Singh 6054ced931 fix: run typos 2023-04-01 22:10:32 +05:30
Dan HelfmanandGitHub 1412038ed3 Fix randomly failing test: test_log_outputs_kills_other_processes_when_one_errors (#635).
Merge pull request #64 from kxxt/master
2023-03-31 23:19:57 -07:00
kxxt fa8bc285c8 Fix randomly failing test. 2023-04-01 14:02:30 +08:00
Dan Helfman f256908b27 Document wording tweaks (#479). 2023-03-31 15:36:59 -07:00
Dan Helfman 3f78ac4085 Automatically use the "archive_name_format" option to filter which archives get used for borgmatic actions that operate on multiple archives (#479). 2023-03-31 15:21:08 -07:00
Dan Helfman 5f595f7ac3 Fix regression in which the "transfer" action produced a traceback (#663). 2023-03-30 23:21:20 -07:00
Dan Helfman b27e625a77 Update schema comment for check_repositories to mention labels (#635). 2023-03-28 15:44:38 -07:00
Dan Helfman fc2c181b74 Add missing Docker Compose depends. 2023-03-28 15:31:37 -07:00
Dan Helfman 010b82d6d8 Remove unnecessary cd in dev documentation. 2023-03-28 12:45:39 -07:00
Dan Helfman aaf3462d17 Fix Drone intentation. 2023-03-28 12:03:12 -07:00
Dan Helfman f709125110 Error out if run-full-tests is run not inside a test container. 2023-03-28 12:02:07 -07:00
Dan Helfman 3512191f3e Add check_repositories regression fix to NEWS (#662). 2023-03-28 11:45:55 -07:00
Dan Helfman 06b5d81baa Merge branch 'master' of github.com:borgmatic-collective/borgmatic 2023-03-28 11:15:31 -07:00
Dan HelfmanandGitHub 9d71bf916e fix: make check repositories work with dict and str repositories (#662).
Merge pull request #63 from diivi/fix/check-repositories-by-label
2023-03-28 11:15:01 -07:00
Dan Helfman 59fe01b56d Update script comment. 2023-03-28 11:09:25 -07:00
Divyansh Singh 08e358e27f add and update tests 2023-03-28 22:51:35 +05:30
Divyansh Singh ce22d2d302 reformat 2023-03-28 22:29:21 +05:30
Divyansh Singh 2d08a63e60 fix: make check repositories work with dict and str repositories 2023-03-28 22:14:50 +05:30
Chirag Aggarwal 98c6aa6443 Use Square brackets to denote version specific flag
Signed-off-by: Chirag Aggarwal <thechiragaggarwal@gmail.com>
2023-03-28 18:15:49 +05:30
Chirag Aggarwal edd79ed86c removed individual action parameters, and used make_flags_from_arguments
Signed-off-by: Chirag Aggarwal <thechiragaggarwal@gmail.com>
2023-03-28 18:10:42 +05:30
Dan Helfman d96f2239c1 Update OpenBSD borgmatic link. 2023-03-27 23:43:39 -07:00
Dan Helfman 67a349ae44 I had one job... (#461). 2023-03-27 23:28:36 -07:00
Dan Helfman dcefded0fa Document that most command-line flags are not config-file-able (#461). 2023-03-27 23:21:14 -07:00
Dan Helfman 1bcdebd1cc Fix multiple repositories example. 2023-03-27 23:16:44 -07:00
Dan Helfman 7a8e0e89dd Mention prior versions of borgmatic in repositories schema. 2023-03-27 21:54:01 -07:00
Dan Helfman 489ae080e5 Update docs with a few more "path:" repositories references (#635). 2023-03-27 21:49:31 -07:00
Dan Helfman 0e3da7be63 Fix repository schema description. 2023-03-27 16:15:24 -07:00
Dan Helfman c5ffb76dfa Bump version for release. 2023-03-27 15:56:49 -07:00
Dan Helfman 61c7b8f13c Add optional repository labels so you can select a repository via "--repository yourlabel" at the command-line (#635). 2023-03-27 15:54:55 -07:00
Dan HelfmanandGitHub 3e8e38011b Labels for repositories (#635).
Merge pull request #57 from diivi/feat/tag-repos
2023-03-27 15:46:22 -07:00
Chirag Aggarwal 4fa4fccab7 Use make_flags_from_arguments on mount; Pending test fixes
Signed-off-by: Chirag Aggarwal <thechiragaggarwal@gmail.com>
2023-03-27 23:24:17 +05:30
Dan Helfman d0d3a39833 When a database command errors, display and log the error message instead of swallowing it (#396). 2023-03-27 10:36:39 -07:00
Divyansh Singh 8bef1c698b add feature to docs 2023-03-27 22:16:39 +05:30
Dan Helfman acbbd6670a Removing debugging command output. 2023-03-26 21:26:35 -07:00
Divyansh Singh b336b9bedf add tests for repo labels 2023-03-27 00:19:23 +05:30
Divyansh Singh ec9def4e71 rename repository arg to repository_path in all borg actions 2023-03-26 23:52:25 +05:30
Divyansh Singh a136fda92d check all tests 2023-03-26 23:35:47 +05:30
Divyansh Singh b511e679ae remove optional label for repos from tests 2023-03-26 16:59:29 +05:30
Dan Helfman f56fdab7a9 Add troubleshooting documentation on PostgreSQL/MySQL authentication errors. 2023-03-25 17:08:17 -07:00
jetchirag ff1f4dc09c minor fixes to prune argument help text 2023-03-26 02:06:46 +05:30
jetchirag 141474ff07 Added TIMESPAN flags to match archive in various commands (Borg2 feature)
Signed-off-by: jetchirag <thechiragaggarwal@gmail.com>
2023-03-26 01:58:03 +05:30
Dan Helfman 8c0eea7229 Add additional documentation link to environment variable feature. Rename constants section. 2023-03-25 08:56:25 -07:00
Dan Helfman 19e95628c3 Add documentation and NEWS for custom constants feature (#612). 2023-03-24 23:47:05 -07:00
Dan HelfmanandGitHub 4d01e53414 Fix: replace primitive values in config without quotes (#612).
Merge pull request #62 from diivi/fix/config-json-replacement
2023-03-24 23:45:36 -07:00
Divyansh Singh a082cb87cb fix: replace primitive values in config without quotes 2023-03-25 12:12:56 +05:30
Dan HelfmanandGitHub 1c51a8e229 Allow defining custom variables in config file (#612).
Merge pull request #60 from diivi/feat/constants-support
2023-03-24 22:50:57 -07:00
Dan Helfman d14a8df71a Hide obnoxious ruamel.yaml warnings during test runs. 2023-03-24 22:43:10 -07:00
Dan Helfman 739a58fe47 Rename scripts/run-full-dev-tests to scripts/run-end-to-end-dev-tests and make it run end-to-end tests only. 2023-03-24 16:24:00 -07:00
Dan HelfmanandGitHub af3431d6ae fix: docs cli reference create spelling
Merge pull request #61 from diivi/docs/cli-reference
2023-03-24 16:09:50 -07:00
Dan Helfman 9851abc2e1 Add documentation on backing up a database running in a container (#649). 2023-03-24 15:18:49 -07:00
Divyansh Singh 61ce6f0473 fix: docs cli reference create spelling 2023-03-25 02:44:56 +05:30
Divyansh Singh 78e8bb6c8c reformat 2023-03-25 02:08:52 +05:30
Divyansh Singh af95134cd2 add test for complex constant 2023-03-25 02:03:36 +05:30
Divyansh Singh d6dfb8753a reformat 2023-03-25 01:50:47 +05:30
Divyansh Singh 1bc003560a Merge branch 'master' of https://github.com/diivi/borgmatic into feat/tag-repos 2023-03-25 01:39:26 +05:30
Divyansh Singh aeaf69f49e pass all tests 2023-03-25 01:34:03 +05:30
Divyansh Singh e83ad9e1e4 use repository["path"] instead of repository 2023-03-25 01:04:57 +05:30
Dan Helfman f42890430c Add code style plugins to enforce use of Python f-strings and prevent single-letter variables. 2023-03-23 23:11:14 -07:00
Divyansh Singh 6f300b0079 feat: constants support 2023-03-24 02:39:37 +05:30
Dan HelfmanandGitHub 9bec029b4f Fix: remove extra links from docs css.
Merge pull request #59 from diivi/fix/remove-extra-links-from-css
2023-03-23 12:57:55 -07:00
Divyansh Singh 08afad5d81 end with newline 2023-03-24 01:25:15 +05:30
Divyansh Singh a01dc62468 fix: remove extra links from docs css 2023-03-24 01:23:40 +05:30
Dan HelfmanandGitHub 8b61225b13 Copy to clipboard support in documentation.
Merge pull request #58 from diivi/docs/copy-to-clipboard-support
2023-03-23 12:39:41 -07:00
Divyansh Singh 66d2f49f18 docs: copy to clipboard support 2023-03-23 14:45:23 +05:30
Dan Helfman 0a72c67c6c Add missing source directory error fix to NEWS (#655). 2023-03-22 13:02:22 -07:00
Dan HelfmanandGitHub ab64b7ef67 Fix error when a source directory doesn't exist and databases are configured (#655).
Merge pull request #56 from diivi/fix/no-error-on-database-backup-without-source-dirs
2023-03-22 12:59:01 -07:00
Divyansh Singh 1e3a3bf1e7 review 2023-03-23 01:18:06 +05:30
Divyansh Singh 7a2f287918 reformat base 2023-03-23 01:08:30 +05:30
Divyansh Singh 8a63c49498 feat: tag repos 2023-03-23 01:01:26 +05:30
Divyansh Singh 3b5ede8044 remove extra parameter from function call 2023-03-22 23:11:44 +05:30
Divyansh Singh bd235f0426 use exit_code_indicates_error and modify it to accept a command 2023-03-22 16:23:53 +05:30
Divyansh Singh 09183464cd fix: no error on database backups without source dirs 2023-03-22 09:41:39 +05:30
Dan Helfman ca6fd6b061 Add confusing error message fix to NEWS (#623). 2023-03-21 14:25:20 -07:00
Dan HelfmanandGitHub dd9a64f4b6 Fix confusing message when an error occurs running actions for a configuration file (#623).
Merge pull request #55 from diivi/fix/rephrase-error-message
2023-03-21 14:23:09 -07:00
Divyansh Singh 23e7f27ee4 fix: rephrase error when running from config
to avoid confusion, as the user might think the problem is with their config file
2023-03-22 02:22:43 +05:30
Dan Helfman f9ef52f9a5 Remove unused module and outdated test expectations (#576). 2023-03-21 10:29:17 -07:00
Dan Helfman 3f17c355ca Add "file://" paths to NEWS (#576). 2023-03-21 10:24:51 -07:00
Dan HelfmanandGitHub c83fae5e5b Support file:// paths for repositories (#576).
Merge pull request #54 from diivi/feat/file-urls-support
2023-03-21 10:22:39 -07:00
Divyansh Singh 39ad8f64c4 add tests and remove magic number 2023-03-21 17:06:03 +05:30
Divyansh Singh e86d223bbf Merge branch 'master' of https://github.com/diivi/borgmatic into feat/file-urls-support 2023-03-21 16:55:05 +05:30
Divyansh Singh 86587ab2dc send repo directly to extract and export_tar 2023-03-20 21:51:45 +05:30
Divyansh Singh 58c95d8015 feat: file:// URLs support 2023-03-20 02:43:23 +05:30
Dan Helfman 6351747da5 Add NixOS package link to installation docs. 2023-03-19 09:02:47 -07:00
Dan Helfman 55c153409e Add "source_directories_must_exist" option to NEWS (#501). 2023-03-18 14:07:38 -07:00
Dan Helfman b115fb2fbe Merge branch 'master' of github.com:borgmatic-collective/borgmatic 2023-03-18 14:01:52 -07:00
Dan HelfmanandGitHub 31d04d9ee3 Optionally error if a source directory does not exist.
feat: add optional check for existence of source directories
2023-03-18 13:59:20 -07:00
Divyansh Singh f803836416 reformat 2023-03-18 17:27:33 +05:30
Divyansh Singh 997f60b3e6 add tests 2023-03-18 17:24:21 +05:30
Dan Helfman c84b26499b Add "borg_files_cache_ttl" option to NEWS. 2023-03-17 19:29:10 -07:00
Dan Helfman 214ae81cbb Add option to set borg_files_cache_ttl in config (#618).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/654
2023-03-18 02:24:41 +00:00
Divyansh Singh d17b2c74db feat: add optional check for existence of source directories 2023-03-18 04:35:55 +05:30
Soumik Dutta fb9677230b add test to ensure integers are converted to string
before setting them up to be environment variable values

Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-03-18 02:57:56 +05:30
Soumik Dutta 0db137efdf add option to set borg_files_cache_ttl in config
Signed-off-by: Soumik Dutta <shalearkane@gmail.com>
2023-03-18 01:48:24 +05:30
Dan Helfman e6605c868d Clarify check frequency default behavior (#653). 2023-03-17 10:09:36 -07:00
286 changed files with 33724 additions and 11575 deletions
-56
View File
@@ -1,56 +0,0 @@
kind: pipeline
name: python-3-8-alpine-3-13
services:
- name: postgresql
image: postgres:13.1-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
- name: mysql
image: mariadb:10.5
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
- name: mongodb
image: mongo:5.0.5
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: test
clone:
skip_verify: true
steps:
- name: build
image: alpine:3.13
pull: always
commands:
- scripts/run-full-tests
---
kind: pipeline
name: documentation
clone:
skip_verify: true
steps:
- name: build
image: plugins/docker
settings:
username:
from_secret: docker_username
password:
from_secret: docker_password
registry: projects.torsion.org
repo: projects.torsion.org/borgmatic-collective/borgmatic
tags: docs
dockerfile: docs/Dockerfile
trigger:
repo:
- borgmatic-collective/borgmatic
branch:
- master
event:
- push
+3
View File
@@ -1,4 +1,5 @@
const pluginSyntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight");
const codeClipboard = require("eleventy-plugin-code-clipboard");
const inclusiveLangPlugin = require("@11ty/eleventy-plugin-inclusive-language");
const navigationPlugin = require("@11ty/eleventy-navigation");
@@ -6,6 +7,7 @@ module.exports = function(eleventyConfig) {
eleventyConfig.addPlugin(pluginSyntaxHighlight);
eleventyConfig.addPlugin(inclusiveLangPlugin);
eleventyConfig.addPlugin(navigationPlugin);
eleventyConfig.addPlugin(codeClipboard);
let markdownIt = require("markdown-it");
let markdownItAnchor = require("markdown-it-anchor");
@@ -31,6 +33,7 @@ module.exports = function(eleventyConfig) {
markdownIt(markdownItOptions)
.use(markdownItAnchor, markdownItAnchorOptions)
.use(markdownItReplaceLink)
.use(codeClipboard.markdownItCopyButton)
);
eleventyConfig.addPassthroughCopy({"docs/static": "static"});
-1
View File
@@ -1 +0,0 @@
select = Q0
-35
View File
@@ -1,35 +0,0 @@
#### What I'm trying to do and why
#### Steps to reproduce (if a bug)
Include (sanitized) borgmatic configuration files if applicable.
#### Actual behavior (if a bug)
Include (sanitized) `--verbosity 2` output if applicable.
#### Expected behavior (if a bug)
#### Other notes / implementation ideas
#### Environment
**borgmatic version:** [version here]
Use `sudo borgmatic --version` or `sudo pip show borgmatic | grep ^Version`
**borgmatic installation method:** [e.g., Debian package, Docker container, etc.]
**Borg version:** [version here]
Use `sudo borg --version`
**Python version:** [version here]
Use `python3 --version`
**Database version (if applicable):** [version here]
Use `psql --version` or `mysql --version` on client and server.
**operating system and version:** [OS here]
+77
View File
@@ -0,0 +1,77 @@
name: "Bug or question/support"
about: "For filing a bug or getting support"
body:
- type: textarea
id: problem
attributes:
label: What I'm trying to do and why
validations:
required: true
- type: textarea
id: repro_steps
attributes:
label: Steps to reproduce
description: Include (sanitized) borgmatic configuration files if applicable.
validations:
required: false
- type: textarea
id: actual_behavior
attributes:
label: Actual behavior
description: Include (sanitized) `--verbosity 2` output if applicable.
validations:
required: false
- type: textarea
id: expected_behavior
attributes:
label: Expected behavior
validations:
required: false
- type: textarea
id: notes
attributes:
label: Other notes / implementation ideas
validations:
required: false
- type: input
id: borgmatic_version
attributes:
label: borgmatic version
description: Use `sudo borgmatic --version` or `sudo pip show borgmatic | grep ^Version`
validations:
required: false
- type: input
id: borgmatic_install_method
attributes:
label: borgmatic installation method
description: e.g., pip install, Debian package, container, etc.
validations:
required: false
- type: input
id: borg_version
attributes:
label: Borg version
description: Use `sudo borg --version`
validations:
required: false
- type: input
id: python_version
attributes:
label: Python version
description: Use `python3 --version`
validations:
required: false
- type: input
id: database_version
attributes:
label: Database version (if applicable)
description: Use `psql --version` / `mysql --version` / `mongodump --version` / `sqlite3 --version`
validations:
required: false
- type: input
id: operating_system_version
attributes:
label: Operating system and version
description: On Linux, use `cat /etc/os-release`
validations:
required: false
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
@@ -0,0 +1,15 @@
name: "Feature"
about: "For filing a feature request or idea"
body:
- type: textarea
id: request
attributes:
label: What I'd like to do and why
validations:
required: true
- type: textarea
id: notes
attributes:
label: Other notes / implementation ideas
validations:
required: false
+30
View File
@@ -0,0 +1,30 @@
name: build
run-name: ${{ gitea.actor }} is building
on:
push:
branches: [main]
jobs:
test:
runs-on: host
steps:
- uses: actions/checkout@v4
- run: scripts/run-end-to-end-tests
docs:
needs: [test]
runs-on: host
env:
IMAGE_NAME: projects.torsion.org/borgmatic-collective/borgmatic:docs
steps:
- uses: actions/checkout@v4
- run: podman login --username "$USERNAME" --password "$PASSWORD" projects.torsion.org
env:
USERNAME: "${{ secrets.REGISTRY_USERNAME }}"
PASSWORD: "${{ secrets.REGISTRY_PASSWORD }}"
- run: podman build --tag "$IMAGE_NAME" --file docs/Dockerfile --storage-opt "overlay.mount_program=/usr/bin/fuse-overlayfs" .
- run: podman push "$IMAGE_NAME"
- run: scripts/export-docs-from-image
- run: curl --user "${{ secrets.REGISTRY_USERNAME }}:${{ secrets.REGISTRY_PASSWORD }}" --upload-file borgmatic-docs.tar.gz https://projects.torsion.org/api/packages/borgmatic-collective/generic/borgmatic-docs/$(head --lines=1 NEWS)/borgmatic-docs.tar.gz
+426 -4
View File
@@ -1,3 +1,425 @@
1.9.2
* #441: Apply the "umask" option to all relevant actions, not just some of them.
* #722: Remove the restriction that the "extract" and "mount" actions must match a single
repository. Now they work more like other actions, where each repository is applied in turn.
* #932: Fix the missing build backend setting in pyproject.toml to allow Fedora builds.
* #934: Update the logic that probes for the borgmatic streaming database dump, bootstrap
metadata, and check state directories to support more platforms and use cases. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory
* #934: Add the "RuntimeDirectory" and "StateDirectory" options to the sample systemd service
file to support the new runtime and state directory logic.
* #939: Fix borgmatic ignoring the "BORG_RELOCATED_REPO_ACCESS_IS_OK" and
"BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK" environment variables.
* Add a Pushover monitoring hook. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pushover-hook
1.9.1
* #928: Fix the user runtime directory location on macOS (and possibly Cygwin).
* #930: Fix an error with the sample systemd service when no credentials are configured.
* #931: Fix an error when implicitly upgrading the check state directory from ~/.borgmatic to
~/.local/state/borgmatic across filesystems.
1.9.0
* #609: Fix the glob expansion of "source_directories" values to respect the "working_directory"
option.
* #609: BREAKING: Apply the "working_directory" option to all actions, not just "create". This
includes repository paths, destination paths, mount points, etc.
* #562: Deprecate the "borgmatic_source_directory" option in favor of "user_runtime_directory"
and "user_state_directory".
* #562: BREAKING: Move the default borgmatic streaming database dump and bootstrap metadata
directory from ~/.borgmatic to /run/user/$UID/borgmatic, which is more XDG-compliant. You can
override this location with the new "user_runtime_directory" option. Existing archives with
database dumps at the old location are still restorable.
* #562, #638: Move the default check state directory from ~/.borgmatic to
~/.local/state/borgmatic. This is more XDG-compliant and also prevents these state files from
getting backed up (unless you explicitly include them). You can override this location with the
new "user_state_directory" option. After the first time you run the "check" action with borgmatic
1.9.0, you can safely delete the ~/.borgmatic directory.
* #838: BREAKING: With Borg 1.4+, store database dumps and bootstrap metadata in a "/borgmatic"
directory within a backup archive, so the path doesn't depend on the current user. This means
that you can now backup as one user and restore or bootstrap as another user, among other use
cases.
* #902: Add loading of encrypted systemd credentials. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/#using-systemd-service-credentials
* #911: Add a "key change-passphrase" action to change the passphrase protecting a repository key.
* #914: Fix a confusing apparent hang when when the repository location changes, and instead
show a helpful error message.
* #915: BREAKING: Rename repository actions like "rcreate" to more explicit names like
"repo-create" for compatibility with recent changes in Borg 2.0.0b10.
* #918: BREAKING: When databases are configured, don't auto-enable the "one_file_system" option,
as existing auto-excludes of special files should be sufficient to prevent Borg from hanging on
them. But if this change causes problems for you, you can always enable "one_file_system"
explicitly.
* #919: Clarify the command-line help for the "--config" flag.
* #919: Document a policy for versioning and breaking changes:
https://torsion.org/borgmatic/docs/how-to/upgrade/#versioning-and-breaking-changes
* #921: BREAKING: Change soft failure command hooks to skip only the current repository rather than
all repositories in the configuration file.
* #922: Replace setup.py (Python packaging metadata) with the more modern pyproject.toml.
* When using Borg 2, default the "archive_name_format" option to just "{hostname}", as Borg 2 does
not require unique archive names; identical archive names form a common "series" that can be
targeted together. See the Borg 2 documentation for more information:
https://borgbackup.readthedocs.io/en/2.0.0b13/changes.html#borg-1-2-x-1-4-x-to-borg-2-0
* Add support for Borg 2's "rclone:" repository URLs, so you can backup to 70+ cloud storage
services whether or not they support Borg explicitly.
* Add support for Borg 2's "sftp://" repository URLs.
* Update the "--match-archives" and "--archive" flags to support Borg 2 series names or archive
hashes.
* Add a "--match-archives" flag to the "prune" action.
* Add "--local-path" and "--remote-path" flags to the "config bootstrap" action for setting the
Borg executable paths used for bootstrapping.
* Add a "--user-runtime-directory" flag to the "config bootstrap" action for helping borgmatic
locate the bootstrap metadata stored in an archive.
* Add a Zabbix monitoring hook. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#zabbix-hook
* Add a tarball of borgmatic's HTML documentation to the packages on the project page.
1.8.14
* #896: Fix an error in borgmatic rcreate/init on an empty repository directory with Borg 1.4.
* #898: Add glob ("*") support to the "--repository" flag. Just quote any values containing
globs so your shell doesn't interpret them.
* #899: Fix for a "bad character" Borg error in which the "spot" check fed Borg an invalid pattern.
* #900: Fix for a potential traceback (TypeError) during the handling of another error.
* #904: Clarify the configuration reference about the "spot" check options:
https://torsion.org/borgmatic/docs/reference/configuration/
* #905: Fix the "source_directories_must_exist" option to work with relative "source_directories"
paths when a "working_directory" is set.
* #906: Add documentation details for how to run custom database dump commands using binaries from
running containers:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
* Fix a regression in which the "color" option had no effect.
* Add a recent contributors section to the documentation, because credit where credit's due! See:
https://torsion.org/borgmatic/#recent-contributors
1.8.13
* #298: Add "delete" and "rdelete" actions to delete archives or entire repositories.
* #785: Add an "only_run_on" option to consistency checks so you can limit a check to running on
particular days of the week. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#check-days
* #885: Add an Uptime Kuma monitoring hook. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#uptime-kuma-hook
* #886: Fix a PagerDuty hook traceback with Python < 3.10.
* #889: Fix the Healthchecks ping body size limit, restoring it to the documented 100,000 bytes.
1.8.12
* #817: Add a "--max-duration" flag to the "check" action and a "max_duration" option to the
repository check configuration. This tells Borg to interrupt a repository check after a certain
duration.
* #860: Fix interaction between environment variable interpolation in constants and shell escaping.
* #863: When color output is disabled (explicitly or implicitly), don't prefix each log line with
the log level.
* #865: Add an "upload_buffer_size" option to set the size of the upload buffer used in "create"
action.
* #866: Fix "Argument list too long" error in the "spot" check when checking hundreds of thousands
of files at once.
* #874: Add the configured repository label as "repository_label" to the interpolated variables
passed to before/after command hooks.
* #881: Fix "Unrecognized argument" error when the same value is used with different command-line
flags.
* In the "spot" check, don't try to hash symlinked directories.
1.8.11
* #815: Add optional Healthchecks auto-provisioning via "create_slug" option.
* #851: Fix lack of file extraction when using "extract --strip-components all" on a path with a
leading slash.
* #854: Fix a traceback when the "data" consistency check is used.
* #857: Fix a traceback with "check --only spot" when the "spot" check is unconfigured.
1.8.10
* #656 (beta): Add a "spot" consistency check that compares file counts and contents between your
source files and the latest archive, ensuring they fall within configured tolerances. This can
catch problems like incorrect excludes, inadvertent deletes, files changed by malware, etc. See
the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#spot-check
* #779: When "--match-archives *" is used with "check" action, don't skip Borg's orphaned objects
check.
* #842: When a command hook exits with a soft failure, ping the log and finish states for any
configured monitoring hooks.
* #843: Add documentation link to Loki dashboard for borgmatic:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook
* #847: Fix "--json" error when Borg includes non-JSON warnings in JSON output.
* #848: SECURITY: Mask the password when logging a MongoDB dump or restore command.
* Fix handling of the NO_COLOR environment variable to ignore an empty value.
* Add documentation about backing up containerized databases by configuring borgmatic to exec into
a container to run a dump command:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
1.8.9
* #311: Add custom dump/restore command options for MySQL and MariaDB.
* #811: Add an "access_token" option to the ntfy monitoring hook for authenticating
without username/password.
* #827: When the "--json" flag is given, suppress console escape codes so as not to
interfere with JSON output.
* #829: Fix "--override" values containing deprecated section headers not actually overriding
configuration options under deprecated section headers.
* #835: Add support for the NO_COLOR environment variable. See the documentation for more
information:
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#colored-output
* #839: Add log sending for the Apprise logging hook, enabled by default. See the documentation for
more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook
* #839: Document a potentially breaking shell quoting edge case within error hooks:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#error-hooks
* #840: When running the "rcreate" action and the repository already exists but with a different
encryption mode than requested, error.
* Switch from Drone to Gitea Actions for continuous integration.
* Rename scripts/run-end-to-end-dev-tests to scripts/run-end-to-end-tests and use it in both dev
and CI for better dev-CI parity.
* Clarify documentation about restoring a database: borgmatic does not create the database upon
restore.
1.8.8
* #370: For the PostgreSQL hook, pass the "PGSSLMODE" environment variable through to Borg when the
database's configuration omits the "ssl_mode" option.
* #818: Allow the "--repository" flag to match across multiple configuration files.
* #820: Fix broken repository detection in the "rcreate" action with Borg 1.4. The issue did not
occur with other versions of Borg.
* #822: Fix broken escaping logic in the PostgreSQL hook's "pg_dump_command" option.
* SECURITY: Prevent additional shell injection attacks within the PostgreSQL hook.
1.8.7
* #736: Store included configuration files within each backup archive in support of the "config
bootstrap" action. Previously, only top-level configuration files were stored.
* #798: Elevate specific Borg warnings to errors or squash errors to
* warnings. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/customize-warnings-and-errors/
* #810: SECURITY: Prevent shell injection attacks within the PostgreSQL hook, the MongoDB hook, the
SQLite hook, the "borgmatic borg" action, and command hook variable/constant interpolation.
* #814: Fix a traceback when providing an invalid "--override" value for a list option.
1.8.6
* #767: Add an "--ssh-command" flag to the "config bootstrap" action for setting a custom SSH
command, as no configuration is available (including the "ssh_command" option) until
bootstrapping completes.
* #794: Fix a traceback when the "repositories" option contains both strings and key/value pairs.
* #800: Add configured repository labels to the JSON output for all actions.
* #802: The "check --force" flag now runs checks even if "check" is in "skip_actions".
* #804: Validate the configured action names in the "skip_actions" option.
* #807: Stream SQLite databases directly to Borg instead of dumping to an intermediate file.
* When logging commands that borgmatic executes, log the environment variables that
borgmatic sets for those commands. (But don't log their values, since they often contain
passwords.)
1.8.5
* #701: Add a "skip_actions" option to skip running particular actions, handy for append-only or
checkless configurations. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#skipping-actions
* #701: Deprecate the "disabled" value for the "checks" option in favor of the new "skip_actions"
option.
* #745: Constants now apply to included configuration, not just the file doing the includes. As a
side effect of this change, constants no longer apply to option names and only substitute into
configuration values.
* #779: Add a "--match-archives" flag to the "check" action for selecting the archives to check,
overriding the existing "archive_name_format" and "match_archives" options in configuration.
* #779: Only parse "--override" values as complex data types when they're for options of those
types.
* #782: Fix environment variable interpolation within configured repository paths.
* #782: Add configuration constant overriding via the existing "--override" flag.
* #783: Upgrade ruamel.yaml dependency to support version 0.18.x.
* #784: Drop support for Python 3.7, which has been end-of-lifed.
1.8.4
* #715: Add a monitoring hook for sending backup status to a variety of monitoring services via the
Apprise library. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook
* #748: When an archive filter causes no matching archives for the "rlist" or "info"
actions, warn the user and suggest how to remove the filter.
* #768: Fix a traceback when an invalid command-line flag or action is used.
* #771: Fix normalization of deprecated sections ("location:", "storage:", "hooks:", etc.) to
support empty sections without erroring.
* #774: Disallow the "--dry-run" flag with the "borg" action, as borgmatic can't guarantee the Borg
command won't have side effects.
1.8.3
* #665: BREAKING: Simplify logging logic as follows: Syslog verbosity is now disabled by
default, but setting the "--syslog-verbosity" flag enables it regardless of whether you're at an
interactive console. Additionally, "--log-file-verbosity" and "--monitoring-verbosity" now
default to 1 (info about steps borgmatic is taking) instead of 0. And both syslog logging and
file logging can be enabled simultaneously.
* #743: Add a monitoring hook for sending backup status and logs to Grafana Loki. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook
* #753: When "archive_name_format" is not set, filter archives using the default archive name
format.
* #754: Fix error handling to log command output as one record per line instead of truncating
too-long output and swallowing the end of some Borg error messages.
* #757: Update documentation so "sudo borgmatic" works for pipx borgmatic installations.
* #761: Fix for borgmatic not stopping Borg immediately when the user presses ctrl-C.
* Update documentation to recommend installing/upgrading borgmatic with pipx instead of pip. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation
https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-borgmatic
1.8.2
* #345: Add "key export" action to export a copy of the repository key for safekeeping in case
the original goes missing or gets damaged.
* #727: Add a MariaDB database hook that uses native MariaDB commands instead of the deprecated
MySQL ones. Be aware though that any existing backups made with the "mysql_databases:" hook are
only restorable with a "mysql_databases:" configuration.
* #738: Fix for potential data loss (data not getting restored) in which the database "restore"
action didn't actually restore anything and indicated success anyway.
* Remove the deprecated use of the MongoDB hook's "--db" flag for database restoration.
* Add source code reference documentation for getting oriented with the borgmatic code as a
developer: https://torsion.org/borgmatic/docs/reference/source-code/
1.8.1
* #326: Add documentation for restoring a database to an alternate host:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#restore-to-an-alternate-host
* #697: Add documentation for "bootstrap" action:
https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#extract-the-configuration-files-used-to-create-an-archive
* #725: Add "store_config_files" option for disabling the automatic backup of configuration files
used by the "config bootstrap" action.
* #728: Fix for "prune" action error when using the "keep_exclude_tags" option.
* #730: Fix for Borg's interactive prompt on the "check --repair" action automatically getting
answered "NO" even when the "check_i_know_what_i_am_doing" option isn't set.
* #732: Include multiple configuration files with a single "!include". See the documentation for
more information:
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#multiple-merge-includes
* #734: Omit "--glob-archives" or "--match-archives" Borg flag when its value would be "*" (meaning
all archives).
1.8.0
* #575: BREAKING: For the "borgmatic borg" action, instead of implicitly injecting
repository/archive into the resulting Borg command-line, pass repository to Borg via an
environment variable and make archive available for explicit use in your commands. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/run-arbitrary-borg-commands/
* #719: Fix an error when running "borg key export" through borgmatic.
* #720: Fix an error when dumping a database and the "exclude_nodump" option is set.
* #724: Add "check_i_know_what_i_am_doing" option to bypass Borg confirmation prompt when running
"check --repair".
* When merging two configuration files, error gracefully if the two files do not adhere to the same
format.
* #721: Remove configuration sections ("location:", "storage:", "hooks:", etc.), while still
keeping deprecated support for them. Now, all options are at the same level, and you don't need
to worry about commenting/uncommenting section headers when you change an option (if you remove
your sections first).
* #721: BREAKING: The retention prefix and the consistency prefix can no longer have different
values (unless one is not set).
* #721: BREAKING: The storage umask and the hooks umask can no longer have different values (unless
one is not set).
* BREAKING: Flags like "--config" that previously took multiple values now need to be given once
per value, e.g. "--config first.yaml --config second.yaml" instead of "--config first.yaml
second.yaml". This prevents argument parsing errors on ambiguous commands.
* BREAKING: Remove the deprecated (and silently ignored) "--successful" flag on the "list" action,
as newer versions of Borg list successful (non-checkpoint) archives by default.
* All deprecated configuration option values now generate warning logs.
* Remove the deprecated (and non-functional) "--excludes" flag in favor of excludes within
configuration.
* Fix an error when logging too-long command output during error handling. Now, long command output
is truncated before logging.
1.7.15
* #326: Add configuration options and command-line flags for backing up a database from one
location while restoring it somewhere else.
* #399: Add a documentation troubleshooting note for MySQL/MariaDB authentication errors.
* #529: Remove upgrade-borgmatic-config command for upgrading borgmatic 1.1.0 INI-style
configuration.
* #529: Deprecate generate-borgmatic-config in favor of new "config generate" action.
* #529: Deprecate validate-borgmatic-config in favor of new "config validate" action.
* #697, #712, #716: Extract borgmatic configuration from backup via new "config bootstrap"
action—even when borgmatic has no configuration yet!
* #669: Add sample systemd user service for running borgmatic as a non-root user.
* #711, #713: Fix an error when "data" check time files are accessed without getting upgraded
first.
1.7.14
* #484: Add a new verbosity level (-2) to disable output entirely (for console, syslog, log file,
or monitoring), so not even errors are shown.
* #688: Tweak archive check probing logic to use the newest timestamp found when multiple exist.
* #659: Add Borg 2 date-based matching flags to various actions for archive selection.
* #703: Fix an error when loading the configuration schema on Fedora Linux.
* #704: Fix "check" action error when repository and archive checks are configured but the archive
check gets skipped due to the configured frequency.
* #706: Fix "--archive latest" on "list" and "info" actions that only worked on the first of
multiple configured repositories.
1.7.13
* #375: Restore particular PostgreSQL schemas from a database dump via "borgmatic restore --schema"
flag. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#restore-particular-schemas
* #678: Fix error from PostgreSQL when dumping a database with a "format" of "plain".
* #678: Fix PostgreSQL hook to support "psql_command" and "pg_restore_command" options containing
commands with arguments.
* #678: Fix calls to psql in PostgreSQL hook to ignore "~/.psqlrc", whose settings can break
database dumping.
* #680: Add support for logging each log line as a JSON object via global "--log-json" flag.
* #682: Fix "source_directories_must_exist" option to expand globs and tildes in source directories.
* #684: Rename "master" development branch to "main" to use more inclusive language. You'll need to
update your development checkouts accordingly.
* #686: Add fish shell completion script so you can tab-complete on the borgmatic command-line. See
the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#shell-completion
* #687: Fix borgmatic error when not finding the configuration schema for certain "pip install
--editable" development installs.
* #688: Fix archive checks being skipped even when particular archives haven't been checked
recently. This occurred when using multiple borgmatic configuration files with different
"archive_name_format"s, for instance.
* #691: Fix error in "borgmatic restore" action when the configured repository path is relative
instead of absolute.
* #694: Run "borgmatic borg" action without capturing output so interactive prompts and flags like
"--progress" still work.
1.7.12
* #413: Add "log_file" context to command hooks so your scripts can consume the borgmatic log file.
See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/
* #666, #670: Fix error when running the "info" action with the "--match-archives" or "--archive"
flags. Also fix the "--match-archives"/"--archive" flags to correctly override the
"match_archives" configuration option for the "transfer", "list", "rlist", and "info" actions.
* #668: Fix error when running the "prune" action with both "archive_name_format" and "prefix"
options set.
* #672: Selectively shallow merge certain mappings or sequences when including configuration files.
See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#shallow-merge
* #672: Selectively omit list values when including configuration files. See the documentation for
more information:
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#list-merge
* #673: View the results of configuration file merging via "validate-borgmatic-config --show" flag.
See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#debugging-includes
* Add optional support for running end-to-end tests and building documentation with rootless Podman
instead of Docker.
1.7.11
* #479, #588: BREAKING: Automatically use the "archive_name_format" option to filter which archives
get used for borgmatic actions that operate on multiple archives. Override this behavior with the
new "match_archives" option in the storage section. This change is "breaking" in that it silently
changes which archives get considered for "rlist", "prune", "check", etc. See the documentation
for more information:
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#archive-naming
* #479, #588: The "prefix" options have been deprecated in favor of the new "archive_name_format"
auto-matching behavior and the "match_archives" option.
* #658: Add "--log-file-format" flag for customizing the log message format. See the documentation
for more information:
https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#logging-to-file
* #662: Fix regression in which the "check_repositories" option failed to match repositories.
* #663: Fix regression in which the "transfer" action produced a traceback.
* Add spellchecking of source code during test runs.
1.7.10
* #396: When a database command errors, display and log the error message instead of swallowing it.
* #501: Optionally error if a source directory does not exist via "source_directories_must_exist"
option in borgmatic's location configuration.
* #576: Add support for "file://" paths within "repositories" option.
* #612: Define and use custom constants in borgmatic configuration files. See the documentation for
more information:
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#constant-interpolation
* #618: Add support for BORG_FILES_CACHE_TTL environment variable via "borg_files_cache_ttl" option
in borgmatic's storage configuration.
* #623: Fix confusing message when an error occurs running actions for a configuration file.
* #635: Add optional repository labels so you can select a repository via "--repository yourlabel"
at the command-line. See the configuration reference for more information:
https://torsion.org/borgmatic/docs/reference/configuration/
* #649: Add documentation on backing up a database running in a container:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
* #655: Fix error when databases are configured and a source directory doesn't exist.
* Add code style plugins to enforce use of Python f-strings and prevent single-letter variables.
To join in the pedantry, refresh your test environment with "tox --recreate".
* Rename scripts/run-full-dev-tests to scripts/run-end-to-end-dev-tests and make it run end-to-end
tests only. Continue using tox to run unit and integration tests.
1.7.9
* #295: Add a SQLite database dump/restore hook.
* #304: Change the default action order when no actions are specified on the command-line to:
@@ -303,7 +725,7 @@
* #398: Clarify canonical home of borgmatic in documentation.
* #406: Clarify that spaces in path names should not be backslashed in path names.
* #423: Fix error handling to error loudly when Borg gets killed due to running out of memory!
* Fix build so as not to attempt to build and push documentation for a non-master branch.
* Fix build so as not to attempt to build and push documentation for a non-main branch.
* "Fix" build failure with Alpine Edge by switching from Edge to Alpine 3.13.
* Move #borgmatic IRC channel from Freenode to Libera Chat due to Freenode takeover drama.
IRC connection info: https://torsion.org/borgmatic/#issues
@@ -366,7 +788,7 @@
configuration schema descriptions.
1.5.6
* #292: Allow before_backup and similiar hooks to exit with a soft failure without altering the
* #292: Allow before_backup and similar 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.
@@ -435,7 +857,7 @@
* 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.
* #276, #285: Disable colored output when "--json" flag is used, so as to produce valid JSON output.
* 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.
@@ -807,7 +1229,7 @@
* #77: Skip non-"*.yaml" config filenames in /etc/borgmatic.d/ so as not to parse backup files,
editor swap files, etc.
* #81: Document user-defined hooks run before/after backup, or on error.
* Add code style guidelines to the documention.
* Add code style guidelines to the documentation.
1.2.0
* #61: Support for Borg --list option via borgmatic command-line to list all archives.
+52 -57
View File
@@ -11,69 +11,67 @@ borgmatic is simple, configuration-driven backup software for servers and
workstations. Protect your files with client-side encryption. Backup your
databases too. Monitor it all with integrated third-party services.
The canonical home of borgmatic is at <a href="https://torsion.org/borgmatic">https://torsion.org/borgmatic</a>.
The canonical home of borgmatic is at <a href="https://torsion.org/borgmatic">https://torsion.org/borgmatic/</a>
Here's an example configuration file:
```yaml
location:
# List of source directories to backup.
source_directories:
- /home
- /etc
# List of source directories to backup.
source_directories:
- /home
- /etc
# Paths of local or remote repositories to backup to.
repositories:
- ssh://1234@usw-s001.rsync.net/./backups.borg
- ssh://k8pDxu32@k8pDxu32.repo.borgbase.com/./repo
- /var/lib/backups/local.borg
# Paths of local or remote repositories to backup to.
repositories:
- path: ssh://k8pDxu32@k8pDxu32.repo.borgbase.com/./repo
label: borgbase
- path: /var/lib/backups/local.borg
label: local
retention:
# Retention policy for how many backups to keep.
keep_daily: 7
keep_weekly: 4
keep_monthly: 6
# Retention policy for how many backups to keep.
keep_daily: 7
keep_weekly: 4
keep_monthly: 6
consistency:
# List of checks to run to validate your backups.
checks:
- name: repository
- name: archives
frequency: 2 weeks
# List of checks to run to validate your backups.
checks:
- name: repository
- name: archives
frequency: 2 weeks
hooks:
# Custom preparation scripts to run.
before_backup:
- prepare-for-backup.sh
# Custom preparation scripts to run.
before_backup:
- prepare-for-backup.sh
# Databases to dump and include in backups.
postgresql_databases:
- name: users
# Databases to dump and include in backups.
postgresql_databases:
- name: users
# Third-party services to notify you if backups aren't happening.
healthchecks: https://hc-ping.com/be067061-cf96-4412-8eae-62b0c50d6a8c
# Third-party services to notify you if backups aren't happening.
healthchecks:
ping_url: https://hc-ping.com/be067061-cf96-4412-8eae-62b0c50d6a8c
```
Want to see borgmatic in action? Check out the <a
href="https://asciinema.org/a/203761?autoplay=1" target="_blank">screencast</a>.
<a href="https://asciinema.org/a/203761?autoplay=1" target="_blank"><img src="https://asciinema.org/a/203761.png" width="480"></a>
borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
## Integrations
<a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://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://ntfy.sh/"><img src="docs/static/ntfy.png" alt="ntfy" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.pushover.net/"><img src="docs/static/pushover.png" alt="Pushover" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://ntfy.sh/"><img src="docs/static/ntfy.png" alt="ntfy" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://grafana.com/oss/loki/"><img src="docs/static/loki.png" alt="Loki" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://github.com/caronc/apprise/wiki"><img src="docs/static/apprise.png" alt="Apprise" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.zabbix.com/"><img src="docs/static/zabbix.png" alt="Zabbix" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
## Getting started
@@ -90,16 +88,15 @@ reference guides</a>.
Need somewhere to store your encrypted off-site backups? The following hosting
providers include specific support for Borg/borgmatic—and fund borgmatic
development and hosting when you use these links to sign up. (These are
referral links, but without any tracking scripts or cookies.)
development and hosting when you use these referral links to sign up:
<ul>
<li class="referral"><a href="https://www.borgbase.com/?utm_source=borgmatic">BorgBase</a>: Borg hosting service with support for monitoring, 2FA, and append-only repos</li>
<li class="referral"><a href="https://hetzner.cloud/?ref=v9dOJ98Ic9I8">Hetzner</a>: A "storage box" that includes support for Borg</li>
</ul>
Additionally, [rsync.net](https://www.rsync.net/products/borg.html) and
[Hetzner](https://www.hetzner.com/storage/storage-box) have compatible storage
offerings, but do not currently fund borgmatic development or hosting.
Additionally, rsync.net has a compatible storage offering, but does not fund
borgmatic development or hosting.
## Support and contributing
@@ -120,10 +117,7 @@ issues.
### Social
Check out the [Borg subreddit](https://www.reddit.com/r/BorgBackup/) for
general Borg and borgmatic discussion and support.
Also follow [borgmatic on Mastodon](https://fosstodon.org/@borgmatic).
Follow [borgmatic on Mastodon](https://fosstodon.org/@borgmatic).
### Chat
@@ -164,5 +158,6 @@ Also, please check out the [borgmatic development
how-to](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) for
info on cloning source code, running tests, etc.
<a href="https://build.torsion.org/borgmatic-collective/borgmatic" alt="build status">![Build Status](https://build.torsion.org/api/badges/borgmatic-collective/borgmatic/status.svg?ref=refs/heads/master)</a>
### Recent contributors
{% include borgmatic/contributors.html %}
+2 -2
View File
@@ -7,8 +7,8 @@ permalink: security-policy/index.html
While we want to hear about security vulnerabilities in all versions of
borgmatic, security fixes are only made to the most recently released version.
It's simply not practical for our small volunteer effort to maintain multiple
release branches and put out separate security patches for each.
It's not practical for our small volunteer effort to maintain multiple release
branches and put out separate security patches for each.
## Reporting a vulnerability
+9
View File
@@ -0,0 +1,9 @@
import argparse
def update_arguments(arguments, **updates):
'''
Given an argparse.Namespace instance of command-line arguments and one or more keyword argument
updates to perform, return a copy of the arguments with those updates applied.
'''
return argparse.Namespace(**dict(vars(arguments), **updates))
+17 -8
View File
@@ -1,14 +1,20 @@
import logging
import borgmatic.borg.borg
import borgmatic.borg.rlist
import borgmatic.borg.repo_list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_borg(
repository, storage, local_borg_version, borg_arguments, local_path, remote_path,
repository,
config,
local_borg_version,
borg_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "borg" action for the given repository.
@@ -16,18 +22,21 @@ def run_borg(
if borg_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, borg_arguments.repository
):
logger.info('{}: Running arbitrary Borg command'.format(repository))
archive_name = borgmatic.borg.rlist.resolve_archive_name(
repository,
logger.info(
f'{repository.get("label", repository["path"])}: Running arbitrary Borg command'
)
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
borg_arguments.archive,
storage,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
borgmatic.borg.borg.run_arbitrary_borg(
repository,
storage,
repository['path'],
config,
local_borg_version,
options=borg_arguments.options,
archive=archive_name,
+16 -3
View File
@@ -7,7 +7,13 @@ logger = logging.getLogger(__name__)
def run_break_lock(
repository, storage, local_borg_version, break_lock_arguments, local_path, remote_path,
repository,
config,
local_borg_version,
break_lock_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "break-lock" action for the given repository.
@@ -15,7 +21,14 @@ def run_break_lock(
if break_lock_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, break_lock_arguments.repository
):
logger.info(f'{repository}: Breaking repository and cache locks')
logger.info(
f'{repository.get("label", repository["path"])}: Breaking repository and cache locks'
)
borgmatic.borg.break_lock.break_lock(
repository, storage, local_borg_version, local_path=local_path, remote_path=remote_path,
repository['path'],
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
+38
View File
@@ -0,0 +1,38 @@
import logging
import borgmatic.borg.change_passphrase
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_change_passphrase(
repository,
config,
local_borg_version,
change_passphrase_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "key change-passprhase" action for the given repository.
'''
if (
change_passphrase_arguments.repository is None
or borgmatic.config.validate.repositories_match(
repository, change_passphrase_arguments.repository
)
):
logger.info(
f'{repository.get("label", repository["path"])}: Changing repository passphrase'
)
borgmatic.borg.change_passphrase.change_passphrase(
repository['path'],
config,
local_borg_version,
change_passphrase_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
+721 -18
View File
@@ -1,19 +1,665 @@
import calendar
import datetime
import hashlib
import itertools
import logging
import os
import pathlib
import random
import shutil
import borgmatic.borg.check
import borgmatic.borg.create
import borgmatic.borg.environment
import borgmatic.borg.extract
import borgmatic.borg.list
import borgmatic.borg.repo_list
import borgmatic.borg.state
import borgmatic.config.paths
import borgmatic.config.validate
import borgmatic.execute
import borgmatic.hooks.command
DEFAULT_CHECKS = (
{'name': 'repository', 'frequency': '1 month'},
{'name': 'archives', 'frequency': '1 month'},
)
logger = logging.getLogger(__name__)
def parse_checks(config, only_checks=None):
'''
Given a configuration dict with a "checks" sequence of dicts and an optional list of override
checks, return a tuple of named checks to run.
For example, given a config of:
{'checks': ({'name': 'repository'}, {'name': 'archives'})}
This will be returned as:
('repository', 'archives')
If no "checks" option is present in the config, return the DEFAULT_CHECKS. If a checks value
has a name of "disabled", return an empty tuple, meaning that no checks should be run.
'''
checks = only_checks or tuple(
check_config['name'] for check_config in (config.get('checks', None) or DEFAULT_CHECKS)
)
checks = tuple(check.lower() for check in checks)
if 'disabled' in checks:
logger.warning(
'The "disabled" value for the "checks" option is deprecated and will be removed from a future release; use "skip_actions" instead'
)
if len(checks) > 1:
logger.warning(
'Multiple checks are configured, but one of them is "disabled"; not running any checks'
)
return ()
return checks
def parse_frequency(frequency):
'''
Given a frequency string with a number and a unit of time, return a corresponding
datetime.timedelta instance or None if the frequency is None or "always".
For instance, given "3 weeks", return datetime.timedelta(weeks=3)
Raise ValueError if the given frequency cannot be parsed.
'''
if not frequency:
return None
frequency = frequency.strip().lower()
if frequency == 'always':
return None
try:
number, time_unit = frequency.split(' ')
number = int(number)
except ValueError:
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
if not time_unit.endswith('s'):
time_unit += 's'
if time_unit == 'months':
number *= 30
time_unit = 'days'
elif time_unit == 'years':
number *= 365
time_unit = 'days'
try:
return datetime.timedelta(**{time_unit: number})
except TypeError:
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
WEEKDAY_DAYS = calendar.day_name[0:5]
WEEKEND_DAYS = calendar.day_name[5:7]
def filter_checks_on_frequency(
config,
borg_repository_id,
checks,
force,
archives_check_id=None,
datetime_now=datetime.datetime.now,
):
'''
Given a configuration dict with a "checks" sequence of dicts, a Borg repository ID, a sequence
of checks, whether to force checks to run, and an ID for the archives check potentially being
run (if any), filter down those checks based on the configured "frequency" for each check as
compared to its check time file.
In other words, a check whose check time file's timestamp is too new (based on the configured
frequency) will get cut from the returned sequence of checks. Example:
config = {
'checks': [
{
'name': 'archives',
'frequency': '2 weeks',
},
]
}
When this function is called with that config and "archives" in checks, "archives" will get
filtered out of the returned result if its check time file is newer than 2 weeks old, indicating
that it's not yet time to run that check again.
Raise ValueError if a frequency cannot be parsed.
'''
if not checks:
return checks
filtered_checks = list(checks)
if force:
return tuple(filtered_checks)
for check_config in config.get('checks', DEFAULT_CHECKS):
check = check_config['name']
if checks and check not in checks:
continue
only_run_on = check_config.get('only_run_on')
if only_run_on:
# Use a dict instead of a set to preserve ordering.
days = dict.fromkeys(only_run_on)
if 'weekday' in days:
days = {
**dict.fromkeys(day for day in days if day != 'weekday'),
**dict.fromkeys(WEEKDAY_DAYS),
}
if 'weekend' in days:
days = {
**dict.fromkeys(day for day in days if day != 'weekend'),
**dict.fromkeys(WEEKEND_DAYS),
}
if calendar.day_name[datetime_now().weekday()] not in days:
logger.info(
f"Skipping {check} check due to day of the week; check only runs on {'/'.join(days)} (use --force to check anyway)"
)
filtered_checks.remove(check)
continue
frequency_delta = parse_frequency(check_config.get('frequency'))
if not frequency_delta:
continue
check_time = probe_for_check_time(config, borg_repository_id, check, archives_check_id)
if not check_time:
continue
# If we've not yet reached the time when the frequency dictates we're ready for another
# check, skip this check.
if datetime_now() < check_time + frequency_delta:
remaining = check_time + frequency_delta - datetime_now()
logger.info(
f'Skipping {check} check due to configured frequency; {remaining} until next check (use --force to check anyway)'
)
filtered_checks.remove(check)
return tuple(filtered_checks)
def make_archives_check_id(archive_filter_flags):
'''
Given a sequence of flags to filter archives, return a unique hash corresponding to those
particular flags. If there are no flags, return None.
'''
if not archive_filter_flags:
return None
return hashlib.sha256(' '.join(archive_filter_flags).encode()).hexdigest()
def make_check_time_path(config, borg_repository_id, check_type, archives_check_id=None):
'''
Given a configuration dict, a Borg repository ID, the name of a check type ("repository",
"archives", etc.), and a unique hash of the archives filter flags, return a path for recording
that check's time (the time of that check last occurring).
'''
borgmatic_state_directory = borgmatic.config.paths.get_borgmatic_state_directory(config)
if check_type in ('archives', 'data'):
return os.path.join(
borgmatic_state_directory,
'checks',
borg_repository_id,
check_type,
archives_check_id if archives_check_id else 'all',
)
return os.path.join(
borgmatic_state_directory,
'checks',
borg_repository_id,
check_type,
)
def write_check_time(path): # pragma: no cover
'''
Record a check time of now as the modification time of the given path.
'''
logger.debug(f'Writing check time at {path}')
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
pathlib.Path(path, mode=0o600).touch()
def read_check_time(path):
'''
Return the check time based on the modification time of the given path. Return None if the path
doesn't exist.
'''
logger.debug(f'Reading check time from {path}')
try:
return datetime.datetime.fromtimestamp(os.stat(path).st_mtime)
except FileNotFoundError:
return None
def probe_for_check_time(config, borg_repository_id, check, archives_check_id):
'''
Given a configuration dict, a Borg repository ID, the name of a check type ("repository",
"archives", etc.), and a unique hash of the archives filter flags, return the corresponding
check time or None if such a check time does not exist.
When the check type is "archives" or "data", this function probes two different paths to find
the check time, e.g.:
~/.borgmatic/checks/1234567890/archives/9876543210
~/.borgmatic/checks/1234567890/archives/all
... and returns the maximum modification time of the files found (if any). The first path
represents a more specific archives check time (a check on a subset of archives), and the second
is a fallback to the last "all" archives check.
For other check types, this function reads from a single check time path, e.g.:
~/.borgmatic/checks/1234567890/repository
'''
check_times = (
read_check_time(group[0])
for group in itertools.groupby(
(
make_check_time_path(config, borg_repository_id, check, archives_check_id),
make_check_time_path(config, borg_repository_id, check),
)
)
)
try:
return max(check_time for check_time in check_times if check_time)
except ValueError:
return None
def upgrade_check_times(config, borg_repository_id):
'''
Given a configuration dict and a Borg repository ID, upgrade any corresponding check times on
disk from old-style paths to new-style paths.
One upgrade performed is moving the checks directory from:
{borgmatic_source_directory}/checks (e.g., ~/.borgmatic/checks)
to:
{borgmatic_state_directory}/checks (e.g. ~/.local/state/borgmatic)
Another upgrade is renaming an archive or data check path that looks like:
{borgmatic_state_directory}/checks/1234567890/archives
to:
{borgmatic_state_directory}/checks/1234567890/archives/all
'''
borgmatic_source_checks_path = os.path.join(
borgmatic.config.paths.get_borgmatic_source_directory(config), 'checks'
)
borgmatic_state_path = borgmatic.config.paths.get_borgmatic_state_directory(config)
borgmatic_state_checks_path = os.path.join(borgmatic_state_path, 'checks')
if os.path.exists(borgmatic_source_checks_path) and not os.path.exists(
borgmatic_state_checks_path
):
logger.debug(
f'Upgrading archives check times directory from {borgmatic_source_checks_path} to {borgmatic_state_checks_path}'
)
os.makedirs(borgmatic_state_path, mode=0o700, exist_ok=True)
shutil.move(borgmatic_source_checks_path, borgmatic_state_checks_path)
for check_type in ('archives', 'data'):
new_path = make_check_time_path(config, borg_repository_id, check_type, 'all')
old_path = os.path.dirname(new_path)
temporary_path = f'{old_path}.temp'
if not os.path.isfile(old_path) and not os.path.isfile(temporary_path):
continue
logger.debug(f'Upgrading archives check time file from {old_path} to {new_path}')
try:
shutil.move(old_path, temporary_path)
except FileNotFoundError:
pass
os.mkdir(old_path)
shutil.move(temporary_path, new_path)
def collect_spot_check_source_paths(
repository, config, local_borg_version, global_arguments, local_path, remote_path
):
'''
Given a repository configuration dict, a configuration dict, the local Borg version, global
arguments as an argparse.Namespace instance, the local Borg path, and the remote Borg path,
collect the source paths that Borg would use in an actual create (but only include files).
'''
stream_processes = any(
borgmatic.hooks.dispatch.call_hooks(
'use_streaming',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
).values()
)
(create_flags, create_positional_arguments, pattern_file, exclude_file) = (
borgmatic.borg.create.make_base_create_command(
dry_run=True,
repository_path=repository['path'],
config=config,
config_paths=(),
local_borg_version=local_borg_version,
global_arguments=global_arguments,
borgmatic_runtime_directories=(),
local_path=local_path,
remote_path=remote_path,
list_files=True,
stream_processes=stream_processes,
)
)
borg_environment = borgmatic.borg.environment.make_environment(config)
working_directory = borgmatic.config.paths.get_working_directory(config)
paths_output = borgmatic.execute.execute_command_and_capture_output(
create_flags + create_positional_arguments,
capture_stderr=True,
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
paths = tuple(
path_line.split(' ', 1)[1]
for path_line in paths_output.split('\n')
if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
)
return tuple(
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
)
BORG_DIRECTORY_FILE_TYPE = 'd'
def collect_spot_check_archive_paths(
repository,
archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
):
'''
Given a repository configuration dict, the name of the latest archive, a configuration dict, the
local Borg version, global arguments as an argparse.Namespace instance, the local Borg path, the
remote Borg path, and the borgmatic runtime directory, collect the paths from the given archive
(but only include files and symlinks and exclude borgmatic runtime directories).
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
return tuple(
path
for line in borgmatic.borg.list.capture_archive_listing(
repository['path'],
archive,
config,
local_borg_version,
global_arguments,
path_format='{type} /{path}{NL}', # noqa: FS003
local_path=local_path,
remote_path=remote_path,
)
for (file_type, path) in (line.split(' ', 1),)
if file_type != BORG_DIRECTORY_FILE_TYPE
if pathlib.Path('/borgmatic') not in pathlib.Path(path).parents
if pathlib.Path(borgmatic_source_directory) not in pathlib.Path(path).parents
if pathlib.Path(borgmatic_runtime_directory) not in pathlib.Path(path).parents
)
SAMPLE_PATHS_SUBSET_COUNT = 10000
def compare_spot_check_hashes(
repository,
archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
log_prefix,
source_paths,
):
'''
Given a repository configuration dict, the name of the latest archive, a configuration dict, the
local Borg version, global arguments as an argparse.Namespace instance, the local Borg path, the
remote Borg path, a log label, and spot check source paths, compare the hashes for a sampling of
the source paths with hashes from corresponding paths in the given archive. Return a sequence of
the paths that fail that hash comparison.
'''
# Based on the configured sample percentage, come up with a list of random sample files from the
# source directories.
spot_check_config = next(check for check in config['checks'] if check['name'] == 'spot')
sample_count = max(
int(len(source_paths) * (min(spot_check_config['data_sample_percentage'], 100) / 100)), 1
)
source_sample_paths = tuple(random.sample(source_paths, sample_count))
working_directory = borgmatic.config.paths.get_working_directory(config)
existing_source_sample_paths = {
source_path
for source_path in source_sample_paths
if os.path.exists(os.path.join(working_directory or '', source_path))
}
logger.debug(
f'{log_prefix}: Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
)
source_sample_paths_iterator = iter(source_sample_paths)
source_hashes = {}
archive_hashes = {}
# Only hash a few thousand files at a time (a subset of the total paths) to avoid an "Argument
# list too long" shell error.
while True:
# Hash each file in the sample paths (if it exists).
source_sample_paths_subset = tuple(
itertools.islice(source_sample_paths_iterator, SAMPLE_PATHS_SUBSET_COUNT)
)
if not source_sample_paths_subset:
break
hash_output = borgmatic.execute.execute_command_and_capture_output(
(spot_check_config.get('xxh64sum_command', 'xxh64sum'),)
+ tuple(
path for path in source_sample_paths_subset if path in existing_source_sample_paths
),
working_directory=working_directory,
)
source_hashes.update(
**dict(
(reversed(line.split(' ', 1)) for line in hash_output.splitlines()),
# Represent non-existent files as having empty hashes so the comparison below still works.
**{
path: ''
for path in source_sample_paths_subset
if path not in existing_source_sample_paths
},
)
)
# Get the hash for each file in the archive.
archive_hashes.update(
**dict(
reversed(line.split(' ', 1))
for line in borgmatic.borg.list.capture_archive_listing(
repository['path'],
archive,
config,
local_borg_version,
global_arguments,
list_paths=source_sample_paths_subset,
path_format='{xxh64} /{path}{NL}', # noqa: FS003
local_path=local_path,
remote_path=remote_path,
)
if line
)
)
# Compare the source hashes with the archive hashes to see how many match.
failing_paths = []
for path, source_hash in source_hashes.items():
archive_hash = archive_hashes.get(path)
if archive_hash is not None and archive_hash == source_hash:
continue
failing_paths.append(path)
return tuple(failing_paths)
def spot_check(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
):
'''
Given a repository dict, a loaded configuration dict, the local Borg version, global arguments
as an argparse.Namespace instance, the local Borg path, the remote Borg path, and the borgmatic
runtime directory, perform a spot check for the latest archive in the given repository.
A spot check compares file counts and also the hashes for a random sampling of source files on
disk to those stored in the latest archive. If any differences are beyond configured tolerances,
then the check fails.
'''
log_prefix = f'{repository.get("label", repository["path"])}'
logger.debug(f'{log_prefix}: Running spot check')
try:
spot_check_config = next(
check for check in config.get('checks', ()) if check.get('name') == 'spot'
)
except StopIteration:
raise ValueError('Cannot run spot check because it is unconfigured')
if spot_check_config['data_tolerance_percentage'] > spot_check_config['data_sample_percentage']:
raise ValueError(
'The data_tolerance_percentage must be less than or equal to the data_sample_percentage'
)
source_paths = collect_spot_check_source_paths(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
logger.debug(f'{log_prefix}: {len(source_paths)} total source paths for spot check')
archive = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
'latest',
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
logger.debug(f'{log_prefix}: Using archive {archive} for spot check')
archive_paths = collect_spot_check_archive_paths(
repository,
archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
)
logger.debug(f'{log_prefix}: {len(archive_paths)} total archive paths for spot check')
# Calculate the percentage delta between the source paths count and the archive paths count, and
# compare that delta to the configured count tolerance percentage.
count_delta_percentage = abs(len(source_paths) - len(archive_paths)) / len(source_paths) * 100
if count_delta_percentage > spot_check_config['count_tolerance_percentage']:
logger.debug(
f'{log_prefix}: Paths in source paths but not latest archive: {", ".join(set(source_paths) - set(archive_paths)) or "none"}'
)
logger.debug(
f'{log_prefix}: Paths in latest archive but not source paths: {", ".join(set(archive_paths) - set(source_paths)) or "none"}'
)
raise ValueError(
f'Spot check failed: {count_delta_percentage:.2f}% file count delta between source paths and latest archive (tolerance is {spot_check_config["count_tolerance_percentage"]}%)'
)
failing_paths = compare_spot_check_hashes(
repository,
archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
log_prefix,
source_paths,
)
# Error if the percentage of failing hashes exceeds the configured tolerance percentage.
logger.debug(f'{log_prefix}: {len(failing_paths)} non-matching spot check hashes')
data_tolerance_percentage = spot_check_config['data_tolerance_percentage']
failing_percentage = (len(failing_paths) / len(source_paths)) * 100
if failing_percentage > data_tolerance_percentage:
logger.debug(
f'{log_prefix}: Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
)
raise ValueError(
f'Spot check failed: {failing_percentage:.2f}% of source paths with data not matching the latest archive (tolerance is {data_tolerance_percentage}%)'
)
logger.info(
f'{log_prefix}: Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
)
def run_check(
config_filename,
repository,
location,
storage,
consistency,
hooks,
config,
hook_context,
local_borg_version,
check_arguments,
@@ -23,6 +669,8 @@ def run_check(
):
'''
Run the "check" action for the given repository.
Raise ValueError if the Borg repository ID cannot be determined.
'''
if check_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, check_arguments.repository
@@ -30,30 +678,85 @@ def run_check(
return
borgmatic.hooks.command.execute_hook(
hooks.get('before_check'),
hooks.get('umask'),
config.get('before_check'),
config.get('umask'),
config_filename,
'pre-check',
global_arguments.dry_run,
**hook_context,
)
logger.info('{}: Running consistency checks'.format(repository))
borgmatic.borg.check.check_archives(
repository,
location,
storage,
consistency,
log_prefix = repository.get('label', repository['path'])
logger.info(f'{log_prefix}: Running consistency checks')
repository_id = borgmatic.borg.check.get_repository_id(
repository['path'],
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
progress=check_arguments.progress,
repair=check_arguments.repair,
only_checks=check_arguments.only,
force=check_arguments.force,
)
upgrade_check_times(config, repository_id)
configured_checks = parse_checks(config, check_arguments.only_checks)
archive_filter_flags = borgmatic.borg.check.make_archive_filter_flags(
local_borg_version, config, configured_checks, check_arguments
)
archives_check_id = make_archives_check_id(archive_filter_flags)
checks = filter_checks_on_frequency(
config,
repository_id,
configured_checks,
check_arguments.force,
archives_check_id,
)
borg_specific_checks = set(checks).intersection({'repository', 'archives', 'data'})
if borg_specific_checks:
borgmatic.borg.check.check_archives(
repository['path'],
config,
local_borg_version,
check_arguments,
global_arguments,
borg_specific_checks,
archive_filter_flags,
local_path=local_path,
remote_path=remote_path,
)
for check in borg_specific_checks:
write_check_time(make_check_time_path(config, repository_id, check, archives_check_id))
if 'extract' in checks:
borgmatic.borg.extract.extract_last_archive_dry_run(
config,
local_borg_version,
global_arguments,
repository['path'],
config.get('lock_wait'),
local_path,
remote_path,
)
write_check_time(make_check_time_path(config, repository_id, 'extract'))
if 'spot' in checks:
with borgmatic.config.paths.Runtime_directory(
config, log_prefix
) as borgmatic_runtime_directory:
spot_check(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
)
write_check_time(make_check_time_path(config, repository_id, 'spot'))
borgmatic.hooks.command.execute_hook(
hooks.get('after_check'),
hooks.get('umask'),
config.get('after_check'),
config.get('umask'),
config_filename,
'post-check',
global_arguments.dry_run,
+14 -11
View File
@@ -11,9 +11,7 @@ logger = logging.getLogger(__name__)
def run_compact(
config_filename,
repository,
storage,
retention,
hooks,
config,
hook_context,
local_borg_version,
compact_arguments,
@@ -31,20 +29,23 @@ def run_compact(
return
borgmatic.hooks.command.execute_hook(
hooks.get('before_compact'),
hooks.get('umask'),
config.get('before_compact'),
config.get('umask'),
config_filename,
'pre-compact',
global_arguments.dry_run,
**hook_context,
)
if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.COMPACT, local_borg_version):
logger.info('{}: Compacting segments{}'.format(repository, dry_run_label))
logger.info(
f'{repository.get("label", repository["path"])}: Compacting segments{dry_run_label}'
)
borgmatic.borg.compact.compact_segments(
global_arguments.dry_run,
repository,
storage,
repository['path'],
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
progress=compact_arguments.progress,
@@ -52,10 +53,12 @@ def run_compact(
threshold=compact_arguments.threshold,
)
else: # pragma: nocover
logger.info('{}: Skipping compact (only available/needed in Borg 1.2+)'.format(repository))
logger.info(
f'{repository.get("label", repository["path"])}: Skipping compact (only available/needed in Borg 1.2+)'
)
borgmatic.hooks.command.execute_hook(
hooks.get('after_compact'),
hooks.get('umask'),
config.get('after_compact'),
config.get('umask'),
config_filename,
'post-compact',
global_arguments.dry_run,
+132
View File
@@ -0,0 +1,132 @@
import json
import logging
import os
import borgmatic.borg.extract
import borgmatic.borg.repo_list
import borgmatic.config.paths
import borgmatic.config.validate
import borgmatic.hooks.command
logger = logging.getLogger(__name__)
def make_bootstrap_config(bootstrap_arguments):
'''
Given the bootstrap arguments as an argparse.Namespace, return a corresponding config dict.
'''
return {
'ssh_command': bootstrap_arguments.ssh_command,
# In case the repo has been moved or is accessed from a different path at the point of
# bootstrapping.
'relocated_repo_access_is_ok': True,
}
def get_config_paths(archive_name, bootstrap_arguments, global_arguments, local_borg_version):
'''
Given an archive name, the bootstrap arguments as an argparse.Namespace (containing the
repository and archive name, Borg local path, Borg remote path, borgmatic runtime directory,
borgmatic source directory, destination directory, and whether to strip components), the global
arguments as an argparse.Namespace (containing the dry run flag and the local borg version),
return the config paths from the manifest.json file in the borgmatic source directory or runtime
directory after extracting it from the repository archive.
Raise ValueError if the manifest JSON is missing, can't be decoded, or doesn't contain the
expected configuration path data.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(
{'borgmatic_source_directory': bootstrap_arguments.borgmatic_source_directory}
)
config = make_bootstrap_config(bootstrap_arguments)
# Probe for the manifest file in multiple locations, as the default location has moved to the
# borgmatic runtime directory (which get stored as just "/borgmatic" with Borg 1.4+). But we
# still want to support reading the manifest from previously created archives as well.
with borgmatic.config.paths.Runtime_directory(
{'user_runtime_directory': bootstrap_arguments.user_runtime_directory},
bootstrap_arguments.repository,
) as borgmatic_runtime_directory:
for base_directory in (
'borgmatic',
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
borgmatic_source_directory,
):
borgmatic_manifest_path = 'sh:' + os.path.join(
base_directory, 'bootstrap', 'manifest.json'
)
extract_process = borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
bootstrap_arguments.repository,
archive_name,
[borgmatic_manifest_path],
config,
local_borg_version,
global_arguments,
local_path=bootstrap_arguments.local_path,
remote_path=bootstrap_arguments.remote_path,
extract_to_stdout=True,
)
manifest_json = extract_process.stdout.read()
if manifest_json:
break
else:
raise ValueError(
'Cannot read configuration paths from archive due to missing bootstrap manifest'
)
try:
manifest_data = json.loads(manifest_json)
except json.JSONDecodeError as error:
raise ValueError(
f'Cannot read configuration paths from archive due to invalid bootstrap manifest JSON: {error}'
)
try:
return manifest_data['config_paths']
except KeyError:
raise ValueError(
'Cannot read configuration paths from archive due to invalid bootstrap manifest'
)
def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
'''
Run the "bootstrap" action for the given repository.
Raise ValueError if the bootstrap configuration could not be loaded.
Raise CalledProcessError or OSError if Borg could not be run.
'''
config = make_bootstrap_config(bootstrap_arguments)
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
bootstrap_arguments.repository,
bootstrap_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path=bootstrap_arguments.local_path,
remote_path=bootstrap_arguments.remote_path,
)
manifest_config_paths = get_config_paths(
archive_name, bootstrap_arguments, global_arguments, local_borg_version
)
logger.info(f"Bootstrapping config paths: {', '.join(manifest_config_paths)}")
borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
bootstrap_arguments.repository,
archive_name,
[config_path.lstrip(os.path.sep) for config_path in manifest_config_paths],
config,
local_borg_version,
global_arguments,
local_path=bootstrap_arguments.local_path,
remote_path=bootstrap_arguments.remote_path,
extract_to_stdout=False,
destination_path=bootstrap_arguments.destination,
strip_components=bootstrap_arguments.strip_components,
progress=bootstrap_arguments.progress,
)
+48
View File
@@ -0,0 +1,48 @@
import logging
import borgmatic.config.generate
import borgmatic.config.validate
import borgmatic.logger
logger = logging.getLogger(__name__)
def run_generate(generate_arguments, global_arguments):
'''
Given the generate arguments and the global arguments, each as an argparse.Namespace instance,
run the "generate" action.
Raise FileExistsError if a file already exists at the destination path and the generate
arguments do not have overwrite set.
'''
borgmatic.logger.add_custom_log_levels()
dry_run_label = ' (dry run; not actually writing anything)' if global_arguments.dry_run else ''
logger.answer(
f'Generating a configuration file at: {generate_arguments.destination_filename}{dry_run_label}'
)
borgmatic.config.generate.generate_sample_configuration(
global_arguments.dry_run,
generate_arguments.source_filename,
generate_arguments.destination_filename,
borgmatic.config.validate.schema_filename(),
overwrite=generate_arguments.overwrite,
)
if generate_arguments.source_filename:
logger.answer(
f'''
Merged in the contents of configuration file at: {generate_arguments.source_filename}
To review the changes made, run:
diff --unified {generate_arguments.source_filename} {generate_arguments.destination_filename}'''
)
logger.answer(
'''
This includes all available configuration options with example values, the few
required options as indicated. Please edit the file to suit your needs.
If you ever need help: https://torsion.org/borgmatic/#issues'''
)
+25
View File
@@ -0,0 +1,25 @@
import logging
import borgmatic.config.generate
import borgmatic.logger
logger = logging.getLogger(__name__)
def run_validate(validate_arguments, configs):
'''
Given the validate arguments as an argparse.Namespace instance and a dict of configuration
filename to corresponding parsed configuration, run the "validate" action.
Most of the validation is actually performed implicitly by the standard borgmatic configuration
loading machinery prior to here, so this function mainly exists to support additional validate
flags like "--show".
'''
borgmatic.logger.add_custom_log_levels()
if validate_arguments.show:
for config_path, config in configs.items():
if len(configs) > 1:
logger.answer('---')
logger.answer(borgmatic.config.generate.render_configuration(config))
+96 -49
View File
@@ -1,7 +1,11 @@
import importlib.metadata
import json
import logging
import os
import borgmatic.actions.json
import borgmatic.borg.create
import borgmatic.config.paths
import borgmatic.config.validate
import borgmatic.hooks.command
import borgmatic.hooks.dispatch
@@ -10,12 +14,37 @@ import borgmatic.hooks.dump
logger = logging.getLogger(__name__)
def create_borgmatic_manifest(config, config_paths, borgmatic_runtime_directory, dry_run):
'''
Given a configuration dict, a sequence of config file paths, the borgmatic runtime directory,
and whether this is a dry run, create a borgmatic manifest file to store the paths to the
configuration files used to create the archive.
'''
if dry_run:
return
borgmatic_manifest_path = os.path.join(
borgmatic_runtime_directory, 'bootstrap', 'manifest.json'
)
if not os.path.exists(borgmatic_manifest_path):
os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True)
with open(borgmatic_manifest_path, 'w') as config_list_file:
json.dump(
{
'borgmatic_version': importlib.metadata.version('borgmatic'),
'config_paths': config_paths,
},
config_list_file,
)
def run_create(
config_filename,
repository,
location,
storage,
hooks,
config,
config_paths,
hook_context,
local_borg_version,
create_arguments,
@@ -35,60 +64,78 @@ def run_create(
return
borgmatic.hooks.command.execute_hook(
hooks.get('before_backup'),
hooks.get('umask'),
config.get('before_backup'),
config.get('umask'),
config_filename,
'pre-backup',
global_arguments.dry_run,
**hook_context,
)
logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_database_dumps',
hooks,
repository,
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
active_dumps = borgmatic.hooks.dispatch.call_hooks(
'dump_databases',
hooks,
repository,
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
stream_processes = [process for processes in active_dumps.values() for process in processes]
json_output = borgmatic.borg.create.create_archive(
global_arguments.dry_run,
repository,
location,
storage,
local_borg_version,
local_path=local_path,
remote_path=remote_path,
progress=create_arguments.progress,
stats=create_arguments.stats,
json=create_arguments.json,
list_files=create_arguments.list_files,
stream_processes=stream_processes,
)
if json_output: # pragma: nocover
yield json.loads(json_output)
log_prefix = repository.get('label', repository['path'])
logger.info(f'{log_prefix}: Creating archive{dry_run_label}')
with borgmatic.config.paths.Runtime_directory(
config, log_prefix
) as borgmatic_runtime_directory:
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
active_dumps = borgmatic.hooks.dispatch.call_hooks(
'dump_data_sources',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
stream_processes = [process for processes in active_dumps.values() for process in processes]
if config.get('store_config_files', True):
create_borgmatic_manifest(
config,
config_paths,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
json_output = borgmatic.borg.create.create_archive(
global_arguments.dry_run,
repository['path'],
config,
config_paths,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
local_path=local_path,
remote_path=remote_path,
progress=create_arguments.progress,
stats=create_arguments.stats,
json=create_arguments.json,
list_files=create_arguments.list_files,
stream_processes=stream_processes,
)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
config_filename,
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_database_dumps',
hooks,
config_filename,
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
borgmatic.hooks.command.execute_hook(
hooks.get('after_backup'),
hooks.get('umask'),
config.get('after_backup'),
config.get('umask'),
config_filename,
'post-backup',
global_arguments.dry_run,
+50
View File
@@ -0,0 +1,50 @@
import logging
import borgmatic.actions.arguments
import borgmatic.borg.delete
import borgmatic.borg.repo_delete
import borgmatic.borg.repo_list
logger = logging.getLogger(__name__)
def run_delete(
repository,
config,
local_borg_version,
delete_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "delete" action for the given repository and archive(s).
'''
if delete_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, delete_arguments.repository
):
logger.answer(f'{repository.get("label", repository["path"])}: Deleting archives')
archive_name = (
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
delete_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
if delete_arguments.archive
else None
)
borgmatic.borg.delete.delete_archives(
repository,
config,
local_borg_version,
borgmatic.actions.arguments.update_arguments(delete_arguments, archive=archive_name),
global_arguments,
local_path,
remote_path,
)
+33
View File
@@ -0,0 +1,33 @@
import logging
import borgmatic.borg.export_key
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_export_key(
repository,
config,
local_borg_version,
export_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "key export" action for the given repository.
'''
if export_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, export_arguments.repository
):
logger.info(f'{repository.get("label", repository["path"])}: Exporting repository key')
borgmatic.borg.export_key.export_key(
repository['path'],
config,
local_borg_version,
export_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
+10 -8
View File
@@ -1,7 +1,7 @@
import logging
import borgmatic.borg.export_tar
import borgmatic.borg.rlist
import borgmatic.borg.repo_list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -9,7 +9,7 @@ logger = logging.getLogger(__name__)
def run_export_tar(
repository,
storage,
config,
local_borg_version,
export_tar_arguments,
global_arguments,
@@ -23,23 +23,25 @@ def run_export_tar(
repository, export_tar_arguments.repository
):
logger.info(
'{}: Exporting archive {} as tar file'.format(repository, export_tar_arguments.archive)
f'{repository["path"]}: Exporting archive {export_tar_arguments.archive} as tar file'
)
borgmatic.borg.export_tar.export_tar_archive(
global_arguments.dry_run,
repository,
borgmatic.borg.rlist.resolve_archive_name(
repository,
repository['path'],
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
export_tar_arguments.archive,
storage,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
),
export_tar_arguments.paths,
export_tar_arguments.destination,
storage,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
tar_filter=export_tar_arguments.tar_filter,
+16 -15
View File
@@ -1,7 +1,7 @@
import logging
import borgmatic.borg.extract
import borgmatic.borg.rlist
import borgmatic.borg.repo_list
import borgmatic.config.validate
import borgmatic.hooks.command
@@ -11,9 +11,7 @@ logger = logging.getLogger(__name__)
def run_extract(
config_filename,
repository,
location,
storage,
hooks,
config,
hook_context,
local_borg_version,
extract_arguments,
@@ -25,8 +23,8 @@ def run_extract(
Run the "extract" action for the given repository.
'''
borgmatic.hooks.command.execute_hook(
hooks.get('before_extract'),
hooks.get('umask'),
config.get('before_extract'),
config.get('umask'),
config_filename,
'pre-extract',
global_arguments.dry_run,
@@ -35,22 +33,25 @@ def run_extract(
if extract_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, extract_arguments.repository
):
logger.info('{}: Extracting archive {}'.format(repository, extract_arguments.archive))
logger.info(
f'{repository.get("label", repository["path"])}: Extracting archive {extract_arguments.archive}'
)
borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
repository,
borgmatic.borg.rlist.resolve_archive_name(
repository,
repository['path'],
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
extract_arguments.archive,
storage,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
),
extract_arguments.paths,
location,
storage,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
destination_path=extract_arguments.destination,
@@ -58,8 +59,8 @@ def run_extract(
progress=extract_arguments.progress,
)
borgmatic.hooks.command.execute_hook(
hooks.get('after_extract'),
hooks.get('umask'),
config.get('after_extract'),
config.get('umask'),
config_filename,
'post-extract',
global_arguments.dry_run,
+26 -15
View File
@@ -1,15 +1,22 @@
import json
import logging
import borgmatic.actions.arguments
import borgmatic.actions.json
import borgmatic.borg.info
import borgmatic.borg.rlist
import borgmatic.borg.repo_list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_info(
repository, storage, local_borg_version, info_arguments, local_path, remote_path,
repository,
config,
local_borg_version,
info_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "info" action for the given repository and archive.
@@ -19,23 +26,27 @@ def run_info(
if info_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, info_arguments.repository
):
if not info_arguments.json: # pragma: nocover
logger.answer(f'{repository}: Displaying archive summary information')
info_arguments.archive = borgmatic.borg.rlist.resolve_archive_name(
repository,
if not info_arguments.json:
logger.answer(
f'{repository.get("label", repository["path"])}: Displaying archive summary information'
)
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
info_arguments.archive,
storage,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
json_output = borgmatic.borg.info.display_archives_info(
repository,
storage,
repository['path'],
config,
local_borg_version,
info_arguments=info_arguments,
local_path=local_path,
remote_path=remote_path,
borgmatic.actions.arguments.update_arguments(info_arguments, archive=archive_name),
global_arguments,
local_path,
remote_path,
)
if json_output: # pragma: nocover
yield json.loads(json_output)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
+30
View File
@@ -0,0 +1,30 @@
import json
import logging
logger = logging.getLogger(__name__)
def parse_json(borg_json_output, label):
'''
Given a Borg JSON output string, parse it as JSON into a dict. Inject the given borgmatic
repository label into it and return the dict.
Raise JSONDecodeError if the JSON output cannot be parsed.
'''
lines = borg_json_output.splitlines()
start_line_index = 0
# Scan forward to find the first line starting with "{" and assume that's where the JSON starts.
for line_index, line in enumerate(lines):
if line.startswith('{'):
start_line_index = line_index
break
json_data = json.loads('\n'.join(lines[start_line_index:]))
if 'repository' not in json_data:
return json_data
json_data['repository']['label'] = label or ''
return json_data
+27 -17
View File
@@ -1,6 +1,7 @@
import json
import logging
import borgmatic.actions.arguments
import borgmatic.actions.json
import borgmatic.borg.list
import borgmatic.config.validate
@@ -8,7 +9,13 @@ logger = logging.getLogger(__name__)
def run_list(
repository, storage, local_borg_version, list_arguments, local_path, remote_path,
repository,
config,
local_borg_version,
list_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "list" action for the given repository and archive.
@@ -18,26 +25,29 @@ def run_list(
if list_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, list_arguments.repository
):
if not list_arguments.json: # pragma: nocover
if list_arguments.find_paths:
logger.answer(f'{repository}: Searching archives')
elif not list_arguments.archive:
logger.answer(f'{repository}: Listing archives')
list_arguments.archive = borgmatic.borg.rlist.resolve_archive_name(
repository,
if not list_arguments.json:
if list_arguments.find_paths: # pragma: no cover
logger.answer(f'{repository.get("label", repository["path"])}: Searching archives')
elif not list_arguments.archive: # pragma: no cover
logger.answer(f'{repository.get("label", repository["path"])}: Listing archives')
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
list_arguments.archive,
storage,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
json_output = borgmatic.borg.list.list_archive(
repository,
storage,
repository['path'],
config,
local_borg_version,
list_arguments=list_arguments,
local_path=local_path,
remote_path=remote_path,
borgmatic.actions.arguments.update_arguments(list_arguments, archive=archive_name),
global_arguments,
local_path,
remote_path,
)
if json_output: # pragma: nocover
yield json.loads(json_output)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
+20 -13
View File
@@ -1,14 +1,20 @@
import logging
import borgmatic.borg.mount
import borgmatic.borg.rlist
import borgmatic.borg.repo_list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_mount(
repository, storage, local_borg_version, mount_arguments, local_path, remote_path,
repository,
config,
local_borg_version,
mount_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "mount" action for the given repository.
@@ -17,26 +23,27 @@ def run_mount(
repository, mount_arguments.repository
):
if mount_arguments.archive:
logger.info('{}: Mounting archive {}'.format(repository, mount_arguments.archive))
logger.info(
f'{repository.get("label", repository["path"])}: Mounting archive {mount_arguments.archive}'
)
else: # pragma: nocover
logger.info('{}: Mounting repository'.format(repository))
logger.info(f'{repository.get("label", repository["path"])}: Mounting repository')
borgmatic.borg.mount.mount_archive(
repository,
borgmatic.borg.rlist.resolve_archive_name(
repository,
repository['path'],
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
mount_arguments.archive,
storage,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
),
mount_arguments.mount_point,
mount_arguments.paths,
mount_arguments.foreground,
mount_arguments.options,
storage,
mount_arguments,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
+10 -13
View File
@@ -10,9 +10,7 @@ logger = logging.getLogger(__name__)
def run_prune(
config_filename,
repository,
storage,
retention,
hooks,
config,
hook_context,
local_borg_version,
prune_arguments,
@@ -30,28 +28,27 @@ def run_prune(
return
borgmatic.hooks.command.execute_hook(
hooks.get('before_prune'),
hooks.get('umask'),
config.get('before_prune'),
config.get('umask'),
config_filename,
'pre-prune',
global_arguments.dry_run,
**hook_context,
)
logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
logger.info(f'{repository.get("label", repository["path"])}: Pruning archives{dry_run_label}')
borgmatic.borg.prune.prune_archives(
global_arguments.dry_run,
repository,
storage,
retention,
repository['path'],
config,
local_borg_version,
prune_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
stats=prune_arguments.stats,
list_archives=prune_arguments.list_archives,
)
borgmatic.hooks.command.execute_hook(
hooks.get('after_prune'),
hooks.get('umask'),
config.get('after_prune'),
config.get('umask'),
config_filename,
'post-prune',
global_arguments.dry_run,
-40
View File
@@ -1,40 +0,0 @@
import logging
import borgmatic.borg.rcreate
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_rcreate(
repository,
storage,
local_borg_version,
rcreate_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "rcreate" action for the given repository.
'''
if rcreate_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, rcreate_arguments.repository
):
return
logger.info('{}: Creating repository'.format(repository))
borgmatic.borg.rcreate.create_repository(
global_arguments.dry_run,
repository,
storage,
local_borg_version,
rcreate_arguments.encryption_mode,
rcreate_arguments.source_repository,
rcreate_arguments.copy_crypt_key,
rcreate_arguments.append_only,
rcreate_arguments.storage_quota,
rcreate_arguments.make_parent_dirs,
local_path=local_path,
remote_path=remote_path,
)
+41
View File
@@ -0,0 +1,41 @@
import logging
import borgmatic.borg.repo_create
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_repo_create(
repository,
config,
local_borg_version,
repo_create_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "repo-create" action for the given repository.
'''
if repo_create_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, repo_create_arguments.repository
):
return
logger.info(f'{repository.get("label", repository["path"])}: Creating repository')
borgmatic.borg.repo_create.create_repository(
global_arguments.dry_run,
repository['path'],
config,
local_borg_version,
global_arguments,
repo_create_arguments.encryption_mode,
repo_create_arguments.source_repository,
repo_create_arguments.copy_crypt_key,
repo_create_arguments.append_only,
repo_create_arguments.storage_quota,
repo_create_arguments.make_parent_dirs,
local_path=local_path,
remote_path=remote_path,
)
+36
View File
@@ -0,0 +1,36 @@
import logging
import borgmatic.borg.repo_delete
logger = logging.getLogger(__name__)
def run_repo_delete(
repository,
config,
local_borg_version,
repo_delete_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "repo-delete" action for the given repository.
'''
if repo_delete_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, repo_delete_arguments.repository
):
logger.answer(
f'{repository.get("label", repository["path"])}: Deleting repository'
+ (' cache' if repo_delete_arguments.cache_only else '')
)
borgmatic.borg.repo_delete.delete_repository(
repository,
config,
local_borg_version,
repo_delete_arguments,
global_arguments,
local_path,
remote_path,
)
+42
View File
@@ -0,0 +1,42 @@
import logging
import borgmatic.actions.json
import borgmatic.borg.repo_info
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_repo_info(
repository,
config,
local_borg_version,
repo_info_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "repo-info" action for the given repository.
If repo_info_arguments.json is True, yield the JSON output from the info for the repository.
'''
if repo_info_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, repo_info_arguments.repository
):
if not repo_info_arguments.json:
logger.answer(
f'{repository.get("label", repository["path"])}: Displaying repository summary information'
)
json_output = borgmatic.borg.repo_info.display_repository_info(
repository['path'],
config,
local_borg_version,
repo_info_arguments=repo_info_arguments,
global_arguments=global_arguments,
local_path=local_path,
remote_path=remote_path,
)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
+40
View File
@@ -0,0 +1,40 @@
import logging
import borgmatic.actions.json
import borgmatic.borg.repo_list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_repo_list(
repository,
config,
local_borg_version,
repo_list_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "repo-list" action for the given repository.
If repo_list_arguments.json is True, yield the JSON output from listing the repository.
'''
if repo_list_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, repo_list_arguments.repository
):
if not repo_list_arguments.json:
logger.answer(f'{repository.get("label", repository["path"])}: Listing repository')
json_output = borgmatic.borg.repo_list.list_repository(
repository['path'],
config,
local_borg_version,
repo_list_arguments=repo_list_arguments,
global_arguments=global_arguments,
local_path=local_path,
remote_path=remote_path,
)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
+322 -208
View File
@@ -1,12 +1,15 @@
import copy
import logging
import os
import pathlib
import shutil
import tempfile
import borgmatic.borg.extract
import borgmatic.borg.list
import borgmatic.borg.mount
import borgmatic.borg.rlist
import borgmatic.borg.state
import borgmatic.borg.repo_list
import borgmatic.config.paths
import borgmatic.config.validate
import borgmatic.hooks.dispatch
import borgmatic.hooks.dump
@@ -17,161 +20,246 @@ logger = logging.getLogger(__name__)
UNSPECIFIED_HOOK = object()
def get_configured_database(
hooks, archive_database_names, hook_name, database_name, configuration_database_name=None
def get_configured_data_source(
config,
archive_data_source_names,
hook_name,
data_source_name,
configuration_data_source_name=None,
):
'''
Find the first database with the given hook name and database name in the configured hooks
dict and the given archive database names dict (from hook name to database names contained in
a particular backup archive). If UNSPECIFIED_HOOK is given as the hook name, search all database
hooks for the named database. If a configuration database name is given, use that instead of the
database name to lookup the database in the given hooks configuration.
Find the first data source with the given hook name and data source name in the configuration
dict and the given archive data source names dict (from hook name to data source names contained
in a particular backup archive). If UNSPECIFIED_HOOK is given as the hook name, search all data
source hooks for the named data source. If a configuration data source name is given, use that
instead of the data source name to lookup the data source in the given hooks configuration.
Return the found database as a tuple of (found hook name, database configuration dict).
Return the found data source as a tuple of (found hook name, data source configuration dict) or
(None, None) if not found.
'''
if not configuration_database_name:
configuration_database_name = database_name
if not configuration_data_source_name:
configuration_data_source_name = data_source_name
if hook_name == UNSPECIFIED_HOOK:
hooks_to_search = hooks
hooks_to_search = {
hook_name: value
for (hook_name, value) in config.items()
if hook_name in borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES
}
else:
hooks_to_search = {hook_name: hooks[hook_name]}
try:
hooks_to_search = {hook_name: config[hook_name]}
except KeyError:
return (None, None)
return next(
(
(name, hook_database)
(name, hook_data_source)
for (name, hook) in hooks_to_search.items()
for hook_database in hook
if hook_database['name'] == configuration_database_name
and database_name in archive_database_names.get(name, [])
for hook_data_source in hook
if hook_data_source['name'] == configuration_data_source_name
and data_source_name in archive_data_source_names.get(name, [])
),
(None, None),
)
def get_configured_hook_name_and_database(hooks, database_name):
def strip_path_prefix_from_extracted_dump_destination(
destination_path, borgmatic_runtime_directory
):
'''
Find the hook name and first database dict with the given database name in the configured hooks
dict. This searches across all database hooks.
Directory-format dump files get extracted into a temporary directory containing a path prefix
that depends how the files were stored in the archive. So, given the destination path where the
dump was extracted and the borgmatic runtime directory, move the dump files such that the
restore doesn't have to deal with that varying path prefix.
For instance, if the dump was extracted to:
/run/user/0/borgmatic/tmp1234/borgmatic/postgresql_databases/test/...
or:
/run/user/0/borgmatic/tmp1234/root/.borgmatic/postgresql_databases/test/...
then this function moves it to:
/run/user/0/borgmatic/postgresql_databases/test/...
'''
for subdirectory_path, _, _ in os.walk(destination_path):
databases_directory = os.path.basename(subdirectory_path)
if not databases_directory.endswith('_databases'):
continue
shutil.move(
subdirectory_path, os.path.join(borgmatic_runtime_directory, databases_directory)
)
break
def restore_single_database(
def restore_single_data_source(
repository,
location,
storage,
hooks,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
archive_name,
hook_name,
database,
): # pragma: no cover
'''
Given (among other things) an archive name, a database hook name, and a configured database
configuration dict, restore that database from the archive.
'''
logger.info(f'{repository}: Restoring database {database["name"]}')
dump_pattern = borgmatic.hooks.dispatch.call_hooks(
'make_database_dump_pattern',
hooks,
repository,
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
location,
database['name'],
)[hook_name]
# Kick off a single database extract to stdout.
extract_process = borgmatic.borg.extract.extract_archive(
dry_run=global_arguments.dry_run,
repository=repository,
archive=archive_name,
paths=borgmatic.hooks.dump.convert_glob_patterns_to_borg_patterns([dump_pattern]),
location_config=location,
storage_config=storage,
local_borg_version=local_borg_version,
local_path=local_path,
remote_path=remote_path,
destination_path='/',
# 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(database.get('format') != 'directory'),
)
# Run a single database restore, consuming the extract stdout (if any).
borgmatic.hooks.dispatch.call_hooks(
'restore_database_dump',
{hook_name: [database]},
repository,
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
extract_process,
)
def collect_archive_database_names(
repository, archive, location, storage, local_borg_version, local_path, remote_path,
data_source,
connection_params,
borgmatic_runtime_directory,
):
'''
Given a local or remote repository path, a resolved archive name, a location configuration dict,
a storage configuration dict, the local Borg version, and local and remote Borg paths, query the
archive for the names of databases it contains and return them as a dict from hook name to a
sequence of database names.
Given (among other things) an archive name, a data source hook name, the hostname, port,
username/password as connection params, and a configured data source configuration dict, restore
that data source from the archive.
'''
borgmatic_source_directory = os.path.expanduser(
location.get(
'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
)
).lstrip('/')
parent_dump_path = os.path.expanduser(
borgmatic.hooks.dump.make_database_dump_path(borgmatic_source_directory, '*_databases/*/*')
logger.info(
f'{repository.get("label", repository["path"])}: Restoring data source {data_source["name"]}'
)
dump_patterns = borgmatic.hooks.dispatch.call_hooks(
'make_data_source_dump_patterns',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic_runtime_directory,
data_source['name'],
)[hook_name]
destination_path = (
tempfile.mkdtemp(dir=borgmatic_runtime_directory)
if data_source.get('format') == 'directory'
else None
)
try:
# Kick off a single data source extract. If using a directory format, extract to a temporary
# directory. Otherwise extract the single dump file to stdout.
extract_process = borgmatic.borg.extract.extract_archive(
dry_run=global_arguments.dry_run,
repository=repository['path'],
archive=archive_name,
paths=[borgmatic.hooks.dump.convert_glob_patterns_to_borg_pattern(dump_patterns)],
config=config,
local_borg_version=local_borg_version,
global_arguments=global_arguments,
local_path=local_path,
remote_path=remote_path,
destination_path=destination_path,
# A directory format dump isn't a single file, and therefore can't extract
# to stdout. In this case, the extract_process return value is None.
extract_to_stdout=bool(data_source.get('format') != 'directory'),
)
if destination_path and not global_arguments.dry_run:
strip_path_prefix_from_extracted_dump_destination(
destination_path, borgmatic_runtime_directory
)
finally:
if destination_path and not global_arguments.dry_run:
shutil.rmtree(destination_path, ignore_errors=True)
# Run a single data source restore, consuming the extract stdout (if any).
borgmatic.hooks.dispatch.call_hooks(
function_name='restore_data_source_dump',
config=config,
log_prefix=repository['path'],
hook_names=[hook_name],
data_source=data_source,
dry_run=global_arguments.dry_run,
extract_process=extract_process,
connection_params=connection_params,
borgmatic_runtime_directory=borgmatic_runtime_directory,
)
def collect_archive_data_source_names(
repository,
archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
):
'''
Given a local or remote repository path, a resolved archive name, a configuration dict, the
local Borg version, global_arguments an argparse.Namespace, local and remote Borg paths, and the
borgmatic runtime directory, query the archive for the names of data sources it contains as
dumps and return them as a dict from hook name to a sequence of data source names.
'''
borgmatic_source_directory = str(
pathlib.Path(borgmatic.config.paths.get_borgmatic_source_directory(config))
)
# Probe for the data source dumps in multiple locations, as the default location has moved to
# the borgmatic runtime directory (which get stored as just "/borgmatic" with Borg 1.4+). But we
# still want to support reading dumps from previously created archives as well.
dump_paths = borgmatic.borg.list.capture_archive_listing(
repository,
archive,
storage,
config,
local_borg_version,
list_path=parent_dump_path,
global_arguments,
list_paths=[
'sh:'
+ borgmatic.hooks.dump.make_data_source_dump_path(base_directory, '*_databases/*/*')
for base_directory in (
'borgmatic',
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
borgmatic_source_directory.lstrip('/'),
)
],
local_path=local_path,
remote_path=remote_path,
)
# Determine the database names corresponding to the dumps found in the archive and
# Determine the data source names corresponding to the dumps found in the archive and
# add them to restore_names.
archive_database_names = {}
archive_data_source_names = {}
for dump_path in dump_paths:
try:
(hook_name, _, database_name) = dump_path.split(
borgmatic_source_directory + os.path.sep, 1
)[1].split(os.path.sep)[0:3]
except (ValueError, IndexError):
logger.warning(
f'{repository}: Ignoring invalid database dump path "{dump_path}" in archive {archive}'
)
if not dump_path:
continue
for base_directory in (
'borgmatic',
borgmatic_runtime_directory,
borgmatic_source_directory,
):
try:
(hook_name, _, data_source_name) = dump_path.split(base_directory + os.path.sep, 1)[
1
].split(os.path.sep)[0:3]
except (ValueError, IndexError):
pass
else:
if data_source_name not in archive_data_source_names.get(hook_name, []):
archive_data_source_names.setdefault(hook_name, []).extend([data_source_name])
break
else:
if database_name not in archive_database_names.get(hook_name, []):
archive_database_names.setdefault(hook_name, []).extend([database_name])
logger.warning(
f'{repository}: Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
)
return archive_database_names
return archive_data_source_names
def find_databases_to_restore(requested_database_names, archive_database_names):
def find_data_sources_to_restore(requested_data_source_names, archive_data_source_names):
'''
Given a sequence of requested database names to restore and a dict of hook name to the names of
databases found in an archive, return an expanded sequence of database names to restore,
replacing "all" with actual database names as appropriate.
Given a sequence of requested data source names to restore and a dict of hook name to the names
of data sources found in an archive, return an expanded sequence of data source names to
restore, replacing "all" with actual data source names as appropriate.
Raise ValueError if any of the requested database names cannot be found in the archive.
Raise ValueError if any of the requested data source names cannot be found in the archive.
'''
# A map from database hook name to the database names to restore for that hook.
# A map from data source hook name to the data source names to restore for that hook.
restore_names = (
{UNSPECIFIED_HOOK: requested_database_names}
if requested_database_names
{UNSPECIFIED_HOOK: requested_data_source_names}
if requested_data_source_names
else {UNSPECIFIED_HOOK: ['all']}
)
@@ -180,64 +268,65 @@ def find_databases_to_restore(requested_database_names, archive_database_names):
if 'all' in restore_names[UNSPECIFIED_HOOK]:
restore_names[UNSPECIFIED_HOOK].remove('all')
for (hook_name, database_names) in archive_database_names.items():
restore_names.setdefault(hook_name, []).extend(database_names)
for hook_name, data_source_names in archive_data_source_names.items():
restore_names.setdefault(hook_name, []).extend(data_source_names)
# If a database is to be restored as part of "all", then remove it from restore names so
# it doesn't get restored twice.
for database_name in database_names:
if database_name in restore_names[UNSPECIFIED_HOOK]:
restore_names[UNSPECIFIED_HOOK].remove(database_name)
# If a data source is to be restored as part of "all", then remove it from restore names
# so it doesn't get restored twice.
for data_source_name in data_source_names:
if data_source_name in restore_names[UNSPECIFIED_HOOK]:
restore_names[UNSPECIFIED_HOOK].remove(data_source_name)
if not restore_names[UNSPECIFIED_HOOK]:
restore_names.pop(UNSPECIFIED_HOOK)
combined_restore_names = set(
name for database_names in restore_names.values() for name in database_names
name for data_source_names in restore_names.values() for name in data_source_names
)
combined_archive_database_names = set(
name for database_names in archive_database_names.values() for name in database_names
combined_archive_data_source_names = set(
name
for data_source_names in archive_data_source_names.values()
for name in data_source_names
)
missing_names = sorted(set(combined_restore_names) - combined_archive_database_names)
missing_names = sorted(set(combined_restore_names) - combined_archive_data_source_names)
if missing_names:
joined_names = ', '.join(f'"{name}"' for name in missing_names)
raise ValueError(
f"Cannot restore database{'s' if len(missing_names) > 1 else ''} {joined_names} missing from archive"
f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from archive"
)
return restore_names
def ensure_databases_found(restore_names, remaining_restore_names, found_names):
def ensure_data_sources_found(restore_names, remaining_restore_names, found_names):
'''
Given a dict from hook name to database names to restore, a dict from hook name to remaining
database names to restore, and a sequence of found (actually restored) database names, raise
ValueError if requested databases to restore were missing from the archive and/or configuration.
Given a dict from hook name to data source names to restore, a dict from hook name to remaining
data source names to restore, and a sequence of found (actually restored) data source names,
raise ValueError if requested data source to restore were missing from the archive and/or
configuration.
'''
combined_restore_names = set(
name
for database_names in tuple(restore_names.values())
for data_source_names in tuple(restore_names.values())
+ tuple(remaining_restore_names.values())
for name in database_names
for name in data_source_names
)
if not combined_restore_names and not found_names:
raise ValueError('No databases were found to restore')
raise ValueError('No data source dumps were found to restore')
missing_names = sorted(set(combined_restore_names) - set(found_names))
if missing_names:
joined_names = ', '.join(f'"{name}"' for name in missing_names)
raise ValueError(
f"Cannot restore database{'s' if len(missing_names) > 1 else ''} {joined_names} missing from borgmatic's configuration"
f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from borgmatic's configuration"
)
def run_restore(
repository,
location,
storage,
hooks,
config,
local_borg_version,
restore_arguments,
global_arguments,
@@ -248,98 +337,123 @@ def run_restore(
Run the "restore" action for the given repository, but only if the repository matches the
requested repository in restore arguments.
Raise ValueError if a configured database could not be found to restore.
Raise ValueError if a configured data source could not be found to restore.
'''
if restore_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, restore_arguments.repository
):
return
logger.info(
'{}: Restoring databases from archive {}'.format(repository, restore_arguments.archive)
)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_database_dumps',
hooks,
repository,
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
log_prefix = repository.get('label', repository['path'])
logger.info(f'{log_prefix}: Restoring data sources from archive {restore_arguments.archive}')
archive_name = borgmatic.borg.rlist.resolve_archive_name(
repository, restore_arguments.archive, storage, local_borg_version, local_path, remote_path,
)
archive_database_names = collect_archive_database_names(
repository, archive_name, location, storage, local_borg_version, local_path, remote_path,
)
restore_names = find_databases_to_restore(restore_arguments.databases, archive_database_names)
found_names = set()
remaining_restore_names = {}
with borgmatic.config.paths.Runtime_directory(
config, log_prefix
) as borgmatic_runtime_directory:
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
for hook_name, database_names in restore_names.items():
for database_name in database_names:
found_hook_name, found_database = get_configured_database(
hooks, archive_database_names, hook_name, database_name
)
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
restore_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
archive_data_source_names = collect_archive_data_source_names(
repository['path'],
archive_name,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
)
restore_names = find_data_sources_to_restore(
restore_arguments.data_sources, archive_data_source_names
)
found_names = set()
remaining_restore_names = {}
connection_params = {
'hostname': restore_arguments.hostname,
'port': restore_arguments.port,
'username': restore_arguments.username,
'password': restore_arguments.password,
'restore_path': restore_arguments.restore_path,
}
if not found_database:
remaining_restore_names.setdefault(found_hook_name or hook_name, []).append(
database_name
for hook_name, data_source_names in restore_names.items():
for data_source_name in data_source_names:
found_hook_name, found_data_source = get_configured_data_source(
config, archive_data_source_names, hook_name, data_source_name
)
continue
found_names.add(database_name)
restore_single_database(
repository,
location,
storage,
hooks,
local_borg_version,
global_arguments,
local_path,
remote_path,
archive_name,
found_hook_name or hook_name,
found_database,
)
if not found_data_source:
remaining_restore_names.setdefault(found_hook_name or hook_name, []).append(
data_source_name
)
continue
# For any database that weren't found via exact matches in the hooks configuration, try to
# fallback to "all" entries.
for hook_name, database_names in remaining_restore_names.items():
for database_name in database_names:
found_hook_name, found_database = get_configured_database(
hooks, archive_database_names, hook_name, database_name, 'all'
)
found_names.add(data_source_name)
restore_single_data_source(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
archive_name,
found_hook_name or hook_name,
dict(found_data_source, **{'schemas': restore_arguments.schemas}),
connection_params,
borgmatic_runtime_directory,
)
if not found_database:
continue
# For any data sources that weren't found via exact matches in the configuration, try to
# fallback to "all" entries.
for hook_name, data_source_names in remaining_restore_names.items():
for data_source_name in data_source_names:
found_hook_name, found_data_source = get_configured_data_source(
config, archive_data_source_names, hook_name, data_source_name, 'all'
)
found_names.add(database_name)
database = copy.copy(found_database)
database['name'] = database_name
if not found_data_source:
continue
restore_single_database(
repository,
location,
storage,
hooks,
local_borg_version,
global_arguments,
local_path,
remote_path,
archive_name,
found_hook_name or hook_name,
database,
)
found_names.add(data_source_name)
data_source = copy.copy(found_data_source)
data_source['name'] = data_source_name
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_database_dumps',
hooks,
repository,
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
location,
global_arguments.dry_run,
)
restore_single_data_source(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
archive_name,
found_hook_name or hook_name,
dict(data_source, **{'schemas': restore_arguments.schemas}),
connection_params,
borgmatic_runtime_directory,
)
ensure_databases_found(restore_names, remaining_restore_names, found_names)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
ensure_data_sources_found(restore_names, remaining_restore_names, found_names)
-32
View File
@@ -1,32 +0,0 @@
import json
import logging
import borgmatic.borg.rinfo
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_rinfo(
repository, storage, local_borg_version, rinfo_arguments, local_path, remote_path,
):
'''
Run the "rinfo" action for the given repository.
If rinfo_arguments.json is True, yield the JSON output from the info for the repository.
'''
if rinfo_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, rinfo_arguments.repository
):
if not rinfo_arguments.json: # pragma: nocover
logger.answer('{}: Displaying repository summary information'.format(repository))
json_output = borgmatic.borg.rinfo.display_repository_info(
repository,
storage,
local_borg_version,
rinfo_arguments=rinfo_arguments,
local_path=local_path,
remote_path=remote_path,
)
if json_output: # pragma: nocover
yield json.loads(json_output)
-32
View File
@@ -1,32 +0,0 @@
import json
import logging
import borgmatic.borg.rlist
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_rlist(
repository, storage, local_borg_version, rlist_arguments, local_path, remote_path,
):
'''
Run the "rlist" action for the given repository.
If rlist_arguments.json is True, yield the JSON output from listing the repository.
'''
if rlist_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, rlist_arguments.repository
):
if not rlist_arguments.json: # pragma: nocover
logger.answer('{}: Listing repository'.format(repository))
json_output = borgmatic.borg.rlist.list_repository(
repository,
storage,
local_borg_version,
rlist_arguments=rlist_arguments,
local_path=local_path,
remote_path=remote_path,
)
if json_output: # pragma: nocover
yield json.loads(json_output)
+7 -4
View File
@@ -7,7 +7,7 @@ logger = logging.getLogger(__name__)
def run_transfer(
repository,
storage,
config,
local_borg_version,
transfer_arguments,
global_arguments,
@@ -17,13 +17,16 @@ def run_transfer(
'''
Run the "transfer" action for the given repository.
'''
logger.info(f'{repository}: Transferring archives to repository')
logger.info(
f'{repository.get("label", repository["path"])}: Transferring archives to repository'
)
borgmatic.borg.transfer.transfer_archives(
global_arguments.dry_run,
repository,
storage,
repository['path'],
config,
local_borg_version,
transfer_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
+28 -22
View File
@@ -1,20 +1,21 @@
import logging
import shlex
import borgmatic.commands.arguments
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, flags
from borgmatic.execute import execute_command
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
REPOSITORYLESS_BORG_COMMANDS = {'serve', None}
BORG_SUBCOMMANDS_WITH_SUBCOMMANDS = {'key', 'debug'}
BORG_SUBCOMMANDS_WITHOUT_REPOSITORY = (('debug', 'info'), ('debug', 'convert-profile'), ())
def run_arbitrary_borg(
repository,
storage_config,
repository_path,
config,
local_borg_version,
options,
archive=None,
@@ -22,12 +23,13 @@ def run_arbitrary_borg(
remote_path=None,
):
'''
Given a local or remote repository path, a storage config dict, the local Borg version, a
Given a local or remote repository path, a configuration dict, the local Borg version, a
sequence of arbitrary command-line Borg options, and an optional archive name, run an arbitrary
Borg command on the given repository/archive.
Borg command, passing in REPOSITORY and ARCHIVE environment variables for optional use in the
command.
'''
borgmatic.logger.add_custom_log_levels()
lock_wait = storage_config.get('lock_wait', None)
lock_wait = config.get('lock_wait', None)
try:
options = options[1:] if options[0] == '--' else options
@@ -36,33 +38,37 @@ def run_arbitrary_borg(
command_options_start_index = 2 if options[0] in BORG_SUBCOMMANDS_WITH_SUBCOMMANDS else 1
borg_command = tuple(options[:command_options_start_index])
command_options = tuple(options[command_options_start_index:])
if borg_command and borg_command[0] in borgmatic.commands.arguments.ACTION_ALIASES.keys():
logger.warning(
f"Borg's {borg_command[0]} subcommand is supported natively by borgmatic. Try this instead: borgmatic {borg_command[0]}"
)
except IndexError:
borg_command = ()
command_options = ()
if borg_command in BORG_SUBCOMMANDS_WITHOUT_REPOSITORY:
repository_archive_flags = ()
elif archive:
repository_archive_flags = flags.make_repository_archive_flags(
repository, archive, local_borg_version
)
else:
repository_archive_flags = flags.make_repository_flags(repository, local_borg_version)
full_command = (
(local_path,)
+ borg_command
+ repository_archive_flags
+ command_options
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('lock-wait', lock_wait)
+ command_options
)
return execute_command(
full_command,
output_log_level=logging.ANSWER,
tuple(shlex.quote(part) for part in full_command),
output_file=DO_NOT_CAPTURE,
shell=True,
extra_environment=dict(
(environment.make_environment(config) or {}),
**{
'BORG_REPO': repository_path,
'ARCHIVE': archive if archive else '',
},
),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
extra_environment=environment.make_environment(storage_config),
borg_exit_codes=config.get('borg_exit_codes'),
)
+22 -9
View File
@@ -1,5 +1,6 @@
import logging
import borgmatic.config.paths
from borgmatic.borg import environment, flags
from borgmatic.execute import execute_command
@@ -7,25 +8,37 @@ logger = logging.getLogger(__name__)
def break_lock(
repository, storage_config, local_borg_version, local_path='borg', remote_path=None,
repository_path,
config,
local_borg_version,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a storage configuration dict, the local Borg version,
and optional local and remote Borg paths, break any repository and cache locks leftover from Borg
aborting.
Given a local or remote repository path, a configuration dict, the local Borg version, an
argparse.Namespace of global arguments, and optional local and remote Borg paths, break any
repository and cache locks leftover from Borg aborting.
'''
umask = storage_config.get('umask', None)
lock_wait = storage_config.get('lock_wait', None)
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
full_command = (
(local_path, 'break-lock')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ flags.make_repository_flags(repository, local_borg_version)
+ flags.make_repository_flags(repository_path, local_borg_version)
)
borg_environment = environment.make_environment(storage_config)
execute_command(full_command, borg_local_path=local_path, extra_environment=borg_environment)
borg_environment = environment.make_environment(config)
execute_command(
full_command,
extra_environment=borg_environment,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
+67
View File
@@ -0,0 +1,67 @@
import logging
import borgmatic.config.paths
import borgmatic.execute
import borgmatic.logger
from borgmatic.borg import environment, flags
logger = logging.getLogger(__name__)
def change_passphrase(
repository_path,
config,
local_borg_version,
change_passphrase_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, change
passphrase arguments, and optional local and remote Borg paths, change the repository passphrase
based on an interactive prompt.
'''
borgmatic.logger.add_custom_log_levels()
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
full_command = (
(local_path, 'key', 'change-passphrase')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ flags.make_repository_flags(
repository_path,
local_borg_version,
)
)
if global_arguments.dry_run:
logger.info(f'{repository_path}: Skipping change password (dry run)')
return
# If the original passphrase is set programmatically, then Borg won't prompt for a new one! So
# don't give Borg any passphrase, and it'll ask the user for both old and new ones.
config_without_passphrase = {
option_name: value
for (option_name, value) in config.items()
if option_name not in ('encryption_passphrase', 'encryption_passcommand')
}
borgmatic.execute.execute_command(
full_command,
output_file=borgmatic.execute.DO_NOT_CAPTURE,
output_log_level=logging.ANSWER,
extra_environment=environment.make_environment(config_without_passphrase),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
logger.answer(
f"{repository_path}: Don't forget to update your encryption_passphrase option (if needed)"
)
+131 -272
View File
@@ -1,155 +1,60 @@
import argparse
import datetime
import json
import logging
import os
import pathlib
from borgmatic.borg import environment, extract, feature, flags, rinfo, state
import borgmatic.config.paths
from borgmatic.borg import environment, feature, flags, repo_info
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
DEFAULT_CHECKS = (
{'name': 'repository', 'frequency': '1 month'},
{'name': 'archives', 'frequency': '1 month'},
)
DEFAULT_PREFIX = '{hostname}-'
logger = logging.getLogger(__name__)
def parse_checks(consistency_config, only_checks=None):
def make_archive_filter_flags(local_borg_version, config, checks, check_arguments):
'''
Given a consistency config with a "checks" sequence of dicts and an optional list of override
checks, return a tuple of named checks to run.
Given the local Borg version, a configuration dict, a parsed sequence of checks, and check
arguments as an argparse.Namespace instance, transform the checks into tuple of command-line
flags for filtering archives in a check command.
For example, given a retention config of:
{'checks': ({'name': 'repository'}, {'name': 'archives'})}
This will be returned as:
('repository', 'archives')
If no "checks" option is present in the config, return the DEFAULT_CHECKS. If a checks value
has a name of "disabled", return an empty tuple, meaning that no checks should be run.
If "check_last" is set in the configuration and "archives" is in checks, then include a "--last"
flag. And if "prefix" is set in configuration and "archives" is in checks, then include a
"--match-archives" flag.
'''
checks = only_checks or tuple(
check_config['name']
for check_config in (consistency_config.get('checks', None) or DEFAULT_CHECKS)
)
checks = tuple(check.lower() for check in checks)
if 'disabled' in checks:
if len(checks) > 1:
logger.warning(
'Multiple checks are configured, but one of them is "disabled"; not running any checks'
check_last = config.get('check_last', None)
prefix = config.get('prefix')
if 'archives' in checks or 'data' in checks:
return (('--last', str(check_last)) if check_last else ()) + (
(
('--match-archives', f'sh:{prefix}*')
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version)
else ('--glob-archives', f'{prefix}*')
)
if prefix
else (
flags.make_match_archives_flags(
check_arguments.match_archives or config.get('match_archives'),
config.get('archive_name_format'),
local_borg_version,
)
)
return ()
return checks
def parse_frequency(frequency):
'''
Given a frequency string with a number and a unit of time, return a corresponding
datetime.timedelta instance or None if the frequency is None or "always".
For instance, given "3 weeks", return datetime.timedelta(weeks=3)
Raise ValueError if the given frequency cannot be parsed.
'''
if not frequency:
return None
frequency = frequency.strip().lower()
if frequency == 'always':
return None
try:
number, time_unit = frequency.split(' ')
number = int(number)
except ValueError:
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
if not time_unit.endswith('s'):
time_unit += 's'
if time_unit == 'months':
number *= 30
time_unit = 'days'
elif time_unit == 'years':
number *= 365
time_unit = 'days'
try:
return datetime.timedelta(**{time_unit: number})
except TypeError:
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
def filter_checks_on_frequency(
location_config, consistency_config, borg_repository_id, checks, force
):
'''
Given a location config, a consistency config with a "checks" sequence of dicts, a Borg
repository ID, a sequence of checks, and whether to force checks to run, filter down those
checks based on the configured "frequency" for each check as compared to its check time file.
In other words, a check whose check time file's timestamp is too new (based on the configured
frequency) will get cut from the returned sequence of checks. Example:
consistency_config = {
'checks': [
{
'name': 'archives',
'frequency': '2 weeks',
},
]
}
When this function is called with that consistency_config and "archives" in checks, "archives"
will get filtered out of the returned result if its check time file is newer than 2 weeks old,
indicating that it's not yet time to run that check again.
Raise ValueError if a frequency cannot be parsed.
'''
filtered_checks = list(checks)
if force:
return tuple(filtered_checks)
for check_config in consistency_config.get('checks', DEFAULT_CHECKS):
check = check_config['name']
if checks and check not in checks:
continue
frequency_delta = parse_frequency(check_config.get('frequency'))
if not frequency_delta:
continue
check_time = read_check_time(
make_check_time_path(location_config, borg_repository_id, check)
)
if not check_time:
continue
# If we've not yet reached the time when the frequency dictates we're ready for another
# check, skip this check.
if datetime.datetime.now() < check_time + frequency_delta:
remaining = check_time + frequency_delta - datetime.datetime.now()
logger.info(
f'Skipping {check} check due to configured frequency; {remaining} until next check'
)
filtered_checks.remove(check)
if check_last:
logger.warning(
'Ignoring check_last option, as "archives" or "data" are not in consistency checks'
)
if prefix:
logger.warning(
'Ignoring consistency prefix option, as "archives" or "data" are not in consistency checks'
)
return tuple(filtered_checks)
return ()
def make_check_flags(local_borg_version, checks, check_last=None, prefix=None):
def make_check_name_flags(checks, archive_filter_flags):
'''
Given the local Borg version and a parsed sequence of checks, transform the checks into tuple of
command-line flags.
Given parsed checks set and a sequence of flags to filter archives, transform the checks into
tuple of command-line check flags.
For example, given parsed checks of:
@@ -161,174 +66,128 @@ def make_check_flags(local_borg_version, checks, check_last=None, prefix=None):
However, if both "repository" and "archives" are in checks, then omit them from the returned
flags because Borg does both checks by default. If "data" is in checks, that implies "archives".
Additionally, if a check_last value is given and "archives" is in checks, then include a
"--last" flag. And if a prefix value is given and "archives" is in checks, then include a
"--match-archives" flag.
'''
if 'data' in checks:
data_flags = ('--verify-data',)
checks += ('archives',)
checks.update({'archives'})
else:
data_flags = ()
if 'archives' in checks:
last_flags = ('--last', str(check_last)) if check_last else ()
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
match_archives_flags = ('--match-archives', f'sh:{prefix}*') if prefix else ()
else:
match_archives_flags = ('--glob-archives', f'{prefix}*') if prefix else ()
else:
last_flags = ()
match_archives_flags = ()
if check_last:
logger.warning(
'Ignoring check_last option, as "archives" or "data" are not in consistency checks'
)
if prefix:
logger.warning(
'Ignoring consistency prefix option, as "archives" or "data" are not in consistency checks'
)
common_flags = (archive_filter_flags if 'archives' in checks else ()) + data_flags
common_flags = last_flags + match_archives_flags + data_flags
if {'repository', 'archives'}.issubset(set(checks)):
if {'repository', 'archives'}.issubset(checks):
return common_flags
return (
tuple('--{}-only'.format(check) for check in checks if check in ('repository', 'archives'))
tuple(f'--{check}-only' for check in checks if check in ('repository', 'archives'))
+ common_flags
)
def make_check_time_path(location_config, borg_repository_id, check_type):
'''
Given a location configuration dict, a Borg repository ID, and the name of a check type
("repository", "archives", etc.), return a path for recording that check's time (the time of
that check last occurring).
'''
return os.path.join(
os.path.expanduser(
location_config.get(
'borgmatic_source_directory', state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
)
),
'checks',
borg_repository_id,
check_type,
)
def write_check_time(path): # pragma: no cover
'''
Record a check time of now as the modification time of the given path.
'''
logger.debug(f'Writing check time at {path}')
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
pathlib.Path(path, mode=0o600).touch()
def read_check_time(path):
'''
Return the check time based on the modification time of the given path. Return None if the path
doesn't exist.
'''
logger.debug(f'Reading check time from {path}')
try:
return datetime.datetime.fromtimestamp(os.stat(path).st_mtime)
except FileNotFoundError:
return None
def check_archives(
repository,
location_config,
storage_config,
consistency_config,
local_borg_version,
local_path='borg',
remote_path=None,
progress=None,
repair=None,
only_checks=None,
force=None,
def get_repository_id(
repository_path, config, local_borg_version, global_arguments, local_path, remote_path
):
'''
Given a local or remote repository path, a storage config dict, a consistency config dict,
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.
Given a local or remote repository path, a configuration dict, the local Borg version, global
arguments, and local/remote commands to run, return the corresponding Borg repository ID.
If there are no consistency checks to run, skip running them.
Raises ValueError if the Borg repository ID cannot be determined.
Raise ValueError if the Borg repository ID cannot be determined.
'''
try:
borg_repository_id = json.loads(
rinfo.display_repository_info(
repository,
storage_config,
return json.loads(
repo_info.display_repository_info(
repository_path,
config,
local_borg_version,
argparse.Namespace(json=True),
global_arguments,
local_path,
remote_path,
)
)['repository']['id']
except (json.JSONDecodeError, KeyError):
raise ValueError(f'Cannot determine Borg repository ID for {repository}')
raise ValueError(f'Cannot determine Borg repository ID for {repository_path}')
checks = filter_checks_on_frequency(
location_config,
consistency_config,
borg_repository_id,
parse_checks(consistency_config, only_checks),
force,
def check_archives(
repository_path,
config,
local_borg_version,
check_arguments,
global_arguments,
checks,
archive_filter_flags,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, check
arguments as an argparse.Namespace instance, global arguments, a set of named Borg checks to run
(some combination "repository", "archives", and/or "data"), archive filter flags, and
local/remote commands to run, check the contained Borg archives for consistency.
'''
lock_wait = config.get('lock_wait')
extra_borg_options = config.get('extra_borg_options', {}).get('check', '')
verbosity_flags = ()
if logger.isEnabledFor(logging.INFO):
verbosity_flags = ('--info',)
if logger.isEnabledFor(logging.DEBUG):
verbosity_flags = ('--debug', '--show-rc')
try:
repository_check_config = next(
check for check in config.get('checks', ()) if check.get('name') == 'repository'
)
except StopIteration:
repository_check_config = {}
if check_arguments.max_duration and 'archives' in checks:
raise ValueError('The archives check cannot run when the --max-duration flag is used')
if repository_check_config.get('max_duration') and 'archives' in checks:
raise ValueError(
'The archives check cannot run when the repository check has the max_duration option set'
)
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
umask = config.get('umask')
borg_environment = environment.make_environment(config)
borg_exit_codes = config.get('borg_exit_codes')
full_command = (
(local_path, 'check')
+ (('--repair',) if check_arguments.repair else ())
+ (('--max-duration', str(max_duration)) if max_duration else ())
+ make_check_name_flags(checks, archive_filter_flags)
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags
+ (('--progress',) if check_arguments.progress else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
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({'repository', 'archives', 'data'}):
lock_wait = storage_config.get('lock_wait', None)
working_directory = borgmatic.config.paths.get_working_directory(config)
verbosity_flags = ()
if logger.isEnabledFor(logging.INFO):
verbosity_flags = ('--info',)
if logger.isEnabledFor(logging.DEBUG):
verbosity_flags = ('--debug', '--show-rc')
prefix = consistency_config.get('prefix', DEFAULT_PREFIX)
full_command = (
(local_path, 'check')
+ (('--repair',) if repair else ())
+ make_check_flags(local_borg_version, checks, check_last, prefix)
+ (('--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 ())
+ flags.make_repository_flags(repository, local_borg_version)
# The Borg repair option triggers an interactive prompt, which won't work when output is
# captured. And progress messes with the terminal directly.
if check_arguments.repair or check_arguments.progress:
execute_command(
full_command,
output_file=DO_NOT_CAPTURE,
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
borg_environment = environment.make_environment(storage_config)
# The Borg repair option triggers an interactive prompt, which won't work when output is
# captured. And progress messes with the terminal directly.
if repair or progress:
execute_command(
full_command, output_file=DO_NOT_CAPTURE, extra_environment=borg_environment
)
else:
execute_command(full_command, extra_environment=borg_environment)
for check in checks:
write_check_time(make_check_time_path(location_config, borg_repository_id, check))
if 'extract' in checks:
extract.extract_last_archive_dry_run(
storage_config, local_borg_version, repository, lock_wait, local_path, remote_path
else:
execute_command(
full_command,
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
write_check_time(make_check_time_path(location_config, borg_repository_id, 'extract'))
+15 -10
View File
@@ -1,5 +1,6 @@
import logging
import borgmatic.config.paths
from borgmatic.borg import environment, flags
from borgmatic.execute import execute_command
@@ -8,9 +9,10 @@ logger = logging.getLogger(__name__)
def compact_segments(
dry_run,
repository,
storage_config,
repository_path,
config,
local_borg_version,
global_arguments,
local_path='borg',
remote_path=None,
progress=False,
@@ -18,17 +20,18 @@ def compact_segments(
threshold=None,
):
'''
Given dry-run flag, a local or remote repository path, a storage config dict, and the local
Borg version, compact the segments in a repository.
Given dry-run flag, a local or remote repository path, a configuration dict, and the local Borg
version, compact the segments in a repository.
'''
umask = storage_config.get('umask', None)
lock_wait = storage_config.get('lock_wait', None)
extra_borg_options = storage_config.get('extra_borg_options', {}).get('compact', '')
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
extra_borg_options = config.get('extra_borg_options', {}).get('compact', '')
full_command = (
(local_path, 'compact')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--progress',) if progress else ())
+ (('--cleanup-commits',) if cleanup_commits else ())
@@ -36,16 +39,18 @@ def compact_segments(
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_flags(repository, local_borg_version)
+ flags.make_repository_flags(repository_path, local_borg_version)
)
if dry_run:
logging.info(f'{repository}: Skipping compact (dry run)')
logging.info(f'{repository_path}: Skipping compact (dry run)')
return
execute_command(
full_command,
output_log_level=logging.INFO,
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
extra_environment=environment.make_environment(storage_config),
borg_exit_codes=config.get('borg_exit_codes'),
)
+232 -136
View File
@@ -5,9 +5,11 @@ import os
import pathlib
import stat
import tempfile
import textwrap
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, feature, flags, state
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import (
DO_NOT_CAPTURE,
execute_command,
@@ -18,26 +20,28 @@ from borgmatic.execute import (
logger = logging.getLogger(__name__)
def expand_directory(directory):
def expand_directory(directory, working_directory):
'''
Given a directory path, expand any tilde (representing a user's home directory) and any globs
therein. Return a list of one or more resulting paths.
'''
expanded_directory = os.path.expanduser(directory)
expanded_directory = os.path.join(working_directory or '', os.path.expanduser(directory))
return glob.glob(expanded_directory) or [expanded_directory]
def expand_directories(directories):
def expand_directories(directories, working_directory=None):
'''
Given a sequence of directory paths, expand tildes and globs in each one. Return all the
resulting directories as a single flattened tuple.
Given a sequence of directory paths and an optional working directory, expand tildes and globs
in each one. Return all the resulting directories as a single flattened tuple.
'''
if directories is None:
return ()
return tuple(
itertools.chain.from_iterable(expand_directory(directory) for directory in directories)
itertools.chain.from_iterable(
expand_directory(directory, working_directory) for directory in directories
)
)
@@ -52,17 +56,19 @@ def expand_home_directories(directories):
return tuple(os.path.expanduser(directory) for directory in directories)
def map_directories_to_devices(directories):
def map_directories_to_devices(directories, working_directory=None):
'''
Given a sequence of directories, return a map from directory to an identifier for the device on
which that directory resides or None if the path doesn't exist.
Given a sequence of directories and an optional working directory, return a map from directory
to an identifier for the device on which that directory resides or None if the path doesn't
exist.
This is handy for determining whether two different directories are on the same filesystem (have
the same device identifier).
'''
return {
directory: os.stat(directory).st_dev if os.path.exists(directory) else None
directory: os.stat(full_directory).st_dev if os.path.exists(full_directory) else None
for directory in directories
for full_directory in (os.path.join(working_directory or '', directory),)
}
@@ -146,12 +152,12 @@ def ensure_files_readable(*filename_lists):
open(file_object).close()
def make_pattern_flags(location_config, pattern_filename=None):
def make_pattern_flags(config, pattern_filename=None):
'''
Given a location config dict with a potential patterns_from option, and a filename containing
any additional patterns, return the corresponding Borg flags for those files as a tuple.
Given a configuration dict with a potential patterns_from option, and a filename containing any
additional patterns, return the corresponding Borg flags for those files as a tuple.
'''
pattern_filenames = tuple(location_config.get('patterns_from') or ()) + (
pattern_filenames = tuple(config.get('patterns_from') or ()) + (
(pattern_filename,) if pattern_filename else ()
)
@@ -162,12 +168,12 @@ def make_pattern_flags(location_config, pattern_filename=None):
)
def make_exclude_flags(location_config, exclude_filename=None):
def make_exclude_flags(config, exclude_filename=None):
'''
Given a location config dict with various exclude options, and a filename containing any exclude
Given a configuration dict with various exclude options, and a filename containing any exclude
patterns, return the corresponding Borg flags as a tuple.
'''
exclude_filenames = tuple(location_config.get('exclude_from') or ()) + (
exclude_filenames = tuple(config.get('exclude_from') or ()) + (
(exclude_filename,) if exclude_filename else ()
)
exclude_from_flags = tuple(
@@ -175,17 +181,15 @@ def make_exclude_flags(location_config, exclude_filename=None):
('--exclude-from', exclude_filename) for exclude_filename in exclude_filenames
)
)
caches_flag = ('--exclude-caches',) if location_config.get('exclude_caches') else ()
caches_flag = ('--exclude-caches',) if config.get('exclude_caches') else ()
if_present_flags = tuple(
itertools.chain.from_iterable(
('--exclude-if-present', if_present)
for if_present in location_config.get('exclude_if_present', ())
for if_present in config.get('exclude_if_present', ())
)
)
keep_exclude_tags_flags = (
('--keep-exclude-tags',) if location_config.get('keep_exclude_tags') else ()
)
exclude_nodump_flags = ('--exclude-nodump',) if location_config.get('exclude_nodump') else ()
keep_exclude_tags_flags = ('--keep-exclude-tags',) if config.get('keep_exclude_tags') else ()
exclude_nodump_flags = ('--exclude-nodump',) if config.get('exclude_nodump') else ()
return (
exclude_from_flags
@@ -217,21 +221,13 @@ def make_list_filter_flags(local_borg_version, dry_run):
return f'{base_flags}-'
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
def collect_borgmatic_source_directories(borgmatic_source_directory):
def collect_borgmatic_runtime_directories(borgmatic_runtime_directory):
'''
Return a list of borgmatic-specific source directories used for state like database backups.
Return a list of borgmatic-specific runtime directories used for temporary runtime data like
streaming database dumps and bootstrap metadata. If no such directories exist, return an empty
list.
'''
if not borgmatic_source_directory:
borgmatic_source_directory = state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
return (
[borgmatic_source_directory]
if os.path.exists(os.path.expanduser(borgmatic_source_directory))
else []
)
return [borgmatic_runtime_directory] if os.path.exists(borgmatic_runtime_directory) else []
ROOT_PATTERN_PREFIX = 'R '
@@ -277,20 +273,25 @@ def any_parent_directories(path, candidate_parents):
def collect_special_file_paths(
create_command, local_path, working_directory, borg_environment, skip_directories
create_command, config, local_path, working_directory, borg_environment, skip_directories
):
'''
Given a Borg create command as a tuple, a local Borg path, a working directory, and a dict of
environment variables to pass to Borg, and a sequence of parent directories to skip, collect the
paths for any special files (character devices, block devices, and named pipes / FIFOs) that
Borg would encounter during a create. These are all paths that could cause Borg to hang if its
--read-special flag is used.
Given a Borg create command as a tuple, a configuration dict, a local Borg path, a working
directory, a dict of environment variables to pass to Borg, and a sequence of parent directories
to skip, collect the paths for any special files (character devices, block devices, and named
pipes / FIFOs) that Borg would encounter during a create. These are all paths that could cause
Borg to hang if its --read-special flag is used.
'''
# Omit "--exclude-nodump" from the Borg dry run command, because that flag causes Borg to open
# files including any named pipe we've created.
paths_output = execute_command_and_capture_output(
create_command + ('--dry-run', '--list'),
tuple(argument for argument in create_command if argument != '--exclude-nodump')
+ ('--dry-run', '--list'),
capture_stderr=True,
working_directory=working_directory,
extra_environment=borg_environment,
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
paths = tuple(
@@ -306,83 +307,112 @@ def collect_special_file_paths(
)
def create_archive(
def check_all_source_directories_exist(source_directories, working_directory=None):
'''
Given a sequence of source directories and an optional working directory to serve as a prefix
for each (if it's a relative directory), check that the source directories all exist. If any do
not, raise an exception.
'''
missing_directories = [
source_directory
for source_directory in source_directories
if not all(
[
os.path.exists(os.path.join(working_directory or '', directory))
for directory in expand_directory(source_directory, working_directory)
]
)
]
if missing_directories:
raise ValueError(f"Source directories do not exist: {', '.join(missing_directories)}")
MAX_SPECIAL_FILE_PATHS_LENGTH = 1000
def make_base_create_command(
dry_run,
repository,
location_config,
storage_config,
repository_path,
config,
config_paths,
local_borg_version,
global_arguments,
borgmatic_runtime_directories,
local_path='borg',
remote_path=None,
progress=False,
stats=False,
json=False,
list_files=False,
stream_processes=None,
):
'''
Given vebosity/dry-run flags, a local or remote repository path, a 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.
Given vebosity/dry-run flags, a local or remote repository path, a configuration dict, a
sequence of loaded configuration paths, the local Borg version, global arguments as an
argparse.Namespace instance, and a sequence of borgmatic source directories, return a tuple of
(base Borg create command flags, Borg create command positional arguments, open pattern file
handle, open exclude file handle).
'''
borgmatic.logger.add_custom_log_levels()
borgmatic_source_directories = expand_directories(
collect_borgmatic_source_directories(location_config.get('borgmatic_source_directory'))
)
working_directory = borgmatic.config.paths.get_working_directory(config)
if config.get('source_directories_must_exist', False):
check_all_source_directories_exist(
config.get('source_directories'), working_directory=working_directory
)
sources = deduplicate_directories(
map_directories_to_devices(
expand_directories(
tuple(location_config.get('source_directories', ())) + borgmatic_source_directories
tuple(config.get('source_directories', ()))
+ borgmatic_runtime_directories
+ tuple(config_paths if config.get('store_config_files', True) else ()),
working_directory=working_directory,
)
),
additional_directory_devices=map_directories_to_devices(
expand_directories(pattern_root_directories(location_config.get('patterns')))
expand_directories(
pattern_root_directories(config.get('patterns')),
working_directory=working_directory,
)
),
)
ensure_files_readable(location_config.get('patterns_from'), location_config.get('exclude_from'))
try:
working_directory = os.path.expanduser(location_config.get('working_directory'))
except TypeError:
working_directory = None
ensure_files_readable(config.get('patterns_from'), config.get('exclude_from'))
pattern_file = (
write_pattern_file(location_config.get('patterns'), sources)
if location_config.get('patterns') or location_config.get('patterns_from')
write_pattern_file(config.get('patterns'), sources)
if config.get('patterns') or config.get('patterns_from')
else None
)
exclude_file = write_pattern_file(
expand_home_directories(location_config.get('exclude_patterns'))
)
checkpoint_interval = storage_config.get('checkpoint_interval', None)
checkpoint_volume = storage_config.get('checkpoint_volume', None)
chunker_params = storage_config.get('chunker_params', None)
compression = storage_config.get('compression', None)
upload_rate_limit = storage_config.get('upload_rate_limit', None)
umask = storage_config.get('umask', None)
lock_wait = storage_config.get('lock_wait', None)
exclude_file = write_pattern_file(expand_home_directories(config.get('exclude_patterns')))
checkpoint_interval = config.get('checkpoint_interval', None)
checkpoint_volume = config.get('checkpoint_volume', None)
chunker_params = config.get('chunker_params', None)
compression = config.get('compression', None)
upload_rate_limit = config.get('upload_rate_limit', None)
upload_buffer_size = config.get('upload_buffer_size', None)
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
list_filter_flags = make_list_filter_flags(local_borg_version, dry_run)
files_cache = location_config.get('files_cache')
archive_name_format = storage_config.get('archive_name_format', DEFAULT_ARCHIVE_NAME_FORMAT)
extra_borg_options = storage_config.get('extra_borg_options', {}).get('create', '')
files_cache = config.get('files_cache')
archive_name_format = config.get(
'archive_name_format', flags.get_default_archive_name_format(local_borg_version)
)
extra_borg_options = config.get('extra_borg_options', {}).get('create', '')
if feature.available(feature.Feature.ATIME, local_borg_version):
atime_flags = ('--atime',) if location_config.get('atime') is True else ()
atime_flags = ('--atime',) if config.get('atime') is True else ()
else:
atime_flags = ('--noatime',) if location_config.get('atime') is False else ()
atime_flags = ('--noatime',) if config.get('atime') is False else ()
if feature.available(feature.Feature.NOFLAGS, local_borg_version):
noflags_flags = ('--noflags',) if location_config.get('flags') is False else ()
noflags_flags = ('--noflags',) if config.get('flags') is False else ()
else:
noflags_flags = ('--nobsdflags',) if location_config.get('flags') is False else ()
noflags_flags = ('--nobsdflags',) if config.get('flags') is False else ()
if feature.available(feature.Feature.NUMERIC_IDS, local_borg_version):
numeric_ids_flags = ('--numeric-ids',) if location_config.get('numeric_ids') else ()
numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else ()
else:
numeric_ids_flags = ('--numeric-owner',) if location_config.get('numeric_ids') else ()
numeric_ids_flags = ('--numeric-owner',) if config.get('numeric_ids') else ()
if feature.available(feature.Feature.UPLOAD_RATELIMIT, local_borg_version):
upload_ratelimit_flags = (
@@ -393,35 +423,28 @@ def create_archive(
('--remote-ratelimit', str(upload_rate_limit)) if upload_rate_limit else ()
)
if stream_processes and location_config.get('read_special') is False:
logger.warning(
f'{repository}: Ignoring configured "read_special" value of false, as true is needed for database hooks.'
)
create_command = (
create_flags = (
tuple(local_path.split(' '))
+ ('create',)
+ make_pattern_flags(location_config, pattern_file.name if pattern_file else None)
+ make_exclude_flags(location_config, exclude_file.name if exclude_file else None)
+ make_pattern_flags(config, pattern_file.name if pattern_file else None)
+ make_exclude_flags(config, exclude_file.name if exclude_file else None)
+ (('--checkpoint-interval', str(checkpoint_interval)) if checkpoint_interval else ())
+ (('--checkpoint-volume', str(checkpoint_volume)) if checkpoint_volume else ())
+ (('--chunker-params', chunker_params) if chunker_params else ())
+ (('--compression', compression) if compression else ())
+ upload_ratelimit_flags
+ (
('--one-file-system',)
if location_config.get('one_file_system') or stream_processes
else ()
)
+ (('--upload-buffer', str(upload_buffer_size)) if upload_buffer_size else ())
+ (('--one-file-system',) if config.get('one_file_system') else ())
+ numeric_ids_flags
+ atime_flags
+ (('--noctime',) if location_config.get('ctime') is False else ())
+ (('--nobirthtime',) if location_config.get('birthtime') is False else ())
+ (('--read-special',) if location_config.get('read_special') or stream_processes else ())
+ (('--noctime',) if config.get('ctime') is False else ())
+ (('--nobirthtime',) if config.get('birthtime') is False else ())
+ (('--read-special',) if config.get('read_special') or stream_processes else ())
+ noflags_flags
+ (('--files-cache', files_cache) if files_cache else ())
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (
('--list', '--filter', list_filter_flags)
@@ -430,8 +453,98 @@ def create_archive(
)
+ (('--dry-run',) if dry_run else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_archive_flags(repository, archive_name_format, local_borg_version)
+ (sources if not pattern_file else ())
)
create_positional_arguments = flags.make_repository_archive_flags(
repository_path, archive_name_format, local_borg_version
) + (sources if not pattern_file else ())
# If database hooks are enabled (as indicated by streaming processes), exclude files that might
# cause Borg to hang. But skip this if the user has explicitly set the "read_special" to True.
if stream_processes and not config.get('read_special'):
logger.warning(
f'{repository_path}: Ignoring configured "read_special" value of false, as true is needed for database hooks.'
)
borg_environment = environment.make_environment(config)
logger.debug(f'{repository_path}: Collecting special file paths')
special_file_paths = collect_special_file_paths(
create_flags + create_positional_arguments,
config,
local_path,
working_directory,
borg_environment,
skip_directories=borgmatic_runtime_directories,
)
if special_file_paths:
truncated_special_file_paths = textwrap.shorten(
', '.join(special_file_paths),
width=MAX_SPECIAL_FILE_PATHS_LENGTH,
placeholder=' ...',
)
logger.warning(
f'{repository_path}: Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}'
)
exclude_file = write_pattern_file(
expand_home_directories(
tuple(config.get('exclude_patterns') or ()) + special_file_paths
),
pattern_file=exclude_file,
)
create_flags += make_exclude_flags(config, exclude_file.name)
return (create_flags, create_positional_arguments, pattern_file, exclude_file)
def create_archive(
dry_run,
repository_path,
config,
config_paths,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
local_path='borg',
remote_path=None,
progress=False,
stats=False,
json=False,
list_files=False,
stream_processes=None,
):
'''
Given vebosity/dry-run flags, a local or remote repository path, a configuration dict, a
sequence of loaded configuration paths, the local Borg version, and global arguments as an
argparse.Namespace instance, create a Borg archive and return Borg's JSON output (if any).
If a sequence of stream processes is given (instances of subprocess.Popen), then execute the
create command while also triggering the given processes to produce output.
'''
borgmatic.logger.add_custom_log_levels()
working_directory = borgmatic.config.paths.get_working_directory(config)
borgmatic_runtime_directories = expand_directories(
collect_borgmatic_runtime_directories(borgmatic_runtime_directory),
working_directory=working_directory,
)
(create_flags, create_positional_arguments, pattern_file, exclude_file) = (
make_base_create_command(
dry_run,
repository_path,
config,
config_paths,
local_borg_version,
global_arguments,
borgmatic_runtime_directories,
local_path,
remote_path,
progress,
json,
list_files,
stream_processes,
)
)
if json:
@@ -445,60 +558,43 @@ def create_archive(
# the terminal directly.
output_file = DO_NOT_CAPTURE if progress else None
borg_environment = environment.make_environment(storage_config)
borg_environment = environment.make_environment(config)
# If database hooks are enabled (as indicated by streaming processes), exclude files that might
# cause Borg to hang. But skip this if the user has explicitly set the "read_special" to True.
if stream_processes and not location_config.get('read_special'):
logger.debug(f'{repository}: Collecting special file paths')
special_file_paths = collect_special_file_paths(
create_command,
local_path,
working_directory,
borg_environment,
skip_directories=borgmatic_source_directories,
)
if special_file_paths:
logger.warning(
f'{repository}: Excluding special files to prevent Borg from hanging: {", ".join(special_file_paths)}'
)
exclude_file = write_pattern_file(
expand_home_directories(
tuple(location_config.get('exclude_patterns') or ()) + special_file_paths
),
pattern_file=exclude_file,
)
create_command += make_exclude_flags(location_config, exclude_file.name)
create_command += (
create_flags += (
(('--info',) if logger.getEffectiveLevel() == logging.INFO 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 ())
+ (('--progress',) if progress else ())
+ (('--json',) if json else ())
)
borg_exit_codes = config.get('borg_exit_codes')
if stream_processes:
return execute_command_with_processes(
create_command,
create_flags + create_positional_arguments,
stream_processes,
output_log_level,
output_file,
borg_local_path=local_path,
working_directory=working_directory,
extra_environment=borg_environment,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
elif output_log_level is None:
return execute_command_and_capture_output(
create_command, working_directory=working_directory, extra_environment=borg_environment,
create_flags + create_positional_arguments,
working_directory=working_directory,
extra_environment=borg_environment,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
else:
execute_command(
create_command,
create_flags + create_positional_arguments,
output_log_level,
output_file,
borg_local_path=local_path,
working_directory=working_directory,
extra_environment=borg_environment,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
+135
View File
@@ -0,0 +1,135 @@
import argparse
import logging
import borgmatic.borg.environment
import borgmatic.borg.feature
import borgmatic.borg.flags
import borgmatic.borg.repo_delete
import borgmatic.config.paths
import borgmatic.execute
logger = logging.getLogger(__name__)
def make_delete_command(
repository,
config,
local_borg_version,
delete_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Given a local or remote repository dict, a configuration dict, the local Borg version, the
arguments to the delete action as an argparse.Namespace, and global arguments, return a command
as a tuple to delete archives from the repository.
'''
return (
(local_path, 'delete')
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ borgmatic.borg.flags.make_flags('dry-run', global_arguments.dry_run)
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
+ borgmatic.borg.flags.make_flags('umask', config.get('umask'))
+ borgmatic.borg.flags.make_flags('log-json', global_arguments.log_json)
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
+ borgmatic.borg.flags.make_flags('list', delete_arguments.list_archives)
+ (
(('--force',) + (('--force',) if delete_arguments.force >= 2 else ()))
if delete_arguments.force
else ()
)
# Ignore match_archives and archive_name_format options from configuration, so the user has
# to be explicit on the command-line about the archives they want to delete.
+ borgmatic.borg.flags.make_match_archives_flags(
delete_arguments.match_archives or delete_arguments.archive,
archive_name_format=None,
local_borg_version=local_borg_version,
default_archive_name_format='*',
)
+ borgmatic.borg.flags.make_flags_from_arguments(
delete_arguments,
excludes=('list_archives', 'force', 'match_archives', 'archive', 'repository'),
)
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
)
ARCHIVE_RELATED_ARGUMENT_NAMES = (
'archive',
'match_archives',
'first',
'last',
'oldest',
'newest',
'older',
'newer',
)
def delete_archives(
repository,
config,
local_borg_version,
delete_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository dict, a configuration dict, the local Borg version, the
arguments to the delete action as an argparse.Namespace, global arguments as an
argparse.Namespace, and local and remote Borg paths, delete the selected archives from the
repository. If no archives are selected, then delete the entire repository.
'''
borgmatic.logger.add_custom_log_levels()
if not any(
getattr(delete_arguments, argument_name, None)
for argument_name in ARCHIVE_RELATED_ARGUMENT_NAMES
):
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.REPO_DELETE, local_borg_version
):
logger.warning(
'Deleting an entire repository with the delete action is deprecated when using Borg 2.x+. Use the repo-delete action instead.'
)
repo_delete_arguments = argparse.Namespace(
repository=repository['path'],
list_archives=delete_arguments.list_archives,
force=delete_arguments.force,
cache_only=delete_arguments.cache_only,
keep_security_info=delete_arguments.keep_security_info,
)
borgmatic.borg.repo_delete.delete_repository(
repository,
config,
local_borg_version,
repo_delete_arguments,
global_arguments,
local_path,
remote_path,
)
return
command = make_delete_command(
repository,
config,
local_borg_version,
delete_arguments,
global_arguments,
local_path,
remote_path,
)
borgmatic.execute.execute_command(
command,
output_log_level=logging.ANSWER,
extra_environment=borgmatic.borg.environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
+28 -8
View File
@@ -1,7 +1,10 @@
import os
OPTION_TO_ENVIRONMENT_VARIABLE = {
'borg_base_directory': 'BORG_BASE_DIR',
'borg_config_directory': 'BORG_CONFIG_DIR',
'borg_cache_directory': 'BORG_CACHE_DIR',
'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
'borg_security_directory': 'BORG_SECURITY_DIR',
'borg_keys_directory': 'BORG_KEYS_DIR',
'encryption_passcommand': 'BORG_PASSCOMMAND',
@@ -10,30 +13,47 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
'temporary_directory': 'TMPDIR',
}
DEFAULT_BOOL_OPTION_TO_ENVIRONMENT_VARIABLE = {
DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE = {
'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK',
'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK',
}
DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE = {
'check_i_know_what_i_am_doing': 'BORG_CHECK_I_KNOW_WHAT_I_AM_DOING',
}
def make_environment(storage_config):
def make_environment(config):
'''
Given a borgmatic storage configuration dict, return its options converted to a Borg environment
Given a borgmatic configuration dict, return its options converted to a Borg environment
variable dict.
'''
environment = {}
for option_name, environment_variable_name in OPTION_TO_ENVIRONMENT_VARIABLE.items():
value = storage_config.get(option_name)
value = config.get(option_name)
if value:
environment[environment_variable_name] = value
environment[environment_variable_name] = str(value)
for (
option_name,
environment_variable_name,
) in DEFAULT_BOOL_OPTION_TO_ENVIRONMENT_VARIABLE.items():
value = storage_config.get(option_name, False)
environment[environment_variable_name] = 'yes' if value else 'no'
) in DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE.items():
if os.environ.get(environment_variable_name) is None:
value = config.get(option_name)
environment[environment_variable_name] = 'yes' if value else 'no'
for (
option_name,
environment_variable_name,
) in DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE.items():
value = config.get(option_name)
if value is not None:
environment[environment_variable_name] = 'YES' if value else 'NO'
# On Borg 1.4.0a1+, take advantage of more specific exit codes. No effect on
# older versions of Borg.
environment['BORG_EXIT_CODES'] = 'modern'
return environment
+74
View File
@@ -0,0 +1,74 @@
import logging
import os
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, flags
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
def export_key(
repository_path,
config,
local_borg_version,
export_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, export
arguments, and optional local and remote Borg paths, export the repository key to the
destination path indicated in the export arguments.
If the destination path is empty or "-", then print the key to stdout instead of to a file.
Raise FileExistsError if a path is given but it already exists on disk.
'''
borgmatic.logger.add_custom_log_levels()
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
working_directory = borgmatic.config.paths.get_working_directory(config)
if export_arguments.path and export_arguments.path != '-':
if os.path.exists(os.path.join(working_directory or '', export_arguments.path)):
raise FileExistsError(
f'Destination path {export_arguments.path} already exists. Aborting.'
)
output_file = None
else:
output_file = DO_NOT_CAPTURE
full_command = (
(local_path, 'key', 'export')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ flags.make_flags('paper', export_arguments.paper)
+ flags.make_flags('qr-html', export_arguments.qr_html)
+ flags.make_repository_flags(
repository_path,
local_borg_version,
)
+ ((export_arguments.path,) if output_file is None else ())
)
if global_arguments.dry_run:
logger.info(f'{repository_path}: Skipping key export (dry run)')
return
execute_command(
full_command,
output_file=output_file,
output_log_level=logging.ANSWER,
extra_environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
+16 -12
View File
@@ -1,6 +1,6 @@
import logging
import os
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, flags
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
@@ -10,12 +10,13 @@ logger = logging.getLogger(__name__)
def export_tar_archive(
dry_run,
repository,
repository_path,
archive,
paths,
destination_path,
storage_config,
config,
local_borg_version,
global_arguments,
local_path='borg',
remote_path=None,
tar_filter=None,
@@ -24,21 +25,22 @@ def export_tar_archive(
):
'''
Given a dry-run flag, a local or remote repository path, an archive name, zero or more paths to
export from the archive, a destination path to export to, a storage configuration dict, the
local Borg version, optional local and remote Borg paths, an optional filter program, whether to
include per-file details, and an optional number of path components to strip, export the archive
into the given destination path as a tar-formatted file.
export from the archive, a destination path to export to, a configuration dict, the local Borg
version, optional local and remote Borg paths, an optional filter program, whether to include
per-file details, and an optional number of path components to strip, export the archive into
the given destination path as a tar-formatted file.
If the destination path is "-", then stream the output to stdout instead of to a file.
'''
borgmatic.logger.add_custom_log_levels()
umask = storage_config.get('umask', None)
lock_wait = storage_config.get('lock_wait', None)
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
full_command = (
(local_path, 'export-tar')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--list',) if list_files else ())
@@ -47,7 +49,7 @@ def export_tar_archive(
+ (('--tar-filter', tar_filter) if tar_filter else ())
+ (('--strip-components', str(strip_components)) if strip_components else ())
+ flags.make_repository_archive_flags(
repository if ':' in repository else os.path.abspath(repository),
repository_path,
archive,
local_borg_version,
)
@@ -61,13 +63,15 @@ def export_tar_archive(
output_log_level = logging.INFO
if dry_run:
logging.info('{}: Skipping export to tar file (dry run)'.format(repository))
logging.info(f'{repository_path}: Skipping export to tar file (dry run)')
return
execute_command(
full_command,
output_file=DO_NOT_CAPTURE if destination_path == '-' else None,
output_log_level=output_log_level,
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
extra_environment=environment.make_environment(storage_config),
borg_exit_codes=config.get('borg_exit_codes'),
)
+70 -28
View File
@@ -2,16 +2,19 @@ import logging
import os
import subprocess
from borgmatic.borg import environment, feature, flags, rlist
import borgmatic.config.paths
import borgmatic.config.validate
from borgmatic.borg import environment, feature, flags, repo_list
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
def extract_last_archive_dry_run(
storage_config,
config,
local_borg_version,
repository,
global_arguments,
repository_path,
lock_wait=None,
local_path='borg',
remote_path=None,
@@ -20,8 +23,6 @@ def extract_last_archive_dry_run(
Perform an extraction dry-run of the most recent archive. If there are no archives, skip the
dry-run.
'''
remote_path_flags = ('--remote-path', remote_path) if remote_path else ()
lock_wait_flags = ('--lock-wait', str(lock_wait)) if lock_wait else ()
verbosity_flags = ()
if logger.isEnabledFor(logging.DEBUG):
verbosity_flags = ('--debug', '--show-rc')
@@ -29,26 +30,39 @@ def extract_last_archive_dry_run(
verbosity_flags = ('--info',)
try:
last_archive_name = rlist.resolve_archive_name(
repository, 'latest', storage_config, local_borg_version, local_path, remote_path
last_archive_name = repo_list.resolve_archive_name(
repository_path,
'latest',
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
except ValueError:
logger.warning('No archives found. Skipping extract consistency check.')
return
list_flag = ('--list',) if logger.isEnabledFor(logging.DEBUG) else ()
borg_environment = environment.make_environment(storage_config)
borg_environment = environment.make_environment(config)
full_extract_command = (
(local_path, 'extract', '--dry-run')
+ remote_path_flags
+ lock_wait_flags
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags
+ list_flag
+ flags.make_repository_archive_flags(repository, last_archive_name, local_borg_version)
+ flags.make_repository_archive_flags(
repository_path, last_archive_name, local_borg_version
)
)
execute_command(
full_extract_command, working_directory=None, extra_environment=borg_environment
full_extract_command,
extra_environment=borg_environment,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
@@ -57,9 +71,9 @@ def extract_archive(
repository,
archive,
paths,
location_config,
storage_config,
config,
local_borg_version,
global_arguments,
local_path='borg',
remote_path=None,
destination_path=None,
@@ -69,36 +83,47 @@ def extract_archive(
):
'''
Given a dry-run flag, a local or remote repository path, an archive name, zero or more paths to
restore from the archive, the local Borg version string, location/storage configuration dicts,
optional local and remote Borg paths, and an optional destination path to extract to, extract
the archive into the current directory.
restore from the archive, the local Borg version string, an argparse.Namespace of global
arguments, a configuration dict, 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)
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
if progress and extract_to_stdout:
raise ValueError('progress and extract_to_stdout cannot both be set')
if feature.available(feature.Feature.NUMERIC_IDS, local_borg_version):
numeric_ids_flags = ('--numeric-ids',) if location_config.get('numeric_ids') else ()
numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else ()
else:
numeric_ids_flags = ('--numeric-owner',) if location_config.get('numeric_ids') else ()
numeric_ids_flags = ('--numeric-owner',) if config.get('numeric_ids') else ()
if strip_components == 'all':
if not paths:
raise ValueError('The --strip-components flag with "all" requires at least one --path')
# Calculate the maximum number of leading path components of the given paths.
strip_components = max(0, *(len(path.split(os.path.sep)) - 1 for path in paths))
# Calculate the maximum number of leading path components of the given paths. "if piece"
# ignores empty path components, e.g. those resulting from a leading slash. And the "- 1"
# is so this doesn't count the final path component, e.g. the filename itself.
strip_components = max(
0,
*(
len(tuple(piece for piece in path.split(os.path.sep) if piece)) - 1
for path in paths
),
)
working_directory = borgmatic.config.paths.get_working_directory(config)
full_command = (
(local_path, 'extract')
+ (('--remote-path', remote_path) if remote_path else ())
+ numeric_ids_flags
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
@@ -107,14 +132,23 @@ def extract_archive(
+ (('--progress',) if progress else ())
+ (('--stdout',) if extract_to_stdout else ())
+ flags.make_repository_archive_flags(
repository if ':' in repository else os.path.abspath(repository),
# Make the repository path absolute so the destination directory used below via changing
# the working directory doesn't prevent Borg from finding the repo. But also apply the
# user's configured working directory (if any) to the repo path.
borgmatic.config.validate.normalize_repository_path(
os.path.join(working_directory or '', repository)
),
archive,
local_borg_version,
)
+ (tuple(paths) if paths else ())
)
borg_environment = environment.make_environment(storage_config)
borg_environment = environment.make_environment(config)
borg_exit_codes = config.get('borg_exit_codes')
full_destination_path = (
os.path.join(working_directory or '', destination_path) if destination_path else None
)
# The progress output isn't compatible with captured and logged output, as progress messes with
# the terminal directly.
@@ -122,8 +156,10 @@ def extract_archive(
return execute_command(
full_command,
output_file=DO_NOT_CAPTURE,
working_directory=destination_path,
extra_environment=borg_environment,
working_directory=full_destination_path,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
return None
@@ -131,13 +167,19 @@ def extract_archive(
return execute_command(
full_command,
output_file=subprocess.PIPE,
working_directory=destination_path,
run_to_completion=False,
extra_environment=borg_environment,
working_directory=full_destination_path,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
# Don't give Borg local path so as to error on warnings, as "borg extract" only gives a warning
# if the restore paths don't exist in the archive.
execute_command(
full_command, working_directory=destination_path, extra_environment=borg_environment
full_command,
extra_environment=borg_environment,
working_directory=full_destination_path,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
+22 -18
View File
@@ -1,6 +1,6 @@
from enum import Enum
from pkg_resources import parse_version
from packaging.version import parse
class Feature(Enum):
@@ -10,25 +10,29 @@ class Feature(Enum):
NUMERIC_IDS = 4
UPLOAD_RATELIMIT = 5
SEPARATE_REPOSITORY_ARCHIVE = 6
RCREATE = 7
RLIST = 8
RINFO = 9
MATCH_ARCHIVES = 10
EXCLUDED_FILES_MINUS = 11
REPO_CREATE = 7
REPO_LIST = 8
REPO_INFO = 9
REPO_DELETE = 10
MATCH_ARCHIVES = 11
EXCLUDED_FILES_MINUS = 12
ARCHIVE_SERIES = 13
FEATURE_TO_MINIMUM_BORG_VERSION = {
Feature.COMPACT: parse_version('1.2.0a2'), # borg compact
Feature.ATIME: parse_version('1.2.0a7'), # borg create --atime
Feature.NOFLAGS: parse_version('1.2.0a8'), # borg create --noflags
Feature.NUMERIC_IDS: parse_version('1.2.0b3'), # borg create/extract/mount --numeric-ids
Feature.UPLOAD_RATELIMIT: parse_version('1.2.0b3'), # borg create --upload-ratelimit
Feature.SEPARATE_REPOSITORY_ARCHIVE: parse_version('2.0.0a2'), # --repo with separate archive
Feature.RCREATE: parse_version('2.0.0a2'), # borg rcreate
Feature.RLIST: parse_version('2.0.0a2'), # borg rlist
Feature.RINFO: parse_version('2.0.0a2'), # borg rinfo
Feature.MATCH_ARCHIVES: parse_version('2.0.0b3'), # borg --match-archives
Feature.EXCLUDED_FILES_MINUS: parse_version('2.0.0b5'), # --list --filter uses "-" for excludes
Feature.COMPACT: parse('1.2.0a2'), # borg compact
Feature.ATIME: parse('1.2.0a7'), # borg create --atime
Feature.NOFLAGS: parse('1.2.0a8'), # borg create --noflags
Feature.NUMERIC_IDS: parse('1.2.0b3'), # borg create/extract/mount --numeric-ids
Feature.UPLOAD_RATELIMIT: parse('1.2.0b3'), # borg create --upload-ratelimit
Feature.SEPARATE_REPOSITORY_ARCHIVE: parse('2.0.0a2'), # --repo with separate archive
Feature.REPO_CREATE: parse('2.0.0a2'), # borg repo-create
Feature.REPO_LIST: parse('2.0.0a2'), # borg repo-list
Feature.REPO_INFO: parse('2.0.0a2'), # borg repo-info
Feature.REPO_DELETE: parse('2.0.0a2'), # borg repo-delete
Feature.MATCH_ARCHIVES: parse('2.0.0b3'), # borg --match-archives
Feature.EXCLUDED_FILES_MINUS: parse('2.0.0b5'), # --list --filter uses "-" for excludes
Feature.ARCHIVE_SERIES: parse('2.0.0b11'), # identically named archives form a series
}
@@ -37,4 +41,4 @@ def available(feature, borg_version):
Given a Borg Feature constant and a Borg version string, return whether that feature is
available in that version of Borg.
'''
return FEATURE_TO_MINIMUM_BORG_VERSION[feature] <= parse_version(borg_version)
return FEATURE_TO_MINIMUM_BORG_VERSION[feature] <= parse(borg_version)
+106 -6
View File
@@ -1,7 +1,12 @@
import itertools
import json
import logging
import re
from borgmatic.borg import feature
logger = logging.getLogger(__name__)
def make_flags(name, value):
'''
@@ -10,7 +15,7 @@ def make_flags(name, value):
if not value:
return ()
flag = '--{}'.format(name.replace('_', '-'))
flag = f"--{name.replace('_', '-')}"
if value is True:
return (flag,)
@@ -33,7 +38,7 @@ def make_flags_from_arguments(arguments, excludes=()):
)
def make_repository_flags(repository, local_borg_version):
def make_repository_flags(repository_path, local_borg_version):
'''
Given the path of a Borg repository and the local Borg version, return Borg-version-appropriate
command-line flags (as a tuple) for selecting that repository.
@@ -42,17 +47,112 @@ def make_repository_flags(repository, local_borg_version):
('--repo',)
if feature.available(feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version)
else ()
) + (repository,)
) + (repository_path,)
def make_repository_archive_flags(repository, archive, local_borg_version):
ARCHIVE_HASH_PATTERN = re.compile('[0-9a-fA-F]{8,}$')
def make_repository_archive_flags(repository_path, archive, local_borg_version):
'''
Given the path of a Borg repository, an archive name or pattern, and the local Borg version,
return Borg-version-appropriate command-line flags (as a tuple) for selecting that repository
and archive.
'''
return (
('--repo', repository, archive)
(
'--repo',
repository_path,
(
f'aid:{archive}'
if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version)
and ARCHIVE_HASH_PATTERN.match(archive)
and not archive.startswith('aid:')
else archive
),
)
if feature.available(feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version)
else (f'{repository}::{archive}',)
else (f'{repository_path}::{archive}',)
)
DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003
DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES = '{hostname}' # noqa: FS003
def get_default_archive_name_format(local_borg_version):
'''
Given the local Borg version, return the corresponding default archive name format.
'''
if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version):
return DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES
return DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES
def make_match_archives_flags(
match_archives,
archive_name_format,
local_borg_version,
default_archive_name_format=None,
):
'''
Return match archives flags based on the given match archives value, if any. If it isn't set,
return match archives flags to match archives created with the given (or default) archive name
format. This is done by replacing certain archive name format placeholders for ephemeral data
(like "{now}") with globs.
'''
if match_archives:
if match_archives in {'*', 're:.*', 'sh:*'}:
return ()
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
if (
feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version)
and ARCHIVE_HASH_PATTERN.match(match_archives)
and not match_archives.startswith('aid:')
):
return ('--match-archives', f'aid:{match_archives}')
return ('--match-archives', match_archives)
else:
return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
derived_match_archives = re.sub(
r'\{(now|utcnow|pid)([:%\w\.-]*)\}',
'*',
archive_name_format
or default_archive_name_format
or get_default_archive_name_format(local_borg_version),
)
if derived_match_archives == '*':
return ()
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
return ('--match-archives', f'sh:{derived_match_archives}')
else:
return ('--glob-archives', f'{derived_match_archives}')
def warn_for_aggressive_archive_flags(json_command, json_output):
'''
Given a JSON archives command and the resulting JSON string output from running it, parse the
JSON and warn if the command used an archive flag but the output indicates zero archives were
found.
'''
archive_flags_used = {'--glob-archives', '--match-archives'}.intersection(set(json_command))
if not archive_flags_used:
return
try:
if len(json.loads(json_output)['archives']) == 0:
logger.warning('An archive filter was applied, but no matching archives were found.')
logger.warning(
'Try adding --match-archives "*" or adjusting archive_name_format/match_archives in configuration.'
)
except json.JSONDecodeError as error:
logger.debug(f'Cannot parse JSON output from archive command: {error}')
except (TypeError, KeyError):
logger.debug('Cannot parse JSON output from archive command: No "archives" key found')
+84 -31
View File
@@ -1,5 +1,7 @@
import argparse
import logging
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import execute_command, execute_command_and_capture_output
@@ -7,23 +9,21 @@ from borgmatic.execute import execute_command, execute_command_and_capture_outpu
logger = logging.getLogger(__name__)
def display_archives_info(
repository,
storage_config,
def make_info_command(
repository_path,
config,
local_borg_version,
info_arguments,
local_path='borg',
remote_path=None,
global_arguments,
local_path,
remote_path,
):
'''
Given a local or remote repository path, a storage config dict, the local Borg version, and the
arguments to the info action, display summary information for Borg archives in the repository or
return JSON summary information.
Given a local or remote repository path, a configuration dict, the local Borg version, the
arguments to the info action as an argparse.Namespace, and global arguments, return a command
as a tuple to display summary information for archives in the repository.
'''
borgmatic.logger.add_custom_log_levels()
lock_wait = storage_config.get('lock_wait', None)
full_command = (
return (
(local_path, 'info')
+ (
('--info',)
@@ -36,7 +36,9 @@ def display_archives_info(
else ()
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('lock-wait', lock_wait)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ (
(
flags.make_flags('match-archives', f'sh:{info_arguments.prefix}*')
@@ -44,27 +46,78 @@ def display_archives_info(
else flags.make_flags('glob-archives', f'{info_arguments.prefix}*')
)
if info_arguments.prefix
else ()
else (
flags.make_match_archives_flags(
info_arguments.match_archives
or info_arguments.archive
or config.get('match_archives'),
config.get('archive_name_format'),
local_borg_version,
)
)
)
+ flags.make_flags_from_arguments(
info_arguments, excludes=('repository', 'archive', 'prefix')
)
+ flags.make_repository_flags(repository, local_borg_version)
+ (
flags.make_flags('match-archives', info_arguments.archive)
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version)
else flags.make_flags('glob-archives', info_arguments.archive)
info_arguments, excludes=('repository', 'archive', 'prefix', 'match_archives')
)
+ flags.make_repository_flags(repository_path, local_borg_version)
)
def display_archives_info(
repository_path,
config,
local_borg_version,
info_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, the
arguments to the info action as an argparse.Namespace, and global arguments, display summary
information for Borg archives in the repository or return JSON summary information.
'''
borgmatic.logger.add_custom_log_levels()
main_command = make_info_command(
repository_path,
config,
local_borg_version,
info_arguments,
global_arguments,
local_path,
remote_path,
)
json_command = make_info_command(
repository_path,
config,
local_borg_version,
argparse.Namespace(**dict(info_arguments.__dict__, json=True)),
global_arguments,
local_path,
remote_path,
)
borg_exit_codes = config.get('borg_exit_codes')
working_directory = borgmatic.config.paths.get_working_directory(config)
json_info = execute_command_and_capture_output(
json_command,
extra_environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
if info_arguments.json:
return execute_command_and_capture_output(
full_command, extra_environment=environment.make_environment(storage_config),
)
else:
execute_command(
full_command,
output_log_level=logging.ANSWER,
borg_local_path=local_path,
extra_environment=environment.make_environment(storage_config),
)
return json_info
flags.warn_for_aggressive_archive_flags(json_command, json_info)
execute_command(
main_command,
output_log_level=logging.ANSWER,
extra_environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
+81 -54
View File
@@ -3,38 +3,37 @@ import copy
import logging
import re
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, feature, flags, rlist
from borgmatic.borg import environment, feature, flags, repo_list
from borgmatic.execute import execute_command, execute_command_and_capture_output
logger = logging.getLogger(__name__)
ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST = ('prefix', 'match_archives', 'sort_by', 'first', 'last')
ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST = ('prefix', 'match_archives', 'sort_by', 'first', 'last')
MAKE_FLAGS_EXCLUDES = (
'repository',
'archive',
'successful',
'paths',
'find_paths',
) + ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST
) + ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST
def make_list_command(
repository,
storage_config,
repository_path,
config,
local_borg_version,
list_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a storage config dict, the arguments to the list
action, and local and remote Borg paths, return a command as a tuple to list archives or paths
within an archive.
Given a local or remote repository path, a configuration dict, the arguments to the list action,
and local and remote Borg paths, return a command as a tuple to list archives or paths within an
archive.
'''
lock_wait = storage_config.get('lock_wait', None)
return (
(local_path, 'list')
+ (
@@ -48,14 +47,16 @@ def make_list_command(
else ()
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('lock-wait', lock_wait)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ flags.make_flags_from_arguments(list_arguments, excludes=MAKE_FLAGS_EXCLUDES)
+ (
flags.make_repository_archive_flags(
repository, list_arguments.archive, local_borg_version
repository_path, list_arguments.archive, local_borg_version
)
if list_arguments.archive
else flags.make_repository_flags(repository, local_borg_version)
else flags.make_repository_flags(repository_path, local_borg_version)
)
+ (tuple(list_arguments.paths) if list_arguments.paths else ())
)
@@ -78,47 +79,57 @@ def make_find_paths(find_paths):
return ()
return tuple(
find_path
if re.compile(r'([-!+RrPp] )|(\w\w:)').match(find_path)
else f'sh:**/*{find_path}*/**'
(
find_path
if re.compile(r'([-!+RrPp] )|(\w\w:)').match(find_path)
else f'sh:**/*{find_path}*/**'
)
for find_path in find_paths
)
def capture_archive_listing(
repository,
repository_path,
archive,
storage_config,
config,
local_borg_version,
list_path=None,
global_arguments,
list_paths=None,
path_format=None,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, an archive name, a storage config dict, the local Borg
version, the archive path in which to list files, and local and remote Borg paths, capture the
Given a local or remote repository path, an archive name, a configuration
dict, the local Borg version, global arguments as an argparse.Namespace,
the archive paths (or Borg patterns) in which to list files, the Borg path
format to use for the output, and local and remote Borg paths, capture the
output of listing that archive and return it as a list of file paths.
'''
borg_environment = environment.make_environment(storage_config)
borg_environment = environment.make_environment(config)
return tuple(
execute_command_and_capture_output(
make_list_command(
repository,
storage_config,
repository_path,
config,
local_borg_version,
argparse.Namespace(
repository=repository,
repository=repository_path,
archive=archive,
paths=[f'sh:{list_path}'],
paths=[path for path in list_paths] if list_paths else None,
find_paths=None,
json=None,
format='{path}{NL}',
format=path_format or '{path}{NL}', # noqa: FS003
),
global_arguments,
local_path,
remote_path,
),
extra_environment=borg_environment,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
.strip('\n')
.split('\n')
@@ -126,30 +137,32 @@ def capture_archive_listing(
def list_archive(
repository,
storage_config,
repository_path,
config,
local_borg_version,
list_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a storage config dict, the local Borg version, the
arguments to the list action, and local and remote Borg paths, display the output of listing
the files of a Borg archive (or return JSON output). If list_arguments.find_paths are given,
list the files by searching across multiple archives. If neither find_paths nor archive name
are given, instead list the archives in the given repository.
Given a local or remote repository path, a configuration dict, the local Borg version, the
arguments to the list action as an argparse.Namespace, global arguments as an
argparse.Namespace, and local and remote Borg paths, display the output of listing the files of
a Borg archive (or return JSON output). If list_arguments.find_paths are given, list the files
by searching across multiple archives. If neither find_paths nor archive name are given, instead
list the archives in the given repository.
'''
borgmatic.logger.add_custom_log_levels()
if not list_arguments.archive and not list_arguments.find_paths:
if feature.available(feature.Feature.RLIST, local_borg_version):
if feature.available(feature.Feature.REPO_LIST, local_borg_version):
logger.warning(
'Omitting the --archive flag on the list action is deprecated when using Borg 2.x+. Use the rlist action instead.'
'Omitting the --archive flag on the list action is deprecated when using Borg 2.x+. Use the repo-list action instead.'
)
rlist_arguments = argparse.Namespace(
repository=repository,
repo_list_arguments = argparse.Namespace(
repository=repository_path,
short=list_arguments.short,
format=list_arguments.format,
json=list_arguments.json,
@@ -159,12 +172,18 @@ def list_archive(
first=list_arguments.first,
last=list_arguments.last,
)
return rlist.list_repository(
repository, storage_config, local_borg_version, rlist_arguments, local_path, remote_path
return repo_list.list_repository(
repository_path,
config,
local_borg_version,
repo_list_arguments,
global_arguments,
local_path,
remote_path,
)
if list_arguments.archive:
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST:
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST:
if getattr(list_arguments, name, None):
logger.warning(
f"The --{name.replace('_', '-')} flag on the list action is ignored when using the --archive flag."
@@ -175,13 +194,14 @@ def list_archive(
'The --json flag on the list action is not supported when using the --archive/--find flags.'
)
borg_environment = environment.make_environment(storage_config)
borg_environment = environment.make_environment(config)
borg_exit_codes = config.get('borg_exit_codes')
# If there are any paths to find (and there's not a single archive already selected), start by
# getting a list of archives to search.
if list_arguments.find_paths and not list_arguments.archive:
rlist_arguments = argparse.Namespace(
repository=repository,
repo_list_arguments = argparse.Namespace(
repository=repository_path,
short=True,
format=None,
json=None,
@@ -195,15 +215,19 @@ def list_archive(
# Ask Borg to list archives. Capture its output for use below.
archive_lines = tuple(
execute_command_and_capture_output(
rlist.make_rlist_command(
repository,
storage_config,
repo_list.make_repo_list_command(
repository_path,
config,
local_borg_version,
rlist_arguments,
repo_list_arguments,
global_arguments,
local_path,
remote_path,
),
extra_environment=borg_environment,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
.strip('\n')
.split('\n')
@@ -213,21 +237,22 @@ def list_archive(
# For each archive listed by Borg, run list on the contents of that archive.
for archive in archive_lines:
logger.answer(f'{repository}: Listing archive {archive}')
logger.answer(f'{repository_path}: Listing archive {archive}')
archive_arguments = copy.copy(list_arguments)
archive_arguments.archive = archive
# This list call is to show the files in a single archive, not list multiple archives. So
# blank out any archive filtering flags. They'll break anyway in Borg 2.
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_RLIST:
for name in ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST:
setattr(archive_arguments, name, None)
main_command = make_list_command(
repository,
storage_config,
repository_path,
config,
local_borg_version,
archive_arguments,
global_arguments,
local_path,
remote_path,
) + make_find_paths(list_arguments.find_paths)
@@ -235,6 +260,8 @@ def list_archive(
execute_command(
main_command,
output_log_level=logging.ANSWER,
borg_local_path=local_path,
extra_environment=borg_environment,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
+33 -21
View File
@@ -1,5 +1,6 @@
import logging
import borgmatic.config.paths
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
@@ -7,38 +8,40 @@ logger = logging.getLogger(__name__)
def mount_archive(
repository,
repository_path,
archive,
mount_point,
paths,
foreground,
options,
storage_config,
mount_arguments,
config,
local_borg_version,
global_arguments,
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, the local Borg version, and optional local and remote Borg paths, mount the archive onto
the mount point.
dict, the local Borg version, global arguments as an argparse.Namespace instance, 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)
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
full_command = (
(local_path, 'mount')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--foreground',) if foreground else ())
+ (('-o', options) if options else ())
+ flags.make_flags_from_arguments(
mount_arguments,
excludes=('repository', 'archive', 'mount_point', 'paths', 'options'),
)
+ (('-o', mount_arguments.options) if mount_arguments.options else ())
+ (
(
flags.make_repository_flags(repository, local_borg_version)
flags.make_repository_flags(repository_path, local_borg_version)
+ (
('--match-archives', archive)
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version)
@@ -47,25 +50,34 @@ def mount_archive(
)
if feature.available(feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version)
else (
flags.make_repository_archive_flags(repository, archive, local_borg_version)
flags.make_repository_archive_flags(repository_path, archive, local_borg_version)
if archive
else flags.make_repository_flags(repository, local_borg_version)
else flags.make_repository_flags(repository_path, local_borg_version)
)
)
+ (mount_point,)
+ (tuple(paths) if paths else ())
+ (mount_arguments.mount_point,)
+ (tuple(mount_arguments.paths) if mount_arguments.paths else ())
)
borg_environment = environment.make_environment(storage_config)
borg_environment = environment.make_environment(config)
working_directory = borgmatic.config.paths.get_working_directory(config)
# Don't capture the output when foreground mode is used so that ctrl-C can work properly.
if foreground:
if mount_arguments.foreground:
execute_command(
full_command,
output_file=DO_NOT_CAPTURE,
borg_local_path=local_path,
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
return
execute_command(full_command, borg_local_path=local_path, extra_environment=borg_environment)
execute_command(
full_command,
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
+47 -34
View File
@@ -1,5 +1,6 @@
import logging
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import execute_command
@@ -7,10 +8,11 @@ from borgmatic.execute import execute_command
logger = logging.getLogger(__name__)
def make_prune_flags(retention_config, local_borg_version):
def make_prune_flags(config, prune_arguments, local_borg_version):
'''
Given a retention config dict mapping from option name to value, tranform it into an iterable of
command-line name-value flag pairs.
Given a configuration dict mapping from option name to value, prune arguments as an
argparse.Namespace instance, and the local Borg version, produce a corresponding sequence of
command-line flags.
For example, given a retention config of:
@@ -23,61 +25,70 @@ def make_prune_flags(retention_config, local_borg_version):
('--keep-monthly', '6'),
)
'''
config = retention_config.copy()
prefix = config.pop('prefix', '{hostname}-')
flag_pairs = (
('--' + option_name.replace('_', '-'), str(value))
for option_name, value in config.items()
if option_name.startswith('keep_') and option_name != 'keep_exclude_tags'
)
prefix = config.get('prefix')
if prefix:
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
config['match_archives'] = f'sh:{prefix}*'
else:
config['glob_archives'] = f'{prefix}*'
return (
('--' + option_name.replace('_', '-'), str(value)) for option_name, value in config.items()
return tuple(element for pair in flag_pairs for element in pair) + (
(
('--match-archives', f'sh:{prefix}*')
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version)
else ('--glob-archives', f'{prefix}*')
)
if prefix
else (
flags.make_match_archives_flags(
prune_arguments.match_archives or config.get('match_archives'),
config.get('archive_name_format'),
local_borg_version,
)
)
)
def prune_archives(
dry_run,
repository,
storage_config,
retention_config,
repository_path,
config,
local_borg_version,
prune_arguments,
global_arguments,
local_path='borg',
remote_path=None,
stats=False,
list_archives=False,
):
'''
Given dry-run flag, a local or remote repository path, a storage config dict, and a
retention config dict, prune Borg archives according to the retention policy specified in that
configuration.
Given dry-run flag, a local or remote repository path, and a configuration dict, prune Borg
archives according to the retention policy specified in that configuration.
'''
borgmatic.logger.add_custom_log_levels()
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', '')
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
extra_borg_options = config.get('extra_borg_options', {}).get('prune', '')
full_command = (
(local_path, 'prune')
+ tuple(
element
for pair in make_prune_flags(retention_config, local_borg_version)
for element in pair
)
+ make_prune_flags(config, prune_arguments, local_borg_version)
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--stats',) if stats and not dry_run else ())
+ (('--stats',) if prune_arguments.stats and not dry_run else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--list',) if list_archives else ())
+ flags.make_flags_from_arguments(
prune_arguments,
excludes=('repository', 'match_archives', 'stats', 'list_archives'),
)
+ (('--list',) if prune_arguments.list_archives else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--dry-run',) if dry_run else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_flags(repository, local_borg_version)
+ flags.make_repository_flags(repository_path, local_borg_version)
)
if stats or list_archives:
if prune_arguments.stats or prune_arguments.list_archives:
output_log_level = logging.ANSWER
else:
output_log_level = logging.INFO
@@ -85,6 +96,8 @@ def prune_archives(
execute_command(
full_command,
output_log_level=output_log_level,
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
extra_environment=environment.make_environment(storage_config),
borg_exit_codes=config.get('borg_exit_codes'),
)
-81
View File
@@ -1,81 +0,0 @@
import argparse
import logging
import subprocess
from borgmatic.borg import environment, feature, flags, rinfo
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
RINFO_REPOSITORY_NOT_FOUND_EXIT_CODE = 2
def create_repository(
dry_run,
repository,
storage_config,
local_borg_version,
encryption_mode,
source_repository=None,
copy_crypt_key=False,
append_only=None,
storage_quota=None,
make_parent_dirs=False,
local_path='borg',
remote_path=None,
):
'''
Given a dry-run flag, a local or remote repository path, a storage configuration dict, the local
Borg version, a Borg encryption mode, the path to another repo whose key material should be
reused, whether the repository should be append-only, and the storage quota to use, create the
repository. If the repository already exists, then log and skip creation.
'''
try:
rinfo.display_repository_info(
repository,
storage_config,
local_borg_version,
argparse.Namespace(json=True),
local_path,
remote_path,
)
logger.info(f'{repository}: Repository already exists. Skipping creation.')
return
except subprocess.CalledProcessError as error:
if error.returncode != RINFO_REPOSITORY_NOT_FOUND_EXIT_CODE:
raise
extra_borg_options = storage_config.get('extra_borg_options', {}).get('rcreate', '')
rcreate_command = (
(local_path,)
+ (
('rcreate',)
if feature.available(feature.Feature.RCREATE, local_borg_version)
else ('init',)
)
+ (('--encryption', encryption_mode) if encryption_mode else ())
+ (('--other-repo', source_repository) if source_repository else ())
+ (('--copy-crypt-key',) if copy_crypt_key else ())
+ (('--append-only',) if append_only else ())
+ (('--storage-quota', storage_quota) if storage_quota else ())
+ (('--make-parent-dirs',) if make_parent_dirs else ())
+ (('--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 ())
+ flags.make_repository_flags(repository, local_borg_version)
)
if dry_run:
logging.info(f'{repository}: Skipping repository creation (dry run)')
return
# Do not capture output here, so as to support interactive prompts.
execute_command(
rcreate_command,
output_file=DO_NOT_CAPTURE,
borg_local_path=local_path,
extra_environment=environment.make_environment(storage_config),
)
+105
View File
@@ -0,0 +1,105 @@
import argparse
import json
import logging
import subprocess
import borgmatic.config.paths
from borgmatic.borg import environment, feature, flags, repo_info
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES = {2, 13, 15}
def create_repository(
dry_run,
repository_path,
config,
local_borg_version,
global_arguments,
encryption_mode,
source_repository=None,
copy_crypt_key=False,
append_only=None,
storage_quota=None,
make_parent_dirs=False,
local_path='borg',
remote_path=None,
):
'''
Given a dry-run flag, a local or remote repository path, a configuration dict, the local Borg
version, a Borg encryption mode, the path to another repo whose key material should be reused,
whether the repository should be append-only, and the storage quota to use, create the
repository. If the repository already exists, then log and skip creation.
Raise ValueError if the requested encryption mode does not match that of the repository.
Raise json.decoder.JSONDecodeError if the "borg info" JSON outputcannot be decoded.
Raise subprocess.CalledProcessError if "borg info" returns an error exit code.
'''
try:
info_data = json.loads(
repo_info.display_repository_info(
repository_path,
config,
local_borg_version,
argparse.Namespace(json=True),
global_arguments,
local_path,
remote_path,
)
)
repository_encryption_mode = info_data.get('encryption', {}).get('mode')
if repository_encryption_mode != encryption_mode:
raise ValueError(
f'Requested encryption mode "{encryption_mode}" does not match existing repository encryption mode "{repository_encryption_mode}"'
)
logger.info(f'{repository_path}: Repository already exists. Skipping creation.')
return
except subprocess.CalledProcessError as error:
if error.returncode not in REPO_INFO_REPOSITORY_NOT_FOUND_EXIT_CODES:
raise
lock_wait = config.get('lock_wait')
umask = config.get('umask')
extra_borg_options = config.get('extra_borg_options', {}).get('repo-create', '')
repo_create_command = (
(local_path,)
+ (
('repo-create',)
if feature.available(feature.Feature.REPO_CREATE, local_borg_version)
else ('init',)
)
+ (('--encryption', encryption_mode) if encryption_mode else ())
+ (('--other-repo', source_repository) if source_repository else ())
+ (('--copy-crypt-key',) if copy_crypt_key else ())
+ (('--append-only',) if append_only else ())
+ (('--storage-quota', storage_quota) if storage_quota else ())
+ (('--make-parent-dirs',) if make_parent_dirs else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug',) if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
if dry_run:
logging.info(f'{repository_path}: Skipping repository creation (dry run)')
return
# Do not capture output here, so as to support interactive prompts.
execute_command(
repo_create_command,
output_file=DO_NOT_CAPTURE,
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
+95
View File
@@ -0,0 +1,95 @@
import logging
import borgmatic.borg.environment
import borgmatic.borg.feature
import borgmatic.borg.flags
import borgmatic.config.paths
import borgmatic.execute
logger = logging.getLogger(__name__)
def make_repo_delete_command(
repository,
config,
local_borg_version,
repo_delete_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Given a local or remote repository dict, a configuration dict, the local Borg version, the
arguments to the repo_delete action as an argparse.Namespace, and global arguments, return a command
as a tuple to repo_delete the entire repository.
'''
return (
(local_path,)
+ (
('repo-delete',)
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.REPO_DELETE, local_borg_version
)
else ('delete',)
)
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ borgmatic.borg.flags.make_flags('dry-run', global_arguments.dry_run)
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
+ borgmatic.borg.flags.make_flags('umask', config.get('umask'))
+ borgmatic.borg.flags.make_flags('log-json', global_arguments.log_json)
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
+ borgmatic.borg.flags.make_flags('list', repo_delete_arguments.list_archives)
+ (
(('--force',) + (('--force',) if repo_delete_arguments.force >= 2 else ()))
if repo_delete_arguments.force
else ()
)
+ borgmatic.borg.flags.make_flags_from_arguments(
repo_delete_arguments, excludes=('list_archives', 'force', 'repository')
)
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
)
def delete_repository(
repository,
config,
local_borg_version,
repo_delete_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository dict, a configuration dict, the local Borg version, the
arguments to the repo_delete action as an argparse.Namespace, global arguments as an
argparse.Namespace, and local and remote Borg paths, repo_delete the entire repository.
'''
borgmatic.logger.add_custom_log_levels()
command = make_repo_delete_command(
repository,
config,
local_borg_version,
repo_delete_arguments,
global_arguments,
local_path,
remote_path,
)
borgmatic.execute.execute_command(
command,
output_log_level=logging.ANSWER,
# Don't capture output when Borg is expected to prompt for interactive confirmation, or the
# prompt won't work.
output_file=(
None
if repo_delete_arguments.force or repo_delete_arguments.cache_only
else borgmatic.execute.DO_NOT_CAPTURE
),
extra_environment=borgmatic.borg.environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
+73
View File
@@ -0,0 +1,73 @@
import logging
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import execute_command, execute_command_and_capture_output
logger = logging.getLogger(__name__)
def display_repository_info(
repository_path,
config,
local_borg_version,
repo_info_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, the
arguments to the repo_info action, and global arguments as an argparse.Namespace, display summary
information for the Borg repository or return JSON summary information.
'''
borgmatic.logger.add_custom_log_levels()
lock_wait = config.get('lock_wait', None)
full_command = (
(local_path,)
+ (
('repo-info',)
if feature.available(feature.Feature.REPO_INFO, local_borg_version)
else ('info',)
)
+ (
('--info',)
if logger.getEffectiveLevel() == logging.INFO and not repo_info_arguments.json
else ()
)
+ (
('--debug', '--show-rc')
if logger.isEnabledFor(logging.DEBUG) and not repo_info_arguments.json
else ()
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', lock_wait)
+ (('--json',) if repo_info_arguments.json else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
extra_environment = environment.make_environment(config)
working_directory = borgmatic.config.paths.get_working_directory(config)
borg_exit_codes = config.get('borg_exit_codes')
if repo_info_arguments.json:
return execute_command_and_capture_output(
full_command,
extra_environment=extra_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
else:
execute_command(
full_command,
output_log_level=logging.ANSWER,
extra_environment=extra_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
+186
View File
@@ -0,0 +1,186 @@
import argparse
import logging
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import execute_command, execute_command_and_capture_output
logger = logging.getLogger(__name__)
def resolve_archive_name(
repository_path,
archive,
config,
local_borg_version,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, an archive name, a configuration dict, the local Borg
version, global arguments as an argparse.Namespace, a local Borg path, and a remote Borg path,
return the archive name. But if the archive name is "latest", then instead introspect the
repository for the latest archive and return its name.
Raise ValueError if "latest" is given but there are no archives in the repository.
'''
if archive != 'latest':
return archive
full_command = (
(
local_path,
(
'repo-list'
if feature.available(feature.Feature.REPO_LIST, local_borg_version)
else 'list'
),
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ flags.make_flags('last', 1)
+ ('--short',)
+ flags.make_repository_flags(repository_path, local_borg_version)
)
output = execute_command_and_capture_output(
full_command,
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
try:
latest_archive = output.strip().splitlines()[-1]
except IndexError:
raise ValueError('No archives found in the repository')
logger.debug(f'{repository_path}: Latest archive is {latest_archive}')
return latest_archive
MAKE_FLAGS_EXCLUDES = ('repository', 'prefix', 'match_archives')
def make_repo_list_command(
repository_path,
config,
local_borg_version,
repo_list_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, the
arguments to the repo_list action, global arguments as an argparse.Namespace instance, and local and
remote Borg paths, return a command as a tuple to list archives with a repository.
'''
return (
(
local_path,
(
'repo-list'
if feature.available(feature.Feature.REPO_LIST, local_borg_version)
else 'list'
),
)
+ (
('--info',)
if logger.getEffectiveLevel() == logging.INFO and not repo_list_arguments.json
else ()
)
+ (
('--debug', '--show-rc')
if logger.isEnabledFor(logging.DEBUG) and not repo_list_arguments.json
else ()
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ (
(
flags.make_flags('match-archives', f'sh:{repo_list_arguments.prefix}*')
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version)
else flags.make_flags('glob-archives', f'{repo_list_arguments.prefix}*')
)
if repo_list_arguments.prefix
else (
flags.make_match_archives_flags(
repo_list_arguments.match_archives or config.get('match_archives'),
config.get('archive_name_format'),
local_borg_version,
)
)
)
+ flags.make_flags_from_arguments(repo_list_arguments, excludes=MAKE_FLAGS_EXCLUDES)
+ flags.make_repository_flags(repository_path, local_borg_version)
)
def list_repository(
repository_path,
config,
local_borg_version,
repo_list_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, the
arguments to the list action, global arguments as an argparse.Namespace instance, and local and
remote Borg paths, display the output of listing Borg archives in the given repository (or
return JSON output).
'''
borgmatic.logger.add_custom_log_levels()
borg_environment = environment.make_environment(config)
main_command = make_repo_list_command(
repository_path,
config,
local_borg_version,
repo_list_arguments,
global_arguments,
local_path,
remote_path,
)
json_command = make_repo_list_command(
repository_path,
config,
local_borg_version,
argparse.Namespace(**dict(repo_list_arguments.__dict__, json=True)),
global_arguments,
local_path,
remote_path,
)
working_directory = borgmatic.config.paths.get_working_directory(config)
borg_exit_codes = config.get('borg_exit_codes')
json_listing = execute_command_and_capture_output(
json_command,
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
if repo_list_arguments.json:
return json_listing
flags.warn_for_aggressive_archive_flags(json_command, json_listing)
execute_command(
main_command,
output_log_level=logging.ANSWER,
extra_environment=borg_environment,
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
-61
View File
@@ -1,61 +0,0 @@
import logging
import borgmatic.logger
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import execute_command, execute_command_and_capture_output
logger = logging.getLogger(__name__)
def display_repository_info(
repository,
storage_config,
local_borg_version,
rinfo_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a storage config dict, the local Borg version, and the
arguments to the rinfo action, display summary information for the Borg repository or return
JSON summary information.
'''
borgmatic.logger.add_custom_log_levels()
lock_wait = storage_config.get('lock_wait', None)
full_command = (
(local_path,)
+ (
('rinfo',)
if feature.available(feature.Feature.RINFO, local_borg_version)
else ('info',)
)
+ (
('--info',)
if logger.getEffectiveLevel() == logging.INFO and not rinfo_arguments.json
else ()
)
+ (
('--debug', '--show-rc')
if logger.isEnabledFor(logging.DEBUG) and not rinfo_arguments.json
else ()
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('lock-wait', lock_wait)
+ (('--json',) if rinfo_arguments.json else ())
+ flags.make_repository_flags(repository, local_borg_version)
)
extra_environment = environment.make_environment(storage_config)
if rinfo_arguments.json:
return execute_command_and_capture_output(
full_command, extra_environment=extra_environment,
)
else:
execute_command(
full_command,
output_log_level=logging.ANSWER,
borg_local_path=local_path,
extra_environment=extra_environment,
)
-127
View File
@@ -1,127 +0,0 @@
import logging
import borgmatic.logger
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import execute_command, execute_command_and_capture_output
logger = logging.getLogger(__name__)
def resolve_archive_name(
repository, archive, storage_config, local_borg_version, 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 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,
'rlist' if feature.available(feature.Feature.RLIST, local_borg_version) else 'list',
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('lock-wait', lock_wait)
+ flags.make_flags('last', 1)
+ ('--short',)
+ flags.make_repository_flags(repository, local_borg_version)
)
output = execute_command_and_capture_output(
full_command, extra_environment=environment.make_environment(storage_config),
)
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
MAKE_FLAGS_EXCLUDES = ('repository', 'prefix')
def make_rlist_command(
repository,
storage_config,
local_borg_version,
rlist_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a storage config dict, the local Borg version, the
arguments to the rlist action, and local and remote Borg paths, return a command as a tuple to
list archives with a repository.
'''
lock_wait = storage_config.get('lock_wait', None)
return (
(
local_path,
'rlist' if feature.available(feature.Feature.RLIST, local_borg_version) else 'list',
)
+ (
('--info',)
if logger.getEffectiveLevel() == logging.INFO and not rlist_arguments.json
else ()
)
+ (
('--debug', '--show-rc')
if logger.isEnabledFor(logging.DEBUG) and not rlist_arguments.json
else ()
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('lock-wait', lock_wait)
+ (
(
flags.make_flags('match-archives', f'sh:{rlist_arguments.prefix}*')
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version)
else flags.make_flags('glob-archives', f'{rlist_arguments.prefix}*')
)
if rlist_arguments.prefix
else ()
)
+ flags.make_flags_from_arguments(rlist_arguments, excludes=MAKE_FLAGS_EXCLUDES)
+ flags.make_repository_flags(repository, local_borg_version)
)
def list_repository(
repository,
storage_config,
local_borg_version,
rlist_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a storage config dict, the local Borg version, the
arguments to the list action, and local and remote Borg paths, display the output of listing
Borg archives in the given repository (or return JSON output).
'''
borgmatic.logger.add_custom_log_levels()
borg_environment = environment.make_environment(storage_config)
main_command = make_rlist_command(
repository, storage_config, local_borg_version, rlist_arguments, local_path, remote_path
)
if rlist_arguments.json:
return execute_command_and_capture_output(main_command, extra_environment=borg_environment,)
else:
execute_command(
main_command,
output_log_level=logging.ANSWER,
borg_local_path=local_path,
extra_environment=borg_environment,
)
+26 -14
View File
@@ -1,5 +1,6 @@
import logging
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, flags
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
@@ -9,16 +10,18 @@ logger = logging.getLogger(__name__)
def transfer_archives(
dry_run,
repository,
storage_config,
repository_path,
config,
local_borg_version,
transfer_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a dry-run flag, a local or remote repository path, a storage config dict, the local Borg
version, and the arguments to the transfer action, transfer archives to the given repository.
Given a dry-run flag, a local or remote repository path, a configuration dict, the local Borg
version, the arguments to the transfer action, and global arguments as an argparse.Namespace
instance, transfer archives to the given repository.
'''
borgmatic.logger.add_custom_log_levels()
@@ -27,18 +30,25 @@ def transfer_archives(
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('lock-wait', storage_config.get('lock_wait', None))
+ (('--progress',) if transfer_arguments.progress else ())
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', config.get('lock_wait', None))
+ (
flags.make_flags(
'match-archives', transfer_arguments.match_archives or transfer_arguments.archive
flags.make_flags_from_arguments(
transfer_arguments,
excludes=('repository', 'source_repository', 'archive', 'match_archives'),
)
or (
flags.make_match_archives_flags(
transfer_arguments.match_archives
or transfer_arguments.archive
or config.get('match_archives'),
config.get('archive_name_format'),
local_borg_version,
)
)
)
+ flags.make_flags_from_arguments(
transfer_arguments,
excludes=('repository', 'source_repository', 'archive', 'match_archives'),
)
+ flags.make_repository_flags(repository, local_borg_version)
+ flags.make_repository_flags(repository_path, local_borg_version)
+ flags.make_flags('other-repo', transfer_arguments.source_repository)
+ flags.make_flags('dry-run', dry_run)
)
@@ -47,6 +57,8 @@ def transfer_archives(
full_command,
output_log_level=logging.ANSWER,
output_file=DO_NOT_CAPTURE if transfer_arguments.progress else None,
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
extra_environment=environment.make_environment(storage_config),
borg_exit_codes=config.get('borg_exit_codes'),
)
+8 -2
View File
@@ -1,11 +1,12 @@
import logging
import borgmatic.config.paths
from borgmatic.execute import execute_command
logger = logging.getLogger(__name__)
def unmount_archive(mount_point, local_path='borg'):
def unmount_archive(config, mount_point, local_path='borg'):
'''
Given a mounted filesystem mount point, and an optional local Borg paths, umount the filesystem
from the mount point.
@@ -17,4 +18,9 @@ def unmount_archive(mount_point, local_path='borg'):
+ (mount_point,)
)
execute_command(full_command)
execute_command(
full_command,
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
+8 -3
View File
@@ -1,14 +1,15 @@
import logging
import borgmatic.config.paths
from borgmatic.borg import environment
from borgmatic.execute import execute_command_and_capture_output
logger = logging.getLogger(__name__)
def local_borg_version(storage_config, local_path='borg'):
def local_borg_version(config, local_path='borg'):
'''
Given a storage configuration dict and a local Borg binary path, return a version string for it.
Given a configuration dict and a local Borg executable path, return a version string for it.
Raise OSError or CalledProcessError if there is a problem running Borg.
Raise ValueError if the version cannot be parsed.
@@ -19,7 +20,11 @@ def local_borg_version(storage_config, local_path='borg'):
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
)
output = execute_command_and_capture_output(
full_command, extra_environment=environment.make_environment(storage_config),
full_command,
extra_environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
try:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
import borgmatic.commands.arguments
def upgrade_message(language: str, upgrade_command: str, completion_file: str):
return f'''
Your {language} completions script is from a different version of borgmatic than is
currently installed. Please upgrade your script so your completions match the
command-line flags in your installed borgmatic! Try this to upgrade:
{upgrade_command}
source {completion_file}
'''
def available_actions(subparsers, current_action=None):
'''
Given subparsers as an argparse._SubParsersAction instance and a current action name (if
any), return the actions names that can follow the current action on a command-line.
This takes into account which sub-actions that the current action supports. For instance, if
"bootstrap" is a sub-action for "config", then "bootstrap" should be able to follow a current
action of "config" but not "list".
'''
action_to_subactions = borgmatic.commands.arguments.get_subactions_for_actions(
subparsers.choices
)
current_subactions = action_to_subactions.get(current_action)
if current_subactions:
return current_subactions
all_subactions = set(
subaction for subactions in action_to_subactions.values() for subaction in subactions
)
return tuple(action for action in subparsers.choices.keys() if action not in all_subactions)
@@ -1,13 +1,5 @@
from borgmatic.commands import arguments
UPGRADE_MESSAGE = '''
Your bash completions script is from a different version of borgmatic than is
currently installed. Please upgrade your script so your completions match the
command-line flags in your installed borgmatic! Try this to upgrade:
sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"
source $BASH_SOURCE
'''
import borgmatic.commands.arguments
import borgmatic.commands.completion.actions
def parser_flags(parser):
@@ -23,9 +15,12 @@ def bash_completion():
Return a bash completion script for the borgmatic command. Produce this by introspecting
borgmatic's command-line argument parsers.
'''
top_level_parser, subparsers = arguments.make_parsers()
global_flags = parser_flags(top_level_parser)
actions = ' '.join(subparsers.choices.keys())
(
unused_global_parser,
action_parsers,
global_plus_action_parser,
) = borgmatic.commands.arguments.make_parsers()
global_flags = parser_flags(global_plus_action_parser)
# Avert your eyes.
return '\n'.join(
@@ -34,7 +29,11 @@ def bash_completion():
' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"',
' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"',
' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];'
' then cat << EOF\n%s\nEOF' % UPGRADE_MESSAGE,
f''' then cat << EOF\n{borgmatic.commands.completion.actions.upgrade_message(
'bash',
'sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"',
'$BASH_SOURCE',
)}\nEOF''',
' fi',
'}',
'complete_borgmatic() {',
@@ -44,12 +43,22 @@ def bash_completion():
COMPREPLY=($(compgen -W "%s %s %s" -- "${COMP_WORDS[COMP_CWORD]}"))
return 0
fi'''
% (action, parser_flags(subparser), actions, global_flags)
for action, subparser in subparsers.choices.items()
% (
action,
parser_flags(action_parser),
' '.join(
borgmatic.commands.completion.actions.available_actions(action_parsers, action)
),
global_flags,
)
for action, action_parser in reversed(action_parsers.choices.items())
)
+ (
' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))'
% (actions, global_flags),
' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))' # noqa: FS003
% (
' '.join(borgmatic.commands.completion.actions.available_actions(action_parsers)),
global_flags,
),
' (check_version &)',
'}',
'\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic',
+176
View File
@@ -0,0 +1,176 @@
import shlex
from argparse import Action
from textwrap import dedent
import borgmatic.commands.arguments
import borgmatic.commands.completion.actions
def has_file_options(action: Action):
'''
Given an argparse.Action instance, return True if it takes a file argument.
'''
return action.metavar in (
'FILENAME',
'PATH',
) or action.dest in ('config_paths',)
def has_choice_options(action: Action):
'''
Given an argparse.Action instance, return True if it takes one of a predefined set of arguments.
'''
return action.choices is not None
def has_unknown_required_param_options(action: Action):
'''
A catch-all for options that take a required parameter, but we don't know what the parameter is.
This should be used last. These are actions that take something like a glob, a list of numbers, or a string.
Actions that match this pattern should not show the normal arguments, because those are unlikely to be valid.
'''
return (
action.required is True
or action.nargs
in (
'+',
'*',
)
or action.metavar in ('PATTERN', 'KEYS', 'N')
or (action.type is not None and action.default is None)
)
def has_exact_options(action: Action):
return (
has_file_options(action)
or has_choice_options(action)
or has_unknown_required_param_options(action)
)
def exact_options_completion(action: Action):
'''
Given an argparse.Action instance, return a completion invocation that forces file completions, options completion,
or just that some value follow the action, if the action takes such an argument and was the last action on the
command line prior to the cursor.
Otherwise, return an empty string.
'''
if not has_exact_options(action):
return ''
args = ' '.join(action.option_strings)
if has_file_options(action):
return f'''\ncomplete -c borgmatic -Fr -n "__borgmatic_current_arg {args}"'''
if has_choice_options(action):
return f'''\ncomplete -c borgmatic -f -a '{' '.join(map(str, action.choices))}' -n "__borgmatic_current_arg {args}"'''
if has_unknown_required_param_options(action):
return f'''\ncomplete -c borgmatic -x -n "__borgmatic_current_arg {args}"'''
raise ValueError(
f'Unexpected action: {action} passes has_exact_options but has no choices produced'
)
def dedent_strip_as_tuple(string: str):
'''
Dedent a string, then strip it to avoid requiring your first line to have content, then return a tuple of the string.
Makes it easier to write multiline strings for completions when you join them with a tuple.
'''
return (dedent(string).strip('\n'),)
def fish_completion():
'''
Return a fish completion script for the borgmatic command. Produce this by introspecting
borgmatic's command-line argument parsers.
'''
(
unused_global_parser,
action_parsers,
global_plus_action_parser,
) = borgmatic.commands.arguments.make_parsers()
all_action_parsers = ' '.join(action for action in action_parsers.choices.keys())
exact_option_args = tuple(
' '.join(action.option_strings)
for action_parser in action_parsers.choices.values()
for action in action_parser._actions
if has_exact_options(action)
) + tuple(
' '.join(action.option_strings)
for action in global_plus_action_parser._actions
if len(action.option_strings) > 0
if has_exact_options(action)
)
# Avert your eyes.
return '\n'.join(
dedent_strip_as_tuple(
f'''
function __borgmatic_check_version
set -fx this_filename (status current-filename)
fish -c '
if test -f "$this_filename"
set this_script (cat $this_filename 2> /dev/null)
set installed_script (borgmatic --fish-completion 2> /dev/null)
if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ]
echo "{borgmatic.commands.completion.actions.upgrade_message(
'fish',
'borgmatic --fish-completion | sudo tee $this_filename',
'$this_filename',
)}"
end
end
' &
end
__borgmatic_check_version
function __borgmatic_current_arg --description 'Check if any of the given arguments are the last on the command line before the cursor'
set -l all_args (commandline -poc)
# premature optimization to avoid iterating all args if there aren't enough
# to have a last arg beyond borgmatic
if [ (count $all_args) -lt 2 ]
return 1
end
for arg in $argv
if [ "$arg" = "$all_args[-1]" ]
return 0
end
end
return 1
end
set --local action_parser_condition "not __fish_seen_subcommand_from {all_action_parsers}"
set --local exact_option_condition "not __borgmatic_current_arg {' '.join(exact_option_args)}"
'''
)
+ ('\n# action_parser completions',)
+ tuple(
f'''complete -c borgmatic -f -n "$action_parser_condition" -n "$exact_option_condition" -a '{action_name}' -d {shlex.quote(action_parser.description)}'''
for action_name, action_parser in action_parsers.choices.items()
)
+ ('\n# global flags',)
+ tuple(
# -n is checked in order, so put faster / more likely to be true checks first
f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)}{exact_options_completion(action)}'''
for action in global_plus_action_parser._actions
# ignore the noargs action, as this is an impossible completion for fish
if len(action.option_strings) > 0
if 'Deprecated' not in action.help
)
+ ('\n# action_parser flags',)
+ tuple(
f'''complete -c borgmatic -f -n "$exact_option_condition" -a '{' '.join(action.option_strings)}' -d {shlex.quote(action.help)} -n "__fish_seen_subcommand_from {action_name}"{exact_options_completion(action)}'''
for action_name, action_parser in action_parsers.choices.items()
for action in action_parser._actions
if 'Deprecated' not in (action.help or ())
)
)
-110
View File
@@ -1,110 +0,0 @@
import os
import sys
import textwrap
from argparse import ArgumentParser
from ruamel import yaml
from borgmatic.config import convert, generate, legacy, validate
DEFAULT_SOURCE_CONFIG_FILENAME = '/etc/borgmatic/config'
DEFAULT_SOURCE_EXCLUDES_FILENAME = '/etc/borgmatic/excludes'
DEFAULT_DESTINATION_CONFIG_FILENAME = '/etc/borgmatic/config.yaml'
def parse_arguments(*arguments):
'''
Given command-line arguments with which this script was invoked, parse the arguments and return
them as an ArgumentParser instance.
'''
parser = ArgumentParser(
description='''
Convert legacy INI-style borgmatic configuration and excludes files to a single YAML
configuration file. Note that this replaces any comments from the source files.
'''
)
parser.add_argument(
'-s',
'--source-config',
dest='source_config_filename',
default=DEFAULT_SOURCE_CONFIG_FILENAME,
help='Source INI-style configuration filename. Default: {}'.format(
DEFAULT_SOURCE_CONFIG_FILENAME
),
)
parser.add_argument(
'-e',
'--source-excludes',
dest='source_excludes_filename',
default=DEFAULT_SOURCE_EXCLUDES_FILENAME
if os.path.exists(DEFAULT_SOURCE_EXCLUDES_FILENAME)
else None,
help='Excludes filename',
)
parser.add_argument(
'-d',
'--destination-config',
dest='destination_config_filename',
default=DEFAULT_DESTINATION_CONFIG_FILENAME,
help='Destination YAML configuration filename. Default: {}'.format(
DEFAULT_DESTINATION_CONFIG_FILENAME
),
)
return parser.parse_args(arguments)
TEXT_WRAP_CHARACTERS = 80
def display_result(args): # pragma: no cover
result_lines = textwrap.wrap(
'Your borgmatic configuration has been upgraded. Please review the result in {}.'.format(
args.destination_config_filename
),
TEXT_WRAP_CHARACTERS,
)
delete_lines = textwrap.wrap(
'Once you are satisfied, you can safely delete {}{}.'.format(
args.source_config_filename,
' and {}'.format(args.source_excludes_filename)
if args.source_excludes_filename
else '',
),
TEXT_WRAP_CHARACTERS,
)
print('\n'.join(result_lines))
print()
print('\n'.join(delete_lines))
def main(): # pragma: no cover
try:
args = parse_arguments(*sys.argv[1:])
schema = yaml.round_trip_load(open(validate.schema_filename()).read())
source_config = legacy.parse_configuration(
args.source_config_filename, legacy.CONFIG_FORMAT
)
source_config_file_mode = os.stat(args.source_config_filename).st_mode
source_excludes = (
open(args.source_excludes_filename).read().splitlines()
if args.source_excludes_filename
else []
)
destination_config = convert.convert_legacy_parsed_config(
source_config, source_excludes, schema
)
generate.write_configuration(
args.destination_config_filename,
generate.render_configuration(destination_config),
mode=source_config_file_mode,
)
display_result(args)
except (ValueError, OSError) as error:
print(error, file=sys.stderr)
sys.exit(1)
+11 -63
View File
@@ -1,69 +1,17 @@
import logging
import sys
from argparse import ArgumentParser
from borgmatic.config import generate, validate
DEFAULT_DESTINATION_CONFIG_FILENAME = '/etc/borgmatic/config.yaml'
import borgmatic.commands.borgmatic
def parse_arguments(*arguments):
'''
Given command-line arguments with which this script was invoked, parse the arguments and return
them as an ArgumentParser instance.
'''
parser = ArgumentParser(description='Generate a sample borgmatic YAML configuration file.')
parser.add_argument(
'-s',
'--source',
dest='source_filename',
help='Optional YAML configuration file to merge into the generated configuration, useful for upgrading your configuration',
)
parser.add_argument(
'-d',
'--destination',
dest='destination_filename',
default=DEFAULT_DESTINATION_CONFIG_FILENAME,
help='Destination YAML configuration file, default: {}'.format(
DEFAULT_DESTINATION_CONFIG_FILENAME
),
)
parser.add_argument(
'--overwrite',
default=False,
action='store_true',
help='Whether to overwrite any existing destination file, defaults to false',
)
return parser.parse_args(arguments)
def main(): # pragma: no cover
try:
args = parse_arguments(*sys.argv[1:])
generate.generate_sample_configuration(
args.source_filename,
args.destination_filename,
validate.schema_filename(),
overwrite=args.overwrite,
def main():
warning_log = logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg='generate-borgmatic-config is deprecated and will be removed from a future release. Please use "borgmatic config generate" instead.',
)
)
print('Generated a sample configuration file at {}.'.format(args.destination_filename))
print()
if args.source_filename:
print(
'Merged in the contents of configuration file at {}.'.format(args.source_filename)
)
print('To review the changes made, run:')
print()
print(
' diff --unified {} {}'.format(args.source_filename, args.destination_filename)
)
print()
print('This includes all available configuration options with example values. The few')
print('required options are indicated. Please edit the file to suit your needs.')
print()
print('If you ever need help: https://torsion.org/borgmatic/#issues')
except (ValueError, OSError) as error:
print(error, file=sys.stderr)
sys.exit(1)
sys.argv = ['borgmatic', 'config', 'generate'] + sys.argv[1:]
borgmatic.commands.borgmatic.main([warning_log])
+10 -49
View File
@@ -1,56 +1,17 @@
import logging
import sys
from argparse import ArgumentParser
from borgmatic.config import collect, validate
logger = logging.getLogger(__name__)
import borgmatic.commands.borgmatic
def parse_arguments(*arguments):
'''
Given command-line arguments with which this script was invoked, parse the arguments and return
them as an ArgumentParser instance.
'''
config_paths = collect.get_default_config_paths()
parser = ArgumentParser(description='Validate borgmatic configuration file(s).')
parser.add_argument(
'-c',
'--config',
nargs='+',
dest='config_paths',
default=config_paths,
help='Configuration filenames or directories, defaults to: {}'.format(
' '.join(config_paths)
),
def main():
warning_log = logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg='validate-borgmatic-config is deprecated and will be removed from a future release. Please use "borgmatic config validate" instead.',
)
)
return parser.parse_args(arguments)
def main(): # pragma: no cover
args = parse_arguments(*sys.argv[1:])
logging.basicConfig(level=logging.INFO, format='%(message)s')
config_filenames = tuple(collect.collect_config_filenames(args.config_paths))
if len(config_filenames) == 0:
logger.critical('No files to validate found')
sys.exit(1)
found_issues = False
for config_filename in config_filenames:
try:
validate.parse_configuration(config_filename, validate.schema_filename())
except (ValueError, OSError, validate.Validation_error) as error:
logging.critical('{}: Error parsing configuration file'.format(config_filename))
logging.critical(error)
found_issues = True
if found_issues:
sys.exit(1)
else:
logger.info(
'All given configuration files are valid: {}'.format(', '.join(config_filenames))
)
sys.argv = ['borgmatic', 'config', 'validate'] + sys.argv[1:]
borgmatic.commands.borgmatic.main([warning_log])
+5 -5
View File
@@ -1,9 +1,9 @@
def repository_enabled_for_checks(repository, consistency):
def repository_enabled_for_checks(repository, config):
'''
Given a repository name and a consistency configuration dict, return whether the repository
is enabled to have consistency checks run.
Given a repository name and a configuration dict, return whether the
repository is enabled to have consistency checks run.
'''
if not consistency.get('check_repositories'):
if not config.get('check_repositories'):
return True
return repository in consistency['check_repositories']
return repository in config['check_repositories']
+7 -7
View File
@@ -16,17 +16,17 @@ def get_default_config_paths(expand_home=True):
return [
'/etc/borgmatic/config.yaml',
'/etc/borgmatic.d',
'%s/borgmatic/config.yaml' % user_config_directory,
'%s/borgmatic.d' % user_config_directory,
os.path.join(user_config_directory, 'borgmatic/config.yaml'),
os.path.join(user_config_directory, 'borgmatic.d'),
]
def collect_config_filenames(config_paths):
'''
Given a sequence of config paths, both filenames and directories, resolve that to an iterable
of files. Accomplish this by listing any given directories looking for contained config files
(ending with the ".yaml" or ".yml" extension). This is non-recursive, so any directories within the given
directories are ignored.
of absolute files. Accomplish this by listing any given directories looking for contained config
files (ending with the ".yaml" or ".yml" extension). This is non-recursive, so any directories
within the given directories are ignored.
Return paths even if they don't exist on disk, so the user can find out about missing
configuration paths. However, skip a default config path if it's missing, so the user doesn't
@@ -41,7 +41,7 @@ def collect_config_filenames(config_paths):
continue
if not os.path.isdir(path) or not exists:
yield path
yield os.path.abspath(path)
continue
if not os.access(path, os.R_OK):
@@ -51,4 +51,4 @@ def collect_config_filenames(config_paths):
full_filename = os.path.join(path, filename)
matching_filetype = full_filename.endswith('.yaml') or full_filename.endswith('.yml')
if matching_filetype and not os.path.isdir(full_filename):
yield full_filename
yield os.path.abspath(full_filename)
+64
View File
@@ -0,0 +1,64 @@
import shlex
def coerce_scalar(value):
'''
Given a configuration value, coerce it to an integer or a boolean as appropriate and return the
result.
'''
try:
return int(value)
except (TypeError, ValueError):
pass
if value == 'true' or value == 'True':
return True
if value == 'false' or value == 'False':
return False
return value
def apply_constants(value, constants, shell_escape=False):
'''
Given a configuration value (bool, dict, int, list, or string) and a dict of named constants,
replace any configuration string values of the form "{constant}" (or containing it) with the
value of the correspondingly named key from the constants. Recurse as necessary into nested
configuration to find values to replace.
For instance, if a configuration value contains "{foo}", replace it with the value of the "foo"
key found within the configuration's "constants".
If shell escape is True, then escape the constant's value before applying it.
Return the configuration value and modify the original.
'''
if not value or not constants:
return value
if isinstance(value, str):
for constant_name, constant_value in constants.items():
value = value.replace(
'{' + constant_name + '}',
shlex.quote(str(constant_value)) if shell_escape else str(constant_value),
)
# Support constants within non-string scalars by coercing the value to its appropriate type.
value = coerce_scalar(value)
elif isinstance(value, list):
for index, list_value in enumerate(value):
value[index] = apply_constants(list_value, constants, shell_escape)
elif isinstance(value, dict):
for option_name, option_value in value.items():
value[option_name] = apply_constants(
option_value,
constants,
shell_escape=(
shell_escape
or option_name.startswith('before_')
or option_name.startswith('after_')
or option_name == 'on_error'
),
)
return value
-95
View File
@@ -1,95 +0,0 @@
import os
from ruamel import yaml
from borgmatic.config import generate
def _convert_section(source_section_config, section_schema):
'''
Given a legacy Parsed_config instance for a single section, convert it to its corresponding
yaml.comments.CommentedMap representation in preparation for actual serialization to YAML.
Where integer types exist in the given section schema, convert their values to integers.
'''
destination_section_config = yaml.comments.CommentedMap(
[
(
option_name,
int(option_value)
if section_schema['properties'].get(option_name, {}).get('type') == 'integer'
else option_value,
)
for option_name, option_value in source_section_config.items()
]
)
return destination_section_config
def convert_legacy_parsed_config(source_config, source_excludes, schema):
'''
Given a legacy Parsed_config instance loaded from an INI-style config file and a list of exclude
patterns, convert them to a corresponding yaml.comments.CommentedMap representation in
preparation for serialization to a single YAML config file.
Additionally, use the given schema as a source of helpful comments to include within the
returned CommentedMap.
'''
destination_config = yaml.comments.CommentedMap(
[
(section_name, _convert_section(section_config, schema['properties'][section_name]))
for section_name, section_config in source_config._asdict().items()
]
)
# Split space-seperated values into actual lists, make "repository" into a list, and merge in
# excludes.
location = destination_config['location']
location['source_directories'] = source_config.location['source_directories'].split(' ')
location['repositories'] = [location.pop('repository')]
location['exclude_patterns'] = source_excludes
if source_config.consistency.get('checks'):
destination_config['consistency']['checks'] = source_config.consistency['checks'].split(' ')
# Add comments to each section, and then add comments to the fields in each section.
generate.add_comments_to_configuration_object(destination_config, schema)
for section_name, section_config in destination_config.items():
generate.add_comments_to_configuration_object(
section_config, schema['properties'][section_name], indent=generate.INDENT
)
return destination_config
class Legacy_configuration_not_upgraded(FileNotFoundError):
def __init__(self):
super(Legacy_configuration_not_upgraded, self).__init__(
'''borgmatic changed its configuration file format in version 1.1.0 from INI-style
to YAML. This better supports validation, and has a more natural way to express
lists of values. To upgrade your existing configuration, run:
sudo upgrade-borgmatic-config
That will generate a new YAML configuration file at /etc/borgmatic/config.yaml
(by default) using the values from both your existing configuration and excludes
files. The new version of borgmatic will consume the YAML configuration file
instead of the old one.'''
)
def guard_configuration_upgraded(source_config_filename, destination_config_filenames):
'''
If legacy source configuration exists but no destination upgraded configs do, raise
Legacy_configuration_not_upgraded.
The idea is that we want to alert the user about upgrading their config if they haven't already.
'''
destination_config_exists = any(
os.path.exists(filename) for filename in destination_config_filenames
)
if os.path.exists(source_config_filename) and not destination_config_exists:
raise Legacy_configuration_not_upgraded()
+24 -15
View File
@@ -1,42 +1,51 @@
import os
import re
_VARIABLE_PATTERN = re.compile(
VARIABLE_PATTERN = re.compile(
r'(?P<escape>\\)?(?P<variable>\$\{(?P<name>[A-Za-z0-9_]+)((:?-)(?P<default>[^}]+))?\})'
)
def _resolve_string(matcher):
def resolve_string(matcher):
'''
Get the value from environment given a matcher containing a name and an optional default value.
If the variable is not defined in environment and no default value is provided, an Error is raised.
Given a matcher containing a name and an optional default value, get the value from environment.
Raise ValueError if the variable is not defined in environment and no default value is provided.
'''
if matcher.group('escape') is not None:
# in case of escaped envvar, unescape it
# In the case of an escaped environment variable, unescape it.
return matcher.group('variable')
# resolve the env var
# Resolve the environment variable.
name, default = matcher.group('name'), matcher.group('default')
out = os.getenv(name, default=default)
if out is None:
raise ValueError('Cannot find variable ${name} in environment'.format(name=name))
raise ValueError(f'Cannot find variable {name} in environment')
return out
def resolve_env_variables(item):
'''
Resolves variables like or ${FOO} from given configuration with values from process environment
Supported formats:
- ${FOO} will return FOO env variable
- ${FOO-bar} or ${FOO:-bar} will return FOO env variable if it exists, else "bar"
Resolves variables like or ${FOO} from given configuration with values from process environment.
If any variable is missing in environment and no default value is provided, an Error is raised.
Supported formats:
* ${FOO} will return FOO env variable
* ${FOO-bar} or ${FOO:-bar} will return FOO env variable if it exists, else "bar"
Raise if any variable is missing in environment and no default value is provided.
'''
if isinstance(item, str):
return _VARIABLE_PATTERN.sub(_resolve_string, item)
return VARIABLE_PATTERN.sub(resolve_string, item)
if isinstance(item, list):
for i, subitem in enumerate(item):
item[i] = resolve_env_variables(subitem)
for index, subitem in enumerate(item):
item[index] = resolve_env_variables(subitem)
if isinstance(item, dict):
for key, value in item.items():
item[key] = resolve_env_variables(value)
return item
+49 -35
View File
@@ -3,7 +3,7 @@ import io
import os
import re
from ruamel import yaml
import ruamel.yaml
from borgmatic.config import load, normalize
@@ -11,20 +11,33 @@ INDENT = 4
SEQUENCE_INDENT = 2
def _insert_newline_before_comment(config, field_name):
def insert_newline_before_comment(config, field_name):
'''
Using some ruamel.yaml black magic, insert a blank line in the config right before the given
field and its comments.
'''
config.ca.items[field_name][1].insert(
0, yaml.tokens.CommentToken('\n', yaml.error.CommentMark(0), None)
0, ruamel.yaml.tokens.CommentToken('\n', ruamel.yaml.error.CommentMark(0), None)
)
def _schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
def get_properties(schema):
'''
Given a schema dict, return its properties. But if it's got sub-schemas with multiple different
potential properties, returned their merged properties instead.
'''
if 'oneOf' in schema:
return dict(
collections.ChainMap(*[sub_schema['properties'] for sub_schema in schema['oneOf']])
)
return schema['properties']
def schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
'''
Given a loaded configuration schema, generate and return sample config for it. Include comments
for each section based on the schema "description".
for each option based on the schema "description".
'''
schema_type = schema.get('type')
example = schema.get('example')
@@ -32,15 +45,15 @@ def _schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
return example
if schema_type == 'array':
config = yaml.comments.CommentedSeq(
[_schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
config = ruamel.yaml.comments.CommentedSeq(
[schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
)
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
elif schema_type == 'object':
config = yaml.comments.CommentedMap(
config = ruamel.yaml.comments.CommentedMap(
[
(field_name, _schema_to_sample_configuration(sub_schema, level + 1))
for field_name, sub_schema in schema['properties'].items()
(field_name, schema_to_sample_configuration(sub_schema, level + 1))
for field_name, sub_schema in get_properties(schema).items()
]
)
indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
@@ -48,18 +61,18 @@ def _schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
config, schema, indent=indent, skip_first=parent_is_sequence
)
else:
raise ValueError('Schema at level {} is unsupported: {}'.format(level, schema))
raise ValueError(f'Schema at level {level} is unsupported: {schema}')
return config
def _comment_out_line(line):
def comment_out_line(line):
# If it's already is commented out (or empty), there's nothing further to do!
stripped_line = line.lstrip()
if not stripped_line or stripped_line.startswith('#'):
return line
# Comment out the names of optional sections, inserting the '#' after any indent for aesthetics.
# Comment out the names of optional options, inserting the '#' after any indent for aesthetics.
matches = re.match(r'(\s*)', line)
indent_spaces = matches.group(0) if matches else ''
count_indent_spaces = len(indent_spaces)
@@ -67,7 +80,7 @@ def _comment_out_line(line):
return '# '.join((indent_spaces, line[count_indent_spaces:]))
def _comment_out_optional_configuration(rendered_config):
def comment_out_optional_configuration(rendered_config):
'''
Post-process a rendered configuration string to comment out optional key/values, as determined
by a sentinel in the comment before each key.
@@ -84,7 +97,7 @@ def _comment_out_optional_configuration(rendered_config):
for line in rendered_config.split('\n'):
# Upon encountering an optional configuration option, comment out lines until the next blank
# line.
if line.strip().startswith('# {}'.format(COMMENTED_OUT_SENTINEL)):
if line.strip().startswith(f'# {COMMENTED_OUT_SENTINEL}'):
optional = True
continue
@@ -92,7 +105,7 @@ def _comment_out_optional_configuration(rendered_config):
if not line.strip():
optional = False
lines.append(_comment_out_line(line) if optional else line)
lines.append(comment_out_line(line) if optional else line)
return '\n'.join(lines)
@@ -101,7 +114,7 @@ def render_configuration(config):
'''
Given a config data structure of nested OrderedDicts, render the config as YAML and return it.
'''
dumper = yaml.YAML()
dumper = ruamel.yaml.YAML(typ='rt')
dumper.indent(mapping=INDENT, sequence=INDENT + SEQUENCE_INDENT, offset=INDENT)
rendered = io.StringIO()
dumper.dump(config, rendered)
@@ -117,9 +130,7 @@ def write_configuration(config_filename, rendered_config, mode=0o600, overwrite=
'''
if not overwrite and os.path.exists(config_filename):
raise FileExistsError(
'{} already exists. Aborting. Use --overwrite to replace the file.'.format(
config_filename
)
f'{config_filename} already exists. Aborting. Use --overwrite to replace the file.'
)
try:
@@ -153,7 +164,7 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
return
for field_name in config[0].keys():
field_schema = schema['items']['properties'].get(field_name, {})
field_schema = get_properties(schema['items']).get(field_name, {})
description = field_schema.get('description')
# No description to use? Skip it.
@@ -167,7 +178,6 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
return
REQUIRED_SECTION_NAMES = {'location', 'retention'}
REQUIRED_KEYS = {'source_directories', 'repositories', 'keep_daily'}
COMMENTED_OUT_SENTINEL = 'COMMENT_OUT'
@@ -181,13 +191,13 @@ def add_comments_to_configuration_object(config, schema, indent=0, skip_first=Fa
if skip_first and index == 0:
continue
field_schema = schema['properties'].get(field_name, {})
field_schema = get_properties(schema).get(field_name, {})
description = field_schema.get('description', '').strip()
# If this is an optional key, add an indicator to the comment flagging it to be commented
# out from the sample configuration. This sentinel is consumed by downstream processing that
# does the actual commenting out.
if field_name not in REQUIRED_SECTION_NAMES and field_name not in REQUIRED_KEYS:
if field_name not in REQUIRED_KEYS:
description = (
'\n'.join((description, COMMENTED_OUT_SENTINEL))
if description
@@ -201,7 +211,7 @@ def add_comments_to_configuration_object(config, schema, indent=0, skip_first=Fa
config.yaml_set_comment_before_after_key(key=field_name, before=description, indent=indent)
if index > 0:
_insert_newline_before_comment(config, field_name)
insert_newline_before_comment(config, field_name)
RUAMEL_YAML_COMMENTS_INDEX = 1
@@ -218,7 +228,7 @@ def remove_commented_out_sentinel(config, field_name):
except KeyError:
return
if last_comment_value == '# {}\n'.format(COMMENTED_OUT_SENTINEL):
if last_comment_value == f'# {COMMENTED_OUT_SENTINEL}\n':
config.ca.items[field_name][RUAMEL_YAML_COMMENTS_INDEX].pop()
@@ -228,8 +238,7 @@ def merge_source_configuration_into_destination(destination_config, source_confi
favoring values from the source when there are collisions.
The purpose of this is to upgrade configuration files from old versions of borgmatic by adding
new
configuration keys and comments.
new configuration keys and comments.
'''
if not source_config:
return destination_config
@@ -239,7 +248,9 @@ def merge_source_configuration_into_destination(destination_config, source_confi
for field_name, source_value in source_config.items():
# Since this key/value is from the source configuration, leave it uncommented and remove any
# sentinel that would cause it to get commented out.
remove_commented_out_sentinel(destination_config, field_name)
remove_commented_out_sentinel(
ruamel.yaml.comments.CommentedMap(destination_config), field_name
)
# This is a mapping. Recurse for this key/value.
if isinstance(source_value, collections.abc.Mapping):
@@ -251,7 +262,7 @@ def merge_source_configuration_into_destination(destination_config, source_confi
# This is a sequence. Recurse for each item in it.
if isinstance(source_value, collections.abc.Sequence) and not isinstance(source_value, str):
destination_value = destination_config[field_name]
destination_config[field_name] = yaml.comments.CommentedSeq(
destination_config[field_name] = ruamel.yaml.comments.CommentedSeq(
[
merge_source_configuration_into_destination(
destination_value[index] if index < len(destination_value) else None,
@@ -262,14 +273,14 @@ def merge_source_configuration_into_destination(destination_config, source_confi
)
continue
# This is some sort of scalar. Simply set it into the destination.
# This is some sort of scalar. Set it into the destination.
destination_config[field_name] = source_config[field_name]
return destination_config
def generate_sample_configuration(
source_filename, destination_filename, schema_filename, overwrite=False
dry_run, source_filename, destination_filename, schema_filename, overwrite=False
):
'''
Given an optional source configuration filename, and a required destination configuration
@@ -278,7 +289,7 @@ def generate_sample_configuration(
schema. If a source filename is provided, merge the parsed contents of that configuration into
the generated configuration.
'''
schema = yaml.round_trip_load(open(schema_filename))
schema = ruamel.yaml.YAML(typ='safe').load(open(schema_filename))
source_config = None
if source_filename:
@@ -286,11 +297,14 @@ def generate_sample_configuration(
normalize.normalize(source_filename, source_config)
destination_config = merge_source_configuration_into_destination(
_schema_to_sample_configuration(schema), source_config
schema_to_sample_configuration(schema), source_config
)
if dry_run:
return
write_configuration(
destination_filename,
_comment_out_optional_configuration(render_configuration(destination_config)),
comment_out_optional_configuration(render_configuration(destination_config)),
overwrite=overwrite,
)
-152
View File
@@ -1,152 +0,0 @@
from collections import OrderedDict, namedtuple
from configparser import RawConfigParser
Section_format = namedtuple('Section_format', ('name', 'options'))
Config_option = namedtuple('Config_option', ('name', 'value_type', 'required'))
def option(name, value_type=str, required=True):
'''
Given a config file option name, an expected type for its value, and whether it's required,
return a Config_option capturing that information.
'''
return Config_option(name, value_type, required)
CONFIG_FORMAT = (
Section_format(
'location',
(
option('source_directories'),
option('one_file_system', value_type=bool, required=False),
option('remote_path', required=False),
option('repository'),
),
),
Section_format(
'storage',
(
option('encryption_passphrase', required=False),
option('compression', required=False),
option('umask', required=False),
),
),
Section_format(
'retention',
(
option('keep_within', required=False),
option('keep_hourly', int, required=False),
option('keep_daily', int, required=False),
option('keep_weekly', int, required=False),
option('keep_monthly', int, required=False),
option('keep_yearly', int, required=False),
option('prefix', required=False),
),
),
Section_format(
'consistency', (option('checks', required=False), option('check_last', required=False))
),
)
def validate_configuration_format(parser, config_format):
'''
Given an open RawConfigParser and an expected config file format, validate that the parsed
configuration file has the expected sections, that any required options are present in those
sections, and that there aren't any unexpected options.
A section is required if any of its contained options are required.
Raise ValueError if anything is awry.
'''
section_names = set(parser.sections())
required_section_names = tuple(
section.name
for section in config_format
if any(option.required for option in section.options)
)
unknown_section_names = section_names - set(
section_format.name for section_format in config_format
)
if unknown_section_names:
raise ValueError(
'Unknown config sections found: {}'.format(', '.join(unknown_section_names))
)
missing_section_names = set(required_section_names) - section_names
if missing_section_names:
raise ValueError('Missing config sections: {}'.format(', '.join(missing_section_names)))
for section_format in config_format:
if section_format.name not in section_names:
continue
option_names = parser.options(section_format.name)
expected_options = section_format.options
unexpected_option_names = set(option_names) - set(
option.name for option in expected_options
)
if unexpected_option_names:
raise ValueError(
'Unexpected options found in config section {}: {}'.format(
section_format.name, ', '.join(sorted(unexpected_option_names))
)
)
missing_option_names = tuple(
option.name
for option in expected_options
if option.required
if option.name not in option_names
)
if missing_option_names:
raise ValueError(
'Required options missing from config section {}: {}'.format(
section_format.name, ', '.join(missing_option_names)
)
)
def parse_section_options(parser, section_format):
'''
Given an open RawConfigParser and an expected section format, return the option values from that
section as a dict mapping from option name to value. Omit those options that are not present in
the parsed options.
Raise ValueError if any option values cannot be coerced to the expected Python data type.
'''
type_getter = {str: parser.get, int: parser.getint, bool: parser.getboolean}
return OrderedDict(
(option.name, type_getter[option.value_type](section_format.name, option.name))
for option in section_format.options
if parser.has_option(section_format.name, option.name)
)
def parse_configuration(config_filename, config_format):
'''
Given a config filename and an expected config file format, return the parsed configuration
as a namedtuple with one attribute for each parsed section.
Raise IOError if the file cannot be read, or ValueError if the format is not as expected.
'''
parser = RawConfigParser()
if not parser.read(config_filename):
raise ValueError('Configuration file cannot be opened: {}'.format(config_filename))
validate_configuration_format(parser, config_format)
# Describes a parsed configuration, where each attribute is the name of a configuration file
# section and each value is a dict of that section's parsed options.
Parsed_config = namedtuple(
'Parsed_config', (section_format.name for section_format in config_format)
)
return Parsed_config(
*(parse_section_options(parser, section_format) for section_format in config_format)
)
+265 -97
View File
@@ -1,5 +1,7 @@
import functools
import itertools
import logging
import operator
import os
import ruamel.yaml
@@ -7,137 +9,277 @@ import ruamel.yaml
logger = logging.getLogger(__name__)
def include_configuration(loader, filename_node, include_directory):
def probe_and_include_file(filename, include_directories, config_paths):
'''
Given a ruamel.yaml.loader.Loader, a ruamel.yaml.serializer.ScalarNode containing the included
filename, and an include directory path to search for matching files, load the given YAML
filename (ignoring the given loader so we can use our own) and return its contents as a data
structure of nested dicts and lists. If the filename is relative, probe for it within 1. the
current working directory and 2. the given include directory.
Given a filename to include, a list of include directories to search for matching files, and a
set of configuration paths, probe for the file, load it, and return the loaded configuration as
a data structure of nested dicts, lists, etc. Add the filename to the given configuration paths.
Raise FileNotFoundError if the included file was not found.
'''
expanded_filename = os.path.expanduser(filename)
if os.path.isabs(expanded_filename):
return load_configuration(expanded_filename, config_paths)
candidate_filenames = {
os.path.join(directory, expanded_filename) for directory in include_directories
}
for candidate_filename in candidate_filenames:
if os.path.exists(candidate_filename):
return load_configuration(candidate_filename, config_paths)
raise FileNotFoundError(
f'Could not find include {filename} at {" or ".join(candidate_filenames)}'
)
def include_configuration(loader, filename_node, include_directory, config_paths):
'''
Given a ruamel.yaml.loader.Loader, a ruamel.yaml.nodes.ScalarNode containing the included
filename (or a list containing multiple such filenames), an include directory path to search for
matching files, and a set of configuration paths, load the given YAML filenames (ignoring the
given loader so we can use our own) and return their contents as data structure of nested dicts,
lists, etc. Add the names of included files to the given configuration paths. If the given
filename node's value is a scalar string, then the return value will be a single value. But if
the given node value is a list, then the return value will be a list of values, one per loaded
configuration file.
If a filename is relative, probe for it within: 1. the current working directory and 2. the
given include directory.
Raise FileNotFoundError if an included file was not found.
'''
include_directories = [os.getcwd(), os.path.abspath(include_directory)]
include_filename = os.path.expanduser(filename_node.value)
if not os.path.isabs(include_filename):
candidate_filenames = [
os.path.join(directory, include_filename) for directory in include_directories
if isinstance(filename_node.value, str):
return probe_and_include_file(filename_node.value, include_directories, config_paths)
if (
isinstance(filename_node.value, list)
and len(filename_node.value)
and isinstance(filename_node.value[0], ruamel.yaml.nodes.ScalarNode)
):
# Reversing the values ensures the correct ordering if these includes are subsequently
# merged together.
return [
probe_and_include_file(node.value, include_directories, config_paths)
for node in reversed(filename_node.value)
]
for candidate_filename in candidate_filenames:
if os.path.exists(candidate_filename):
include_filename = candidate_filename
break
else:
raise FileNotFoundError(
f'Could not find include {filename_node.value} at {" or ".join(candidate_filenames)}'
)
raise ValueError(
'!include value is not supported; use a single filename or a list of filenames'
)
return load_configuration(include_filename)
def raise_retain_node_error(loader, node):
'''
Given a ruamel.yaml.loader.Loader and a YAML node, raise an error about "!retain" usage.
Raise ValueError if a mapping or sequence node is given, as that indicates that "!retain" was
used in a configuration file without a merge. In configuration files with a merge, mapping and
sequence nodes with "!retain" tags are handled by deep_merge_nodes() below.
Also raise ValueError if a scalar node is given, as "!retain" is not supported on scalar nodes.
'''
if isinstance(node, (ruamel.yaml.nodes.MappingNode, ruamel.yaml.nodes.SequenceNode)):
raise ValueError(
'The !retain tag may only be used within a configuration file containing a merged !include tag.'
)
raise ValueError('The !retain tag may only be used on a mapping or list.')
def raise_omit_node_error(loader, node):
'''
Given a ruamel.yaml.loader.Loader and a YAML node, raise an error about "!omit" usage.
Raise ValueError unconditionally, as an "!omit" node here indicates it was used in a
configuration file without a merge. In configuration files with a merge, nodes with "!omit"
tags are handled by deep_merge_nodes() below.
'''
raise ValueError(
'The !omit tag may only be used on a scalar (e.g., string) or list element within a configuration file containing a merged !include tag.'
)
class Include_constructor(ruamel.yaml.SafeConstructor):
'''
A YAML "constructor" (a ruamel.yaml concept) that supports a custom "!include" tag for including
separate YAML configuration files. Example syntax: `retention: !include common.yaml`
separate YAML configuration files. Example syntax: `option: !include common.yaml`
'''
def __init__(self, preserve_quotes=None, loader=None, include_directory=None):
def __init__(
self, preserve_quotes=None, loader=None, include_directory=None, config_paths=None
):
super(Include_constructor, self).__init__(preserve_quotes, loader)
self.add_constructor(
'!include',
functools.partial(include_configuration, include_directory=include_directory),
functools.partial(
include_configuration,
include_directory=include_directory,
config_paths=config_paths,
),
)
# These are catch-all error handlers for tags that don't get applied and removed by
# deep_merge_nodes() below.
self.add_constructor('!retain', raise_retain_node_error)
self.add_constructor('!omit', raise_omit_node_error)
def flatten_mapping(self, node):
'''
Support the special case of deep merging included configuration into an existing mapping
using the YAML '<<' merge key. Example syntax:
```
retention:
keep_daily: 1
option:
sub_option: 1
<<: !include common.yaml
```
These includes are deep merged into the current configuration file. For instance, in this
example, any "retention" options in common.yaml will get merged into the "retention" section
in the example configuration file.
example, any "option" with sub-options in common.yaml will get merged into the corresponding
"option" with sub-options in the example configuration file.
'''
representer = ruamel.yaml.representer.SafeRepresenter()
for index, (key_node, value_node) in enumerate(node.value):
if key_node.tag == u'tag:yaml.org,2002:merge' and value_node.tag == '!include':
included_value = representer.represent_data(self.construct_object(value_node))
node.value[index] = (key_node, included_value)
# Replace the merge include with a sequence of included configuration nodes ready
# for merging. The construct_object() call here triggers include_configuration()
# among other constructors.
node.value[index] = (
key_node,
representer.represent_data(self.construct_object(value_node)),
)
# This super().flatten_mapping() call actually performs "<<" merges.
super(Include_constructor, self).flatten_mapping(node)
node.value = deep_merge_nodes(node.value)
def load_configuration(filename):
def load_configuration(filename, config_paths=None):
'''
Load the given configuration file and return its contents as a data structure of nested dicts
and lists.
and lists. Add the filename to the given configuration paths set, and also add any included
configuration filenames.
Raise ruamel.yaml.error.YAMLError if something goes wrong parsing the YAML, or RecursionError
if there are too many recursive includes.
'''
# Use an embedded derived class for the include constructor so as to capture the filename
# value. (functools.partial doesn't work for this use case because yaml.Constructor has to be
# an actual class.)
class Include_constructor_with_include_directory(Include_constructor):
if config_paths is None:
config_paths = set()
# Use an embedded derived class for the include constructor so as to capture the include
# directory and configuration paths values. (functools.partial doesn't work for this use case
# because yaml.Constructor has to be an actual class.)
class Include_constructor_with_extras(Include_constructor):
def __init__(self, preserve_quotes=None, loader=None):
super(Include_constructor_with_include_directory, self).__init__(
preserve_quotes, loader, include_directory=os.path.dirname(filename)
super(Include_constructor_with_extras, self).__init__(
preserve_quotes,
loader,
include_directory=os.path.dirname(filename),
config_paths=config_paths,
)
yaml = ruamel.yaml.YAML(typ='safe')
yaml.Constructor = Include_constructor_with_include_directory
yaml.Constructor = Include_constructor_with_extras
config_paths.add(filename)
return yaml.load(open(filename))
with open(filename) as file:
return yaml.load(file.read())
DELETED_NODE = object()
def filter_omitted_nodes(nodes, values):
'''
Given a nested borgmatic configuration data structure as a list of tuples in the form of:
[
(
ruamel.yaml.nodes.ScalarNode as a key,
ruamel.yaml.nodes.MappingNode or other Node as a value,
),
...
]
... and a combined list of all values for those nodes, return a filtered list of the values,
omitting any that have an "!omit" tag (or with a value matching such nodes).
But if only a single node is given, bail and return the given values unfiltered, as "!omit" only
applies when there are merge includes (and therefore multiple nodes).
'''
if len(nodes) <= 1:
return values
omitted_values = tuple(node.value for node in values if node.tag == '!omit')
return [node for node in values if node.value not in omitted_values]
def merge_values(nodes):
'''
Given a nested borgmatic configuration data structure as a list of tuples in the form of:
[
(
ruamel.yaml.nodes.ScalarNode as a key,
ruamel.yaml.nodes.MappingNode or other Node as a value,
),
...
]
... merge its sequence or mapping node values and return the result. For sequence nodes, this
means appending together its contained lists. For mapping nodes, it means merging its contained
dicts.
'''
return functools.reduce(operator.add, (value.value for key, value in nodes))
def deep_merge_nodes(nodes):
'''
Given a nested borgmatic configuration data structure as a list of tuples in the form of:
[
(
ruamel.yaml.nodes.ScalarNode as a key,
ruamel.yaml.nodes.MappingNode or other Node as a value,
),
...
]
... deep merge any node values corresponding to duplicate keys and return the result. If
there are colliding keys with non-MappingNode values (e.g., integers or strings), the last
of the values wins.
... deep merge any node values corresponding to duplicate keys and return the result. The
purpose of merging like this is to support, for instance, merging one borgmatic configuration
file into another for reuse, such that a configuration option with sub-options does not
completely replace the corresponding option in a merged file.
If there are colliding keys with scalar values (e.g., integers or strings), the last of the
values wins.
For instance, given node values of:
[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
MappingNode(tag='tag:yaml.org,2002:map', value=[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_hourly'),
ScalarNode(tag='tag:yaml.org,2002:int', value='24')
ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option1'),
ScalarNode(tag='tag:yaml.org,2002:int', value='1')
),
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
ScalarNode(tag='tag:yaml.org,2002:int', value='7')
ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'),
ScalarNode(tag='tag:yaml.org,2002:int', value='2')
),
]),
),
(
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
MappingNode(tag='tag:yaml.org,2002:map', value=[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'),
ScalarNode(tag='tag:yaml.org,2002:int', value='5')
),
]),
@@ -148,69 +290,95 @@ def deep_merge_nodes(nodes):
[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
MappingNode(tag='tag:yaml.org,2002:map', value=[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_hourly'),
ScalarNode(tag='tag:yaml.org,2002:int', value='24')
ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option1'),
ScalarNode(tag='tag:yaml.org,2002:int', value='1')
),
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'),
ScalarNode(tag='tag:yaml.org,2002:int', value='5')
),
]),
),
]
The purpose of deep merging like this is to support, for instance, merging one borgmatic
configuration file into another for reuse, such that a configuration section ("retention",
etc.) does not completely replace the corresponding section in a merged file.
This function supports multi-way merging, meaning that if the same option name exists three or
more times (at the same scope level), all of those instances get merged together.
If a mapping or sequence node has a YAML "!retain" tag, then that node is not merged.
Raise ValueError if a merge is implied using multiple incompatible types.
'''
# Map from original node key/value to the replacement merged node. DELETED_NODE as a replacement
# node indications deletion.
replaced_nodes = {}
merged_nodes = []
# To find nodes that require merging, compare each node with each other node.
for a_key, a_value in nodes:
for b_key, b_value in nodes:
# If we've already considered one of the nodes for merging, skip it.
if (a_key, a_value) in replaced_nodes or (b_key, b_value) in replaced_nodes:
continue
def get_node_key_name(node):
return node[0].value
# If the keys match and the values are different, we need to merge these two A and B nodes.
if a_key.tag == b_key.tag and a_key.value == b_key.value and a_value != b_value:
# Since we're merging into the B node, consider the A node a duplicate and remove it.
replaced_nodes[(a_key, a_value)] = DELETED_NODE
# Bucket the nodes by their keys. Then merge all of the values sharing the same key.
for key_name, grouped_nodes in itertools.groupby(
sorted(nodes, key=get_node_key_name), get_node_key_name
):
grouped_nodes = list(grouped_nodes)
# If we're dealing with MappingNodes, recurse and merge its values as well.
if isinstance(b_value, ruamel.yaml.nodes.MappingNode):
replaced_nodes[(b_key, b_value)] = (
b_key,
# The merged node inherits its attributes from the final node in the group.
(last_node_key, last_node_value) = grouped_nodes[-1]
value_types = set(type(value) for (_, value) in grouped_nodes)
if len(value_types) > 1:
raise ValueError(
f'Incompatible types found when trying to merge "{key_name}:" values across configuration files: {", ".join(value_type.id for value_type in value_types)}'
)
# If we're dealing with MappingNodes, recurse and merge its values as well.
if ruamel.yaml.nodes.MappingNode in value_types:
# A "!retain" tag says to skip deep merging for this node. Replace the tag so
# downstream schema validation doesn't break on our application-specific tag.
if last_node_value.tag == '!retain' and len(grouped_nodes) > 1:
last_node_value.tag = 'tag:yaml.org,2002:map'
merged_nodes.append((last_node_key, last_node_value))
else:
merged_nodes.append(
(
last_node_key,
ruamel.yaml.nodes.MappingNode(
tag=b_value.tag,
value=deep_merge_nodes(a_value.value + b_value.value),
start_mark=b_value.start_mark,
end_mark=b_value.end_mark,
flow_style=b_value.flow_style,
comment=b_value.comment,
anchor=b_value.anchor,
),
)
# If we're dealing with SequenceNodes, merge by appending one sequence to the other.
elif isinstance(b_value, ruamel.yaml.nodes.SequenceNode):
replaced_nodes[(b_key, b_value)] = (
b_key,
ruamel.yaml.nodes.SequenceNode(
tag=b_value.tag,
value=a_value.value + b_value.value,
start_mark=b_value.start_mark,
end_mark=b_value.end_mark,
flow_style=b_value.flow_style,
comment=b_value.comment,
anchor=b_value.anchor,
tag=last_node_value.tag,
value=deep_merge_nodes(merge_values(grouped_nodes)),
start_mark=last_node_value.start_mark,
end_mark=last_node_value.end_mark,
flow_style=last_node_value.flow_style,
comment=last_node_value.comment,
anchor=last_node_value.anchor,
),
)
)
return [
replaced_nodes.get(node, node) for node in nodes if replaced_nodes.get(node) != DELETED_NODE
]
continue
# If we're dealing with SequenceNodes, merge by appending sequences together.
if ruamel.yaml.nodes.SequenceNode in value_types:
if last_node_value.tag == '!retain' and len(grouped_nodes) > 1:
last_node_value.tag = 'tag:yaml.org,2002:seq'
merged_nodes.append((last_node_key, last_node_value))
else:
merged_nodes.append(
(
last_node_key,
ruamel.yaml.nodes.SequenceNode(
tag=last_node_value.tag,
value=filter_omitted_nodes(grouped_nodes, merge_values(grouped_nodes)),
start_mark=last_node_value.start_mark,
end_mark=last_node_value.end_mark,
flow_style=last_node_value.flow_style,
comment=last_node_value.comment,
anchor=last_node_value.anchor,
),
)
)
continue
merged_nodes.append((last_node_key, last_node_value))
return merged_nodes
+236 -40
View File
@@ -1,4 +1,61 @@
import logging
import os
def normalize_sections(config_filename, config):
'''
Given a configuration filename and a configuration dict of its loaded contents, airlift any
options out of sections ("location:", etc.) to the global scope and delete those sections.
Return any log message warnings produced based on the normalization performed.
Raise ValueError if the "prefix" option is set in both "location" and "consistency" sections.
'''
try:
location = config.get('location') or {}
except AttributeError:
raise ValueError('Configuration does not contain any options')
storage = config.get('storage') or {}
consistency = config.get('consistency') or {}
hooks = config.get('hooks') or {}
if (
location.get('prefix')
and consistency.get('prefix')
and location.get('prefix') != consistency.get('prefix')
):
raise ValueError(
'The retention prefix and the consistency prefix cannot have different values (unless one is not set).'
)
if storage.get('umask') and hooks.get('umask') and storage.get('umask') != hooks.get('umask'):
raise ValueError(
'The storage umask and the hooks umask cannot have different values (unless one is not set).'
)
any_section_upgraded = False
# Move any options from deprecated sections into the global scope.
for section_name in ('location', 'storage', 'retention', 'consistency', 'output', 'hooks'):
section_config = config.get(section_name)
if section_config is not None:
any_section_upgraded = True
del config[section_name]
config.update(section_config)
if any_section_upgraded:
return [
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: Configuration sections (like location:, storage:, retention:, consistency:, and hooks:) are deprecated and support will be removed from a future release. To prepare for this, move your options out of sections to the global scope.',
)
)
]
return []
def normalize(config_filename, config):
@@ -6,83 +63,222 @@ def normalize(config_filename, config):
Given a configuration filename and a configuration dict of its loaded contents, apply particular
hard-coded rules to normalize the configuration to adhere to the current schema. Return any log
message warnings produced based on the normalization performed.
Raise ValueError the configuration cannot be normalized.
'''
logs = []
location = config.get('location') or {}
storage = config.get('storage') or {}
consistency = config.get('consistency') or {}
hooks = config.get('hooks') or {}
logs = normalize_sections(config_filename, config)
if config.get('borgmatic_source_directory'):
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The borgmatic_source_directory option is deprecated and will be removed from a future release. Use borgmatic_runtime_directory and borgmatic_state_directory instead.',
)
)
)
# Upgrade exclude_if_present from a string to a list.
exclude_if_present = location.get('exclude_if_present')
exclude_if_present = config.get('exclude_if_present')
if isinstance(exclude_if_present, str):
config['location']['exclude_if_present'] = [exclude_if_present]
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The exclude_if_present option now expects a list value. String values for this option are deprecated and support will be removed from a future release.',
)
)
)
config['exclude_if_present'] = [exclude_if_present]
# Upgrade various monitoring hooks from a string to a dict.
healthchecks = hooks.get('healthchecks')
healthchecks = config.get('healthchecks')
if isinstance(healthchecks, str):
config['hooks']['healthchecks'] = {'ping_url': healthchecks}
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The healthchecks hook now expects a key/value pair with "ping_url" as a key. String values for this option are deprecated and support will be removed from a future release.',
)
)
)
config['healthchecks'] = {'ping_url': healthchecks}
cronitor = hooks.get('cronitor')
cronitor = config.get('cronitor')
if isinstance(cronitor, str):
config['hooks']['cronitor'] = {'ping_url': cronitor}
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.',
)
)
)
config['cronitor'] = {'ping_url': cronitor}
pagerduty = hooks.get('pagerduty')
pagerduty = config.get('pagerduty')
if isinstance(pagerduty, str):
config['hooks']['pagerduty'] = {'integration_key': pagerduty}
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.',
)
)
)
config['pagerduty'] = {'integration_key': pagerduty}
cronhub = hooks.get('cronhub')
cronhub = config.get('cronhub')
if isinstance(cronhub, str):
config['hooks']['cronhub'] = {'ping_url': cronhub}
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The healthchecks hook now expects key/value pairs. String values for this option are deprecated and support will be removed from a future release.',
)
)
)
config['cronhub'] = {'ping_url': cronhub}
# Upgrade consistency checks from a list of strings to a list of dicts.
checks = consistency.get('checks')
checks = config.get('checks')
if isinstance(checks, list) and len(checks) and isinstance(checks[0], str):
config['consistency']['checks'] = [{'name': check_type} for check_type in checks]
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The checks option now expects a list of key/value pairs. Lists of strings for this option are deprecated and support will be removed from a future release.',
)
)
)
config['checks'] = [{'name': check_type} for check_type in checks]
# Rename various configuration options.
numeric_owner = location.pop('numeric_owner', None)
numeric_owner = config.pop('numeric_owner', None)
if numeric_owner is not None:
config['location']['numeric_ids'] = numeric_owner
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The numeric_owner option has been renamed to numeric_ids. numeric_owner is deprecated and support will be removed from a future release.',
)
)
)
config['numeric_ids'] = numeric_owner
bsd_flags = location.pop('bsd_flags', None)
bsd_flags = config.pop('bsd_flags', None)
if bsd_flags is not None:
config['location']['flags'] = bsd_flags
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The bsd_flags option has been renamed to flags. bsd_flags is deprecated and support will be removed from a future release.',
)
)
)
config['flags'] = bsd_flags
remote_rate_limit = storage.pop('remote_rate_limit', None)
remote_rate_limit = config.pop('remote_rate_limit', None)
if remote_rate_limit is not None:
config['storage']['upload_rate_limit'] = remote_rate_limit
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The remote_rate_limit option has been renamed to upload_rate_limit. remote_rate_limit is deprecated and support will be removed from a future release.',
)
)
)
config['upload_rate_limit'] = remote_rate_limit
# Upgrade remote repositories to ssh:// syntax, required in Borg 2.
repositories = location.get('repositories')
repositories = config.get('repositories')
if repositories:
config['location']['repositories'] = []
for repository in repositories:
if '~' in repository:
if any(isinstance(repository, str) for repository in repositories):
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The repositories option now expects a list of key/value pairs. Lists of strings for this option are deprecated and support will be removed from a future release.',
)
)
)
config['repositories'] = [
{'path': repository} if isinstance(repository, str) else repository
for repository in repositories
]
repositories = config['repositories']
config['repositories'] = []
for repository_dict in repositories:
repository_path = repository_dict['path']
if '~' in repository_path:
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: Repository paths containing "~" are deprecated in borgmatic and no longer work in Borg 2.x+.',
msg=f'{config_filename}: Repository paths containing "~" are deprecated in borgmatic and support will be removed from a future release.',
)
)
)
if ':' in repository and not repository.startswith('ssh://'):
rewritten_repository = (
f"ssh://{repository.replace(':~', '/~').replace(':/', '/').replace(':', '/./')}"
)
logs.append(
logging.makeLogRecord(
if ':' in repository_path:
if repository_path.startswith('file://'):
updated_repository_path = os.path.abspath(
repository_path.partition('file://')[-1]
)
config['repositories'].append(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: Remote repository paths without ssh:// syntax are deprecated. Interpreting "{repository}" as "{rewritten_repository}"',
repository_dict,
path=updated_repository_path,
)
)
elif (
repository_path.startswith('ssh://')
or repository_path.startswith('sftp://')
or repository_path.startswith('rclone:')
):
config['repositories'].append(repository_dict)
else:
rewritten_repository_path = f"ssh://{repository_path.replace(':~', '/~').replace(':/', '/').replace(':', '/./')}"
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: Remote repository paths without ssh:// or rclone: syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
)
)
)
config['repositories'].append(
dict(
repository_dict,
path=rewritten_repository_path,
)
)
)
config['location']['repositories'].append(rewritten_repository)
else:
config['location']['repositories'].append(repository)
config['repositories'].append(repository_dict)
if config.get('prefix'):
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The prefix option is deprecated and support will be removed from a future release. Use archive_name_format or match_archives instead.',
)
)
)
return logs
+79 -19
View File
@@ -13,6 +13,11 @@ def set_values(config, keys, value):
first_key = keys[0]
if len(keys) == 1:
if isinstance(config, list):
raise ValueError(
'When overriding a list option, the value must use list syntax (e.g., "[foo, bar]" or "[{key: value}]" as appropriate)'
)
config[first_key] = value
return
@@ -22,29 +27,70 @@ def set_values(config, keys, value):
set_values(config[first_key], keys[1:], value)
def convert_value_type(value):
def convert_value_type(value, option_type):
'''
Given a string value, determine its logical type (string, boolean, integer, etc.), and return it
converted to that type.
Given a string value and its schema type as a string, determine its logical type (string,
boolean, integer, etc.), and return it converted to that type.
If the option type is a string, leave the value as a string so that special characters in it
don't get interpreted as YAML during conversion.
Raise ruamel.yaml.error.YAMLError if there's a parse issue with the YAML.
'''
if option_type == 'string':
return value
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:
LEGACY_SECTION_NAMES = {'location', 'storage', 'retention', 'consistency', 'output', 'hooks'}
['section.my_option=value1', 'section.other_option=value2']
def strip_section_names(parsed_override_key):
'''
Given a parsed override key as a tuple of option and suboption names, strip out any initial
legacy section names, since configuration file normalization also strips them out.
'''
if parsed_override_key[0] in LEGACY_SECTION_NAMES:
return parsed_override_key[1:]
return parsed_override_key
def type_for_option(schema, option_keys):
'''
Given a configuration schema and a sequence of keys identifying an option, e.g.
('extra_borg_options', 'init'), return the schema type of that option as a string.
Return None if the option or its type cannot be found in the schema.
'''
option_schema = schema
for key in option_keys:
try:
option_schema = option_schema['properties'][key]
except KeyError:
return None
try:
return option_schema['type']
except KeyError:
return None
def parse_overrides(raw_overrides, schema):
'''
Given a sequence of configuration file override strings in the form of "option.suboption=value"
and a configuration schema dict, parse and return a sequence of tuples (keys, values), where
keys is a sequence of strings. For instance, given the following raw overrides:
['my_option.suboption=value1', 'other_option=value2']
... return this:
(
(('section', 'my_option'), 'value1'),
(('section', 'other_option'), 'value2'),
(('my_option', 'suboption'), 'value1'),
(('other_option'), 'value2'),
)
Raise ValueError if an override can't be parsed.
@@ -57,10 +103,18 @@ def parse_overrides(raw_overrides):
for raw_override in raw_overrides:
try:
raw_keys, value = raw_override.split('=', 1)
parsed_overrides.append((tuple(raw_keys.split('.')), convert_value_type(value),))
keys = tuple(raw_keys.split('.'))
option_type = type_for_option(schema, keys)
parsed_overrides.append(
(
keys,
convert_value_type(value, option_type),
)
)
except ValueError:
raise ValueError(
f"Invalid override '{raw_override}'. Make sure you use the form: SECTION.OPTION=VALUE"
f"Invalid override '{raw_override}'. Make sure you use the form: OPTION=VALUE or OPTION.SUBOPTION=VALUE"
)
except ruamel.yaml.error.YAMLError as error:
raise ValueError(f"Invalid override '{raw_override}': {error.problem}")
@@ -68,12 +122,18 @@ def parse_overrides(raw_overrides):
return tuple(parsed_overrides)
def apply_overrides(config, raw_overrides):
def apply_overrides(config, schema, raw_overrides):
'''
Given a configuration dict and a sequence of configuration file override strings in the form of
"section.option=value", parse each override and set it the configuration dict.
'''
overrides = parse_overrides(raw_overrides)
Given a configuration dict, a corresponding configuration schema dict, and a sequence of
configuration file override strings in the form of "option.suboption=value", parse each override
and set it into the configuration dict.
for (keys, value) in overrides:
Set the overrides into the configuration both with and without deprecated section names (if
used), so that the overrides work regardless of whether the configuration is also using
deprecated section names.
'''
overrides = parse_overrides(raw_overrides, schema)
for keys, value in overrides:
set_values(config, keys, value)
set_values(config, strip_section_names(keys), value)
+136
View File
@@ -0,0 +1,136 @@
import logging
import os
import tempfile
logger = logging.getLogger(__name__)
def expand_user_in_path(path):
'''
Given a directory path, expand any tildes in it.
'''
try:
return os.path.expanduser(path or '') or None
except TypeError:
return None
def get_working_directory(config): # pragma: no cover
'''
Given a configuration dict, get the working directory from it, expanding any tildes.
'''
return expand_user_in_path(config.get('working_directory'))
def get_borgmatic_source_directory(config):
'''
Given a configuration dict, get the (deprecated) borgmatic source directory, expanding any
tildes. Defaults to ~/.borgmatic.
'''
return expand_user_in_path(config.get('borgmatic_source_directory') or '~/.borgmatic')
TEMPORARY_DIRECTORY_PREFIX = 'borgmatic-'
class Runtime_directory:
'''
A Python context manager for creating and cleaning up the borgmatic runtime directory used for
storing temporary runtime data like streaming database dumps and bootstrap metadata.
Example use as a context manager:
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
do_something_with(borgmatic_runtime_directory)
For the scope of that "with" statement, the runtime directory is available. Afterwards, it
automatically gets cleaned up as necessary.
'''
def __init__(self, config, log_prefix):
'''
Given a configuration dict and a log prefix, determine the borgmatic runtime directory,
creating a secure, temporary directory within it if necessary. Defaults to
$XDG_RUNTIME_DIR/./borgmatic or $RUNTIME_DIRECTORY/./borgmatic or
$TMPDIR/borgmatic-[random]/./borgmatic or $TEMP/borgmatic-[random]/./borgmatic or
/tmp/borgmatic-[random]/./borgmatic where "[random]" is a randomly generated string intended
to avoid path collisions.
If XDG_RUNTIME_DIR or RUNTIME_DIRECTORY is set and already ends in "/borgmatic", then don't
tack on a second "/borgmatic" path component.
The "/./" is taking advantage of a Borg feature such that the part of the path before the "/./"
does not get stored in the file path within an archive. That way, the path of the runtime
directory can change without leaving database dumps within an archive inaccessible.
'''
runtime_directory = (
config.get('user_runtime_directory')
or os.environ.get('XDG_RUNTIME_DIR') # Set by PAM on Linux.
or os.environ.get('RUNTIME_DIRECTORY') # Set by systemd if configured.
)
if runtime_directory:
self.temporary_directory = None
else:
base_directory = os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp'
os.makedirs(base_directory, mode=0o700, exist_ok=True)
self.temporary_directory = tempfile.TemporaryDirectory(
prefix=TEMPORARY_DIRECTORY_PREFIX,
dir=base_directory,
)
runtime_directory = self.temporary_directory.name
(base_path, final_directory) = os.path.split(runtime_directory.rstrip(os.path.sep))
self.runtime_path = expand_user_in_path(
os.path.join(
base_path if final_directory == 'borgmatic' else runtime_directory, '.', 'borgmatic'
)
)
os.makedirs(self.runtime_path, mode=0o700, exist_ok=True)
logger.debug(f'{log_prefix}: Using runtime directory {os.path.normpath(self.runtime_path)}')
def __enter__(self):
'''
Return the borgmatic runtime path as a string.
'''
return self.runtime_path
def __exit__(self, exception, value, traceback):
'''
Delete any temporary directory that was created as part of initialization.
'''
if self.temporary_directory:
self.temporary_directory.cleanup()
def make_runtime_directory_glob(borgmatic_runtime_directory):
'''
Given a borgmatic runtime directory path, make a glob that would match that path, specifically
replacing any randomly generated temporary subdirectory with "*" since such a directory's name
changes on every borgmatic run.
'''
return os.path.join(
*(
'*' if subdirectory.startswith(TEMPORARY_DIRECTORY_PREFIX) else subdirectory
for subdirectory in os.path.normpath(borgmatic_runtime_directory).split(os.path.sep)
)
)
def get_borgmatic_state_directory(config):
'''
Given a configuration dict, get the borgmatic state directory used for storing borgmatic state
files like records of when checks last ran. Defaults to $XDG_STATE_HOME/borgmatic or
~/.local/state/./borgmatic.
'''
return expand_user_in_path(
os.path.join(
config.get('user_state_directory')
or os.environ.get('XDG_STATE_HOME')
or os.environ.get('STATE_DIRECTORY') # Set by systemd if configured.
or '~/.local/state',
'borgmatic',
)
)
+2212 -1220
View File
File diff suppressed because it is too large Load Diff
+74 -63
View File
@@ -1,18 +1,24 @@
import fnmatch
import os
import jsonschema
import pkg_resources
import ruamel.yaml
from borgmatic.config import environment, load, normalize, override
import borgmatic.config
from borgmatic.config import constants, environment, load, normalize, override
def schema_filename():
'''
Path to the installed YAML configuration schema file, used to validate and parse the
configuration.
Raise FileNotFoundError when the schema path does not exist.
'''
return pkg_resources.resource_filename('borgmatic', 'config/schema.yaml')
schema_path = os.path.join(os.path.dirname(borgmatic.config.__file__), 'schema.yaml')
with open(schema_path):
return schema_path
def format_json_error_path_element(path_element):
@@ -20,9 +26,9 @@ def format_json_error_path_element(path_element):
Given a path element into a JSON data structure, format it for display as a string.
'''
if isinstance(path_element, int):
return str('[{}]'.format(path_element))
return str(f'[{path_element}]')
return str('.{}'.format(path_element))
return str(f'.{path_element}')
def format_json_error(error):
@@ -30,10 +36,10 @@ def format_json_error(error):
Given an instance of jsonschema.exceptions.ValidationError, format it for display as a string.
'''
if not error.path:
return 'At the top level: {}'.format(error.message)
return f'At the top level: {error.message}'
formatted_path = ''.join(format_json_error_path_element(element) for element in error.path)
return "At '{}': {}".format(formatted_path.lstrip('.'), error.message)
return f"At '{formatted_path.lstrip('.')}': {error.message}"
class Validation_error(ValueError):
@@ -54,9 +60,10 @@ class Validation_error(ValueError):
'''
Render a validation error as a user-facing string.
'''
return 'An error occurred while parsing a configuration file at {}:\n'.format(
self.config_filename
) + '\n'.join(error for error in self.errors)
return (
f'An error occurred while parsing a configuration file at {self.config_filename}:\n'
+ '\n'.join(error for error in self.errors)
)
def apply_logical_validation(config_filename, parsed_configuration):
@@ -65,17 +72,15 @@ def apply_logical_validation(config_filename, parsed_configuration):
below), run through any additional logical validation checks. If there are any such validation
problems, raise a Validation_error.
'''
location_repositories = parsed_configuration.get('location', {}).get('repositories')
check_repositories = parsed_configuration.get('consistency', {}).get('check_repositories', [])
repositories = parsed_configuration.get('repositories')
check_repositories = parsed_configuration.get('check_repositories', [])
for repository in check_repositories:
if repository not in location_repositories:
if not any(
repositories_match(repository, config_repository) for config_repository in repositories
):
raise Validation_error(
config_filename,
(
'Unknown repository in the "consistency" section\'s "check_repositories": {}'.format(
repository
),
),
(f'Unknown repository in "check_repositories": {repository}',),
)
@@ -83,29 +88,38 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
'''
Given the path to a config filename in YAML format, the path to a schema filename in a YAML
rendition of JSON Schema format, a sequence of configuration file override strings in the form
of "section.option=value", return the parsed configuration as a data structure of nested dicts
of "option.suboption=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']}}
{
'source_directories': ['/home', '/etc'],
'repository': 'hostname.borg',
'keep_daily': 7,
'checks': ['repository', 'archives'],
}
Also return a sequence of logging.LogRecord instances containing any warnings about the
configuration.
Also return a set of loaded configuration paths and a sequence of logging.LogRecord instances
containing any warnings about the configuration.
Raise FileNotFoundError if the file does not exist, PermissionError if the user does not
have permissions to read the file, or Validation_error if the config does not match the schema.
'''
config_paths = set()
try:
config = load.load_configuration(config_filename)
config = load.load_configuration(config_filename, config_paths)
schema = load.load_configuration(schema_filename)
except (ruamel.yaml.error.YAMLError, RecursionError) as error:
raise Validation_error(config_filename, (str(error),))
override.apply_overrides(config, overrides)
logs = normalize.normalize(config_filename, config)
override.apply_overrides(config, schema, overrides)
constants.apply_constants(config, config.get('constants') if config else {})
if resolve_env:
environment.resolve_env_variables(config)
logs = normalize.normalize(config_filename, config)
try:
validator = jsonschema.Draft7Validator(schema)
except AttributeError: # pragma: no cover
@@ -119,35 +133,57 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
apply_logical_validation(config_filename, config)
return config, logs
return config, config_paths, logs
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:
# A colon in the repository could mean that it's either a file:// URL or a remote repository.
# If it's a remote repository, we don't want to normalize it. If it's a file:// URL, we do.
if ':' not in repository:
return os.path.abspath(repository)
elif repository.startswith('file://'):
return os.path.abspath(repository.partition('file://')[-1])
else:
return repository
return os.path.abspath(repository)
def glob_match(first, second):
'''
Given two strings, return whether the first matches the second. Globs are
supported.
'''
if first is None or second is None:
return False
return fnmatch.fnmatch(first, second) or fnmatch.fnmatch(second, first)
def repositories_match(first, second):
'''
Given two repository paths (relative and/or absolute), return whether they match.
Given two repository dicts with keys "path" (relative and/or absolute),
and "label", two repository paths as strings, or a mix of the two formats,
return whether they match. Globs are supported.
'''
return normalize_repository_path(first) == normalize_repository_path(second)
if isinstance(first, str):
first = {'path': first, 'label': first}
if isinstance(second, str):
second = {'path': second, 'label': second}
return glob_match(first.get('label'), second.get('label')) or glob_match(
normalize_repository_path(first.get('path')), normalize_repository_path(second.get('path'))
)
def guard_configuration_contains_repository(repository, configurations):
'''
Given a repository path and a dict mapping from config filename to corresponding parsed config
dict, ensure that the repository is declared exactly once in all of the configurations. If no
dict, ensure that the repository is declared at least once in all of the configurations. If no
repository is given, skip this check.
Raise ValueError if the repository is not found in a configuration, or is declared multiple
times.
Raise ValueError if the repository is not found in any configurations.
'''
if not repository:
return
@@ -156,35 +192,10 @@ def guard_configuration_contains_repository(repository, configurations):
tuple(
config_repository
for config in configurations.values()
for config_repository in config['location']['repositories']
if repositories_match(repository, config_repository)
for config_repository in config['repositories']
if repositories_match(config_repository, repository)
)
)
if count == 0:
raise ValueError('Repository {} not found in configuration files'.format(repository))
if count > 1:
raise ValueError('Repository {} found in multiple configuration files'.format(repository))
def guard_single_repository_selected(repository, configurations):
'''
Given a repository path and a dict mapping from config filename to corresponding parsed config
dict, ensure either a single repository exists across all configuration files or a repository
path was given.
'''
if repository:
return
count = len(
tuple(
config_repository
for config in configurations.values()
for config_repository in config['location']['repositories']
)
)
if count != 1:
raise ValueError(
"Can't determine which repository to use. Use --repository to disambiguate"
)
raise ValueError(f'Repository "{repository}" not found in configuration files')
+181 -53
View File
@@ -1,31 +1,70 @@
import collections
import enum
import logging
import os
import select
import subprocess
import textwrap
logger = logging.getLogger(__name__)
ERROR_OUTPUT_MAX_LINE_COUNT = 25
BORG_ERROR_EXIT_CODE = 2
BORG_ERROR_EXIT_CODE_START = 2
BORG_ERROR_EXIT_CODE_END = 99
def exit_code_indicates_error(process, exit_code, borg_local_path=None):
class Exit_status(enum.Enum):
STILL_RUNNING = 1
SUCCESS = 2
WARNING = 3
ERROR = 4
def interpret_exit_code(command, exit_code, borg_local_path=None, borg_exit_codes=None):
'''
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.
Return an Exit_status value (e.g. SUCCESS, ERROR, or WARNING) based on interpreting the given
exit code. If a Borg local path is given and matches the process' command, then interpret the
exit code based on Borg's documented exit code semantics. And if Borg exit codes are given as a
sequence of exit code configuration dicts, then take those configured preferences into account.
'''
if exit_code is None:
return False
command = process.args.split(' ') if isinstance(process.args, str) else process.args
return Exit_status.STILL_RUNNING
if exit_code == 0:
return Exit_status.SUCCESS
if borg_local_path and command[0] == borg_local_path:
return bool(exit_code < 0 or exit_code >= BORG_ERROR_EXIT_CODE)
# First try looking for the exit code in the borg_exit_codes configuration.
for entry in borg_exit_codes or ():
if entry.get('code') == exit_code:
treat_as = entry.get('treat_as')
return bool(exit_code != 0)
if treat_as == 'error':
logger.error(
f'Treating exit code {exit_code} as an error, as per configuration'
)
return Exit_status.ERROR
elif treat_as == 'warning':
logger.warning(
f'Treating exit code {exit_code} as a warning, as per configuration'
)
return Exit_status.WARNING
# If the exit code doesn't have explicit configuration, then fall back to the default Borg
# behavior.
return (
Exit_status.ERROR
if (
exit_code < 0
or (
exit_code >= BORG_ERROR_EXIT_CODE_START
and exit_code <= BORG_ERROR_EXIT_CODE_END
)
)
else Exit_status.WARNING
)
return Exit_status.ERROR
def command_for_process(process):
@@ -45,7 +84,24 @@ def output_buffer_for_process(process, exclude_stdouts):
return process.stderr if process.stdout in exclude_stdouts else process.stdout
def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
def append_last_lines(last_lines, captured_output, line, output_log_level):
'''
Given a rolling list of last lines, a list of captured output, a line to append, and an output
log level, append the line to the last lines and (if necessary) the captured output. Then log
the line at the requested output log level.
'''
last_lines.append(line)
if len(last_lines) > ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.pop(0)
if output_log_level is None:
captured_output.append(line)
else:
logger.log(output_log_level, line)
def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path, borg_exit_codes):
'''
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
@@ -53,7 +109,8 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
path).
If output log level is None, then instead of logging, capture output for each process and return
it as a dict from the process to its output.
it as a dict from the process to its output. Use the given Borg local path and exit code
configuration to decide what's an error and what's a warning.
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.
@@ -100,15 +157,12 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
# 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)
if output_log_level is None:
captured_outputs[ready_process].append(line)
else:
logger.log(output_log_level, line)
append_last_lines(
buffer_last_lines[ready_buffer],
captured_outputs[ready_process],
line,
output_log_level,
)
if not still_running:
break
@@ -120,14 +174,28 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
if exit_code is None:
still_running = True
command = process.args.split(' ') if isinstance(process.args, str) else process.args
continue
# If any process errors, then raise accordingly.
if exit_code_indicates_error(process, exit_code, borg_local_path):
command = process.args.split(' ') if isinstance(process.args, str) else process.args
exit_status = interpret_exit_code(command, exit_code, borg_local_path, borg_exit_codes)
if exit_status in (Exit_status.ERROR, Exit_status.WARNING):
# 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 []
# Collect any straggling output lines that came in since we last gathered output.
while output_buffer: # pragma: no cover
line = output_buffer.readline().rstrip().decode()
if not line:
break
append_last_lines(
last_lines, captured_outputs[process], line, output_log_level=logging.ERROR
)
if len(last_lines) == ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.insert(0, '...')
@@ -138,9 +206,13 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
other_process.stdout.read(0)
other_process.kill()
raise subprocess.CalledProcessError(
exit_code, command_for_process(process), '\n'.join(last_lines)
)
if exit_status == Exit_status.ERROR:
raise subprocess.CalledProcessError(
exit_code, command_for_process(process), '\n'.join(last_lines)
)
still_running = False
break
if captured_outputs:
return {
@@ -148,19 +220,47 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
}
def log_command(full_command, input_file=None, output_file=None):
SECRET_COMMAND_FLAG_NAMES = {'--password'}
def mask_command_secrets(full_command):
'''
Given a command as a sequence, mask secret values for flags like "--password" in preparation for
logging.
'''
masked_command = []
previous_piece = None
for piece in full_command:
masked_command.append('***' if previous_piece in SECRET_COMMAND_FLAG_NAMES else piece)
previous_piece = piece
return tuple(masked_command)
MAX_LOGGED_COMMAND_LENGTH = 1000
def log_command(full_command, input_file=None, output_file=None, environment=None):
'''
Log the given command (a sequence of command/argument strings), along with its input/output file
paths.
paths and extra environment variables (with omitted values in case they contain passwords).
'''
logger.debug(
' '.join(full_command)
+ (' < {}'.format(getattr(input_file, 'name', '')) if input_file else '')
+ (' > {}'.format(getattr(output_file, 'name', '')) if output_file else '')
textwrap.shorten(
' '.join(
tuple(f'{key}=***' for key in (environment or {}).keys())
+ mask_command_secrets(full_command)
),
width=MAX_LOGGED_COMMAND_LENGTH,
placeholder=' ...',
)
+ (f" < {getattr(input_file, 'name', '')}" if input_file else '')
+ (f" > {getattr(output_file, 'name', '')}" if output_file else '')
)
# An sentinel passed as an output file to execute_command() to indicate that the command's output
# A 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()
@@ -175,6 +275,7 @@ def execute_command(
extra_environment=None,
working_directory=None,
borg_local_path=None,
borg_exit_codes=None,
run_to_completion=True,
):
'''
@@ -185,12 +286,13 @@ def execute_command(
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.
instead of an error. But if Borg exit codes are given as a sequence of exit code configuration
dicts, then use that configuration to decide what's an error and what's a warning. If run to
completion is False, then return the process for the command without executing it to completion.
Raise subprocesses.CalledProcessError if an error occurs while running the command.
'''
log_command(full_command, input_file, output_file)
log_command(full_command, input_file, output_file, extra_environment)
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
@@ -208,33 +310,55 @@ def execute_command(
return process
log_outputs(
(process,), (input_file, output_file), output_log_level, borg_local_path=borg_local_path
(process,),
(input_file, output_file),
output_log_level,
borg_local_path,
borg_exit_codes,
)
def execute_command_and_capture_output(
full_command, capture_stderr=False, shell=False, extra_environment=None, working_directory=None,
full_command,
capture_stderr=False,
shell=False,
extra_environment=None,
working_directory=None,
borg_local_path=None,
borg_exit_codes=None,
):
'''
Execute the given command (a sequence of command/argument strings), capturing and returning its
output (stdout). If capture stderr is True, then capture and return stderr in addition to
stdout. If shell is True, execute the command within a shell. If an extra environment dict is
given, then use it to augment the current environment, and pass the result into the command. If
a working directory is given, use that as the present working directory when running the command.
a working directory is given, use that as the present working directory when running the
command. If a Borg local path is given, and the command matches it (regardless of arguments),
treat exit code 1 as a warning instead of an error. But if Borg exit codes are given as a
sequence of exit code configuration dicts, then use that configuration to decide what's an error
and what's a warning.
Raise subprocesses.CalledProcessError if an error occurs while running the command.
'''
log_command(full_command)
log_command(full_command, environment=extra_environment)
environment = {**os.environ, **extra_environment} if extra_environment else None
command = ' '.join(full_command) if shell else full_command
output = subprocess.check_output(
command,
stderr=subprocess.STDOUT if capture_stderr else None,
shell=shell,
env=environment,
cwd=working_directory,
)
try:
output = subprocess.check_output(
command,
stderr=subprocess.STDOUT if capture_stderr else None,
shell=shell,
env=environment,
cwd=working_directory,
)
except subprocess.CalledProcessError as error:
if (
interpret_exit_code(command, error.returncode, borg_local_path, borg_exit_codes)
== Exit_status.ERROR
):
raise
output = error.output
return output.decode() if output is not None else None
@@ -249,6 +373,7 @@ def execute_command_with_processes(
extra_environment=None,
working_directory=None,
borg_local_path=None,
borg_exit_codes=None,
):
'''
Execute the given command (a sequence of command/argument strings) and log its output at the
@@ -263,12 +388,14 @@ def execute_command_with_processes(
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.
treat exit code 1 as a warning instead of an error. But if Borg exit codes are given as a
sequence of exit code configuration dicts, then use that configuration to decide what's an error
and what's a warning.
Raise subprocesses.CalledProcessError if an error occurs while running the command or in the
upstream process.
'''
log_command(full_command, input_file, output_file)
log_command(full_command, input_file, output_file, extra_environment)
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
@@ -278,9 +405,9 @@ def execute_command_with_processes(
command,
stdin=input_file,
stdout=None if do_not_capture else (output_file or subprocess.PIPE),
stderr=None
if do_not_capture
else (subprocess.PIPE if output_file else subprocess.STDOUT),
stderr=(
None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT)
),
shell=shell,
env=environment,
cwd=working_directory,
@@ -298,7 +425,8 @@ def execute_command_with_processes(
tuple(processes) + (command_process,),
(input_file, output_file),
output_log_level,
borg_local_path=borg_local_path,
borg_local_path,
borg_exit_codes,
)
if output_log_level is None:
+109
View File
@@ -0,0 +1,109 @@
import logging
import operator
import borgmatic.hooks.logs
import borgmatic.hooks.monitor
logger = logging.getLogger(__name__)
DEFAULT_LOGS_SIZE_LIMIT_BYTES = 100000
HANDLER_IDENTIFIER = 'apprise'
def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Add a handler to the root logger that stores in memory the most recent logs emitted. That way,
we can send them all to an Apprise notification service upon a finish or failure state. But skip
this if the "send_logs" option is false.
'''
if hook_config.get('send_logs') is False:
return
logs_size_limit = max(
hook_config.get('logs_size_limit', DEFAULT_LOGS_SIZE_LIMIT_BYTES)
- len(borgmatic.hooks.logs.PAYLOAD_TRUNCATION_INDICATOR),
0,
)
borgmatic.hooks.logs.add_handler(
borgmatic.hooks.logs.Forgetful_buffering_handler(
HANDLER_IDENTIFIER, logs_size_limit, monitoring_log_level
)
)
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
'''
Ping the configured Apprise service URLs. Use the given configuration filename in any log
entries. If this is a dry run, then don't actually ping anything.
'''
try:
import apprise
from apprise import NotifyFormat, NotifyType
except ImportError: # pragma: no cover
logger.warning('Unable to import Apprise in monitoring hook')
return
state_to_notify_type = {
'start': NotifyType.INFO,
'finish': NotifyType.SUCCESS,
'fail': NotifyType.FAILURE,
'log': NotifyType.INFO,
}
run_states = hook_config.get('states', ['fail'])
if state.name.lower() not in run_states:
return
state_config = hook_config.get(
state.name.lower(),
{
'title': f'A borgmatic {state.name} event happened',
'body': f'A borgmatic {state.name} event happened',
},
)
if not hook_config.get('services'):
logger.info(f'{config_filename}: No Apprise services to ping')
return
dry_run_string = ' (dry run; not actually pinging)' if dry_run else ''
labels_string = ', '.join(map(operator.itemgetter('label'), hook_config.get('services')))
logger.info(f'{config_filename}: Pinging Apprise services: {labels_string}{dry_run_string}')
apprise_object = apprise.Apprise()
apprise_object.add(list(map(operator.itemgetter('url'), hook_config.get('services'))))
if dry_run:
return
body = state_config.get('body')
if state in (
borgmatic.hooks.monitor.State.FINISH,
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitor.State.LOG,
):
formatted_logs = borgmatic.hooks.logs.format_buffered_logs_for_payload(HANDLER_IDENTIFIER)
if formatted_logs:
body += f'\n\n{formatted_logs}'
result = apprise_object.notify(
title=state_config.get('title', ''),
body=body,
body_format=NotifyFormat.TEXT,
notify_type=state_to_notify_type[state.name.lower()],
)
if result is False:
logger.warning(f'{config_filename}: Error sending some Apprise notifications')
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Remove the monitor handler that was added to the root logger. This prevents the handler from
getting reused by other instances of this monitor.
'''
borgmatic.hooks.logs.remove_handler(HANDLER_IDENTIFIER)
+10 -15
View File
@@ -1,6 +1,7 @@
import logging
import os
import re
import shlex
from borgmatic import execute
@@ -16,7 +17,7 @@ def interpolate_context(config_filename, hook_description, command, context):
names/values, interpolate the values by "{name}" into the command and return the result.
'''
for name, value in context.items():
command = command.replace('{%s}' % name, str(value))
command = command.replace(f'{{{name}}}', shlex.quote(str(value)))
for unsupported_variable in re.findall(r'{\w+}', command):
logger.warning(
@@ -38,7 +39,7 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
Raise subprocesses.CalledProcessError if an error occurs in a hook.
'''
if not commands:
logger.debug('{}: No commands to run for {} hook'.format(config_filename, description))
logger.debug(f'{config_filename}: No commands to run for {description} hook')
return
dry_run_label = ' (dry run; not actually running hooks)' if dry_run else ''
@@ -49,19 +50,15 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
]
if len(commands) == 1:
logger.info(
'{}: Running command for {} hook{}'.format(config_filename, description, dry_run_label)
)
logger.info(f'{config_filename}: Running command for {description} hook{dry_run_label}')
else:
logger.info(
'{}: Running {} commands for {} hook{}'.format(
config_filename, len(commands), description, dry_run_label
)
f'{config_filename}: Running {len(commands)} commands for {description} hook{dry_run_label}',
)
if umask:
parsed_umask = int(str(umask), 8)
logger.debug('{}: Set hook umask to {}'.format(config_filename, oct(parsed_umask)))
logger.debug(f'{config_filename}: Set hook umask to {oct(parsed_umask)}')
original_umask = os.umask(parsed_umask)
else:
original_umask = None
@@ -71,9 +68,9 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
if not dry_run:
execute.execute_command(
[command],
output_log_level=logging.ERROR
if description == 'on-error'
else logging.WARNING,
output_log_level=(
logging.ERROR if description == 'on-error' else logging.WARNING
),
shell=True,
)
finally:
@@ -93,9 +90,7 @@ def considered_soft_failure(config_filename, error):
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
)
f'{config_filename}: Command hook exited with soft failure exit code ({SOFT_FAIL_EXIT_CODE}); skipping remaining repository actions',
)
return True
+6 -8
View File
@@ -14,7 +14,7 @@ MONITOR_STATE_TO_CRONHUB = {
def initialize_monitor(
ping_url, config_filename, monitoring_log_level, dry_run
ping_url, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No initialization is necessary for this monitor.
@@ -22,7 +22,7 @@ def initialize_monitor(
pass
def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
'''
Ping the configured Cronhub URL, modified with the monitor.State. Use the given configuration
filename in any log entries. If this is a dry run, then don't actually ping anything.
@@ -34,17 +34,15 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
return
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
formatted_state = '/{}/'.format(MONITOR_STATE_TO_CRONHUB[state])
formatted_state = f'/{MONITOR_STATE_TO_CRONHUB[state]}/'
ping_url = (
hook_config['ping_url']
.replace('/start/', formatted_state)
.replace('/ping/', formatted_state)
)
logger.info(
'{}: Pinging Cronhub {}{}'.format(config_filename, state.name.lower(), dry_run_label)
)
logger.debug('{}: Using Cronhub ping URL {}'.format(config_filename, ping_url))
logger.info(f'{config_filename}: Pinging Cronhub {state.name.lower()}{dry_run_label}')
logger.debug(f'{config_filename}: Using Cronhub ping URL {ping_url}')
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
@@ -57,7 +55,7 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
def destroy_monitor(
ping_url_or_uuid, config_filename, monitoring_log_level, dry_run
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
+6 -8
View File
@@ -14,7 +14,7 @@ MONITOR_STATE_TO_CRONITOR = {
def initialize_monitor(
ping_url, config_filename, monitoring_log_level, dry_run
ping_url, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No initialization is necessary for this monitor.
@@ -22,7 +22,7 @@ def initialize_monitor(
pass
def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
'''
Ping the configured Cronitor URL, modified with the monitor.State. Use the given configuration
filename in any log entries. If this is a dry run, then don't actually ping anything.
@@ -34,12 +34,10 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
return
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
ping_url = '{}/{}'.format(hook_config['ping_url'], MONITOR_STATE_TO_CRONITOR[state])
ping_url = f"{hook_config['ping_url']}/{MONITOR_STATE_TO_CRONITOR[state]}"
logger.info(
'{}: Pinging Cronitor {}{}'.format(config_filename, state.name.lower(), dry_run_label)
)
logger.debug('{}: Using Cronitor ping URL {}'.format(config_filename, ping_url))
logger.info(f'{config_filename}: Pinging Cronitor {state.name.lower()}{dry_run_label}')
logger.debug(f'{config_filename}: Using Cronitor ping URL {ping_url}')
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
@@ -52,7 +50,7 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
def destroy_monitor(
ping_url_or_uuid, config_filename, monitoring_log_level, dry_run
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
+33 -22
View File
@@ -1,60 +1,71 @@
import logging
from borgmatic.hooks import (
apprise,
cronhub,
cronitor,
healthchecks,
loki,
mariadb,
mongodb,
mysql,
ntfy,
pagerduty,
postgresql,
pushover,
sqlite,
uptimekuma,
zabbix,
)
logger = logging.getLogger(__name__)
HOOK_NAME_TO_MODULE = {
'apprise': apprise,
'cronhub': cronhub,
'cronitor': cronitor,
'healthchecks': healthchecks,
'loki': loki,
'mariadb_databases': mariadb,
'mongodb_databases': mongodb,
'mysql_databases': mysql,
'ntfy': ntfy,
'pagerduty': pagerduty,
'postgresql_databases': postgresql,
'pushover': pushover,
'sqlite_databases': sqlite,
'uptime_kuma': uptimekuma,
'zabbix': zabbix,
}
def call_hook(function_name, hooks, log_prefix, hook_name, *args, **kwargs):
def call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs):
'''
Given the hooks configuration dict and a prefix to use in log entries, call the requested
function of the Python module corresponding to the given hook name. Supply that call with the
configuration for this hook (if any), the log prefix, and any given args and kwargs. Return any
return value.
Given a configuration dict and a prefix to use in log entries, call the requested function of
the Python module corresponding to the given hook name. Supply that call with the configuration
for this hook (if any), the log prefix, and any given args and kwargs. Return any return value.
Raise ValueError if the hook name is unknown.
Raise AttributeError if the function name is not found in the module.
Raise anything else that the called function raises.
'''
config = hooks.get(hook_name, {})
hook_config = config.get(hook_name, {})
try:
module = HOOK_NAME_TO_MODULE[hook_name]
except KeyError:
raise ValueError('Unknown hook name: {}'.format(hook_name))
raise ValueError(f'Unknown hook name: {hook_name}')
logger.debug('{}: Calling {} hook function {}'.format(log_prefix, hook_name, function_name))
return getattr(module, function_name)(config, log_prefix, *args, **kwargs)
logger.debug(f'{log_prefix}: Calling {hook_name} hook function {function_name}')
return getattr(module, function_name)(hook_config, config, log_prefix, *args, **kwargs)
def call_hooks(function_name, hooks, log_prefix, hook_names, *args, **kwargs):
def call_hooks(function_name, config, log_prefix, hook_names, *args, **kwargs):
'''
Given the hooks configuration dict and a prefix to use in log entries, call the requested
function of the Python module corresponding to each given hook name. Supply each call with the
configuration for that hook, the log prefix, and any given args and kwargs. Collect any return
values into a dict from hook name to return value.
Given a configuration dict and a prefix to use in log entries, call the requested function of
the Python module corresponding to each given hook name. Supply each call with the configuration
for that hook, the log prefix, and any given args and kwargs. Collect any return values into a
dict from hook name to return value.
If the hook name is not present in the hooks configuration, then don't call the function for it
and omit it from the return values.
@@ -64,23 +75,23 @@ def call_hooks(function_name, hooks, log_prefix, hook_names, *args, **kwargs):
Raise anything else that a called function raises. An error stops calls to subsequent functions.
'''
return {
hook_name: call_hook(function_name, hooks, log_prefix, hook_name, *args, **kwargs)
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
for hook_name in hook_names
if hooks.get(hook_name)
if config.get(hook_name)
}
def call_hooks_even_if_unconfigured(function_name, hooks, log_prefix, hook_names, *args, **kwargs):
def call_hooks_even_if_unconfigured(function_name, config, log_prefix, hook_names, *args, **kwargs):
'''
Given the hooks configuration dict and a prefix to use in log entries, call the requested
function of the Python module corresponding to each given hook name. Supply each call with the
configuration for that hook, the log prefix, and any given args and kwargs. Collect any return
values into a dict from hook name to return value.
Given a configuration dict and a prefix to use in log entries, call the requested function of
the Python module corresponding to each given hook name. Supply each call with the configuration
for that hook, the log prefix, and any given args and kwargs. Collect any return values into a
dict from hook name to return value.
Raise AttributeError if the function name is not found in the module.
Raise anything else that a called function raises. An error stops calls to subsequent functions.
'''
return {
hook_name: call_hook(function_name, hooks, log_prefix, hook_name, *args, **kwargs)
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
for hook_name in hook_names
}
+29 -30
View File
@@ -1,41 +1,38 @@
import fnmatch
import logging
import os
import shutil
from borgmatic.borg.state import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
logger = logging.getLogger(__name__)
DATABASE_HOOK_NAMES = (
'postgresql_databases',
DATA_SOURCE_HOOK_NAMES = (
'mariadb_databases',
'mysql_databases',
'mongodb_databases',
'postgresql_databases',
'sqlite_databases',
)
def make_database_dump_path(borgmatic_source_directory, database_hook_name):
def make_data_source_dump_path(borgmatic_runtime_directory, data_source_hook_name):
'''
Given a borgmatic source directory (or None) and a database hook name, construct a database dump
Given a borgmatic runtime directory and a data source hook name, construct a data source dump
path.
'''
if not borgmatic_source_directory:
borgmatic_source_directory = DEFAULT_BORGMATIC_SOURCE_DIRECTORY
return os.path.join(borgmatic_source_directory, database_hook_name)
return os.path.join(borgmatic_runtime_directory, data_source_hook_name)
def make_database_dump_filename(dump_path, name, hostname=None):
def make_data_source_dump_filename(dump_path, name, hostname=None):
'''
Based on the given dump directory path, database name, and hostname, return a filename to use
for the database dump. The hostname defaults to localhost.
Based on the given dump directory path, data source name, and hostname, return a filename to use
for the data source dump. The hostname defaults to localhost.
Raise ValueError if the database name is invalid.
Raise ValueError if the data source name is invalid.
'''
if os.path.sep in name:
raise ValueError('Invalid database name {}'.format(name))
raise ValueError(f'Invalid data source name {name}')
return os.path.join(os.path.expanduser(dump_path), hostname or 'localhost', name)
return os.path.join(dump_path, hostname or 'localhost', name)
def create_parent_directory_for_dump(dump_path):
@@ -53,29 +50,31 @@ def create_named_pipe_for_dump(dump_path):
os.mkfifo(dump_path, mode=0o600)
def remove_database_dumps(dump_path, database_type_name, log_prefix, dry_run):
def remove_data_source_dumps(dump_path, data_source_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.
Remove all data source dumps in the given dump directory path (including the directory itself).
If this is a dry run, then don't actually remove anything.
'''
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
logger.debug(
'{}: Removing {} database dumps{}'.format(log_prefix, database_type_name, dry_run_label)
)
expanded_path = os.path.expanduser(dump_path)
logger.debug(f'{log_prefix}: Removing {data_source_type_name} data source dumps{dry_run_label}')
if dry_run:
return
if os.path.exists(expanded_path):
shutil.rmtree(expanded_path)
if os.path.exists(dump_path):
shutil.rmtree(dump_path)
def convert_glob_patterns_to_borg_patterns(patterns):
def convert_glob_patterns_to_borg_pattern(patterns):
'''
Convert a sequence of shell glob patterns like "/etc/*" to the corresponding Borg archive
patterns like "sh:etc/*".
Convert a sequence of shell glob patterns like "/etc/*", "/tmp/*" to the corresponding Borg
regular expression archive pattern as a single string like "re:etc/.*|tmp/.*".
'''
return ['sh:{}'.format(pattern.lstrip(os.path.sep)) for pattern in patterns]
# Remove the "\Z" generated by fnmatch.translate() because we don't want the pattern to match
# only at the end of a path, as directory format dumps require extracting files with paths
# longer than the pattern. E.g., a pattern of "borgmatic/*/foo_databases/test" should also match
# paths like "borgmatic/*/foo_databases/test/toc.dat"
return 're:' + '|'.join(
fnmatch.translate(pattern.lstrip('/')).replace('\\Z', '') for pattern in patterns
)
+27 -72
View File
@@ -1,7 +1,9 @@
import logging
import re
import requests
import borgmatic.hooks.logs
from borgmatic.hooks import monitor
logger = logging.getLogger(__name__)
@@ -13,64 +15,11 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
monitor.State.LOG: 'log',
}
PAYLOAD_TRUNCATION_INDICATOR = '...\n'
DEFAULT_PING_BODY_LIMIT_BYTES = 100000
HANDLER_IDENTIFIER = 'healthchecks'
class Forgetful_buffering_handler(logging.Handler):
'''
A buffering log handler that stores log messages in memory, and throws away messages (oldest
first) once a particular capacity in bytes is reached. But if the given byte capacity is zero,
don't throw away any messages.
'''
def __init__(self, byte_capacity, log_level):
super().__init__()
self.byte_capacity = byte_capacity
self.byte_count = 0
self.buffer = []
self.forgot = False
self.setLevel(log_level)
def emit(self, record):
message = record.getMessage() + '\n'
self.byte_count += len(message)
self.buffer.append(message)
if not self.byte_capacity:
return
while self.byte_count > self.byte_capacity and self.buffer:
self.byte_count -= len(self.buffer[0])
self.buffer.pop(0)
self.forgot = True
def format_buffered_logs_for_payload():
'''
Get the handler previously added to the root logger, and slurp buffered logs out of it to
send to Healthchecks.
'''
try:
buffering_handler = next(
handler
for handler in logging.getLogger().handlers
if isinstance(handler, Forgetful_buffering_handler)
)
except StopIteration:
# No handler means no payload.
return ''
payload = ''.join(message for message in buffering_handler.buffer)
if buffering_handler.forgot:
return PAYLOAD_TRUNCATION_INDICATOR + payload
return payload
def initialize_monitor(hook_config, config_filename, monitoring_log_level, dry_run):
def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Add a handler to the root logger that stores in memory the most recent logs emitted. That way,
we can send them all to Healthchecks upon a finish or failure state. But skip this if the
@@ -81,16 +30,18 @@ def initialize_monitor(hook_config, config_filename, monitoring_log_level, dry_r
ping_body_limit = max(
hook_config.get('ping_body_limit', DEFAULT_PING_BODY_LIMIT_BYTES)
- len(PAYLOAD_TRUNCATION_INDICATOR),
- len(borgmatic.hooks.logs.PAYLOAD_TRUNCATION_INDICATOR),
0,
)
logging.getLogger().addHandler(
Forgetful_buffering_handler(ping_body_limit, monitoring_log_level)
borgmatic.hooks.logs.add_handler(
borgmatic.hooks.logs.Forgetful_buffering_handler(
HANDLER_IDENTIFIER, ping_body_limit, monitoring_log_level
)
)
def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_run):
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
'''
Ping the configured Healthchecks URL or UUID, modified with the monitor.State. Use the given
configuration filename in any log entries, and log to Healthchecks with the giving log level.
@@ -99,7 +50,7 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
ping_url = (
hook_config['ping_url']
if hook_config['ping_url'].startswith('http')
else 'https://hc-ping.com/{}'.format(hook_config['ping_url'])
else f"https://hc-ping.com/{hook_config['ping_url']}"
)
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
@@ -109,17 +60,25 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
)
return
ping_url_is_uuid = re.search(r'\w{8}-\w{4}-\w{4}-\w{4}-\w{12}$', ping_url)
healthchecks_state = MONITOR_STATE_TO_HEALTHCHECKS.get(state)
if healthchecks_state:
ping_url = '{}/{}'.format(ping_url, healthchecks_state)
ping_url = f'{ping_url}/{healthchecks_state}'
logger.info(
'{}: Pinging Healthchecks {}{}'.format(config_filename, state.name.lower(), dry_run_label)
)
logger.debug('{}: Using Healthchecks ping URL {}'.format(config_filename, ping_url))
if hook_config.get('create_slug'):
if ping_url_is_uuid:
logger.warning(
f'{config_filename}: Healthchecks UUIDs do not support auto provisionning; ignoring'
)
else:
ping_url = f'{ping_url}?create=1'
logger.info(f'{config_filename}: Pinging Healthchecks {state.name.lower()}{dry_run_label}')
logger.debug(f'{config_filename}: Using Healthchecks ping URL {ping_url}')
if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
payload = format_buffered_logs_for_payload()
payload = borgmatic.hooks.logs.format_buffered_logs_for_payload(HANDLER_IDENTIFIER)
else:
payload = ''
@@ -135,13 +94,9 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
logger.warning(f'{config_filename}: Healthchecks error: {error}')
def destroy_monitor(hook_config, config_filename, monitoring_log_level, dry_run):
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Remove the monitor handler that was added to the root logger. This prevents the handler from
getting reused by other instances of this monitor.
'''
logger = logging.getLogger()
for handler in tuple(logger.handlers):
if isinstance(handler, Forgetful_buffering_handler):
logger.removeHandler(handler)
borgmatic.hooks.logs.remove_handler(HANDLER_IDENTIFIER)
+91
View File
@@ -0,0 +1,91 @@
import logging
PAYLOAD_TRUNCATION_INDICATOR = '...\n'
class Forgetful_buffering_handler(logging.Handler):
'''
A buffering log handler that stores log messages in memory, and throws away messages (oldest
first) once a particular capacity in bytes is reached. But if the given byte capacity is zero,
don't throw away any messages.
The given identifier is used to distinguish the instance of this handler used for one monitoring
hook from those instances used for other monitoring hooks.
'''
def __init__(self, identifier, byte_capacity, log_level):
super().__init__()
self.identifier = identifier
self.byte_capacity = byte_capacity
self.byte_count = 0
self.buffer = []
self.forgot = False
self.setLevel(log_level)
def emit(self, record):
message = record.getMessage() + '\n'
self.byte_count += len(message)
self.buffer.append(message)
if not self.byte_capacity:
return
while self.byte_count > self.byte_capacity and self.buffer:
self.byte_count -= len(self.buffer[0])
self.buffer.pop(0)
self.forgot = True
def add_handler(handler): # pragma: no cover
'''
Add the given handler to the global logger.
'''
logging.getLogger().addHandler(handler)
def get_handler(identifier):
'''
Given the identifier for an existing Forgetful_buffering_handler instance, return the handler.
Raise ValueError if the handler isn't found.
'''
try:
return next(
handler
for handler in logging.getLogger().handlers
if isinstance(handler, Forgetful_buffering_handler) and handler.identifier == identifier
)
except StopIteration:
raise ValueError(f'A buffering handler for {identifier} was not found')
def format_buffered_logs_for_payload(identifier):
'''
Get the handler previously added to the root logger, and slurp buffered logs out of it to
send to Healthchecks.
'''
try:
buffering_handler = get_handler(identifier)
except ValueError:
# No handler means no payload.
return ''
payload = ''.join(message for message in buffering_handler.buffer)
if buffering_handler.forgot:
return PAYLOAD_TRUNCATION_INDICATOR + payload
return payload
def remove_handler(identifier):
'''
Given the identifier for an existing Forgetful_buffering_handler instance, remove it.
'''
logger = logging.getLogger()
try:
logger.removeHandler(get_handler(identifier))
except ValueError:
pass
+154
View File
@@ -0,0 +1,154 @@
import json
import logging
import os
import platform
import time
import requests
from borgmatic.hooks import monitor
logger = logging.getLogger(__name__)
MONITOR_STATE_TO_LOKI = {
monitor.State.START: 'Started',
monitor.State.FINISH: 'Finished',
monitor.State.FAIL: 'Failed',
}
# Threshold at which logs get flushed to loki
MAX_BUFFER_LINES = 100
class Loki_log_buffer:
'''
A log buffer that allows to output the logs as loki requests in json. Allows
adding labels to the log stream and takes care of communication with loki.
'''
def __init__(self, url, dry_run):
self.url = url
self.dry_run = dry_run
self.root = {'streams': [{'stream': {}, 'values': []}]}
def add_value(self, value):
'''
Add a log entry to the stream.
'''
timestamp = str(time.time_ns())
self.root['streams'][0]['values'].append((timestamp, value))
def add_label(self, label, value):
'''
Add a label to the logging stream.
'''
self.root['streams'][0]['stream'][label] = value
def to_request(self):
return json.dumps(self.root)
def __len__(self):
'''
Gets the number of lines currently in the buffer.
'''
return len(self.root['streams'][0]['values'])
def flush(self):
if self.dry_run:
# Just empty the buffer and skip
self.root['streams'][0]['values'] = []
logger.info('Skipped uploading logs to loki due to dry run')
return
if len(self) == 0:
# Skip as there are not logs to send yet
return
request_body = self.to_request()
self.root['streams'][0]['values'] = []
request_header = {'Content-Type': 'application/json'}
try:
result = requests.post(self.url, headers=request_header, data=request_body, timeout=5)
result.raise_for_status()
except requests.RequestException:
logger.warning('Failed to upload logs to loki')
class Loki_log_handler(logging.Handler):
'''
A log handler that sends logs to loki.
'''
def __init__(self, url, dry_run):
super().__init__()
self.buffer = Loki_log_buffer(url, dry_run)
def emit(self, record):
'''
Add a log record from the logging module to the stream.
'''
self.raw(record.getMessage())
def add_label(self, key, value):
'''
Add a label to the logging stream.
'''
self.buffer.add_label(key, value)
def raw(self, msg):
'''
Add an arbitrary string as a log entry to the stream.
'''
self.buffer.add_value(msg)
if len(self.buffer) > MAX_BUFFER_LINES:
self.buffer.flush()
def flush(self):
'''
Send the logs to loki and empty the buffer.
'''
self.buffer.flush()
def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Add a handler to the root logger to regularly send the logs to loki.
'''
url = hook_config.get('url')
loki = Loki_log_handler(url, dry_run)
for key, value in hook_config.get('labels').items():
if value == '__hostname':
loki.add_label(key, platform.node())
elif value == '__config':
loki.add_label(key, os.path.basename(config_filename))
elif value == '__config_path':
loki.add_label(key, config_filename)
else:
loki.add_label(key, value)
logging.getLogger().addHandler(loki)
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
'''
Add an entry to the loki logger with the current state.
'''
for handler in tuple(logging.getLogger().handlers):
if isinstance(handler, Loki_log_handler):
if state in MONITOR_STATE_TO_LOKI.keys():
handler.raw(f'{config_filename}: {MONITOR_STATE_TO_LOKI[state]} backup')
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Remove the monitor handler that was added to the root logger.
'''
logger = logging.getLogger()
for handler in tuple(logger.handlers):
if isinstance(handler, Loki_log_handler):
handler.flush()
logger.removeHandler(handler)
+278
View File
@@ -0,0 +1,278 @@
import copy
import logging
import os
import shlex
import borgmatic.config.paths
from borgmatic.execute import (
execute_command,
execute_command_and_capture_output,
execute_command_with_processes,
)
from borgmatic.hooks import dump
logger = logging.getLogger(__name__)
def make_dump_path(base_directory): # pragma: no cover
'''
Given a base directory, make the corresponding dump path.
'''
return dump.make_data_source_dump_path(base_directory, 'mariadb_databases')
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
'''
Given a requested database config, return the corresponding sequence of database names to dump.
In the case of "all", query for the names of databases on the configured host and return them,
excluding any system databases that will cause problems during restore.
'''
if database['name'] != 'all':
return (database['name'],)
if dry_run:
return ()
mariadb_show_command = tuple(
shlex.quote(part) for part in shlex.split(database.get('mariadb_command') or 'mariadb')
)
show_command = (
mariadb_show_command
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
+ ('--skip-column-names', '--batch')
+ ('--execute', 'show schemas')
)
logger.debug(f'{log_prefix}: Querying for "all" MariaDB databases to dump')
show_output = execute_command_and_capture_output(
show_command, extra_environment=extra_environment
)
return tuple(
show_name
for show_name in show_output.strip().splitlines()
if show_name not in SYSTEM_DATABASE_NAMES
)
def execute_dump_command(
database, log_prefix, dump_path, database_names, extra_environment, dry_run, dry_run_label
):
'''
Kick off a dump for the given MariaDB database (provided as a configuration dict) to a named
pipe constructed from the given dump path and database name. Use the given log prefix in any
log entries.
Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if
this is a dry run, then don't actually dump anything and return None.
'''
database_name = database['name']
dump_filename = dump.make_data_source_dump_filename(
dump_path, database['name'], database.get('hostname')
)
if os.path.exists(dump_filename):
logger.warning(
f'{log_prefix}: Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}'
)
return None
mariadb_dump_command = tuple(
shlex.quote(part)
for part in shlex.split(database.get('mariadb_dump_command') or 'mariadb-dump')
)
dump_command = (
mariadb_dump_command
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
+ (('--add-drop-database',) if database.get('add_drop_database', True) else ())
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
+ ('--databases',)
+ database_names
+ ('--result-file', dump_filename)
)
logger.debug(
f'{log_prefix}: Dumping MariaDB database "{database_name}" to {dump_filename}{dry_run_label}'
)
if dry_run:
return None
dump.create_named_pipe_for_dump(dump_filename)
return execute_command(
dump_command,
extra_environment=extra_environment,
run_to_completion=False,
)
def use_streaming(databases, config, log_prefix):
'''
Given a sequence of MariaDB database configuration dicts, a configuration dict (ignored), and a
log prefix (ignored), return whether streaming will be using during dumps.
'''
return any(databases)
def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory, dry_run):
'''
Dump the given MariaDB databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given
borgmatic runtime directory to construct the destination path and the given log prefix in any
log entries.
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
'''
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
processes = []
logger.info(f'{log_prefix}: Dumping MariaDB databases{dry_run_label}')
for database in databases:
dump_path = make_dump_path(borgmatic_runtime_directory)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
dump_database_names = database_names_to_dump(
database, extra_environment, log_prefix, dry_run
)
if not dump_database_names:
if dry_run:
continue
raise ValueError('Cannot find any MariaDB databases to dump.')
if database['name'] == 'all' and database.get('format'):
for dump_name in dump_database_names:
renamed_database = copy.copy(database)
renamed_database['name'] = dump_name
processes.append(
execute_dump_command(
renamed_database,
log_prefix,
dump_path,
(dump_name,),
extra_environment,
dry_run,
dry_run_label,
)
)
else:
processes.append(
execute_dump_command(
database,
log_prefix,
dump_path,
dump_database_names,
extra_environment,
dry_run,
dry_run_label,
)
)
return [process for process in processes if process]
def remove_data_source_dumps(
databases, config, log_prefix, borgmatic_runtime_directory, dry_run
): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the
borgmatic_runtime_directory to construct the destination path and the log prefix in any log
entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(
make_dump_path(borgmatic_runtime_directory), 'MariaDB', log_prefix, dry_run
)
def make_data_source_dump_patterns(
databases, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, the
borgmatic runtime directory, and a database name to match, return the corresponding glob
patterns to match the database dump in an archive.
'''
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
return (
dump.make_data_source_dump_filename(make_dump_path('borgmatic'), name, hostname='*'),
dump.make_data_source_dump_filename(
make_dump_path(borgmatic_runtime_directory), name, hostname='*'
),
dump.make_data_source_dump_filename(
make_dump_path(borgmatic_source_directory), name, hostname='*'
),
)
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
connection_params,
borgmatic_runtime_directory,
):
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given log prefix is used
for any log entries. If this is a dry run, then don't actually restore anything. Trigger the
given active extract process (an instance of subprocess.Popen) to produce output to consume.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
hostname = connection_params['hostname'] or data_source.get(
'restore_hostname', data_source.get('hostname')
)
port = str(
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
)
username = connection_params['username'] or data_source.get(
'restore_username', data_source.get('username')
)
password = connection_params['password'] or data_source.get(
'restore_password', data_source.get('password')
)
mariadb_restore_command = tuple(
shlex.quote(part) for part in shlex.split(data_source.get('mariadb_command') or 'mariadb')
)
restore_command = (
mariadb_restore_command
+ ('--batch',)
+ (
tuple(data_source['restore_options'].split(' '))
if 'restore_options' in data_source
else ()
)
+ (('--host', hostname) if hostname else ())
+ (('--port', str(port)) if port else ())
+ (('--protocol', 'tcp') if hostname or port else ())
+ (('--user', username) if username else ())
)
extra_environment = {'MYSQL_PWD': password} if password else None
logger.debug(f"{log_prefix}: Restoring MariaDB database {data_source['name']}{dry_run_label}")
if dry_run:
return
# Don't give Borg local path so as to error on warnings, as "borg extract" only gives a warning
# if the restore paths don't exist in the archive.
execute_command_with_processes(
restore_command,
[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment=extra_environment,
)

Some files were not shown because too many files have changed in this diff Show More