Compare commits

...
612 Commits
Author SHA1 Message Date
Dan Helfman 4af31db99c Bump version for release. 2026-06-01 15:52:45 -07:00
Dan Helfman 7cd9e6b361 Add a minimal stand-alone borgmatic binary in addition to the standard one (#1314). 2026-06-01 15:51:01 -07:00
Dan Helfman 4ee057a909 Experimental "browse" action, a console UI for browsing your backups (#1312).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1312
2026-06-01 20:09:24 +00:00
Dan Helfman 3e7e7879bc Remove some default system commands that aren't particular useful for this app. 2026-06-01 12:59:52 -07:00
Dan Helfman d29cfaf227 Use full URL for image. 2026-06-01 12:47:53 -07:00
Dan Helfman 23a8d665d6 Add a screenshot to the browse docs. 2026-06-01 12:44:32 -07:00
Dan Helfman 2a026a4842 Stop using Ruff "preview" rules, as CI has an older version of Ruff. 2026-06-01 12:42:02 -07:00
Dan Helfman 16c8f81a5a Attempted CI test fixes. Also spelling. 2026-06-01 12:24:57 -07:00
Dan Helfman 1670ba2aeb Add missing tests. 2026-06-01 11:57:49 -07:00
Dan Helfman 5accda1a65 Complete tests. 2026-06-01 11:54:28 -07:00
Dan Helfman ec8e52944c More tests! 2026-05-31 20:30:59 -07:00
Dan Helfman 30ca942f76 Improve performance wen loading file previews. 2026-05-31 18:55:56 -07:00
Dan Helfman 9748a0298a Add icon to configuration files list border title. 2026-05-30 23:11:41 -07:00
Dan Helfman dc91f81a39 Fix loading race conditions. 2026-05-30 22:22:06 -07:00
Dan Helfman 64ceebb2c8 Replace cross-thread calls with signals. 2026-05-30 21:12:02 -07:00
Dan Helfman 7439d7cb8f Docstrings. 2026-05-30 16:58:29 -07:00
Dan Helfman f832a361ed More tests. 2026-05-30 13:58:04 -07:00
Dan Helfman 3a4bdfb3c5 Additional tests and docstrings. 2026-05-30 11:27:59 -07:00
Dan Helfman ed955c2a73 Add additional tests. 2026-05-29 19:56:17 -07:00
Dan Helfman 174aa3d522 Additional tests. Also fix a potential race that loses loaded archive paths for display. 2026-05-29 16:59:00 -07:00
Dan Helfman 5a74b3d081 Fix broken test. 2026-05-29 11:57:13 -07:00
Dan Helfman a876ce2058 Yet more tests. 2026-05-29 11:52:02 -07:00
Dan Helfman e82c1bb195 More tests and refactoring. 2026-05-28 20:00:47 -07:00
Dan Helfman 16061f4f6d Fix tests after recent refactoring. 2026-05-28 17:15:13 -07:00
Dan Helfman 59e6e61786 Merge branch 'main' into browse-action 2026-05-28 11:53:23 -07:00
Dan Helfman b7d0f006d6 MariaDB systemd database documentation clarifications (#1303). 2026-05-28 11:50:25 -07:00
Dan Helfman 6869a5d5f2 Additional tests and docstrings. 2026-05-27 22:40:13 -07:00
Dan Helfman 9da6d0f90a Split out panels.py into multiple separate source files. 2026-05-26 23:06:36 -07:00
Dan Helfman ac65e65302 Merge get_archive_path() into get_archive_paths(). 2026-05-26 22:53:04 -07:00
Dan Helfman 8820a1eeab Faster exit for the browse action. 2026-05-26 12:01:55 -07:00
Dan Helfman 3abe025a80 Better performance when bulk populating directory lists. 2026-05-26 11:38:42 -07:00
Dan Helfman b979e0356d Fix display of ".." while loading is still underway in the browse action. 2026-05-25 17:30:06 -07:00
Dan Helfman ea0ed8345a Performance: Switch browse action to list archive files only once per archive. 2026-05-25 16:27:36 -07:00
Dan Helfman 4b8a6d1a34 More browse tests. 2026-05-24 19:22:50 -07:00
Dan Helfman 60201541e0 Add Textual to stand-alone binary to support the browse action. 2026-05-24 10:16:36 -07:00
Dan Helfman bfd79a5500 Refuse to preview named pipes. 2026-05-23 23:05:28 -07:00
Dan Helfman 43e995a4f3 Binary versus text file detection for borgmatic browse. 2026-05-23 22:50:51 -07:00
Dan Helfman 94a2d68198 Switch from a Static to a RichLog for file preview so we can get scrolling. 2026-05-23 19:20:24 -07:00
Dan Helfman c9ba0762f0 Add docstring. 2026-05-23 18:31:24 -07:00
Dan Helfman 13031a4ce4 Upgrade/add test requirements. 2026-05-22 20:03:32 -07:00
Dan Helfman 9a2e853ea7 Merge branch 'main' into browse-action 2026-05-22 20:01:50 -07:00
Dan Helfman b1f42eb233 Upgrade dependencies. 2026-05-22 20:00:40 -07:00
Dan Helfman 7e7c3c273e Update the Apprise monitoring hook's "url" option to support loading credentials with the "{credential ...}" syntax (#1308). 2026-05-22 15:25:42 -07:00
Dan Helfman 704db9c9d7 Add a "--quick-stats" flag and corresponding "quick_statistics" option (#1309). 2026-05-22 14:12:46 -07:00
Dan Helfman 61d86c55b4 Add missing Rich library to test requirements. 2026-05-22 13:44:38 -07:00
Dan Helfman ecedccadee Yet another NEWS wording tweak. 2026-05-22 13:43:44 -07:00
Dan Helfman 006825838f NEWS tweak. 2026-05-22 13:39:45 -07:00
Dan Helfman 1102bf3787 Merge branch 'main' into browse-action 2026-05-22 13:37:44 -07:00
Dan Helfman 220c76b3c0 Formatting. 2026-05-22 13:37:24 -07:00
Dan Helfman 9aea0b1d90 Fix broken tests. 2026-05-22 12:19:34 -07:00
Dan Helfman b6ab326bb4 Switch to PEP 639 license format. 2026-05-22 10:26:12 -07:00
Dan Helfman c4170bb126 Several browse action unit and integration tests. 2026-05-21 23:23:19 -07:00
Dan Helfman 9935d0ef8d Linting and more file refactoring for the browse action. 2026-05-21 17:01:07 -07:00
Dan Helfman ec11e8817d Code formatting. 2026-05-21 16:08:10 -07:00
Dan Helfman 73222e7b5b Factor out make_next_panel() out of browse action widgets into a plain function. 2026-05-21 15:50:34 -07:00
Dan Helfman fd98f4fdd1 Browse action documentation tweaks. 2026-05-21 14:10:49 -07:00
Dan Helfman 16e743d8d9 More browse action option list keys. 2026-05-21 11:41:44 -07:00
Dan Helfman e93fe4cc15 Browse action refactoring into separate files. 2026-05-21 11:33:05 -07:00
Dan Helfman da0698f6e5 Browse action logging improvements. 2026-05-21 10:08:42 -07:00
Dan Helfman 5075c1e539 Skip the browse action's configuration list if there's only one config file. 2026-05-20 23:10:29 -07:00
Dan Helfman 4b4851e78a Add browse action documentation. 2026-05-20 22:57:52 -07:00
Dan Helfman 8e91af6820 Make Textual an optional dependency. 2026-05-20 22:17:19 -07:00
Dan Helfman 9be1698762 Add syntax highlighting to previews. 2026-05-20 21:00:16 -07:00
Dan Helfman 9310b42d9f Add a few more browse action bindings. 2026-05-20 20:16:30 -07:00
Dan Helfman d1d757a5d1 Fix focus on file preview widgets so key bindings for them work in the browse action. 2026-05-20 20:01:03 -07:00
Dan Helfman 3f4a87517b Fix browse action option list highlights. 2026-05-20 19:40:47 -07:00
Dan Helfman a2dcd4747f Get rid of automatic preview panels in the browse action; they're just too slow and annoying in real-world use. 2026-05-20 17:10:00 -07:00
Dan Helfman 0396a89f72 Various browse action improvements. 2026-05-20 16:38:51 -07:00
Dan Helfman f170ef19df Switch to a generalized "carousel" for the borgmatic browse panels. 2026-05-17 16:10:58 -07:00
Dan Helfman 9729809f6f Add the first level of file loading. 2026-05-16 23:04:30 -07:00
Dan Helfman dfb3c0830e Convert browse action UI from a tree to a series of option lists. 2026-05-16 22:06:39 -07:00
Dan Helfman b22a068a60 Put browse action archive files into an actual tree hierarchy. 2026-05-14 23:26:09 -07:00
Dan Helfman 1286ccce46 Browse action MVC refactoring into multiple files. 2026-05-14 22:53:59 -07:00
Dan Helfman e339ce2fd1 Initial work on a browse action. 2026-05-14 22:24:06 -07:00
Dan Helfman aaf5812a3f Cosmetic function argument ordering fix (#1256). 2026-05-12 22:13:05 -07:00
Dan Helfman 47a6691886 Upgrade dependencies. 2026-05-12 15:29:56 -07:00
Dan Helfman 596d59ef60 Fix a race condition in which borgmatic sometimes swallows Borg error output without logging it (#1256). 2026-05-12 15:28:31 -07:00
Dan Helfman 9839b3dada Fix end-to-end database tests (#1303). 2026-05-09 11:05:28 -07:00
Dan Helfman 411685280c For MariaDB/MySQL, dump events, routines, and tablespaces. For MariaDB, dump a subset of system data when dumping "mysql"/"all" (#1303). 2026-05-08 09:38:13 -07:00
Dan Helfman cce679248f Clarify "umask" option semantics in regards to integer and octal (#1305). 2026-05-06 14:57:43 -07:00
Dan Helfman 68c9516424 Upgrade Apprise dependency. 2026-05-02 19:08:24 -07:00
Dan Helfman a508cabe3f Update the KeePassXC credential hook to support KeePassXC's secret service integration. 2026-04-29 12:52:27 -07:00
Dan Helfman 0e1659bd73 Expand the "patterns_from" and "exclude_from" options to support paths containing tildes and globs (#1301). 2026-04-27 10:13:21 -07:00
Dan Helfman ad61ad356e Update the documentation to use Pagefind's component-based search UI. 2026-04-26 09:10:07 -07:00
Dan Helfman 5c7d03910b Fix the "source_directories_must_exist" option to support source directories relative to a "working_directory" (#1300). 2026-04-25 13:49:55 -07:00
Dan Helfman 468af1de0b Fix the PostgreSQL database hook to properly parse "*options" values containing quoted spaces. 2026-04-24 19:24:44 -07:00
Dan Helfman 524d8263a7 Upgrade dependencies. 2026-04-24 17:04:29 -07:00
Dan Helfman 1d2aea0951 Add a development script for upgrading pinned dependencies. 2026-04-24 11:54:37 -07:00
Dan Helfman 961ff7c724 For the MariaDB and MySQL database hooks, escape quotes in passwords when the "password_transport" option is "pipe". 2026-04-24 10:24:45 -07:00
Dan Helfman 3e80056956 Add projects.torsion.org reply by email to NEWS. 2026-04-20 21:52:22 -07:00
Dan Helfman d8f558ce0d Add the NEWS changelog file to release tarball (#1298). 2026-04-20 15:42:17 -07:00
Dan Helfman 9da75fdc58 Use pytest tmp_path fixture instead of hardcoded /tmp paths (#1296).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1296
2026-04-20 18:10:02 +00:00
Dan Helfman ac9c8bb644 Fix build of standalone binary. 2026-04-20 10:28:57 -07:00
Dan Helfman d085fc2398 Bump version for release. 2026-04-20 09:46:54 -07:00
Dan Helfman 40d2d521a7 Fix the ZFS hook to properly unmount snapshots for empty datasets (#1295). 2026-04-18 11:43:58 -07:00
Sirio Balmelli 0b34ef0e1a tests: use pytest tmp_path fixture instead of hardcoded /tmp paths
tmp_path is a built-in pytest fixture providing a unique per-test tempdir.

Hardcoded /tmp paths break when multiple builders run tests concurrently
(e.g. Nix parallel builds): leftover or colliding files cause spurious failures.

Signed-off-by: Sirio Balmelli <sirio@b-ad.ch>
2026-04-15 16:35:41 +02:00
Dan Helfman 3f34d0848e Modify NEWS entry. 2026-04-13 15:09:04 -07:00
Dan Helfman 54289e3ee0 Fix documentation typo (#1229). 2026-04-13 10:51:32 -07:00
Dan Helfman 6eea2d5323 Document the permissions needed for the PostgreSQL database hook (#1229). 2026-04-13 10:40:10 -07:00
Dan Helfman 0ca5333fd4 Add Loki documentation link to NEWS (#1289). 2026-04-13 09:22:22 -07:00
Dan Helfman 462e1392da Add Loki mTLS to NEWS and remove unnecesary log (#1289). 2026-04-13 09:16:15 -07:00
Dan Helfman 71e2762aa7 Add mTLS support for Loki monitoring hook (#1289).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1293
Reviewed-by: Dan Helfman <witten@torsion.org>
2026-04-13 16:07:56 +00:00
Maxime Hamon edfa708fa3 Add mTLS support for Loki monitoring hook 2026-04-12 17:57:01 +02:00
Dan Helfman aee16e32e2 Fix a regression in which SSH warnings from remote repositories broke the "spot" check (#1294). 2026-04-10 20:28:14 -07:00
Dan Helfman f3ae04225d Fix a "source directories do not exist" regression when configuration paths are relative symlinks and the bootstrap data source hook is enabled (#1292). 2026-04-06 16:03:39 -07:00
Dan Helfman 3f70cf0b29 Add Borgmatic Director UI link. 2026-04-06 09:10:36 -07:00
Dan Helfman 731e8d7c37 Bump version for release. 2026-04-05 19:57:22 -07:00
Dan Helfman 3bcf592d53 Fix the "spot" check, "extract" check, and all uses of the archive name "latest" to respect the "match_archives" and "archives_name_format" options (#1268). 2026-04-05 09:58:54 -07:00
Dan Helfman 9bb5791e9f Fix a traceback in the "spot" check with Borg 2. 2026-04-04 21:48:10 -07:00
Dan Helfman 8a60fb6398 Fix broken CI build (#1264). 2026-04-04 18:02:30 -07:00
Dan Helfman 9db2bb2b54 Improve performance of the "info" and "repo-list" actions by eliminating a second "borg info" call that supports a "no matching archives" warning (#1264). 2026-04-03 20:57:25 -07:00
Dan Helfman 87bfd6e97f Fix end-to-end tests. 2026-03-30 12:37:42 -07:00
Dan Helfman 52f9442377 More requirements updates. 2026-03-30 11:29:15 -07:00
Dan Helfman af841e0c89 Upgrade Pygments. 2026-03-30 11:17:27 -07:00
Dan Helfman ed8320c1bb Upgrade requests. 2026-03-30 11:16:21 -07:00
Dan Helfman 26b3a03721 Fix for the Loki monitoring hook not respecting the monitoring verbosity (#1257). 2026-03-30 11:13:55 -07:00
Dan Helfman 52234c47e6 Fix a regression in which running borgmatic with no arguments and no configuration files doesn't error as expected (#1286). 2026-03-22 20:10:46 -07:00
Dan Helfman b74b6aa18d Remove Nuitka's --show-modules flag, as it's mostly for debugging (#1266). 2026-03-21 13:26:43 -07:00
Dan Helfman 0380ecd8fb Add stand-alone borgmatic binary to documentation (#1266). 2026-03-21 12:53:33 -07:00
Dan Helfman 32b7d1a0f7 Add a stand-alone borgmatic binary to the release downloads to serve as another way to install borgmatic (#1266). 2026-03-21 11:10:14 -07:00
Dan Helfman da873c09f8 Update test requirements. 2026-03-21 11:05:04 -07:00
Dan Helfman d612d398e7 When Borg exits with a warning exit code, show a description of it, so you don't have to lookup the code. 2026-03-15 21:38:27 -07:00
Dan Helfman 1301bec702 Fix missing words in upgrade documentation. 2026-03-14 14:01:24 -07:00
Dan Helfman c7fc68a49a Fix typo. 2026-03-14 13:31:26 -07:00
Dan Helfman c66e29906e Attempt to fix documentation build. 2026-03-13 16:41:50 -07:00
Dan Helfman 475389a094 Move project tracker from Gitea to Forgejo. 2026-03-13 16:28:09 -07:00
Dan Helfman 9f59bf2827 Attempt to get workflow working. 2026-03-13 13:16:08 -07:00
Dan Helfman f4e9569297 Add Forgejo actions metadata. 2026-03-13 12:48:58 -07:00
Dan Helfman f43c2f7130 Add dedicated documenation installation link in Getting Started. 2026-03-08 12:29:14 -07:00
Dan Helfman 889b599d55 Switch the default borgmatic installation method from pipx to uv, as uv is faster and used for borgmatic development. 2026-03-07 21:13:53 -08:00
Dan Helfman 53791d4dc9 Fix key for documentation page. 2026-03-07 20:02:53 -08:00
Dan Helfman ee58adb4eb Split out borgmatic installation documentation to its own page, so it's easier to find. 2026-03-07 19:27:04 -08:00
Dan Helfman d2903640a8 Bump version for release. 2026-03-06 10:19:42 -08:00
Dan Helfman ec25a40ddc Fix a unicode error when backing up a non-UTF-8 source filename with a corresponding system locale (#1281). 2026-03-06 10:06:39 -08:00
Dan Helfman 23e451e641 Treat Borg "file not found" warnings (exit code 107) as warnings again instead of errors. Also un-deprecate the "source_directories_must_exist" option and default it to true (#1248). 2026-03-05 13:14:03 -08:00
Dan Helfman 43d8c25234 Disable BenchNine font the in borgmatic docs tarball, because Debian policy doesn't allow fonts they don't compile themselves. 2026-03-02 23:19:03 -08:00
Dan Helfman e693e42b9e Minor Python modernization fixes. 2026-03-02 20:47:11 -08:00
Dan Helfman 1abbfe8ee6 Add doc development updates to NEWS and get Podman + Docker Compose working again (#1274). 2026-03-01 11:55:56 -08:00
Dan Helfman 8a65b43ae3 Allow dev users to select the port that is listened on when building docs (#1274).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1274
Reviewed-by: Dan Helfman <witten@torsion.org>
2026-03-01 19:37:13 +00:00
lingfish dbbce4167f Probe for podman-compose instead, and use it, otherwise fallback to docker-compose. 2026-03-01 16:55:43 +11:00
Dan Helfman 68c2a4d231 Add "files_changed" and "msgpack_version_check" to NEWS. 2026-02-27 23:26:02 -08:00
Dan Helfman 12e92acd15 Add "files_changed" and "msgpack_version_check" options (#1175).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1272
2026-02-28 07:19:55 +00:00
slantsh 1ada61a1d3 ruff formatting? 2026-02-28 09:40:41 +05:30
slantsh 75dcd41050 test reconfigured 2026-02-28 09:28:26 +05:30
Dan Helfman a6878829a2 Expand tests for "diff" actions (#1243). 2026-02-27 19:23:20 -08:00
Dan Helfman 7a44be38b7 Add "diff" action to NEWS (#1243). 2026-02-26 17:24:34 -08:00
Dan Helfman 92add238d9 Add a "diff" action for viewing the difference between two archives (#1243).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1253
Reviewed-by: Dan Helfman <witten@torsion.org>
2026-02-27 01:19:21 +00:00
Jason 24e1e38615 Merge branch 'main' into add-diff 2026-02-27 00:29:18 +00:00
lingfish cb8b298648 Final changes. This modifies the dev-docs script a little to either use docker-compose or podman-compose. I've found that Podman runs smoother this way, and it isn't required to install docker-compose. 2026-02-27 11:24:45 +11:00
lingfish eaf3d959e6 More test changes. 2026-02-26 13:56:46 +11:00
Dan Helfman c2449ad811 Tweak spot check error output for clarity (#1228). 2026-02-25 14:20:59 -08:00
lingfish 89703334f3 Add more doco. 2026-02-26 08:51:11 +11:00
slantsh ee32cc19e9 change BORG_MSGPACK_VERSION_CHECK to default bool 2026-02-25 19:58:08 +05:30
slantsh ceb487a6b9 Fixed documentation in schema 2026-02-25 19:54:10 +05:30
lingfish f90d7a99c6 Add the CLI doco generation for the diff action. 2026-02-25 17:22:39 +11:00
lingfish 012c02d962 Add the CLI doco generation for the diff action. 2026-02-25 17:06:16 +11:00
Jason 607ff20971 Merge branch 'main' into docs-port-config 2026-02-25 04:59:59 +00:00
lingfish bf2a1c6b25 Allow dev users to select the port that is listened on. 2026-02-25 15:22:45 +11:00
Dan Helfman 7dbdd103d1 Adjust the "spot" check so error output includes more information about what failed (#1228). 2026-02-24 16:51:55 -08:00
lingfish 06e2b4f2c7 Attempt to use with_args(). 2026-02-24 11:28:36 +11:00
Jason 6cfa31e2c2 Merge branch 'main' into add-diff 2026-02-23 22:38:17 +00:00
Dan Helfman 3473f034ae Fix the "spot" check to skip hard links, as Borg doesn't produces hashes for them (#1236). 2026-02-22 21:25:27 -08:00
slantsh 7dcb4e3c3a added BORG_MSGPACK_VERSION_CHECK, no test 2026-02-23 06:03:06 +05:30
lingfish 42c006cf1d Next raft of suggested fixes. 2026-02-23 11:03:25 +11:00
slantsh ea2a253134 added --files-changed flag 2026-02-22 13:17:09 +05:30
Dan Helfman 7ca42a8f4f Follow symlinks when backing up borgmatic configuration files to support the "bootstrap" action (#1270). 2026-02-20 19:55:52 -08:00
lingfish 4f25581a12 A bit of ruff, and some WIP tests. 2026-02-21 13:38:49 +11:00
lingfish 394f33f28a Make sort-by a comma separated list. 2026-02-20 14:32:52 +11:00
lingfish f5ff2f9ae3 Make the default to diff the full archives, and add a "--only-patterns" flag if a diff with patterns is desired. 2026-02-20 12:29:13 +11:00
Dan Helfman 67eb48e643 Add a policy about the use of generative AI in the borgmatic codebase. 2026-02-19 11:11:57 -08:00
Dan Helfman c7c2ef048c Remove GitHub PRs URL from contributor fetching script. 2026-02-17 11:00:16 -08:00
Dan Helfman 90d1857494 Remove GitHub pull request template, because PRs are disabled there now. 2026-02-17 10:16:47 -08:00
Dan Helfman 55375cc9e7 Fix the ZFS hook to support datasets with a "canmount" property of "noauto" (#1269). 2026-02-15 11:06:34 -08:00
Dan Helfman d532fc0f88 Add a "config show" action to display computed borgmatic configuration as YAML or JSON (#1218). 2026-02-14 21:30:53 -08:00
lingfish 6501bd9823 A small raft of changes to address comments raised. 2026-02-13 16:02:14 +11:00
Jason 477320e36c Merge branch 'main' into add-diff 2026-02-08 22:35:08 +00:00
Dan Helfman ad8d074eff Bump version for release. 2026-02-06 12:53:27 -08:00
Dan Helfman fc7439af3a If a source file is deleted during a "spot" check, consider the file as non-matching and move on instead of immediately failing the entire (#1231). 2026-02-05 23:11:59 -08:00
Dan Helfman ea05a4660c Deduplicate overlapping source directories and patterns so they don't throw off "spot" check file counts and cause spurious check failures. 2026-02-05 12:50:39 -08:00
Dan Helfman 957f6be4a2 Switch to iterable unpacking (#1252). 2026-02-05 10:38:07 -08:00
Dan Helfman 730a4b2f18 Work around Borg returning a warning exit code when a repository/archive check fails (#1252). 2026-02-05 10:19:06 -08:00
Dan Helfman c64c79ad0e Fix for SSH warnings from Borg showing up as JSON logs even without the "--log-json" flag (#1260). 2026-02-03 10:12:15 -08:00
Dan Helfman acd1a8d1dd Update ntfy test to use @pytest.mark.parametrize (#1251) 2026-02-02 11:58:50 -08:00
Dan Helfman c66fde4a93 Fix ntfy monitoring hook to send tags as an array (#1251).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1259
Reviewed-by: Dan Helfman <witten@torsion.org>
2026-02-02 19:43:50 +00:00
Mashrafi Rahman dbe3891819 Merge branch 'main' into fix-ntfy-tags-schema-array 2026-02-02 13:16:27 +00:00
Mashrafi Rahman dc89c9ec73 Update and test the convert_string_to_array function. 2026-02-02 18:26:38 +08:00
Dan Helfman a27dc95c87 Fix a "codec can't decode byte" error when running commands that output multi-byte unicode characters (#1258). 2026-02-01 20:15:03 -08:00
Mashrafi Rahman 6b5390f5dd Send tags as array in ntfy monitoring hook. 2026-02-01 15:47:13 +08:00
Dan Helfman fd485e64a3 Fix incorrect deprecated hook option name in documentation. 2026-01-30 22:43:54 -08:00
Dan Helfman f5de6bf43c Clarify command hooks documentation about YAML sequences (#1255). 2026-01-30 22:37:12 -08:00
lingfish 6e611f9b38 arg fixes. 2026-01-30 13:57:14 +11:00
lingfish 06ebff878b Add the other supported args. 2026-01-30 13:27:09 +11:00
lingfish 38cc5d4ee4 Half clueless attempt at fixing test_schema.py. 2026-01-30 11:52:47 +11:00
lingfish 794e7eadab Make the action actually work. PR still pending a bunch of stuff. 2026-01-29 17:38:06 +11:00
lingfish de35ed82be Initial commit (unfinished) to address #1243. 2026-01-29 14:35:20 +11:00
Dan Helfman aa25dc7b31 Fix a regression in which the "--stats" flag hides statistics at default verbosity (#1250). 2026-01-28 11:49:24 -08:00
Dan Helfman aba45f03d6 Bump version for release. 2026-01-27 13:09:10 -08:00
Dan Helfman f6124528df When the "unsafe_skip_path_validation_before_create" option is enabled, don't log a warning about it (#1244). 2026-01-25 12:17:35 -08:00
Dan Helfman b67dcf829e Fix a regression in which the ntfy monitoring hook failed to send a ping when the "priority" option was set (#1246). 2026-01-24 16:35:55 -08:00
Dan Helfman 71e25756f2 Fix a regression in which the KeePassXC credential hook password prompt was invisible (#1245). 2026-01-24 16:17:32 -08:00
Dan Helfman ff2f9fd5ee Add an additional test and "fix" code coverage (#1242). 2026-01-24 12:28:33 -08:00
Dan Helfman ca4447ffab Fix "spot" check hang (#1242).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1247
2026-01-24 20:20:08 +00:00
Dan Helfman 104fe35e39 Add another test to get some additional coverage that's timing dependent (#1242). 2026-01-23 23:04:15 -08:00
Dan Helfman 248fa1db64 Add automated tests for new code (#1242). 2026-01-23 22:50:37 -08:00
Dan Helfman 97f7c65f6c More refactoring and test fixes (#1242). 2026-01-22 23:01:34 -08:00
Dan Helfman 765eba5315 Get existing unit/integration tests passing (#1242). 2026-01-22 17:29:30 -08:00
Dan Helfman bd051beced Structural refactor just to get code out of log_outputs() and into separate utility functions (#1242). 2026-01-22 14:02:48 -08:00
Dan Helfman d5cd4efecd Fix spot check hang (#1242). 2026-01-22 10:27:47 -08:00
Dan Helfman 13fd225a0b Abolish ICE. 2026-01-21 19:29:22 -08:00
Dan Helfman 87c5863218 Some cleanup and also fix delayed logs (#1242). 2026-01-19 22:22:15 -08:00
Dan Helfman 677871aa89 Initial stab at fixing spot check hang by actually draining and consuming buffers after a process exits (#1242). 2026-01-19 16:26:22 -08:00
Dan Helfman d2390581e7 Fix implicit string concatenation instead of trying to paper over it (incidental work included in #1241). 2026-01-18 20:56:29 -08:00
Dan Helfman efd0f0d618 For the "recreate" action, actually pass the "--dry-run" flag through to Borg instead of just skipping the Borg call (#1241). 2026-01-18 18:39:29 -08:00
Dan Helfman 4ff7dccab4 Expand on archive argument to recreate (#1239).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1239
2026-01-17 03:15:21 +00:00
Jason Lingohr 76537f6c11 Expand on archive argument
Make a small but specific help expansion on the `archive` option.
2026-01-17 02:45:15 +00:00
Dan Helfman 486a5fb6b5 Bump version for release. 2026-01-16 12:42:25 -08:00
Dan Helfman 2419ae314e Add "unsafe_skip_path_validation_before_create" option to documentation (#1221). 2026-01-16 12:41:12 -08:00
Dan Helfman f73a79d647 Fix another warning from LVM about leaked file descriptors, this time when calling "lvcreate" from a command hook (#1230). 2026-01-16 12:04:32 -08:00
Dan Helfman 5c55402021 Fix for the ntfy monitoring hook erroring on emojis in the "title" option (#1234). 2026-01-16 10:04:14 -08:00
Dan Helfman 2bb818402d Fix the "recreate" action to include borgmatic-specific paths (database dumps, etc.) in recreated archives. 2026-01-15 22:16:41 -08:00
Dan Helfman beecdada68 Several related fixes and improvements in logging, output capturing, and command execution (#1235).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1235
2026-01-16 03:28:46 +00:00
Dan Helfman 7e4a894cc4 Prevent Borg error-level log about repo not existing when "borgmatic repo-create" probes for repo existence. 2026-01-14 22:38:09 -08:00
Dan Helfman e39c503fb7 Add missing test coverage and fix log_outputs() variable use. 2026-01-14 22:18:19 -08:00
Dan Helfman c301858cca MOAR --log-json. 2026-01-14 21:59:36 -08:00
Dan Helfman 762beb46c2 Fix "restore" action and broken end-to-end tests. 2026-01-14 12:54:36 -08:00
Dan Helfman d354cf0942 Get unit/integration tests passing. 2026-01-13 16:02:02 -08:00
Dan Helfman 64e25144a8 Going back to using readline() for reading lines from executed programs. 2026-01-13 10:08:40 -08:00
Dan Helfman c4de141899 Several related fixes and improvements in logging, output capturing, and command execution. 2026-01-12 15:26:50 -08:00
Dan Helfman 10f2d6bdfd Revert "Fix for stderr warnings from ssh dirtying Borg JSON output and breaking "check" (#485)."
This reverts commit 7987a5ff1f.
2026-01-11 10:00:08 -08:00
Dan Helfman 7bb27e5366 Revert "Fix for incorrect use of "--log-json" when looking up latest archive (#1204)."
This reverts commit 2c530cc1b9.
2026-01-11 09:51:50 -08:00
Dan Helfman 2c530cc1b9 Fix for incorrect use of "--log-json" when looking up latest archive (#1204). 2026-01-10 21:25:20 -08:00
Dan Helfman 9a1c84f645 Fix broken end-to-end tests (#1092). 2026-01-10 21:04:59 -08:00
Dan Helfman 7987a5ff1f Fix for stderr warnings from ssh dirtying Borg JSON output and breaking "check" (#485). 2026-01-10 20:55:35 -08:00
Dan Helfman bdd2258701 BREAKING: Treat most Borg warnings as errors by default and deprecate the "source_directories_must_exist" option (#1092). 2026-01-10 19:35:07 -08:00
Dan Helfman 022d7a4bc7 Fix accidentally commented out tests. 2026-01-10 12:03:51 -08:00
Dan Helfman 5cb2af4683 Disable Loki buffering when log sending is disabled, and fix monitoring end-to-end tests (#1132). 2026-01-10 11:59:01 -08:00
Dan Helfman 774436817a BREAKING/SECURITY: For the Healthchecks, Apprise, Pagerduty, and Loki monitoring hooks, disable log sending when not explicitly enabled (#1132). 2026-01-09 22:25:50 -08:00
Dan Helfman a5f7c8cafc Reduce memory consumption of "create" action path validation (#1225).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1227
2026-01-09 21:29:07 +00:00
Dan Helfman 6c7af8c2bb Merge branch 'main' into reduce-path-validation-memory-consumption 2026-01-09 13:02:59 -08:00
Dan Helfman 3e4115cc92 For the "extract"/"restore" actions, fix use of "--log-json" when extracting to stdout (#485). 2026-01-09 13:02:04 -08:00
Dan Helfman d589d351ed Merge branch 'main' into reduce-path-validation-memory-consumption 2026-01-09 12:17:57 -08:00
Dan Helfman bb149e4ee8 Upgrade urllib3 in test requirements for security fix. 2026-01-09 12:14:31 -08:00
Dan Helfman 4e6319f5d3 In the "export-tar" action, fix the use of "--log-json" when exporting to stdout (#485). 2026-01-09 12:12:42 -08:00
Dan Helfman 528dc6fe87 For the "mount" action, fix use of "--log-json" when "--foreground" is set (#485). 2026-01-09 12:01:21 -08:00
Dan Helfman 6f4f1fa16e For the "key export" action, fix use of "--log-json" when exporting to stdout (#485). 2026-01-09 11:57:27 -08:00
Dan Helfman fd373cfefc Fix for an error in the "key import" action when importing a key from stdin. 2026-01-09 11:51:14 -08:00
Dan Helfman 4d26650884 Fix incorrect use of "--log-json" in the "key change-passphrase" action (#485). 2026-01-08 22:54:37 -08:00
Dan Helfman 776bf3fcd4 Track "last lines" per-process instead of per-buffer, now that each process can have multiple active buffers (#485). 2026-01-08 22:26:15 -08:00
Dan Helfman a23d889c6d Merge branch 'main' into reduce-path-validation-memory-consumption 2026-01-08 21:30:10 -08:00
Dan Helfman 1299aefeff For the "repo-delete" action, don't pass "--log-json" to Borg when running Borg interacively (#485). 2026-01-08 21:29:15 -08:00
Dan Helfman 0c79126ccd Merge branch 'main' into reduce-path-validation-memory-consumption 2026-01-08 21:14:14 -08:00
Dan Helfman 4eeba085de Add disclaimer to individual documentation pages about options/flags and borgmatic versions. 2026-01-08 21:08:29 -08:00
Dan Helfman 536b24f6ab Merge branch 'main' into reduce-path-validation-memory-consumption 2026-01-08 10:44:56 -08:00
Dan Helfman bc8649e11a For the "repo-info" action, don't pass "--log-json" to Borg when "--json" is also set (#485). 2026-01-08 10:42:35 -08:00
Dan Helfman 0ffa4ec429 For the "repo-create" action, don't pass "--log-json" to Borg, as its output isn't captured and parsed (#485). 2026-01-08 10:27:02 -08:00
Dan Helfman cf78ad681d NEWS clarifications. 2026-01-07 23:25:25 -08:00
Dan Helfman a308ffca64 Add missing test coverage (#1225). 2026-01-07 22:13:55 -08:00
Dan Helfman 6407b3bd80 Fix existing tests (#1225). 2026-01-07 19:58:25 -08:00
Dan Helfman aa6b6e0d96 Fix many tests (#1225). 2026-01-06 22:32:42 -08:00
Dan Helfman e2f7cd8edc Reorganize path validation logic (#1225). 2026-01-06 16:55:36 -08:00
Dan Helfman 310b287cff Make execute_command_with_processes() a generator like execute_command_and_capture_output() (#1225). 2026-01-06 15:39:19 -08:00
Dan Helfman 305ac0d65d Various fixes and efficiency improvements related to captured output changes (#1225). 2026-01-06 15:27:04 -08:00
Dan Helfman d9d30d12cd Opportunistic efficiency improvements in spot check (#1225). 2026-01-06 15:11:54 -08:00
Dan Helfman cc14678eed Add NEWS entry (#1225). 2026-01-06 14:37:33 -08:00
Dan Helfman 549707d28b Reduce memory consumption of "create" action path validation (#1225) 2026-01-06 14:30:50 -08:00
Dan Helfman b0e083eff2 BREAKING: Support disabling both constant and variable interpolation by escaping with backslashes (#1213). 2026-01-05 15:39:14 -08:00
Dan Helfman 71291409fe SECURITY: Prevent shell injection attacks via constant interpolation in command hooks. 2026-01-04 21:56:53 -08:00
Dan Helfman 7deab735fd Add a NEWS entry for the "unsafe_skip_path_validation_before_create" option (#1221). 2026-01-04 20:48:13 -08:00
Dan Helfman 6db2622cce Add "unsafe_skip_path_validation_before_create" option to skip pre-backup validation (#1221).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1222
2026-01-05 04:39:49 +00:00
Dan Helfman 8fdf18e471 Add NEWS entry for "extract" check progress support (#1224). 2026-01-04 20:12:18 -08:00
Dan Helfman a220f20653 Run borg with --progress during extract dry run if passed to borgmatic check (#1224).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1224
2026-01-05 04:09:01 +00:00
Simon Pilkington c0b4c596ef Run borg with --progress during extract dry run if passed to borgmatic check. 2026-01-05 03:40:01 +01:00
Julien Cornebise 3bec359355 Merge branch 'main' into add-unsafe-create-without-precheck-option 2026-01-04 22:07:15 +00:00
Julien Cornebise c08f1b8178 Rename unsafe_create_without_precheck to unsafe_skip_path_validation_before_create and address PR feedback (#1222).
- Use skip_ prefix consistent with other borgmatic options
- Reduce runtime warning to single line (users read the config docs)
- Broaden "database dumps" to "data from borgmatic data source hooks"
- Simplify schema docs by referencing user_runtime_directory instead of listing defaults
2026-01-04 22:02:47 +00:00
Dan Helfman c58b9f1c67 Promote the ZFS, LVM, and Btrfs hooks from beta features to stable. 2026-01-03 22:33:17 -08:00
Dan Helfman 0f3234d681 Cleanup snapshots immediately after ZFS, LVM, or Btrfs hooks error (#1220). 2026-01-03 22:22:55 -08:00
Dan Helfman ead3028e9d Elevate command stderr to error logs, add "--log-json" support for borgmatic logs, and log Borg output at varying log levels based on verbosities (#485, #858, #1204).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1207
2026-01-04 03:06:21 +00:00
Dan Helfman 4264575345 NEWS clarifications. 2026-01-03 19:02:55 -08:00
Dan Helfman bb36d8f00f Spelling. 2026-01-03 17:21:46 -08:00
Dan Helfman 3c84c92968 Add documentation for systemd journal integration and also the "--log-json" flag. 2026-01-03 14:18:33 -08:00
Julien Cornebise 7e1cb691b6 Add "unsafe_create_without_precheck" option to skip pre-backup validation (#1221).
This option allows users to skip the dry-run validation that checks for
special files and runtime directory exclusions. This can significantly
improve performance for large filesystems with millions of files, where
the validation can take hours.

The option name includes "unsafe" to communicate risk, and a warning is
logged on each backup showing the runtime directory path and advising
users to verify their exclude patterns manually.
2026-01-03 20:54:27 +01:00
Dan Helfman 92c141602d Fix typo in test name. 2026-01-03 10:06:39 -08:00
Dan Helfman 3c2099f587 Add missing test for log_outputs() stderr -> ERROR log. 2026-01-02 23:04:53 -08:00
Dan Helfman 233a9054c9 Merge branch 'main' into subprocess-stderr-to-error-logs 2026-01-02 18:51:16 -08:00
Dan Helfman 76c0432fca Fix end-to-end tests. 2026-01-02 14:57:38 -08:00
Dan Helfman 6f075f52fd Get unit/integration tests passing. 2026-01-01 22:24:56 -08:00
Dan Helfman b19e0e6455 Add some missing execute.py test coverage. 2025-12-31 23:05:50 -08:00
Dan Helfman 11253d2b1d Get existing test passing. 2025-12-30 23:19:10 -08:00
Dan Helfman 9f6cce2a39 Get several existing tests passing and refactor logging of lines from external programs. 2025-12-30 22:25:12 -08:00
Dan Helfman 7388e6f5b9 Support journald logging critical. 2025-12-28 23:14:09 -08:00
Dan Helfman 7883be2f42 Crept scope to include:
* #858: With the "--log-json" flag, log borgmatic's own logs as JSON, not just Borg's.
 * #1204: When verbosity levels differ between console/monitoring/syslog/file, log Borg's output to each one at a different level.
 * When syslog verbosity is enabled, log to systemd's journal (if present) with structured data.
2025-12-28 22:55:03 -08:00
Dan Helfman ec8168af46 Fix an error about the runtime directory getting excluded by tweaking its logic and lowering the error to a warning (#1211). 2025-12-26 22:53:21 -08:00
Dan Helfman 126b08b1ac Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2025-12-26 09:48:21 -08:00
Dan Helfman 802ca5aef3 Fix an error when running the "spot" check or "extract" action with the "progress" option or "--progress" flag (#1210). 2025-12-26 09:46:39 -08:00
Dan Helfman 6de87b6cf6 Fix for the database "restore" action restoring more databases than the "--database" flag specifies (#1208).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1209
2025-12-25 22:05:19 +00:00
Dan Helfman 047a374514 Fix existing automated tests (#1208). 2025-12-25 13:56:01 -08:00
Dan Helfman 00e2284662 When creating patterns for a "borg extract" during a restore, match directory format dumps too. Also make dump restore ordering to be deterministic (#1208). 2025-12-24 12:56:52 -08:00
Dan Helfman ef7e934f5b Merge branch 'main' into fix-restore-too-many-databases 2025-12-24 10:43:10 -08:00
Dan Helfman a3750d1e06 Fix an error when restoring multiple directory-format database dumps at once (#1212). 2025-12-23 20:28:15 -08:00
Dan Helfman ea25d969cb Fix typo in MySQL hook (#1208). 2025-12-22 16:02:53 -08:00
Dan Helfman b8642439cd Fix for the database "restore" action restoring more databases than the "--database" flag specifies (#1208). 2025-12-22 15:46:28 -08:00
Dan Helfman 45d35614f2 Fix several tests (#485). 2025-12-22 11:52:55 -08:00
Dan Helfman 1b046fd3e7 When running commands (database clients, command hooks, etc.), elevate stderr output to borgmatic error logs (#485). 2025-12-21 21:49:58 -08:00
Dan Helfman 3c8935fd69 Remove NEWS entry that was from a previous release. 2025-12-20 17:31:55 -08:00
Dan Helfman 08ff743cd7 Bump version for release. 2025-12-20 17:16:51 -08:00
Dan Helfman 406b533b15 Adjust Btrfs snapshot paths so that Borg 1.x gets file cache hits when backing them up (#1206).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1206
2025-12-21 01:10:33 +00:00
Dan Helfman 18a1d4f00b Update documention mentioning Btrfs snapshot path containing process ID (#1206). 2025-12-20 17:01:06 -08:00
Dan Helfman 54d955bc95 Fix automated tests and update NEWS (#1206). 2025-12-20 16:59:13 -08:00
Dan Helfman a72f9b24b5 Merge branch jacksonchen666-btrfs-remove-pid into btrfs-remove-pid and streamline the docs a bit. 2025-12-20 16:02:42 -08:00
Dan Helfman 1b471f5346 Code formatting (#1054). 2025-12-19 23:20:38 -08:00
Dan Helfman 9428a92297 Fix broken Btrfs end-to-end test (#1054). 2025-12-19 23:10:33 -08:00
Dan Helfman c0d4489c25 Add unnecessary fake Btrfs read-only flag in end-to-end test (#1054). 2025-12-19 19:15:09 -08:00
Dan Helfman b4ea45ab74 Allow the Btrfs hook to create and delete snapshots even when running as a non-root user (#1054). 2025-12-19 19:10:31 -08:00
Dan Helfman 6ae9b973fc For the MariaDB and MySQL database hooks, creates a consistent snapshot by dumping all tables in a single transaction (#1193). 2025-12-19 11:53:30 -08:00
Dan Helfman 01c7d0a0db Fix that errors and exits when the borgmatic runtime directory is partially excluded by configured excludes (#1203). 2025-12-18 19:04:42 -08:00
Dan Helfman a31f913f54 For the MariaDB and MySQL database hooks, add a "socket_path" option for Unix socket database connections (#1193). 2025-12-14 21:13:21 -08:00
Dan Helfman 0b086a3c18 Fix documentation typo (#1201). 2025-12-13 10:02:41 -08:00
Dan Helfman dfdb1cb15e Document a problematic interaction between borgmatic and systemd-tmpfiles (#1201). 2025-12-13 09:57:33 -08:00
Dan Helfman c8307065fa Add a "file_list_format" option for setting the "list" action's output format and an "archive_list_format" option for setting the "repo-list" action's format (#1179). 2025-12-12 22:31:41 -08:00
Dan Helfman ae00770c35 Upgrade urllib3 in test requirements for security fixes. 2025-12-09 11:01:18 -08:00
Dan Helfman 77782bc215 "Fix" code coverage complaint. 2025-12-09 10:59:30 -08:00
Dan Helfman ff2009fe19 Fix ruff errors by removing unused imports (#1200).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1200
2025-12-09 18:57:58 +00:00
Christian Henriksen 141f2d68c1 Fix ruff errors by removing unused imports. 2025-12-09 11:47:47 +01:00
Dan Helfman 772c4c1b83 In the documentation for the MariaDB/MySQL database hooks, clarify how to set custom command-line flags for database commands (#1193). 2025-12-03 13:52:30 -08:00
Dan Helfman a77de447d4 Document potential interactions between systemd security settings and the ZFS, LVM, and Btrfs hooks (#1192, #1163). 2025-12-02 17:25:36 -08:00
Dan Helfman 50a062611e Fix a regression in the ZFS, LVM, and Btrfs hooks in which snapshotted paths ignored global excludes (#1195). 2025-12-02 15:40:42 -08:00
Dan Helfman 42bf6c6f8e Fix typo in database restore documentation example (#1197). 2025-12-02 12:13:06 -08:00
Dan Helfman e95d652abb Reword NEWS entry for clarity (#1192). 2025-12-02 10:58:29 -08:00
Dan Helfman 2a97ac0174 Fix for over-aggressive deduplication of source directories that contain the borgmatic runtime directory (#1192). 2025-12-01 17:14:31 -08:00
Dan Helfman 5029ab1ffd Update the sample systemd timer with a shorter random delay when catching up on a missed run. 2025-11-29 22:25:43 -08:00
Dan Helfman 7dc902f5db Fix for an incorrect diff command shown when running the "generate config" action with a source configuration file (#1194). 2025-11-28 19:53:46 -08:00
Dan Helfman 8134bdec7f Fix release script brokenness. 2025-11-26 15:40:16 -08:00
Dan Helfman 5e7d14e419 Bump version for release. 2025-11-26 15:31:04 -08:00
Dan Helfman 8f2ae5e9a1 Fix a regression in the Btrfs hook in which subvolume snapshots didn't get cleaned up until the start of the next borgmatic run (#1186). 2025-11-26 13:59:23 -08:00
Dan Helfman 928bb3fb8c Fix an unreleased regression in the KeePassXC hook in which it raised when it was used without explicit "keepassxc:" configuration (#1181). 2025-11-25 22:48:05 -08:00
Dan Helfman 31623f9c91 Add borgmatic release artifacts to each release on the releases page. 2025-11-25 20:41:25 -08:00
JacksonandDan Helfman f158d42b28 apply docs suggestions
Co-authored-by: Dan Helfman <witten@torsion.org>
2025-11-23 09:20:03 +01:00
Jackson dec47298bd somehow make a test work 2025-11-22 08:03:40 +01:00
Jackson 4976352fd3 add to btrfs docs that caching works now 2025-11-22 05:49:15 +01:00
Jackson f95fbaa6aa fix at least some tests (with changed snapshot path)
this really is just a blanket find and replace though
2025-11-21 09:22:34 +01:00
Jackson 7dc7b77f6c static btrfs snapshot paths
i don't know the implications of this change, except for maybe
concurrent borgmatic processes
2025-11-21 08:07:33 +01:00
Dan Helfman 47ce12e4b6 Explicitly note Borg v1 caching doesn't work with Btrfs (#1187).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1187
2025-11-21 06:55:39 +00:00
Jackson e72ace5294 remove lingering ) 2025-11-21 05:17:44 +01:00
Jackson 72c3e524be docs: btrfs: note that btrfs hook doesn't cache even with runtime dir 2025-11-21 05:17:44 +01:00
Dan Helfman 399576e7c9 Add data hooks working directory fix to NEWS (#1127). 2025-11-19 10:42:11 -08:00
Dan Helfman a7356f2360 Fix for the database dumps hooks not respecting the "working_directory" option (#1127).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1129
2025-11-19 18:40:50 +00:00
Dan Helfman 1f8c39fb17 Add fish shell completion fix to NEWS (#1184). 2025-11-18 22:29:19 -08:00
Dan Helfman 66e567f78c Hard-code default configuration paths in command-line flag help (#1184).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1184
2025-11-19 06:25:56 +00:00
Tim Ruffing ea45a37db8 Omit "-c" when describing default value for -c 2025-11-18 09:55:54 +01:00
Dan Helfman a229fc7f92 Give the borgmatic logo a white circle background so it actually shows up properly in dark mode. 2025-11-17 22:20:27 -08:00
Tim Ruffing 573405bb88 Hard-code default config paths in help strings
This fixes a bug with fish completions. Before this commit,
`borgmatic --fish-completion` would generate a fish completion script
with an expanded XDG_CONFIG_HOME (if this variable is set).

This is problematic for package maintainers if the variable is set
during the creation of a package.

Moreover, it is problematic if the user has set this variable because
the fish completion scripts checks its own consistency with `borg
--fish-completion` during startup to detect version mismatches. If the
user has a packaged fish completion file with "~/.config/$HOME" in it,
this won't match the output of `borg --fish-completion` because the
latter contains the expansion of $XDG_CONFIG_HOME. As a result, the
script will incorrectly conclude that it is outdated.
2025-11-17 15:42:30 +01:00
Dan Helfman 94dc4c497a Note in the KeePassXC documentation which features are in which versions of borgmatic (#1181). 2025-11-14 21:27:07 -08:00
Dan Helfman 0e9193c46b Add an "ask_for_password" option to the KeePassXC credential hook for disabling KeePassXC's password prompt (#1181). 2025-11-14 21:20:21 -08:00
Dan Helfman 151becbaea Move shell autocompletion documentation out to reference docs. 2025-11-11 10:21:01 -08:00
bockulator ce7f0226be Mock get_working_directory() calls in data source hook tests 2025-11-11 15:36:51 +01:00
bockulator 51ef9c7708 Fix database dumps hooks not respecting working_directory 2025-11-11 14:22:34 +01:00
Dan Helfman 14a8055e71 In the SQLite database hook, run SQLite such that it exits upon encountering an error instead of, you know, not doing that. 2025-11-07 23:22:44 -08:00
Dan Helfman fcfc7ee726 Emojis everywhere! 2025-11-04 23:04:16 -08:00
Dan Helfman 859c338d06 Improve documentation search results for individual configuration options. 2025-11-04 22:34:15 -08:00
Dan Helfman d1f2d8a061 Add documentation on repositories, including SSH, Rclone, S3, and B2. 2025-11-03 13:56:15 -08:00
Dan Helfman bddc82951e Bump version for release. 2025-11-02 19:41:38 -08:00
Dan Helfman 3d15c51039 Fix the "--repository" flag not applying to command hooks (#1176). 2025-11-02 19:32:08 -08:00
Dan Helfman f9206c15e7 Fix documentation typos. 2025-11-02 09:36:08 -08:00
Dan Helfman 0da4b64838 Drop support for Python 3.9, which has been end-of-lifed. (Also hopefully fix the build.) 2025-11-01 23:16:13 -07:00
Dan Helfman 85f06a900a Revert "Attempt to fix broken build (rpds module not found error)."
This reverts commit bae8f983e0.
2025-11-01 21:48:59 -07:00
Dan Helfman bae8f983e0 Attempt to fix broken build (rpds module not found error). 2025-11-01 21:39:10 -07:00
Dan Helfman 89c0cd0af6 Add documentation on patterns and excludes. 2025-11-01 21:18:22 -07:00
Dan Helfman 292d9cb2c7 Add documentation on patterns and excludes. 2025-11-01 21:17:35 -07:00
Dan Helfman 181030c5d1 Add an "environment" option to the Sentry monitoring hook (#1172). 2025-10-30 15:39:49 -07:00
Dan Helfman 2600598359 Add environment option to Sentry monitoring hook (#1172).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1172
Reviewed-by: Dan Helfman <witten@torsion.org>
2025-10-30 22:37:53 +00:00
Tom Janssen 9e649af2a3 fix typo 2025-10-29 10:13:45 +01:00
Tom Janssen 370bc7e7f0 fix schema length 2025-10-29 09:55:03 +01:00
Tom Janssen 7cca1358fb incorporate feedback 2025-10-29 09:50:55 +01:00
Dan Helfman 67406b60b3 Cleanup "other ways to install" section and boost "Docker" there so it shows up in search. 2025-10-28 23:01:51 -07:00
Dan Helfman 72fde728a4 Fix typo (#1173).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1173
2025-10-28 18:16:36 +00:00
StefanSchoof d4433eeec1 Fix typo 2025-10-28 16:43:40 +00:00
Tom Janssen d0b5244888 add environment option 2025-10-28 16:52:50 +01:00
Dan Helfman 1ed207c6b1 Document borgmatic's limitations around parallelism—both its own and Borg's (#957). 2025-10-27 15:58:12 -07:00
Dan Helfman 5ef257ebb6 Attempt to clarify recursion root patterns in the schema comments. 2025-10-27 14:34:20 -07:00
Dan Helfman eb2b0b35c1 Fix for an inconsistent log level for Borg's last output line before exiting (#1170). 2025-10-27 14:20:27 -07:00
Dan Helfman 0f7ebcb4b7 Fix for a regression in the ZFS, LVM, and Btrfs hooks in which partial excludes of snapshot paths were ignored (#1169). 2025-10-26 14:12:52 -07:00
Dan Helfman 51d2ce7963 Add a "rename" option to "extra_borg_options" to support passing arbitrary flags to "borg rename". 2025-10-24 23:02:46 -07:00
Dan Helfman 0c4222037d Fix for the "list", "info", and "delete" options in "extra_borg_options" being ignored when "--archive" is omitted with Borg 1.x (#1168). 2025-10-24 22:57:23 -07:00
Dan Helfman 25e8aad517 Add systemd multiple service directories fix to NEWS (#1165). 2025-10-24 09:36:52 -07:00
Dan Helfman 11ffd76cc6 Support multiple configured systemd service directories (RuntimeDirectory, StateDirectory) (#1165). 2025-10-24 16:31:59 +00:00
Simon Pilkington 68864395b5 Support multiple configured systemd service directories (RuntimeDirectory, StateDirectory) 2025-10-24 08:20:59 +02:00
Dan Helfman 0777d40218 Bump version for release. 2025-10-23 21:47:56 -07:00
Dan Helfman f680869d31 Expand the "borg_extra_options" option to support every Borg sub-command that borgmatic uses (#427). 2025-10-23 21:13:16 -07:00
Dan Helfman 9b66004819 Fix images in README.md to use absolute URLs (#1167).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1167
2025-10-23 23:08:25 +00:00
fruworg aa43906b28 icons fix 2025-10-23 21:49:37 +00:00
Dan Helfman 134bf1ebdd Add a "borg_key_file" option for setting the Borg repository key file path (#1166). 2025-10-23 10:36:00 -07:00
Dan Helfman dcd567f4f0 Fix for a runtime directory error when the configured patterns contain a global exclude (#1150). 2025-10-22 11:10:42 -07:00
Dan Helfman 44d63cac07 Mention that repo-create is also known as init in command-line help. 2025-10-22 10:28:20 -07:00
Dan Helfman 4017897b1a For the MariaDB and MySQL database hooks, add a "skip_names" option to ignore particular databases when dumping "all" (#973). 2025-10-16 14:48:42 -07:00
Dan Helfman d808d7424f Document the fact that missing "source_directories" are warnings, not errors (#1076). 2025-10-15 16:15:36 -07:00
Dan Helfman a327496c99 Document that the "exclude_nodump" option is Borg 1.x only (#1111). 2025-10-15 16:00:55 -07:00
Dan Helfman 46bb49f40b Add "Related documentation" on action reference documentation. 2025-10-15 15:48:00 -07:00
Dan Helfman 9cd607af9d Clarify schema documentation about borg_security_directory and borg_keys_directory defaults (#1162). 2025-10-15 10:42:18 -07:00
Dan Helfman b80d126424 Fix for a runtime directory error when the "create" action is used with the "--log-json" flag (#1150). 2025-10-15 10:35:16 -07:00
Dan Helfman dea1c0179b Clarify what command hooks are in the reference documentation. 2025-10-15 10:22:02 -07:00
Dan Helfman 2deb27c985 Fix page <title> on documentation home. 2025-10-14 16:20:34 -07:00
Dan Helfman 6f9bf687d9 Combining a few sections (#942). 2025-10-14 15:57:04 -07:00
Dan Helfman 7e7b6e012c Remove incorrect bash code styling (#942). 2025-10-14 15:41:52 -07:00
Dan Helfman 3ee1203435 Tweaks to search indexing and results. 2025-10-14 13:05:50 -07:00
Dan Helfman 2db5525f72 Link to action reference documentation instead of "--help". 2025-10-14 12:42:35 -07:00
Dan Helfman e3c369a579 Individual actions command-line reference documentation (#942). 2025-10-14 12:29:39 -07:00
Dan Helfman 9787c9fda0 Replace various how-to links with replacement reference links (#942). 2025-10-13 18:38:25 -07:00
Dan Helfman c7053f8202 Additional documentation reference refactoring (#942). 2025-10-13 18:14:39 -07:00
Dan Helfman 044b1722e3 Factor out command hook reference documentation. 2025-10-13 16:21:42 -07:00
Dan Helfman b5f0dc7f1f Factor out snapshot-related reference documentation. 2025-10-13 13:51:09 -07:00
Dan Helfman efc4316a45 Split out example configuration into different pages of reference documentation (#942). 2025-10-12 21:28:52 -07:00
Dan Helfman 5e6ceea7e2 Merge branch 'main' into reference-docs 2025-10-12 15:59:09 -07:00
Dan Helfman a1acf1e0dd Add documentation search. 2025-10-12 15:37:28 -07:00
Dan Helfman 8ea9a7cb55 Change the URL of the local documentation development server to be more like the production URL. 2025-10-12 15:16:25 -07:00
Dan Helfman f45258b6b7 Revert "Add documentation search."
This reverts commit 6d7dc378a9.
2025-10-12 11:18:41 -07:00
Dan Helfman 85177b78a6 Merge branch 'main' into reference-docs 2025-10-12 10:59:19 -07:00
Dan Helfman 6d7dc378a9 Add documentation search. 2025-10-12 10:55:48 -07:00
Dan Helfman 2a69598e02 Partial work on factoring out references docs from how-to guides (#942). 2025-10-10 22:21:15 -07:00
Dan Helfman 4c7435378c Add to NEWS: Fix a traceback (TypeError) in the "check" action with Python 3.14 (#1161). 2025-10-09 14:34:57 -07:00
Dan Helfman d15bed5d4f Fix Python 3.14 incompatibility (#1161).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1161
2025-10-09 21:33:25 +00:00
Carmen Bianca BAKKER 2b34c62e6f Remove non-existent argument to Path
'mode' is not a valid argument to (Pure)Path. Passing keyword arguments is
deprecated since Python 3.12. I don't suspect it ever did anything. It is
removed in Python 3.14.

Without this patch, borgmatic crashes on Python 3.14.

This code was first introduced in e76bfa555f.
2025-10-09 22:09:40 +02:00
Dan Helfman 2a5e202daf Bump version for release. 2025-10-07 09:55:41 -07:00
Dan Helfman 7f2e3e0054 Fix to snapshot both parent and child volumes/filesystems instead of just the parent (#1156).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1158
2025-10-07 16:14:31 +00:00
Dan Helfman 173ba00caa Fix to snapshot both parent and child volumes/filesystems instead of just the parent (#1156). 2025-10-04 14:35:42 -07:00
Dan Helfman 498d662b3d More accurately collect Btrfs subvolumes to snapshot. As part of this, the Btrfs hook no longer uses "findmnt" (#1105).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1154
2025-10-03 03:27:37 +00:00
Dan Helfman 75405bed89 Cache Btrfs get property commands (#1105). 2025-10-01 22:50:33 -07:00
Dan Helfman c989b73103 Various cleanup (#1105). 2025-10-01 22:28:54 -07:00
Dan Helfman 35feeb4615 Fix restore and get end-to-end tests passing (#1105). 2025-09-30 21:56:04 -07:00
Dan Helfman 5dca281439 Simplify logic around checking for read-only Btrfs subvolumes (#1105). 2025-09-30 12:19:57 -07:00
Dan Helfman 13fbee858a Fix a traceback (TypeError) regression in the "spot" check when the "local_path" option isn't set. 2025-09-30 09:51:47 -07:00
Dan Helfman e2cdcba4e7 Get tests passing (#1105). 2025-09-29 23:06:49 -07:00
Dan Helfman 595c639d25 Merge branch 'main' into btrfs-remove-findmnt 2025-09-28 17:55:40 -07:00
Dan Helfman b89f057be0 Fix a regression in the Loki monitoring hook in which log messages weren't sending (#1152). 2025-09-28 16:44:59 -07:00
Dan Helfman 98ddb3e535 Fix several existing tests (#1105). 2025-09-28 15:45:21 -07:00
Dan Helfman 339186b579 More accurately collect Btrfs subvolumes to snapshot by using the "btrfs" command rather than "findmnt" (#1105). 2025-09-27 19:43:43 -07:00
Dan Helfman 1192451718 Fix snapshotting in the ZFS, Btrfs, and LVM hooks to play nicely with the Borg 1.4+ "slashdot" hack within source directory paths (#1151). 2025-09-25 10:24:12 -07:00
Dan Helfman 06e24dfbc1 Fix incomplete documentation description. 2025-09-24 23:14:24 -07:00
Dan Helfman 4f0162d5f2 Add loading of systemd credentials even when running borgmatic outside of a systemd service (#1123). 2025-09-24 10:34:20 -07:00
Dan Helfman ed58bc2943 Attempt to fix CI build broken due to missing schema file (#1149). 2025-09-22 21:16:03 -07:00
Dan Helfman cab6c3ee9b Include automated tests in the source dist tarball uploaded to PyPI (#1149). 2025-09-22 14:26:00 -07:00
Dan Helfman 65c17c91b3 Add support for Python 3.14 (#1149). 2025-09-22 10:34:46 -07:00
Dan Helfman 546c0bf83b Add documentation note about borgmatic source packages (#1149). 2025-09-22 09:56:22 -07:00
Dan Helfman 6d48ff95d2 Bump version for release. 2025-09-19 17:32:13 -07:00
Dan Helfman 22b77a4262 Merge branch 'fix-borg-2-latest-archive' 2025-09-19 14:32:44 -07:00
Dan Helfman f15e8282ab Fix a broken "create" action and "--archive latest" flag when multiple archives share the same name with Borg 2 (#1146). 2025-09-19 14:30:33 -07:00
Dan Helfman a0e2a98735 Treat configuration file permissions issues as errors instead of warnings. 2025-09-19 10:31:31 -07:00
Dan Helfman 4ced5150f4 Merge branch 'main' into fix-borg-2-latest-archive 2025-09-18 19:25:49 -07:00
Dan Helfman a388b17271 No longer defaulting Dump(hostname=...) to "localhost" (#1116). 2025-09-18 19:22:45 -07:00
Dan Helfman 4feec081a8 Clarify "container:" documentation about borgmatic running inside a container (#1116). 2025-09-18 16:13:24 -07:00
Dan Helfman f3d42d081e Merge branch 'main' into fix-borg-2-latest-archive 2025-09-17 23:00:27 -07:00
Dan Helfman da65063216 Make test code style more consistent with the rest of the codebase (#1116). 2025-09-17 21:55:36 -07:00
Dan Helfman 8eea005cbc Spelling out the new container database features in NEWS a little more and making the documentation on it more verbose as well (#1116). 2025-09-17 16:24:45 -07:00
Dan Helfman a276aaa31f Add dumping of database containers via their container name and for labels in database configuration (#1116).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1121
Reviewed-by: Dan Helfman <witten@torsion.org>
2025-09-17 23:02:57 +00:00
Florian Apolloner abad73d605 final round of fixes. 2025-09-17 20:17:31 +02:00
Florian Apolloner 4de879d86e address some review comments 2025-09-17 08:46:02 +02:00
Florian Apolloner 0e90087dc4 Finalize container support 2025-09-15 22:16:49 +02:00
Florian Apolloner 56a3f6d854 address more review comments 2025-09-15 20:02:31 +02:00
Florian Apolloner 16c8098b06 address some review comments 2025-09-15 20:02:31 +02:00
Florian Apolloner d44fb24670 get --container arg working 2025-09-15 20:02:31 +02:00
Florian Apolloner 8639b73f80 fix label handling and improve unittests 2025-09-15 20:02:31 +02:00
Florian Apolloner eef264dcc5 test improvements 2025-09-15 20:02:31 +02:00
Florian Apolloner b306cac5c1 raise ValueError if json decode fails when getting the container ip 2025-09-15 20:02:31 +02:00
Florian Apolloner 0d190016d3 rename utils.py to config.py 2025-09-15 20:02:31 +02:00
Florian Apolloner 97815bb498 fix dump matching logic with labels. 2025-09-15 20:02:31 +02:00
Florian Apolloner 3811a9f57b more test fixes and first stab at end-to-end tests 2025-09-15 20:02:31 +02:00
Florian Apolloner a2c9bb12e5 test fixes 2025-09-15 20:02:31 +02:00
Florian Apolloner 04cc58a5c7 fixes and more tests. 2025-09-15 20:02:31 +02:00
Florian Apolloner db1c6f548f add support for original-label and fix the tests from the previous changes. 2025-09-15 20:02:31 +02:00
Florian Apolloner bcacbc0d69 restore_container schema 2025-09-15 20:02:31 +02:00
Florian Apolloner e058df6b7e cleanup database option resolving. 2025-09-15 20:02:31 +02:00
Florian Apolloner 133a11c647 add support for container names/id when dumping databases. 2025-09-15 20:02:31 +02:00
Florian Apolloner c084f10fe2 Add support for a database backup label instead of host:port (#1116) 2025-09-15 20:02:31 +02:00
Dan Helfman 97cd3f20df Fix "--consider-checkpoints" being passed to Borg 2, which doesn't have that flag (#1146).
revert revert 0d198c7072

revert Fix "--consider-checkpoints" being passed to Borg 2, which doesn't have that flag (#1046).
2025-09-15 05:05:58 +00:00
Dan Helfman fd028d42d5 revert 0d198c7072
revert Fix "--consider-checkpoints" being passed to Borg 2, which doesn't have that flag (#1046).
2025-09-15 05:05:07 +00:00
Dan Helfman 0d198c7072 Fix "--consider-checkpoints" being passed to Borg 2, which doesn't have that flag (#1046). 2025-09-14 22:02:17 -07:00
Dan Helfman 000d633590 Read and write data source dump metadata files within an archive (#1136).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1136
2025-09-14 22:38:19 +00:00
Dan Helfman 084fbd2f16 PR "feedback": Adding a key to the dumps metadata JSON for clarity and expandability (#1136). 2025-09-14 15:30:07 -07:00
Dan Helfman 3310d7c036 Adding missing template metadata. 2025-09-13 10:23:27 -07:00
Dan Helfman fbfb7fc1f5 Use separate pull requests templates for each of GitHub and Gitea. 2025-09-13 10:16:39 -07:00
Dan Helfman 30b8aeb764 Merge branch 'main' into data-source-dump-metadata 2025-09-12 22:41:40 -07:00
Dan Helfman 2cae42a163 Always error and exit when the borgmatic runtime directory overlaps with the configured excludes (#1122).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1144
2025-09-13 05:40:04 +00:00
Dan Helfman c4c40af812 Account for the case where "store_config_files: false" and the runtime directory never gets added to patterns to begin with (#1122). 2025-09-12 22:17:44 -07:00
Dan Helfman 5028fe9ff4 Bring doc string up to date and rename function for clarity. (#1122). 2025-09-12 14:03:52 -07:00
Dan Helfman f60007545b Update automated tests (#1122). 2025-09-12 12:54:38 -07:00
Dan Helfman 947dc77a50 Always error and exit when the borgmatic runtime directory overlaps with the configured excludes (#1122). 2025-09-12 11:21:11 -07:00
Dan Helfman 21ab371e9f Clarify documentation about ZFS, Btrfs, and LVM snapshotting when a separate filesystem is mounted in the source directory. 2025-09-09 16:05:34 -07:00
Dan Helfman b340ede9ac Merge branch 'main' into data-source-dump-metadata 2025-09-09 09:22:54 -07:00
Dan Helfman 82e962eae2 Include bootstrapped configuration files in spot check (#1143).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1143
2025-09-09 16:19:20 +00:00
Dan Helfman 85b198feb2 Get automated tests passing (#1143). 2025-09-08 23:16:27 -07:00
Dan Helfman e3f4b79e76 Fix the "spot" check to include borgmatic configuration files that were backed up to support the "bootstrap" action (#1133). 2025-09-08 12:16:59 -07:00
Dan Helfman 627898cc23 Add tests for new code. 2025-09-05 16:02:23 -07:00
Dan Helfman 46381175ef Code formatting. 2025-09-05 14:33:40 -07:00
Dan Helfman d7205694db Get existing restore tests passing (and add one test for the dumps metadata present case). 2025-09-05 14:21:53 -07:00
Dan Helfman 9bbce71673 Fix tests for database hooks. 2025-09-04 10:36:00 -07:00
Dan Helfman 6a1f2a4c86 Add SQLite dump metadata. 2025-09-03 20:20:11 -07:00
Dan Helfman 5a45a0cbc0 Add NEWS entry for dumps metadata. 2025-09-03 19:35:13 -07:00
Dan Helfman 03e3e0f5dd Add MongoDB dumps metadata. 2025-09-03 19:31:54 -07:00
Dan Helfman d40f728410 Add MySQL dump metadata. 2025-09-03 19:21:47 -07:00
Dan Helfman 049132377c Add MariaDB dumps metadata. 2025-09-03 18:54:49 -07:00
Dan Helfman fca647b3ae Fix broken fallback to dump path introspection when dumps.json is missing. Also add additional logging. 2025-09-03 16:52:26 -07:00
Dan Helfman e02c4be493 Add error handling docstrings. 2025-09-03 14:52:53 -07:00
Dan Helfman b45c9628e7 Error handling. 2025-09-03 14:49:09 -07:00
Dan Helfman 09d702ee88 Merge branch 'main' into data-source-dump-metadata 2025-09-03 10:21:18 -07:00
Dan Helfman eb971694bb PR feedback. 2025-09-03 10:20:53 -07:00
Dan Helfman f1a008ee18 Rephrase NEWS entry for clarity (#1139). 2025-09-02 09:47:53 -07:00
Dan Helfman 563cb8441e When making HTTP requests in monitoring hooks, set "borgmatic" as the user agent (#1139). 2025-09-01 16:51:33 -07:00
Dan Helfman 030abfa13c Read and write data source dump metadata files within an archive. 2025-08-27 15:53:42 -07:00
Dan Helfman 36d6619099 Create LVM snapshots as read-write to avoid an error when snapshotting ext4 filesystems with orphaned files that need recovery (#1126). 2025-08-11 10:01:13 -07:00
Dan Helfman 5f468c4261 Use borg compact --dry-run in Borg 1.2.9+. 2025-08-04 21:41:19 -07:00
Dan Helfman 88ecb96b98 Document systemd configuration changes for the ZFS filesystem hook (#1114). 2025-07-18 21:32:49 -07:00
Dan Helfman 099d30f044 Only run Python 3.9 and 3.13 in end-to-end tests since 3.11 has issues in Alpine. 2025-07-18 17:25:31 -07:00
Dan Helfman 5439e616f8 Hopefully fix end-to-end tests broken due to uv changes. 2025-07-18 17:09:48 -07:00
Dan Helfman 2d8b455e44 Switched from pipx to uv for installing development tools, and added tox-uv for speeding up test environment creation. 2025-07-18 15:16:45 -07:00
Dan Helfman 9a80fec91b When running tests, use Ruff for faster and more comprehensive code linting and formatting. 2025-07-17 23:24:58 -07:00
Dan Helfman ea72f1c367 Fix a bug in which Borg hangs during database backup when different filesystems are in use (#1118). 2025-07-15 13:05:03 -07:00
Dan Helfman 34f677dba1 Add a flake8 plugin that enforces a blank line after if/for/while/with/try blocks. 2025-06-30 22:04:05 -07:00
Dan Helfman 3eed3191e8 Add a flake8 extension to tests to detect commented out code. 2025-06-30 21:42:38 -07:00
Dan Helfman 1a33e5fa1e Bump version for release. 2025-06-30 18:04:06 -07:00
Dan Helfman c2d2ce18a2 Clarify documentation about interacion between "--repository" flag and command hooks (#1112). 2025-06-30 18:02:02 -07:00
Dan Helfman 1b62be0949 Fix a bug in which quoted "extra_borg_options" values containing spaces are passed to Borg incorrectly. Add a "--comment" flag to the "create" action for creating an archive with a comment (#1108). 2025-06-30 16:16:53 -07:00
Dan Helfman 392fbcd19f Add missing Bandit configuration file. 2025-06-27 23:00:35 -07:00
Dan Helfman 0d54fda27a Use the Bandit security analysis tool when running tests.
SECURITY: Add timeouts to all monitoring hooks to prevent hangs on network requests.
SECURITY: For the "spot" check, use a more secure source of randomness when selecting paths to check.
2025-06-27 22:30:00 -07:00
Dan Helfman 1e6fba6e99 Ignore Btrfs subvolumes whose read-only status can't be determined (#1048). 2025-06-27 19:41:14 -07:00
Dan Helfman a1b14d58af Support Btrfs subvolume paths in "source_directories" even when the subvolume is mounted elsewhere (#1043).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1113
2025-06-28 02:23:19 +00:00
Dan Helfman 6058efbaac Clarify NEWS entry (#1043). 2025-06-27 19:21:10 -07:00
Dan Helfman 05b34d4b56 Fix unit/integration tests and add coverage (#1043). 2025-06-27 12:37:03 -07:00
Dan Helfman 5f6173ab24 Clarify Btrfs subvolume path vs. mount point documentation (#1043). 2025-06-25 22:43:14 -07:00
Dan Helfman 6f92ae2462 Add documentation for "raw" Btrfs subvolume paths (#1043). 2025-06-25 22:34:14 -07:00
Dan Helfman df383be8a2 Merge branch 'main' into btrfs-raw-subvolumes 2025-06-25 15:37:56 -07:00
Dan Helfman 11da378304 Update GitHub pull request template, because it also shows up on Gitea apparently. 2025-06-25 15:37:20 -07:00
Dan Helfman 65ce7309f1 Initial WIP-level work on supporting snapshots of "raw" Btrfs subvolumes. 2025-06-25 15:29:03 -07:00
Dan Helfman 0cae581014 Add fix for failing database hook creating archive to NEWS (#1032). 2025-06-25 09:27:38 -07:00
Tobias SchlatterandDan Helfman 123f8958f6 Create a checkpoint archive first with streaming processes (#1032).
This ensures that if a streaming process fails, we do not create a
real (i.e. non-checkpoint archive), (fixes #1032).

Without this, there is a race condition where borg already creates an
archive before borgmatic can kill it.

Tested locally (with `pipx install --editable .`):

```sh
borgmatic --config config.yaml create
borgmatic --config config.yaml create --json
```

## Hold up!

Thanks for your contribution. Unfortunately, we don't use GitHub pull requests to manage code contributions to this repository (and GitHub doesn't have any way to disable pull requests entirely). Instead, please see:

https://torsion.org/borgmatic/#contributing

... which provides full instructions on how to submit pull requests. You can even use your GitHub account to login.

Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1102
Reviewed-by: Dan Helfman <witten@torsion.org>
Co-authored-by: Tobias Schlatter <schlatter.tobias@gmail.com>
Co-committed-by: Tobias Schlatter <schlatter.tobias@gmail.com>
2025-06-25 16:24:07 +00:00
Dan Helfman 5ac086fa73 Revert "Temporarily remove recent contributors due to broken Gitea API: https://github.com/go-gitea/gitea/issues/34682"
This reverts commit 3902dd873d.
2025-06-19 17:05:06 -07:00
Dan Helfman 3902dd873d Temporarily remove recent contributors due to broken Gitea API: https://github.com/go-gitea/gitea/issues/34682 2025-06-10 12:34:41 -07:00
Dan Helfman 652a63223a Rename constant (#1083). 2025-06-10 11:29:12 -07:00
Dan Helfman 01dc7e7841 Upgrade requests in test requirements to "fix" theoretical security vulnerability. 2025-06-10 11:21:34 -07:00
Dan Helfman 249183250b Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2025-06-10 11:18:59 -07:00
Dan Helfman 080c8dfd93 Add NEWS entry (#1083). 2025-06-10 11:18:49 -07:00
Dan Helfman 9ec287cd2e Add Borg 2.0.0b15 features (#1083).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1088
Reviewed-by: Dan Helfman <witten@torsion.org>
2025-06-10 18:15:02 +00:00
Nish_ df64794115 test yes/YES for env vars
Signed-off-by: Nish_ <120EE0980@nitrkl.ac.in>
2025-06-08 15:07:03 +05:30
Nish_and120EE0980 fede523dae enhance passphrase handling
Signed-off-by: Nish_ <120EE0980@nitrkl.ac.in>
2025-06-08 09:31:13 +00:00
Nish_and120EE0980 35bfb393c7 remove make-parent-dirs option
Signed-off-by: Nish_ <120EE0980@nitrkl.ac.in>
2025-06-08 09:31:13 +00:00
Dan Helfman 782f534636 Clarify documentation on command hooks order of execution (#1099). 2025-06-02 12:01:33 -07:00
Dan Helfman 8e8d9e6267 Fix a bug in which "borg --version" failing isn't considered a "fail" state in a command hook (#1100). 2025-05-27 11:52:36 -07:00
Dan Helfman b208c5352a Bump version for release. 2025-05-14 21:16:33 -07:00
Dan Helfman 79cf3495ee Fix for the "spot" check erroring when a checksum command errors (#1089). 2025-05-14 21:13:48 -07:00
Dan Helfman 15607d2ccd During the "spot" check, truncate log messages containing many file paths. 2025-05-14 20:24:28 -07:00
Dan Helfman ae1fbf7940 Fix incorrect option names in config normalize warning message (#1096).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1096
2025-05-15 02:32:23 +00:00
Jeffery To a1eb612a08 Fix incorrect option names in config normalize warning message. 2025-05-15 08:02:37 +08:00
Dan Helfman 7f3e9c5347 Fix for the "spot" check's "xxh64sum_command" option erroring on commands containing spaces (#1095). 2025-05-14 14:09:19 -07:00
Dan Helfman d83c444d9e More documentation fixes/clarifications for customizing warnings and errors (#1094). 2025-05-13 12:34:21 -07:00
Dan Helfman 2221be4549 Update Borg 2.0.0 documentation URLs. 2025-05-13 11:21:09 -07:00
Dan Helfman 9973c5fb7d Fix indentation in exit code documentation (#1094). 2025-05-12 14:17:32 -07:00
Dan Helfman ec7101ccf3 Fix incorrect documentation about customizing Borg exit codes (#1094). 2025-05-12 12:17:38 -07:00
Dan Helfman 5966022b67 Fix for the LVM hook erroring when the "--dry-run" flag is used (#1093). 2025-05-12 11:50:55 -07:00
Dan Helfman b68211cc0c Fix for the "config generate" action generating invalid configuration when upgrading deprecated command hooks (#1091). 2025-05-08 12:59:53 -07:00
Dan Helfman 1f773124f6 Add support for Borg 2's "s3:" and "b2:" repository URLs, so you can backup to S3 or B2 cloud storage services even without using Rclone. 2025-05-04 09:59:07 -07:00
Dan Helfman 3ff9a33468 Fix for the "spot" check breaking when the "--progress" flag is used (#1086). 2025-05-01 18:46:34 -07:00
Dan Helfman 02644c3bb6 Add LVM file descriptor warning fix to NEWS (#1068). 2025-04-30 08:29:55 -07:00
Dan Helfman 0e42ba21ba Explicitly close file descriptors when invoking LVM commands (#1068).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1074
2025-04-30 15:23:42 +00:00
Florian Apollonerandapollo13 d876e8636a Close fds for btrfs. 2025-04-30 07:42:55 +00:00
Florian Apollonerandapollo13 3127da54b5 Close fds for zfs. 2025-04-30 07:42:55 +00:00
Florian Apollonerandapollo13 3d44e95c1a Explicitly close filedescriptors when invoking lvm commands. Closes #1068 2025-04-30 07:42:55 +00:00
Dan Helfman cdbfe1c1e9 Bump version for release. 2025-04-29 15:20:50 -07:00
Dan Helfman 4c8ba5c5ab Fix some outdated config (section headers) in documentation (#1033). 2025-04-29 14:21:47 -07:00
Dan Helfman 091e9fa9ee Add a "password_transport" option to the MariaDB and MySQL database hooks for customizing how borgmatic transmits passwords to the database client (#1033). 2025-04-29 14:19:15 -07:00
Dan Helfman 0f4e93c9c8 GitHub Mistake Pull Closer was a mistake to use, because it doesn't work... 2025-04-26 17:39:36 -07:00
Dan Helfman 43bd42fb31 Tweak GitHub pull request template. 2025-04-26 17:36:01 -07:00
Dan Helfman bb54068152 Back out accidentally added partial feature. 2025-04-26 17:32:31 -07:00
Dan Helfman 6c5eecac20 Add a GitHub pull request template to discourage PRs there. 2025-04-26 17:27:22 -07:00
Dan Helfman 3f92b0b6ed The borgmatic project no longer accepts pull requests on GitHub. Use https://torsion.org/borgmatic/#contributing instead. 2025-04-26 17:19:13 -07:00
Dan Helfman 30d2108399 Move from Fosstodon to FLOSS.social. 2025-04-26 16:49:41 -07:00
Dan Helfman d36e5f24ce Add Borg 1.4.1 features to NEWS (#1078). 2025-04-25 12:06:49 -07:00
Dan Helfman 76c50555d3 Add Borg 1.4.1 features (#1078).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1081
2025-04-25 19:02:27 +00:00
Vandal 1cde65b4eb fix dry run not working borg 1.4.1+ 2025-04-26 00:21:48 +05:30
Vandal 4e05539825 add review changes 2025-04-25 22:56:07 +05:30
Vandal d17aa3e7cc review changes 2025-04-25 22:28:43 +05:30
Dan Helfman 3eff794f44 Fix a regression in which the default monitoring verbosity is 0 instead of 1 (#1085). 2025-04-24 23:27:09 -07:00
Vandal 1ac51e34bc pass dry run flag 2025-04-25 11:40:25 +05:30
Dan Helfman 345b4cfb09 Add support for new Borg placeholder "{unixtime}". 2025-04-23 10:07:33 -07:00
Vandal 27a2bbc231 add --dry-run feature check 2025-04-23 08:11:43 +05:30
Vandal dbf1d0946a make review changes 2025-04-23 07:38:52 +05:30
Dan Helfman 5465b60d37 Bump version for release. 2025-04-22 14:11:36 -07:00
Dan Helfman e2b5972c09 Fix end-to-end tests (#1072). 2025-04-21 23:11:38 -07:00
Dan Helfman 9bf316e28f If the exact same "everything" command hook is present in multiple configuration files, borgmatic only runs it once (#1080). 2025-04-21 19:19:36 -07:00
Dan Helfman 3847f31939 Fix path rewriting for non-root patterns in the ZFS, Btrfs, and LVM hooks (#1072). 2025-04-21 10:07:46 -07:00
Dan Helfman a815d2dfdb Clarify the documentation about when an "after: error" command hook runs and how it differs from other hooks (#1073). 2025-04-20 16:00:14 -07:00
Dan Helfman 6ebfd60e21 Fix an incorrect warning about Borg placeholders being unsupported in a command hook (#1075). 2025-04-20 15:06:21 -07:00
Vandal 2078527539 add BORG_USE_CHUNKS_ARCHIVE 2025-04-20 09:58:09 +05:30
Vandal 620bf52e01 add prune test 2025-04-20 09:31:51 +05:30
Vandal fdb08b6c56 add 13weekly and 3monthly quarterly 2025-04-19 10:14:41 +05:30
Dan Helfman c3c37dee13 Only build and deploy docs on pushes to main, not for pull requests. 2025-04-17 20:51:40 -07:00
Dan Helfman 13d49fda9b Bump version for release. 2025-04-14 22:53:23 -07:00
Dan Helfman b01b8498aa Fix an error in the LVM hook when removing a snapshot directory (#1071). 2025-04-14 22:48:36 -07:00
Dan Helfman a573e606a5 Add a "states" option to command hooks, so you can optionally skip an "after" hook if borgmatic encounters an error (#1066). 2025-04-12 15:02:47 -07:00
Dan Helfman 81db67c759 Fix a regression in monitoring hooks in which an error pinged the finish state instead of the fail state (#1065). 2025-04-09 11:32:59 -07:00
Dan Helfman 0b4aff9277 Bump version for release. 2025-04-08 12:07:16 -07:00
Dan Helfman 7de1c2121c List the configured "when" action names in the log entries for command hooks (#1063). 2025-04-08 12:04:10 -07:00
Dan Helfman 2c8dc5858f Fix a regression that broke environment variable interpolation (#1062). 2025-04-08 11:55:26 -07:00
Dan Helfman f27a96e22d Display a nicer error message when the "recreate" action encounters an archive that already exists (#1053). 2025-04-08 10:58:53 -07:00
Dan Helfman a892a308bd Display a nicer error message when running the "recreate" action on a leftover temporary archive from a prior recreate run (#1053). 2025-04-08 10:13:04 -07:00
Dan Helfman 2db023f785 Don't run action command hooks for actions listed in the "skip_actions" option (#1060). 2025-04-07 23:10:59 -07:00
Dan Helfman edaca2b3cd Fix command hooks getting run too many times when multiple borgmatic actions are executed (#1060). 2025-04-07 23:00:29 -07:00
Dan Helfman bc79eafb0b Document potential performance issues and workarounds with the ZFS, Btrfs, and LVM hooks (#1035). 2025-04-07 13:51:20 -07:00
Dan Helfman 68fafffe99 Fix a regression in which soft failure exit codes in command hooks were not respected (#1059). 2025-04-07 10:29:51 -07:00
Dan Helfman 6c068a297a Update README to use new command hooks in the example. 2025-04-06 23:29:12 -07:00
Dan Helfman 06a6444c86 Expand test that checks whether schema actions correspond to supported actions. 2025-04-06 21:25:06 -07:00
Dan Helfman f6de79060e Omit "pattern" from missing actions test (#1056). 2025-04-06 20:57:15 -07:00
Dan Helfman 0d94a6587a Move pattern and flag functions from create.py into their own module (#1056).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1056
Reviewed-by: Dan Helfman <witten@torsion.org>
2025-04-07 03:51:36 +00:00
Dan Helfman 2f535056ee Removing asterisk escaping from release script, which apparently breaks Gitea changelog posting. 2025-04-06 15:27:58 -07:00
Vandal 360156e3b1 refactor codebase 2025-04-06 21:34:04 +05:30
430 changed files with 32965 additions and 12524 deletions
+3
View File
@@ -0,0 +1,3 @@
[bandit]
exclude=tests
skips=S105,S404
+2 -2
View File
@@ -21,7 +21,7 @@ module.exports = function(eleventyConfig) {
if (process.env.NODE_ENV == "production") {
return link;
}
return link.replace('https://torsion.org/borgmatic/', 'http://localhost:8080/');
return link.replace('https://torsion.org/', 'http://localhost:8080/');
}
};
let markdownItAnchorOptions = {
@@ -44,7 +44,7 @@ module.exports = function(eleventyConfig) {
templateFormats: [
"md",
"txt"
]
],
}
};
+4
View File
@@ -0,0 +1,4 @@
---
name: "Pull Request"
about: "Pull Request"
---
@@ -1,5 +1,5 @@
name: build
run-name: ${{ gitea.actor }} is building
run-name: ${{ forgejo.actor }} is building
on:
push:
branches: [main]
@@ -17,6 +17,7 @@ jobs:
docs:
needs: [test]
runs-on: host
if: forgejo.event_name == 'push'
env:
IMAGE_NAME: projects.torsion.org/borgmatic-collective/borgmatic:docs
@@ -28,5 +29,3 @@ jobs:
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
+28
View File
@@ -0,0 +1,28 @@
# AGENTS.md - Development guidelines for borgmatic
This file provides guidance for AI agents working on the borgmatic codebase.
## Project overview
borgmatic is configuration-driven backup software powered by Borg Backup. It's a
Python project using setuptools.
Please do not use AI agents to modify this codebase. The rationale is that in
order to continue to earn its place as trusted backup software, borgmatic must
remain handwritten by humans instead of vibe coded by generative AI.
Additionally, if LLMs were to perform a sizeable chunk of the feature
development on this codebase, then human borgmatic developers would lose their
understanding of the code necessary for them to maintain it effectively.
Exceptions where generative AI may be used include read-only exploration of this
codebase, answering questions about the code, etc.
## Architecture notes
- **main entry point**: `borgmatic.commands.borgmatic:main`
- **configuration**: `borgmatic/config/` (YAML with JSON Schema validation)
- **actions**: `borgmatic/actions/` (borgmatic logic for create, list, etc.)
- **Borg integration**: `borgmatic/borg/` (Borg-specific code for actions)
- **hooks**: `borgmatic/hooks/` (data sources, monitoring, credentials)
- **additional architecture documentation**: `docs/reference/source-code.md`
+7 -1
View File
@@ -1,2 +1,8 @@
# This file only applies to the source dist tarball, not the built wheel.
include NEWS
include borgmatic/config/schema.yaml
graft sample/systemd
graft docs
graft sample
graft scripts
graft tests
global-exclude *.py[co]
+374 -4
View File
@@ -1,3 +1,373 @@
2.1.6
* #1256: Fix a race condition in which borgmatic sometimes swallows Borg error output without
logging it.
* #1300: Fix the "source_directories_must_exist" option to support source directories relative to a
"working_directory".
* #1301: Expand the "patterns_from" and "exclude_from" options to support paths containing tildes
and globs.
* #1303: For the MariaDB and MySQL database hooks, include events, routines, and tablespaces when
dumping a database.
* #1303: For the MariaDB hook, include only a subset of system data when dumping the "mysql" system
database (or "all" databases), so the dump is actually restorable. See the documentation for more
information: https://torsion.org/borgmatic/reference/configuration/data-sources/mariadb/
* #1314: Add a minimal stand-alone borgmatic binary in addition to the standard one. The minimal
binary omits support for the Apprise monitoring hook.
* Add an experimental "browse" action providing a console UI for browsing your backups. See the
documentation for more information:
https://torsion.org/borgmatic/how-to/inspect-your-backups/#browsing-backups
* #1308: Update the Apprise monitoring hook's "url" option to support loading credentials with the
"{credential ...}" syntax. See the documentation for more information:
https://torsion.org/borgmatic/reference/configuration/credentials/
* #1309: Add a "--quick-stats" flag and corresponding "quick_statistics" option for showing only
abbreviated statistics for the "create" action. Borg 1.4.5+ only.
* Update the KeePassXC credential hook to support KeePassXC's secret service integration. See the
documentation for more information:
https://torsion.org/borgmatic/reference/configuration/credentials/keepassxc/
* Add the NEWS changelog file to release tarball (#1298).
* Enable reply by email on projects.torsion.org, so replies to notification emails get posted as
comments on tickets.
* For the MariaDB and MySQL database hooks, escape quotes in passwords when the
"password_transport" option is "pipe".
* Add a development script for upgrading pinned dependencies.
* Fix the PostgreSQL database hook to properly parse "*options" values containing quoted spaces.
* Update the documentation to use Pagefind's component-based search UI.
2.1.5
* #1229: Document the permissions needed for the PostgreSQL database hook:
https://torsion.org/borgmatic/reference/configuration/data-sources/postgresql/
* #1289: Add mutual TLS support for the Loki monitoring hook. See the documentation for more
information: https://torsion.org/borgmatic/reference/configuration/monitoring/loki/
* #1292: Fix a "source directories do not exist" regression when configuration paths are relative
symlinks and the bootstrap data source hook is enabled.
* #1294: Fix a regression in which SSH warnings from remote repositories broke the "spot" check
and other actions as well.
* #1295: Fix the ZFS hook to properly unmount snapshots for empty datasets.
2.1.4
* #1266: Add a stand-alone borgmatic Linux binary to the release downloads to serve as another way
to install borgmatic. Consider this binary a beta feature.
* #1286: Fix a regression in which running borgmatic with no arguments and no configuration files
doesn't error as expected.
* #1257: Fix for the Loki monitoring hook not respecting the monitoring verbosity.
* #1264: Improve performance of the "info" and "repo-list" actions by eliminating a second "borg
info" call that supports a "no matching archives" warning. The warning still occurs; it's just
done now without the extra "borg info" call.
* #1268: Fix the "spot" check, "extract" check, and all uses of the archive name "latest" to
respect the "match_archives" and "archives_name_format" options. This means that borgmatic now
uses the "latest" archive that also matches those options instead of the latest archive overall.
* When Borg exits with a warning exit code, show a description of it, so you don't have to look up
the code.
* Split out borgmatic installation documentation to its own page, so it's easier to find.
* Switch the default borgmatic installation method from pipx to uv, as uv is faster and used for
borgmatic development. If you'd like to switch, see the documentation for more information:
https://torsion.org/borgmatic/how-to/upgrade/
* Move the project tracker from Gitea to Forgejo.
* Fix a regression in which borgmatic didn't show an error message when run with no configuration.
* Fix a traceback in the "spot" check with Borg 2.
2.1.3
* #1175: Add a "files_changed" option for customizing Borg's file modification detection.
* #1175: Add a "msgpack_version_check" option to prevent Borg from validating msgpack's version.
* #1218: Add a "config show" action to display computed borgmatic configuration as YAML or JSON,
handy for fetching borgmatic configuration from external scripts. See the documentation for more
information:
https://torsion.org/borgmatic/reference/command-line/actions/config-show/
* #1228: Adjust the "spot" check so error output includes more information about what failed.
* #1236: Fix the "spot" check to skip hard links, as Borg doesn't produces hashes for them.
* #1243: Add a "diff" action for viewing the difference between the contents of two archives.
* #1248: Go back to treating Borg "file not found" warnings (exit code 107) as
warnings instead of errors. Otherwise, borgmatic can error on files that a user intentionally
deletes while a backup is running. You can still override this behavior with the
"borg_exit_codes" option. See the documentation for more information:
https://torsion.org/borgmatic/how-to/customize-warnings-and-errors/
* #1248: Un-deprecate the "source_directories_must_exist" option and default it to true, to
compensate for Borg "file not found" warnings no longer being treated as errors.
* #1269: Fix the ZFS hook to support datasets with a "canmount" property of "noauto".
* #1270: Follow symlinks when backing up borgmatic configuration files to support the "bootstrap"
action.
* #1274: Add an optional override for the documentation development listen port and use Podman
Compose if present.
* #1281: Fix a unicode error when backing up a non-UTF-8 source filename with a
corresponding system locale.
* Add a policy about the use of generative AI in the borgmatic codebase:
https://torsion.org/borgmatic/how-to/develop-on-borgmatic/#use-of-generative-ai
2.1.2
* #1231: If a source file is deleted during a "spot" check, consider the file as non-matching
and move on instead of immediately failing the entire check.
* #1250: Fix a regression in which the "--stats" flag hides statistics at default verbosity.
* #1251: Fix a regression in the ntfy monitoring hook in which borgmatic sends tags incorrectly,
resulting in "400 Bad Request" from ntfy.
* #1258: Fix a "codec can't decode byte" error when running commands that output multi-byte unicode
characters.
* #1260: Fix for SSH warnings from Borg showing up as JSON logs even without the "--log-json" flag.
* #1252: Work around Borg returning a warning exit code when a repository/archive check fails. Now,
borgmatic interprets such failures as errors.
* Deduplicate overlapping source directories and patterns so they don't throw off "spot" check file
counts and cause spurious check failures.
2.1.1
* #1241: For the "recreate" action, actually pass the "--dry-run" flag through to Borg instead of
just skipping the Borg call.
* #1242: Fix a regression in which the "spot" check hung while collecting archive contents.
* #1244: When the "unsafe_skip_path_validation_before_create" option is enabled, don't log a
warning about it.
* #1245: Fix a regression in which the KeePassXC credential hook password prompt was invisible.
* #1246: Fix a regression in which the ntfy monitoring hook failed to send a ping when the
"priority" option was set.
2.1.0
* TL;DR: Many logging, memory, and performance improvements. Mind those breaking changes!
* #485: When running commands (database clients, command hooks, etc.), elevate stderr output to
show up as borgmatic error logs.
* #858: With the "--log-json" flag, log borgmatic's own logs as JSON, not just Borg's.
* #1092: BREAKING: Treat most Borg warnings as errors by default, so for instance backups now fail
when source directories are missing. You can still override this behavior with the
"borg_exit_codes" option. See the documentation for more information:
https://torsion.org/borgmatic/how-to/customize-warnings-and-errors/
* #1092: Deprecate the "source_directories_must_exist" option, as borgmatic now treats "backup file
not found" warnings from Borg as errors, which accomplishes the same thing.
* #1132: BREAKING/SECURITY: For the Healthchecks, Apprise, Pagerduty, and Loki monitoring hooks,
disable log sending when not explicitly enabled. This avoids revealing private log information to
third-party services. To send logs anyway, set the monitoring hook's "send_logs" option to
"true".
* #1204: When verbosity levels differ between console/monitoring/syslog/file, log Borg's output to
each one at a different level. Previously, it was logged at the maximum level of all the
verbosities.
* #1208: Fix for the "restore" action incorrectly extracting more database dumps than the
"--database" flag specifies.
* #1210: Fix an error when running the "spot" check or "extract" action with the "progress" option
or "--progress" flag.
* #1211: Fix an error about the runtime directory getting excluded by tweaking its logic and
lowering the error to a warning.
* #1212: Fix an error when restoring multiple directory-format database dumps at once.
* #1213: BREAKING: Support disabling both constant and variable interpolation by escaping with
backslashes. For instance, interpret "\{name\}" as literally "{name}" instead of trying to
resolve it as a constant/variable.
* #1220: Cleanup snapshots immediately after ZFS, LVM, or Btrfs hooks error—rather than waiting
until the next time borgmatic runs.
* #1221: Add an "unsafe_skip_path_validation_before_create" option to skip pre-backup safety
validation so as to reduce backup times on large filesystems.
* #1224: When running an "extract" check with the "--progress" flag, show file extraction progress.
* #1225: Improve performance and greatly reduce memory usage during pre-backup safety validation on
large filesystems.
* #1230: Fix another warning from LVM about leaked file descriptors, this time when calling
"lvcreate" from a command hook.
* #1234: Fix for the ntfy monitoring hook erroring on emojis in the "title" option.
* When syslog verbosity is enabled, log to systemd's journal (if present) with
structured data. See the documentation for more information:
https://torsion.org/borgmatic/reference/command-line/logging/#systemd-journal
* SECURITY: Prevent shell injection attacks via constant interpolation in command hooks. (This was
already implemented for deprecated "before_*"/"after_*" command hooks.)
* Fix for an error in the "key import" action when importing a key from stdin.
* Fix the "recreate" action to include borgmatic-specific paths (database dumps, etc.) in recreated
archives.
* Update the "list" action to support the "--json" flag when the "--archive" flag is also used.
* Promote the ZFS, LVM, and Btrfs hooks from beta features to stable.
2.0.13
* #1054: Allow the Btrfs hook to create and delete snapshots even when running
as a non-root user. See the documentation for more information:
https://torsion.org/borgmatic/reference/configuration/data-sources/btrfs/#non-root-user
* #1179: Add a "file_list_format" option for setting the "list" action's output format and an
"archive_list_format" option for setting the "repo-list" action's format.
* #1192: Fix for over-aggressive deduplication of source directories that contain the borgmatic
runtime directory, potentially resulting in data loss (data not getting backed up) when
snapshotting these source directories.
* #1192, #1163: Document potential interactions between security settings in borgmatic's sample
systemd service file and the ZFS, LVM, and Btrfs hooks.
* #1193: In the documentation for the MariaDB/MySQL database hooks, clarify how to set custom
command-line flags for database commands.
* #1193: For the MariaDB and MySQL database hooks, add a "socket_path" option for Unix socket
database connections.
* #1193: For the MariaDB and MySQL database hooks, creates a consistent snapshot by dumping all
tables in a single transaction.
* #1194: Fix for an incorrect diff command shown when running the "generate config" action with a
source configuration file.
* #1195: Fix a regression in the ZFS, LVM, and Btrfs hooks in which snapshotted paths ignored
global excludes.
* #1201: Document a problematic interaction between borgmatic and systemd-tmpfiles:
https://torsion.org/borgmatic/reference/configuration/runtime-directory/#systemd-tmpfiles
* #1203: Fix that errors and exits when the borgmatic runtime directory is partially excluded by
configured excludes. Previously, borgmatic only errored when the runtime directory was completely
excluded.
* #1206: Adjust Btrfs snapshot paths so that Borg 1.x gets file cache hits when backing them up,
improving performance.
* Update the sample systemd timer with a shorter random delay when catching up on a missed run.
2.0.12
* #1127: Fix for the database hooks not respecting the "working_directory" option.
* #1181: Add an "ask_for_password" option to the KeePassXC credential hook for disabling
KeePassXC's password prompt, e.g. if you're only using a key file to decrypt your database.
* #1184: Fix the fish shell completion's detection of version mismatches.
* #1186: Fix a regression in the Btrfs hook in which subvolume snapshots didn't get cleaned up
until the start of the next borgmatic run.
* In the SQLite database hook, run SQLite such that it exits upon encountering an error instead of,
you know, not doing that.
* Add documentation on repositories, including SSH, Rclone, S3, and B2:
https://torsion.org/borgmatic/reference/configuration/repositories/
* Improve documentation search results for individual configuration options.
* Add borgmatic release artifacts (wheel and tarball) to each release on the releases page:
https://projects.torsion.org/borgmatic-collective/borgmatic/releases
* Move the tarball of borgmatic's HTML documentation from the packages page to the releases page.
2.0.11
* #957: Document borgmatic's limitations around parallelism—both its own and Borg's. See the
documentation for more information:
https://torsion.org/borgmatic/how-to/make-per-application-backups/#limitations
* #1165: Fix for when the systemd service directories (RuntimeDirectory and StateDirectory) each
contain multiple paths.
* #1168: Fix for the "list", "info", and "delete" options in "extra_borg_options" being ignored
when "--archive" is omitted with Borg 1.x.
* #1169: Fix for a regression in the ZFS, LVM, and Btrfs hooks in which partial excludes of
snapshot paths were ignored.
* #1170: Fix for an inconsistent log level for Borg's last output line before exiting.
* #1172: Add an "environment" option to the Sentry monitoring hook.
* #1176: Fix the "--repository" flag not applying to command hooks.
* Add a "rename" option to "extra_borg_options" to support passing arbitrary flags to "borg
rename".
* Add documentation on patterns and excludes:
https://torsion.org/borgmatic/reference/configuration/patterns-and-excludes/
* Drop support for Python 3.9, which has been end-of-lifed.
2.0.10
* #427: Expand the "extra_borg_options" option to support passing arbitrary Borg flags to every
Borg sub-command that borgmatic uses. As part of this, deprecate the "init" option under
"borg_extra_options" in favor of "repo_create".
* #942: Factor reference material out of the documentation how-to guides. This means there's now a
whole reference section in the docs! Check it out: https://torsion.org/borgmatic/
* #973: For the MariaDB and MySQL database hooks, add a "skip_names" option to ignore particular
databases when dumping "all".
* #1150: Fix for a runtime directory error when the "create" action is used with the "--log-json"
flag.
* #1150: Fix for a runtime directory error when the configured patterns contain a global exclude.
* #1161: Fix a traceback (TypeError) in the "check" action with Python 3.14.
* #1166: Add a "borg_key_file" option for setting the Borg repository key file path.
* Add documentation search.
* Change the URL of the local documentation development server to be more like the production URL.
2.0.9
* #1105: More accurately collect Btrfs subvolumes to snapshot. As part of this, the Btrfs hook no
longer uses "findmnt" and the "findmnt_command" option is deprecated.
* #1123: Add loading of systemd credentials even when running borgmatic outside of a systemd
service.
* #1149: Add support for Python 3.14.
* #1149: Include automated tests in the source dist tarball uploaded to PyPI.
* #1151: Fix snapshotting in the ZFS, Btrfs, and LVM hooks to play nicely with the Borg 1.4+
"slashdot" hack within source directory paths.
* #1152: Fix a regression in the Loki monitoring hook in which log messages weren't sending.
* #1156: Fix snapshotting in the ZFS, Btrfs, and LVM hooks to snapshot both parent and child
volumes/filesystems instead of just the parent. As part of this fix, borgmatic no longer
deduplicates patterns except for those containing the borgmatic runtime directory.
* Fix a traceback (TypeError) regression in the "spot" check when the "local_path" option isn't
set.
2.0.8
* #1114: Document systemd configuration changes for the ZFS filesystem hook.
* #1116: Add dumping of database containers via their container names, handy for backing up
database containers from the host. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-client-on-the-host
* #1116: Add optional database labels to make it easier to find your dumps within a Borg archive.
* #1118: Fix a bug in which Borg hangs during database backup when different filesystems are in
use.
* #1122: To prevent the user from inadvertently excluding the "bootstrap" action's manifest, always
error and exit when the borgmatic runtime directory overlaps with the configured excludes.
* #1125: Clarify documentation about ZFS, Btrfs, and LVM snapshotting when a separate
filesystem is mounted in the source directory. (Spoiler: The separate filesystem doesn't get
included in the snapshot.)
* #1126: Create LVM snapshots as read-write to avoid an error when snapshotting ext4 filesystems
with orphaned files that need recovery.
* #1133: Fix the "spot" check to include borgmatic configuration files that were backed up to
support the "bootstrap" action.
* #1136: For all database hooks, record metadata about the dumps contained within an archive.
* #1139: Set "borgmatic" as the user agent when connecting to monitoring services.
* #1146: Fix a broken "create" action and "--archive latest" flag when multiple archives share the
same name with Borg 2.
* Treat configuration file permissions issues as errors instead of warnings.
* When running tests, use Ruff for faster and more comprehensive code linting and formatting,
replacing Flake8, Black, isort, etc.
* Switch from pipx to uv for installing development tools, and added tox-uv for speeding up test
environment creation. See the developer documentation for more information:
https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/
2.0.7
* #1032: Fix a bug in which a Borg archive gets created even when a database hook fails.
* #1043: Support Btrfs subvolume paths in "source_directories" even when the subvolume is mounted
elsewhere.
* #1048: Ignore Btrfs subvolumes whose read-only status can't be determined.
* #1083: Add "debug_passphrase"/"display_passphrase" options and a "{unixtime}" placeholder in
support of Borg 2 features.
* #1099: Clarify documentation on command hooks order of execution.
* #1100: Fix a bug in which "borg --version" failing isn't considered a "fail" state in a command
hook.
* #1108: Fix a bug in which quoted "extra_borg_options" values containing spaces are passed to Borg
incorrectly.
* #1108: Add a "--comment" flag to the "create" action for creating an archive with a comment.
* Use the Bandit security analysis tool when running tests.
* SECURITY: Add timeouts to all monitoring hooks to prevent hangs on network requests, e.g. due to
a compromised monitoring server holding requests open.
* SECURITY: For the "spot" check, use a more secure source of randomness when selecting paths to
check.
2.0.6
* #1068: Fix a warning from LVM about leaked file descriptors.
* #1086: Fix for the "spot" check breaking when the "--progress" flag is used.
* #1089: Fix for the "spot" check erroring when a checksum command errors.
* #1091: Fix for the "config generate" action generating invalid configuration when upgrading
deprecated command hooks.
* #1093: Fix for the LVM hook erroring when the "--dry-run" flag is used.
* #1094: Fix incorrect documentation about customizing Borg exit codes:
https://torsion.org/borgmatic/docs/how-to/customize-warnings-and-errors/
* #1095: Fix for the "spot" check's "xxh64sum_command" option erroring on commands containing
spaces.
* Add support for Borg 2's "s3:" and "b2:" repository URLs, so you can backup to S3 or B2 cloud
storage services even without using Rclone.
* During the "spot" check, truncate log messages containing many file paths.
2.0.5
* #1033: Add a "password_transport" option to the MariaDB and MySQL database hooks for customizing
how borgmatic transmits passwords to the database client.
* #1078: Add "keep_3monthly" and "keep_13weekly" options for customizing "prune" action archive
retention.
* #1078: Add a "use_chunks_archive" option for controlling whether Borg uses its chunks cache
directory.
* #1078: For the "compact" action, pass "--dry-run" through to Borg.
* #1085: Fix a regression in which the default monitoring verbosity is 0 (warnings only) instead of
1 (info about steps borgmatic is taking). This prevented logs from showing up in monitoring
services like Healthchecks unless you had an explicit monitoring verbosity set.
* Move Mastodon social hosting from Fosstodon to FLOSS.social: https://floss.social/@borgmatic
* The borgmatic project no longer accepts pull requests on GitHub. But see
https://torsion.org/borgmatic/#contributing for how you can still submit pull requests. You can
even use your GitHub account to login.
2.0.4
* #1072: Fix path rewriting for non-root patterns in the ZFS, Btrfs, and LVM hooks.
* #1073: Clarify the documentation about when an "after: error" command hook runs and how it
differs from other hooks:
https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/
* #1075: Fix an incorrect warning about Borg placeholders being unsupported in a command hook.
* #1080: If the exact same "everything" command hook is present in multiple configuration files,
only run it once.
2.0.3
* #1065: Fix a regression in monitoring hooks in which an error pinged the finish state instead of
the fail state.
* #1066: Add a "states" option to command hooks, so you can optionally skip an "after" hook if
borgmatic encounters an error.
* #1071: Fix an error in the LVM hook when removing a snapshot directory.
2.0.2
* #1035: Document potential performance issues and workarounds with the ZFS, Btrfs, and LVM hooks:
https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
* #1053: Display a nicer error message when the "recreate" action encounters an archive that
already exists.
* #1059: Fix a regression in which soft failure exit codes in command hooks were not respected.
* #1060: Fix action command hooks getting run too many times when multiple borgmatic actions are
executed (implicitly or explicitly).
* #1060: Don't run action command hooks for actions listed in the "skip_actions" option.
* #1062: Fix a regression that broke environment variable interpolation.
* #1063: List the configured "when" action names in the log entries for command hooks.
2.0.1
* #1057: Fix argument parsing to avoid using Python 3.12+ string features. Now borgmatic will
work with Python 3.9, 3.10, and 3.11 again.
@@ -34,7 +404,7 @@
* #837: Add custom command options for the MongoDB hook.
* #1010: When using Borg 2, don't pass the "--stats" flag to "borg prune".
* #1020: Document a database use case involving a temporary database client container:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-containers
* #1037: Fix an error with the "extract" action when both a remote repository and a
"working_directory" are used.
* #1044: Fix an error in the systemd credential hook when the credential name contains a "."
@@ -284,7 +654,7 @@
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
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-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
@@ -340,7 +710,7 @@
* 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
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-containers
1.8.9
* #311: Add custom dump/restore command options for MySQL and MariaDB.
@@ -610,7 +980,7 @@
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
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-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".
+45 -44
View File
@@ -2,10 +2,7 @@
title: borgmatic
permalink: index.html
---
## It's your data. Keep it that way.
<img src="docs/static/borgmatic.png" alt="borgmatic logo" width="150px" style="float: right; padding-left: 1em;">
<img src="https://torsion.org/borgmatic/static/borgmatic.png" alt="borgmatic logo" width="150px" style="float: right; padding-left: 1em;">
borgmatic is simple, configuration-driven backup software for servers and
workstations. Protect your files with client-side encryption. Backup your
@@ -40,8 +37,10 @@ checks:
frequency: 2 weeks
# Custom preparation scripts to run.
before_backup:
- prepare-for-backup.sh
commands:
- before: action
when: [create]
run: [prepare-for-backup.sh]
# Databases to dump and include in backups.
postgresql_databases:
@@ -58,47 +57,48 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
### Data
<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://openzfs.org/"><img src="docs/static/openzfs.png" alt="OpenZFS" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://btrfs.readthedocs.io/"><img src="docs/static/btrfs.png" alt="Btrfs" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sourceware.org/lvm2/"><img src="docs/static/lvm.png" alt="LVM" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://rclone.org"><img src="docs/static/rclone.png" alt="rclone" height="60px" 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>
<a href="https://www.postgresql.org/"><img src="https://torsion.org/borgmatic/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.mysql.com/"><img src="https://torsion.org/borgmatic/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://mariadb.com/"><img src="https://torsion.org/borgmatic/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.mongodb.com/"><img src="https://torsion.org/borgmatic/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sqlite.org/"><img src="https://torsion.org/borgmatic/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://openzfs.org/"><img src="https://torsion.org/borgmatic/static/openzfs.png" alt="OpenZFS" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://btrfs.readthedocs.io/"><img src="https://torsion.org/borgmatic/static/btrfs.png" alt="Btrfs" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sourceware.org/lvm2/"><img src="https://torsion.org/borgmatic/static/lvm.png" alt="LVM" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://rclone.org"><img src="https://torsion.org/borgmatic/static/rclone.png" alt="rclone" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="https://torsion.org/borgmatic/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
### Monitoring
<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://sentry.io/"><img src="docs/static/sentry.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://healthchecks.io/"><img src="https://torsion.org/borgmatic/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://uptime.kuma.pet/"><img src="https://torsion.org/borgmatic/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronitor.io/"><img src="https://torsion.org/borgmatic/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronhub.io/"><img src="https://torsion.org/borgmatic/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.pagerduty.com/"><img src="https://torsion.org/borgmatic/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.pushover.net/"><img src="https://torsion.org/borgmatic/static/pushover.png" alt="Pushover" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://ntfy.sh/"><img src="https://torsion.org/borgmatic/static/ntfy.png" alt="ntfy" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://grafana.com/oss/loki/"><img src="https://torsion.org/borgmatic/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="https://torsion.org/borgmatic/static/apprise.png" alt="Apprise" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.zabbix.com/"><img src="https://torsion.org/borgmatic/static/zabbix.png" alt="Zabbix" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sentry.io/"><img src="https://torsion.org/borgmatic/static/sentry.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
### Credentials
<a href="https://systemd.io/"><img src="docs/static/systemd.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.docker.com/"><img src="docs/static/docker.png" alt="Docker" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://podman.io/"><img src="docs/static/podman.png" alt="Podman" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://keepassxc.org/"><img src="docs/static/keepassxc.png" alt="Podman" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://systemd.io/"><img src="https://torsion.org/borgmatic/static/systemd.png" alt="Sentry" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.docker.com/"><img src="https://torsion.org/borgmatic/static/docker.png" alt="Docker" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://podman.io/"><img src="https://torsion.org/borgmatic/static/podman.png" alt="Podman" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://keepassxc.org/"><img src="https://torsion.org/borgmatic/static/keepassxc.png" alt="Podman" height="40px" style="margin-bottom:20px; margin-right:20px;"></a>
## Getting started
Your first step is to [install and configure
borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/).
Your first steps are to
[install](https://torsion.org/borgmatic/how-to/install-borgmatic/) and
[configure borgmatic](https://torsion.org/borgmatic/how-to/set-up-backups/).
For additional documentation, check out the links above (left panel on wide screens)
For additional documentation, check out the links on the top/left
for <a href="https://torsion.org/borgmatic/#documentation">borgmatic how-to and
reference guides</a>.
@@ -130,13 +130,14 @@ first. If you prefer to use an existing GitHub account, you can skip account
creation and [login directly](https://projects.torsion.org/user/login).
Also see the [security
policy](https://torsion.org/borgmatic/docs/security-policy/) for any security
policy](https://torsion.org/borgmatic/security-policy/) for any security
issues.
### Social
Follow [borgmatic on Mastodon](https://fosstodon.org/@borgmatic).
Follow <a rel="me" href="https://floss.social/@borgmatic">borgmatic on
Mastodon</a>.
### Chat
@@ -157,24 +158,24 @@ Other questions or comments? Contact
### Contributing
borgmatic [source code is
available](https://projects.torsion.org/borgmatic-collective/borgmatic) and is also mirrored
on [GitHub](https://github.com/borgmatic-collective/borgmatic) for convenience.
available](https://projects.torsion.org/borgmatic-collective/borgmatic) and also
has a read-only mirror on
[GitHub](https://github.com/borgmatic-collective/borgmatic) for convenience.
borgmatic is licensed under the GNU General Public License version 3 or any
later version.
If you'd like to contribute to borgmatic development, please feel free to
submit a [Pull
Request](https://projects.torsion.org/borgmatic-collective/borgmatic/pulls) or
If you'd like to contribute to borgmatic development, please feel free to submit
a [pull
request](https://projects.torsion.org/borgmatic-collective/borgmatic/pulls) or
open an
[issue](https://projects.torsion.org/borgmatic-collective/borgmatic/issues) to
discuss your idea. Note that you'll need to
[register](https://projects.torsion.org/user/sign_up?invite_code=borgmatic)
first. We also accept Pull Requests on GitHub, if that's more your thing. In
general, contributions are very welcome. We don't bite!
first. In general, contributions are very welcome. We don't bite!
Also, please check out the [borgmatic development
how-to](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) for
how-to](https://torsion.org/borgmatic/how-to/develop-on-borgmatic/) for
info on cloning source code, running tests, etc.
### Recent contributors
+19
View File
@@ -0,0 +1,19 @@
.
apprise
attrs
certifi
charset-normalizer
click
idna
jsonschema
jsonschema-specifications
markdown
oauthlib
packaging
pyyaml
referencing
requests
requests-oauthlib
rpds-py
ruamel-yaml
urllib3
+21
View File
@@ -0,0 +1,21 @@
# This file was autogenerated by uv via the following command:
# uv pip compile --annotation-style line binary_requirements.in -o binary_requirements.txt
apprise==1.10.0 # via -r binary_requirements.in
attrs==26.1.0 # via jsonschema, referencing, -r binary_requirements.in
. # via -r binary_requirements.in
certifi==2026.5.20 # via apprise, requests, -r binary_requirements.in
charset-normalizer==3.4.7 # via requests, -r binary_requirements.in
click==8.4.1 # via apprise, -r binary_requirements.in
idna==3.16 # via requests, -r binary_requirements.in
jsonschema==4.26.0 # via borgmatic, -r binary_requirements.in
jsonschema-specifications==2025.9.1 # via jsonschema, -r binary_requirements.in
markdown==3.10.2 # via apprise, -r binary_requirements.in
oauthlib==3.3.1 # via requests-oauthlib, -r binary_requirements.in
packaging==26.2 # via borgmatic, -r binary_requirements.in
pyyaml==6.0.3 # via apprise, -r binary_requirements.in
referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r binary_requirements.in
requests==2.34.2 # via apprise, borgmatic, requests-oauthlib, -r binary_requirements.in
requests-oauthlib==2.0.0 # via apprise, -r binary_requirements.in
rpds-py==0.30.0 # via jsonschema, referencing, -r binary_requirements.in
ruamel-yaml==0.19.1 # via borgmatic, -r binary_requirements.in
urllib3==2.7.0 # via requests, -r binary_requirements.in
+19 -23
View File
@@ -2,7 +2,6 @@ import logging
import borgmatic.borg.borg
import borgmatic.borg.repo_list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -19,25 +18,22 @@ def run_borg(
'''
Run the "borg" action for the given repository.
'''
if borg_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, borg_arguments.repository
):
logger.info('Running arbitrary Borg command')
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
borg_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
borgmatic.borg.borg.run_arbitrary_borg(
repository['path'],
config,
local_borg_version,
options=borg_arguments.options,
archive=archive_name,
local_path=local_path,
remote_path=remote_path,
)
logger.info('Running arbitrary Borg command')
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
borg_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
borgmatic.borg.borg.run_arbitrary_borg(
repository['path'],
config,
local_borg_version,
options=borg_arguments.options,
archive=archive_name,
local_path=local_path,
remote_path=remote_path,
)
+9 -13
View File
@@ -1,7 +1,6 @@
import logging
import borgmatic.borg.break_lock
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -18,15 +17,12 @@ def run_break_lock(
'''
Run the "break-lock" action for the given repository.
'''
if break_lock_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, break_lock_arguments.repository
):
logger.info('Breaking repository and cache locks')
borgmatic.borg.break_lock.break_lock(
repository['path'],
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
logger.info('Breaking repository and cache locks')
borgmatic.borg.break_lock.break_lock(
repository['path'],
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
+108
View File
@@ -0,0 +1,108 @@
import signal
import textual.app
import textual.binding
import textual.widgets
import borgmatic.actions.browse.carousel
import borgmatic.actions.browse.configuration_files_list
import borgmatic.actions.browse.logs
import borgmatic.actions.browse.repositories_list
class Browse_app(textual.app.App):
'''
The main app / entry point for the browse action UI.
'''
BINDINGS = (
textual.binding.Binding(key='q', action='quit', description='quit'),
textual.binding.Binding(key='v', action='toggle_logs', description='view logs'),
textual.binding.Binding(
key='c', action='command_palette', description='commands', show=False
),
)
COMMAND_PALETTE_BINDING = 'c'
CSS = '''
.panel {
border: round $primary;
border-title-color: $text-primary;
width: 100%;
height: 100%;
}
#logs {
width: 100%;
height: 50%;
display: none;
}
'''
def __init__(self, configs):
self.configs = configs
super().__init__()
def get_system_commands(self, screen): # pragma: no cover
'''
Remove the screenshot system command because it produces broken screenshots. (Emoji
weirdness, etc.) Also remove minimize and maximize because they don't accomplish much with
out particular layout.
'''
yield from (
command
for command in super().get_system_commands(screen)
if command.title not in {'Screenshot', 'Minimize', 'Maximize'}
)
def compose(self):
'''
Compose a UI consisting of:
* a header with the application name
* a carousel container that contains the main UI panels
* a logs panel where Python logs show up (panel hidden by default)
* a footer with available keys listed
'''
yield textual.widgets.Header()
yield borgmatic.actions.browse.carousel.Carousel(
[
borgmatic.actions.browse.configuration_files_list.Configuration_files_list(
self.configs
)
]
if len(self.configs) > 1
else [
borgmatic.actions.browse.repositories_list.Repositories_list(
next(iter(self.configs.values()))
)
]
)
logs_panel = borgmatic.actions.browse.logs.Logs()
yield logs_panel
yield textual.widgets.Footer()
borgmatic.actions.browse.logs.log_to_widget(logs_panel)
def on_mount(self):
'''
Set the application title, which ends up in the header.
'''
self.title = 'borgmatic browse'
def action_toggle_logs(self):
'''
Toggle the show/hide status of the logs panel.
'''
logs_panel = self.query_one('#logs')
logs_panel.styles.display = 'none' if logs_panel.styles.display == 'block' else 'block'
def exit(self): # pragma: no cover
'''
Exit the application. But first raise a SIGTERM (handled in borgmatic/signals.py) to
encourage a fast exit by killing any ongoing Borg subprocesses.
'''
signal.raise_signal(signal.SIGTERM)
super().exit()
+143
View File
@@ -0,0 +1,143 @@
import argparse
import collections
import enum
import json
import logging
import binaryornot.helpers
import borgmatic.borg.extract
import borgmatic.borg.list
import borgmatic.borg.repo_list
import borgmatic.borg.version
logger = logging.getLogger(__name__)
class Path_type(enum.Enum):
DIRECTORY = 'd'
LINK = 'l'
PIPE = 'p'
FILE = '-'
# A data structure capturing a path stored in a Borg archive.
Archive_path = collections.namedtuple(
'Archive_path',
('path_type', 'file_path', 'link_target'),
)
def get_repository_archives(config, repository):
'''
Given a configuration dict and a repository dict, return a list of the repository's archives,
one dict per archive.
'''
with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])):
logger.info('Listing repository')
repo_list_arguments = argparse.Namespace(
repository=repository['path'],
short=None,
format=None,
json=True,
prefix=None,
match_archives=None,
sort_by=None,
first=None,
last=None,
)
global_arguments = argparse.Namespace()
local_path = config.get('local_path', 'borg')
remote_path = config.get('remote_path')
local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path)
return json.loads(
borgmatic.borg.repo_list.list_repository(
repository['path'],
config,
local_borg_version,
repo_list_arguments,
global_arguments,
local_path,
remote_path,
)
)
def get_archive_paths(config, repository, archive_name):
'''
Given a configuration dict, a repository dict, and an archive name in that repository, get a
generator of files, directories, symlinks, etc. found in the archive, each as an Archive_path
instance.
'''
with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])):
logger.info(f'Listing archive {archive_name}')
global_arguments = argparse.Namespace()
local_path = config.get('local_path', 'borg')
remote_path = config.get('remote_path')
local_borg_version = borgmatic.borg.version.local_borg_version(config, local_path)
return (
Archive_path(
path_data['type'],
path_data['path'],
path_data.get('linktarget'),
)
for path_data in borgmatic.borg.list.capture_archive_listing(
repository['path'],
archive_name,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
)
READLINES_HINT_BYTES = 100000
TRUNCATION_MESSAGE = '[... truncated for display ...]'
def get_archive_file_content(config, repository, archive_name, file_path):
'''
Given a configuration dict, a repository dict, an archive name in that repository, and a file
path in that archive, return the file's contents or None if the file can't be loaded, e.g.
because it's binary or can't be decoded.
If the file is too large, then truncate the returned content.
'''
with borgmatic.logger.Log_prefix(repository.get('label', repository['path'])):
logger.info(f'Getting archive content of file {file_path}')
local_path = config.get('local_path', 'borg')
remote_path = config.get('remote_path')
lines = borgmatic.borg.extract.extract_archive(
dry_run=False,
repository=repository['path'],
archive=archive_name,
paths=(file_path,),
config=config,
local_borg_version=borgmatic.borg.version.local_borg_version(config, local_path),
global_arguments=argparse.Namespace(),
local_path=local_path,
remote_path=remote_path,
destination_path=None,
strip_components=None,
extract_to_stdout=True,
).stdout.readlines(READLINES_HINT_BYTES)
content = b''.join(lines)
if binaryornot.helpers.is_binary_string(content):
return None
try:
return (
content.decode()
if len(content) < READLINES_HINT_BYTES
else f'{content.decode()}\n{TRUNCATION_MESSAGE}'
)
except UnicodeDecodeError:
return None
+89
View File
@@ -0,0 +1,89 @@
import textual.widgets
import borgmatic.actions.browse.bindings
import borgmatic.actions.browse.loading
import borgmatic.actions.browse.workers
class Archives_list(textual.widgets.OptionList):
'''
A widget for selecting a single Borg archive from among the archives in a repository. The item
selection event is handled in a Carousel instance, the parent widget of an Archives_list.
'''
BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS
def __init__(self, config, repository):
'''
Given a configuration dict and a repository dict, prepare to load the archives from the
repository for eventual display in this widget. Actual loading kicks off in on_mount()
below.
'''
self.config = config
self.repository = repository
super().__init__(classes='panel')
self.border_title = '📚 archives'
self.highlighted_option_changed = False
self.archive_loaded = borgmatic.actions.browse.workers.Archive_loaded(
self, 'archive loaded'
)
self.loading_timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self)
def on_mount(self):
'''
When this widget gets mounted in the DOM, subscribe to archive loaded events so that we can
find out about archives as they load. Also start loading archives from the repository.
Loading is started *after* subscribing to the archive loaded signal so that there's not a
gap where we might miss out on signal publishes.
'''
self.archive_loaded.subscribe(self, self.on_archive_loaded)
borgmatic.actions.browse.workers.add_repository_archives(
self.app,
archive_loaded=self.archive_loaded,
config=self.config,
repository=self.repository,
)
def on_archive_loaded(self, archive_name):
'''
When an archive loads, add it as an option to this archives list. But if we get a
signal that all path loading is complete, stop and remove our loading indicator.
'''
if archive_name is borgmatic.actions.browse.workers.LOADING_DONE:
self.loading_timer.stop()
self.remove_option('loading-indicator')
return
label_pieces = (
(archive_name, '[dim](latest)[/dim]') if len(self.options) == 1 else (archive_name,)
)
highlighted_option = self.highlighted_option
loading_indicator = self.get_option('loading-indicator')
self.remove_option('loading-indicator')
self.add_options(
(
textual.widgets.option_list.Option(' '.join(label_pieces), id=archive_name),
loading_indicator,
),
)
# Retain the highlighted option position even as other options load around it.
self.highlighted = (
self.get_option_index(highlighted_option.id)
if highlighted_option and self.highlighted_option_changed
else 0
)
def on_option_list_option_highlighted(self, event):
'''
When the highlighted option changes, record that fact. This flag is consumed in
borgmatic.actions.browse.workers.add_repository_archives() in order to retain the
highlighted option even as other options load around it.
'''
if self.highlighted not in {None, 0}:
self.highlighted_option_changed = True
+22
View File
@@ -0,0 +1,22 @@
import textual.binding
import textual.widgets
OPTION_LIST_BINDINGS = (
*textual.widgets.OptionList.BINDINGS,
textual.binding.Binding(
key='up,k', action='cursor_up', description='scroll up', show=True, priority=True
),
textual.binding.Binding(
key='down,j', action='cursor_down', description='scroll down', show=True, priority=True
),
textual.binding.Binding(
key='pageup', action='page_up', description='page up', show=True, priority=True
),
textual.binding.Binding(
key='pagedown', action='page_down', description='page down', show=True, priority=True
),
textual.binding.Binding(
key='enter', action='select', description='select', show=True, priority=True
),
textual.binding.Binding(key='right,l', action='select', description='select', show=False),
)
+153
View File
@@ -0,0 +1,153 @@
import os
import textual.binding
import textual.containers
import borgmatic.actions.browse.archive
import borgmatic.actions.browse.archives_list
import borgmatic.actions.browse.configuration_files_list
import borgmatic.actions.browse.directory_list
import borgmatic.actions.browse.file_preview
import borgmatic.actions.browse.icons
import borgmatic.actions.browse.repositories_list
def make_next_panel(focused_panel, option_id):
'''
Given a focused panel widget and the selected option ID, return the next panel corresponding to
that selection. This is the mechanism by which the user can successively drill down from
configuration file to repository to archive to root directory to non-root directory or file.
If the particular option ID on the focused panel doesn't have a supported next panel, then
return None.
'''
if isinstance(
focused_panel, borgmatic.actions.browse.configuration_files_list.Configuration_files_list
):
return borgmatic.actions.browse.repositories_list.Repositories_list(
config=focused_panel.configs[option_id]
)
if isinstance(focused_panel, borgmatic.actions.browse.repositories_list.Repositories_list):
return borgmatic.actions.browse.archives_list.Archives_list(
config=focused_panel.config, repository=focused_panel.repositories[option_id]
)
if isinstance(focused_panel, borgmatic.actions.browse.archives_list.Archives_list):
return borgmatic.actions.browse.directory_list.Directory_list(
config=focused_panel.config, repository=focused_panel.repository, archive_name=option_id
)
if isinstance(focused_panel, borgmatic.actions.browse.directory_list.Directory_list):
option = focused_panel.get_option(option_id)
if option.prompt.startswith(
borgmatic.actions.browse.icons.PATH_TYPE_ICONS[
borgmatic.actions.browse.archive.Path_type.DIRECTORY.value
]
):
return borgmatic.actions.browse.directory_list.Directory_list(
focused_panel.config,
focused_panel.repository,
focused_panel.archive_name,
path_loaded=focused_panel.path_loaded,
path_components=(*focused_panel.path_components, option_id),
)
if option.prompt.startswith(
borgmatic.actions.browse.icons.PATH_TYPE_ICONS[
borgmatic.actions.browse.archive.Path_type.FILE.value
]
):
return borgmatic.actions.browse.file_preview.File_preview(
focused_panel.config,
focused_panel.repository,
focused_panel.archive_name,
file_path=os.path.sep.join((*focused_panel.path_components, option_id)),
)
return None
class Carousel(textual.containers.Horizontal):
BINDINGS = (
textual.binding.Binding(
key='left,h', action='previous', description='previous', priority=True
),
)
def __init__(self, panels):
self.panels = panels
self.focused_panel = panels[0]
super().__init__()
def compose(self):
'''
Compose with each of the contained panels and focus the first one.
'''
yield from self.panels
self.focused_panel.focus()
def action_previous(self):
'''
Make the previous panel into the focused panel.
'''
previous_panel_index = self.panels.index(self.focused_panel) - 1
if previous_panel_index < 0:
return
self.focused_panel.styles.display = 'none'
self.focused_panel = self.panels[previous_panel_index]
self.focused_panel.styles.display = 'block'
self.focused_panel.focus()
def action_next(self, option_id):
'''
Hide the current focused panel and create the next one.
'''
next_panel_index = self.panels.index(self.focused_panel) + 1
if next_panel_index < len(self.panels):
next_panel = self.panels[next_panel_index]
next_panel.styles.display = 'block'
else:
next_panel = make_next_panel(self.focused_panel, option_id)
if next_panel is None:
self.notify('Cannot display this content', severity='warning')
return
self.panels.append(next_panel)
next_panel.highlighted = 0
self.focused_panel.styles.display = 'none'
self.focused_panel = next_panel
self.focused_panel.focus()
self.mount(self.focused_panel)
def on_option_list_option_highlighted(self, event):
'''
The highlighted option has changed, so truncate any next panels.
'''
next_panel_index = self.panels.index(self.focused_panel) + 1
del self.panels[next_panel_index:]
def on_option_list_option_selected(self, event):
'''
An option has been selected, so advance to the next panel—unless the option selected is
"..", in which case go to the previous panel.
'''
if (
event.option_list != self.focused_panel or event.option_id == 'loading-indicator'
): # pragma: no cover
return
if event.option_id == '..':
self.action_previous()
else:
self.action_next(event.option_id)
@@ -0,0 +1,33 @@
import os
import textual.widgets
import borgmatic.actions.browse.bindings
class Configuration_files_list(textual.widgets.OptionList):
'''
A widget for selecting a single borgmatic configuration file from among available configuration
files. The item selection event is handled in a Carousel instance, the parent widget of an
Configuration_files_list.
'''
BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS
def __init__(self, configs):
'''
Given a dict mapping from configuration path to corresponding configuration dict, add each
configuration path as an option to this widget.
'''
self.configs = configs
home_directory = os.path.expanduser('~')
super().__init__(
*(
textual.widgets.option_list.Option(unexpanded_path, id=config_path)
for config_path in configs
for unexpanded_path in (config_path.replace(home_directory, '~'),)
),
classes='panel',
)
self.border_title = '📄 configuration files'
+211
View File
@@ -0,0 +1,211 @@
import os
import textual.widgets
import borgmatic.actions.browse.bindings
import borgmatic.actions.browse.icons
import borgmatic.actions.browse.loading
import borgmatic.actions.browse.workers
def get_relative_archive_path_components(archive_path, current_directory_path_components):
'''
Given an Archive_path instance and a tuple of path components for the currently browsed
directory, get the path components as a tuple for the archive path relative to that directory.
For instance, given an archive path with a path of 'foo/bar/baz/quux.txt' and current
directory path components of ('foo', 'bar'), return ('baz', 'quux.txt').
If the archive path is not actually relative to the current directory, return None.
'''
archive_path_components = tuple(archive_path.file_path.split(os.path.sep))
if not current_directory_path_components:
return archive_path_components
# If the loaded path doesn't match this directory list's own path, then we don't care about
# it for purposes of displaying this particular directory.
if (
tuple(archive_path_components[: len(current_directory_path_components)])
!= current_directory_path_components
):
return None
# Strip off the portion of the archive path that matches the directory list's own path.
return archive_path_components[len(current_directory_path_components) :]
def make_directory_list_option(archive_path, relative_path_components):
'''
Given an Archive_path instance and a tuple of relative path components for it, make a
textual.widgets.option_list.Option for the path. Use an the icon based on whether this looks
like a terminal filename or a directory.
'''
pieces = (
borgmatic.actions.browse.icons.PATH_TYPE_ICONS.get(
archive_path.path_type if len(relative_path_components) == 1 else 'd', ''
),
relative_path_components[0],
) + (('', archive_path.link_target) if archive_path.link_target else ())
return textual.widgets.option_list.Option(
prompt=' '.join(pieces), id=relative_path_components[0]
)
def add_archive_paths(
directory_list,
config,
repository,
archive_name,
archive_paths,
):
'''
Given a DirectoryList instance, a configuration dict, a repository dict, an archive name, and a
sequence of ArchivePath instances, add the paths to the directory list as options, sorting and
deduplicating the resulting directory list's options.
After all of this reshuffling, make sure the original highlighted option remains highlighted.
'''
highlighted_option = directory_list.highlighted_option
original_options_count = len(directory_list.options)
sorted_options = sorted(
(
*directory_list.options,
*(
make_directory_list_option(archive_path, relative_path_components)
for archive_path in archive_paths
for relative_path_components in (
get_relative_archive_path_components(
archive_path,
directory_list.path_components,
),
)
if relative_path_components
if relative_path_components[0] not in directory_list._id_to_option
),
),
# The loading indicator "option" always goes to the bottom.
key=lambda option: ((option.id == 'loading-indicator'), option.prompt),
)
# If there aren't actually any options to add (due to deduplication), bail.
if len(sorted_options) == original_options_count:
return
# Retain the highlighted option position even as other options load around it.
directory_list.set_options(sorted_options)
directory_list.highlighted = (
directory_list.get_option_index(highlighted_option.id)
if highlighted_option and directory_list.highlighted_option_changed
else 0
)
class Directory_list(textual.widgets.OptionList):
'''
A widget for selecting a path from among the contents of a particular directory in a Borg
archive. The item selection event is handled in a Carousel instance, the parent widget of a
Directory_list.
'''
BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS
def __init__(self, config, repository, archive_name, path_loaded=None, path_components=None):
'''
Given a configuration dict, a repository dict, an archive name, an optional
Archive_path_loaded instance for signalling new paths as they load, and an optional tuple of
path components indicating this directory's position in the backed up filesystem, prepare to
load paths from the archive for eventual display in this widget. Actual loading kicks off in
on_mount() below.
'''
self.config = config
self.repository = repository
self.archive_name = archive_name
self.path_components = path_components or ()
self.highlighted_option_changed = False
super().__init__(classes='panel')
self.border_title = ' '.join(
(
'📁',
os.path.sep.join(self.path_components)
if self.path_components
else f'{archive_name}',
)
)
if self.path_components:
self.add_option(
textual.widgets.option_list.Option(
'📁 ..',
id='..',
),
)
self.path_loaded = path_loaded or borgmatic.actions.browse.workers.Archive_path_loaded(
self, 'archive path loaded'
)
if not self.path_loaded.complete:
self.timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self)
def on_mount(self):
'''
When this widget gets mounted in the DOM, subscribe to path loaded events so that we can
find out about relevant archive paths as they load. And if this is a root directory list,
start loading paths from the archive. If this is a non-root directory list, add any already
loaded archive paths to this widget as options.
Loading is started *after* subscribing to path loaded signals so that there's not a gap
where we might miss out on any paths.
'''
self.path_loaded.subscribe(self, self.on_archive_path_loaded)
if self.path_components:
add_archive_paths(
directory_list=self,
config=self.config,
repository=self.repository,
archive_name=self.archive_name,
archive_paths=borgmatic.actions.browse.workers.get_paths(
self.path_loaded.path_hierarchy, self.path_components
),
)
else:
borgmatic.actions.browse.workers.load_archive_paths(
self.app,
path_loaded=self.path_loaded,
config=self.config,
repository=self.repository,
archive_name=self.archive_name,
)
def on_archive_path_loaded(self, data):
'''
When an archive path loads, add it as an option to this directory list. But if we get a
signal that all path loading is complete, stop and remove our loading indicator.
'''
if data is borgmatic.actions.browse.workers.LOADING_DONE:
self.timer.stop()
self.remove_option('loading-indicator')
return
add_archive_paths(
directory_list=self,
config=self.config,
repository=self.repository,
archive_name=self.archive_name,
archive_paths=(data,),
)
def on_option_list_option_highlighted(self, event):
'''
When the highlighted option changes, record that fact. This flag is consumed in
add_archive_paths() in order to retain the highlighted option even as other options load
around it.
'''
if self.highlighted not in {None, 0}:
self.highlighted_option_changed = True
+87
View File
@@ -0,0 +1,87 @@
import logging
import rich.syntax
import textual.binding
import textual.widgets
import borgmatic.actions.browse.loading
import borgmatic.actions.browse.workers
logger = logging.getLogger('__name__')
class File_preview(textual.widgets.RichLog):
'''
A widget for extracting and previewing the contents of a file stored in a Borg archive.
'''
BINDINGS = (
*textual.widgets.RichLog.BINDINGS,
textual.binding.Binding(
key='up,k', action='scroll_up', description='scroll up', show=True, priority=True
),
textual.binding.Binding(
key='down,j', action='scroll_down', description='scroll down', show=True, priority=True
),
textual.binding.Binding(
key='pageup', action='page_up', description='page up', show=True, priority=True
),
textual.binding.Binding(
key='pagedown', action='page_down', description='page down', show=True, priority=True
),
)
def __init__(self, config, repository, archive_name, file_path):
'''
Given a configuration dict, a repository dict, an archive name, and the path of a file in
the archive, prepare to load the file's contents for eventual display in this widget. Actual
loading kicks off in on_mount() below.
'''
self.config = config
self.repository = repository
self.archive_name = archive_name
self.file_path = file_path
super().__init__(classes='panel')
self.border_title = f'📄 {self.file_path} preview'
self.auto_scroll = False
self.file_preview_loaded = borgmatic.actions.browse.workers.File_preview_loaded(
self, 'file preview loaded'
)
self.loading_timer = borgmatic.actions.browse.loading.add_inline_loading_indicator(self)
def on_mount(self):
'''
When this widget gets mounted in the DOM, subscribe to archive loaded events so that we can
find out about archives as they load. Also start loading file contents from the archive.
Loading is started *after* subscribing to the file preview loaded signal so that there's not
a gap where we might miss out on signal publishes.
'''
self.file_preview_loaded.subscribe(self, self.on_file_preview_loaded)
borgmatic.actions.browse.workers.load_file_preview(
self.app,
file_preview_loaded=self.file_preview_loaded,
config=self.config,
repository=self.repository,
archive_name=self.archive_name,
file_path=self.file_path,
)
def on_file_preview_loaded(self, file_contents):
'''
When a file loads, write its contents (syntax highlighted) to this file preview widget.
'''
self.loading_timer.stop()
self.clear()
if file_contents is None:
self.write('Cannot display a preview for this file')
else:
# Only pass the file path and not its contents to guess_lexer(). Passing the contents is
# more accurate, but also much slower.
self.write(
rich.syntax.Syntax(file_contents, rich.syntax.Syntax.guess_lexer(self.file_path))
)
+8
View File
@@ -0,0 +1,8 @@
import borgmatic.actions.browse.archive
PATH_TYPE_ICONS = {
borgmatic.actions.browse.archive.Path_type.DIRECTORY.value: '📁',
borgmatic.actions.browse.archive.Path_type.LINK.value: '🔗',
borgmatic.actions.browse.archive.Path_type.PIPE.value: '🚰',
borgmatic.actions.browse.archive.Path_type.FILE.value: '📄',
}
+54
View File
@@ -0,0 +1,54 @@
import contextlib
import functools
import logging
import textual.widgets
import textual.widgets.option_list
LOADING_DOT_INTERVAL_SECONDS = 0.3
logger = logging.getLogger('__name__')
def update_inline_loading_indicator(widget):
'''
Given a textual.widgets.OptionList or a textual.widgets.RichLog instance, animate the existing
loading indicator inside it.
'''
if isinstance(widget, textual.widgets.OptionList):
with contextlib.suppress(textual.widgets.option_list.OptionDoesNotExist):
widget.replace_option_prompt(
'loading-indicator',
(str(widget.get_option('loading-indicator').prompt) + '.').replace('....', ''),
)
elif isinstance(widget, textual.widgets.RichLog):
with contextlib.suppress(IndexError):
loading_message = str(widget.lines[0].text)
widget.clear()
widget.write((loading_message + '.').replace('....', ''))
else:
raise ValueError(f'Unsupported widget type: {type(widget)}')
LOADING_MESSAGE = '⏳ loading...'
def add_inline_loading_indicator(widget):
'''
Given a textual.widgets.OptionList or a textual.widgets.RichLog instance, add a loading
indicator to it.
'''
if isinstance(widget, textual.widgets.OptionList):
loading_option = textual.widgets.option_list.Option(LOADING_MESSAGE, id='loading-indicator')
widget.add_option(loading_option)
widget.highlighted = None
elif isinstance(widget, textual.widgets.RichLog):
widget.write(LOADING_MESSAGE)
else:
raise ValueError(f'Unsupported widget type: {type(widget)}')
return widget.set_interval(
LOADING_DOT_INTERVAL_SECONDS,
functools.partial(update_inline_loading_indicator, widget),
)
+102
View File
@@ -0,0 +1,102 @@
import contextlib
import logging
import textual._context
import textual.widgets
import textual.worker
import borgmatic.logger
class Rich_color_formatter(logging.Formatter):
'''
A Python logging formatter that formats log records with Rich-compatible color markup according
to their levels.
'''
def __init__(self, *args, **kwargs):
self.prefix = None
super().__init__(
'{prefix}{message}',
*args,
style='{',
**kwargs,
)
def format(self, record):
'''
Given a log record, format it with Rich-compatibe color markup corresponding to its log
level.
'''
borgmatic.logger.add_custom_log_levels()
color = {
logging.CRITICAL: 'bright_red',
logging.ERROR: 'bright_red',
logging.WARNING: 'bright_yellow',
logging.ANSWER: 'bright_magenta',
logging.INFO: 'bright_green',
logging.DEBUG: 'bright_cyan',
}.get(record.levelno)
record.prefix = f'{self.prefix}: ' if self.prefix else ''
return f'[{color}]{super().format(record)}[/{color}]'
class Browse_log_handler(logging.Handler):
'''
A Python log handler that writes any log records to a logging widget.
'''
def __init__(self, logs_widget):
'''
Given a logs widget, save it for use below.
'''
self.logs_widget = logs_widget
super().__init__()
def emit(self, record):
'''
Given a log record, format it and log it to the logs widgets. This works whether or not the
logging is happening in the main thread.
'''
message = self.format(record)
try:
textual.worker.get_current_worker()
self.logs_widget.app.call_from_thread(self.logs_widget.write, message)
except (RuntimeError, textual.worker.NoActiveWorker):
with contextlib.suppress(textual._context.NoActiveAppError):
self.logs_widget.write(message)
def log_to_widget(logs_widget):
'''
Given a Textual RichLog logs widget, add a log handler and formatter that logs to it. Also
remove the default borgmatic console log handler so it doesn't try to log all over our UI.
'''
handler = Browse_log_handler(logs_widget)
handler.setFormatter(Rich_color_formatter())
logger = logging.getLogger()
logger.setLevel(min(handler.level for handler in logger.handlers))
logger.addHandler(handler)
with contextlib.suppress(StopIteration):
console_handler = next(
handler
for handler in logging.getLogger().handlers
if isinstance(handler, borgmatic.logger.Multi_stream_handler)
)
logger.removeHandler(console_handler)
class Logs(textual.widgets.RichLog):
'''
A widget for viewing borgmatic logs in realtime. The log level is determined by borgmatic's
current verbosity level.
'''
def __init__(self):
super().__init__(markup=True, id='logs', classes='panel')
self.border_title = '🪵 logs'
@@ -0,0 +1,30 @@
import textual.widgets
import borgmatic.actions.browse.bindings
class Repositories_list(textual.widgets.OptionList):
'''
A widget for selecting a single Borg repository from among the repositories in a borgmatic
configuration file. The item selection event is handled in a Carousel instance, the parent
widget of a Repositories_list.
'''
BINDINGS = borgmatic.actions.browse.bindings.OPTION_LIST_BINDINGS
def __init__(self, config):
'''
Given a configuration dict, populate the repositories in this widget.
'''
self.config = config
self.repositories = config['repositories']
super().__init__(
*(
textual.widgets.option_list.Option(label, id=index)
for index, repository in enumerate(self.repositories)
for label in (repository.get('label', repository.get('path')),)
),
classes='panel',
)
self.border_title = '📦 repositories'
+29
View File
@@ -0,0 +1,29 @@
import logging
def run_browse(
diff_arguments,
global_arguments,
configs,
):
'''
Run the "browse" action for the given borgmatic configurations. This launches a console UI.
Raise ValueError if the Textual library (a prerequisite for this action) can't be imported.
'''
if not configs:
return
logging.getLogger('asyncio').setLevel(logging.WARNING)
try:
import textual # noqa: F401, PLC0415
except ImportError: # pragma: no cover
raise ValueError(
'Unable to import the Textual library for the browse action; try installing "borgmatic[browse]"'
)
import borgmatic.actions.browse.app # noqa: PLC0415
app = borgmatic.actions.browse.app.Browse_app(configs)
app.run()
+192
View File
@@ -0,0 +1,192 @@
import logging
import os
import textual
import textual.signal
import borgmatic.actions.browse.archive
logger = logging.getLogger('__name__')
LOADING_DONE = object()
class Archive_loaded(textual.signal.Signal):
'''
A signal that publishes when each subsequent archive is loaded from a repository, intended for
consumption in widgets that display archives as they are loaded. This signal also publishes
when loading is complete.
Each subscribed callback call includes the archive as an archive name string. Given the lack of
other identifying information (configuration file, repository), there should be a separate
Archive_loaded instance per repository.
'''
@textual.work(thread=True)
def add_repository_archives(browse_app, archive_loaded, config, repository):
'''
Given a running Browse_app instance, an Archive_loaded instance, a configuration dict, and a
repository dict, load a list of the archives from the repository and add them as options in the
archives list. Reverse the order so the most recent archive is first.
This function runs in a separate thread from the main UI. When loading is complete, publish a
loading done signal.
'''
archives_data = borgmatic.actions.browse.archive.get_repository_archives(config, repository)
# Reverse the archives, so the common case of accessing the latest archive is easy because it's
# at the top.
for archive in reversed(archives_data['archives']):
archive_loaded.publish(archive['archive'])
archive_loaded.publish(LOADING_DONE)
def record_path(archive_path, hierarchy, path_components):
'''
Given an Archive_path instance, a dict capturing a filesystem hierarchy of paths, and a tuple of
path components for the archive path, set the archive path into the hierarchy data structure.
For instance, if given an archive path and path components representing "foo/bar/baz.txt",
produce a hierarchy that looks like:
{'foo': {'bar': {'baz.txt': Archive_path('-', 'foo/bar/baz.txt', '')}}}
Note that the hierarchy is modified in place, so any existing paths there are retained.
'''
if len(path_components) == 1:
hierarchy[path_components[0]] = {} if archive_path.path_type == 'd' else archive_path
return
record_path(archive_path, hierarchy.setdefault(path_components[0], {}), path_components[1:])
def get_paths(hierarchy, path_components, full_path_components=None):
'''
Given a dict capturing a filesystem hierarchy of paths (or a subset thereof), a tuple of path
components for a directory path relative to the hierarchy root, and an optional tuple of
*absolute* path components for the same path (if different), return a generator of the
contained file and directory Archive_path instances from the hierarchy.
For instance, given the following hierarchy:
{'foo': {'bar': {'baz.txt': Archive_path('-', 'foo/bar/baz.txt', ''), 'quux': {}}}}
... and path components of ('foo', 'bar'), return a generator with the following:
* Archive_path('-', 'foo/bar/baz.txt', '')
* Archive_path('d', 'foo/bar/quux', '')
The given absolute path components are use to construct directory paths like that last archive
path.
'''
if full_path_components is None:
full_path_components = path_components
if len(path_components) == 1:
try:
return (
archive_path
if isinstance(archive_path, borgmatic.actions.browse.archive.Archive_path)
else borgmatic.actions.browse.archive.Archive_path(
'd', os.path.join(*full_path_components, component), ''
)
for component, archive_path in hierarchy[path_components[0]].items()
)
except KeyError:
raise ValueError(f'Unknown file or directory: {path_components[0]}')
try:
return get_paths(hierarchy[path_components[0]], path_components[1:], full_path_components)
except KeyError:
raise ValueError(f'Unknown directory: {path_components[0]}')
class Archive_path_loaded(textual.signal.Signal):
'''
A signal that publishes when each subsequent path is loaded from an archive, intended for
consumption in widgets that display paths as they are loaded. This signal also tracks the
complete filesystem hierarchy seen thus far, so new widgets that get created after loading has
started can "catch up" with existing known paths. Lastly, this signal publishes and tracks when
loading is complete.
Each subscrided callback call includes the loaded path as an Archive_path instance. There is
intended to be a separate Archive_path_loaded instance per archive, but that instance should be
shared among several different widgets for the same archive for performance reasons.
'''
def __init__(self, owner, name):
self.path_hierarchy = {}
self.complete = False
super().__init__(owner, name)
def publish(self, archive_path):
'''
Publish the given archive path to subscribers and record its path locally. But if the
archive path is actually LOADING_DONE, then record loading as complete.
'''
super().publish(archive_path)
if archive_path is LOADING_DONE:
self.complete = True
else:
record_path(
archive_path, self.path_hierarchy, archive_path.file_path.split(os.path.sep)
)
@textual.work(thread=True)
def load_archive_paths(browse_app, path_loaded, config, repository, archive_name):
'''
Given a running Browse_app instance, an Archive_path_loaded instance, a configuration dict, a
repository dict, and an archive name, load the paths in this archive and publish each one via
the Archive_path_loaded signal, so interested widgets can subscribe. Also send a "loading done"
signal when loading completes.
This function runs in a separate thread from the main UI. When loading is complete, publish a
loading done signal.
'''
for archive_path in borgmatic.actions.browse.archive.get_archive_paths(
config, repository, archive_name
):
path_loaded.publish(archive_path)
path_loaded.publish(LOADING_DONE)
class File_preview_loaded(textual.signal.Signal):
'''
A signal that publishes when file contents are loaded from an archive, intended for consumption
in widgets that display loaded files. This signal also publishes when loading is complete.
Each published callback includes a the file's contents as a string. Given the lack of other
identifying information (configuration file, repository, archive), there should be a separate
Archive_loaded instance per previewed file.
'''
@textual.work(thread=True)
def load_file_preview(
browse_app,
file_preview_loaded,
config,
repository,
archive_name,
file_path,
):
'''
Given a running Browse_app instance, a File_preview_loaded instance, a configuration dict, a
repository dict, an archive name, and the path of a file in that archive, load the contents of
the file and write it into the given file preview widget.
This function runs in a separate thread from the main UI. When loading is complete, publish a
loading done signal.
'''
file_contents = borgmatic.actions.browse.archive.get_archive_file_content(
config, repository, archive_name, file_path
)
file_preview_loaded.publish(file_contents)
+10 -17
View File
@@ -1,7 +1,6 @@
import logging
import borgmatic.borg.change_passphrase
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -18,19 +17,13 @@ def run_change_passphrase(
'''
Run the "key change-passphrase" 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('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,
)
logger.info('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,
)
+213 -122
View File
@@ -1,4 +1,5 @@
import calendar
import contextlib
import datetime
import hashlib
import itertools
@@ -6,20 +7,22 @@ import logging
import os
import pathlib
import random
import shlex
import shutil
import subprocess
import textwrap
import borgmatic.actions.create
import borgmatic.actions.config.bootstrap
import borgmatic.actions.pattern
import borgmatic.borg.check
import borgmatic.borg.create
import borgmatic.borg.environment
import borgmatic.borg.extract
import borgmatic.borg.list
import borgmatic.borg.pattern
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'},
@@ -53,12 +56,14 @@ def parse_checks(config, only_checks=None):
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'
'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'
'Multiple checks are configured, but one of them is "disabled"; not running any checks',
)
return ()
return checks
@@ -162,6 +167,7 @@ def filter_checks_on_frequency(
**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'),
@@ -170,7 +176,7 @@ def filter_checks_on_frequency(
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(day.title() for day in days)} (use --force to check anyway)"
f"Skipping {check} check due to day of the week; check only runs on {'/'.join(day.title() for day in days)} (use --force to check anyway)",
)
filtered_checks.remove(check)
continue
@@ -188,7 +194,7 @@ def filter_checks_on_frequency(
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)'
f'Skipping {check} check due to configured frequency; {remaining} until next check (use --force to check anyway)',
)
filtered_checks.remove(check)
@@ -214,7 +220,7 @@ def make_check_time_path(config, borg_repository_id, check_type, archives_check_
'''
borgmatic_state_directory = borgmatic.config.paths.get_borgmatic_state_directory(config)
if check_type in ('archives', 'data'):
if check_type in {'archives', 'data'}:
return os.path.join(
borgmatic_state_directory,
'checks',
@@ -238,7 +244,7 @@ def write_check_time(path): # pragma: no cover
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()
pathlib.Path(path).touch(mode=0o600)
def read_check_time(path):
@@ -249,7 +255,7 @@ def read_check_time(path):
logger.debug(f'Reading check time from {path}')
try:
return datetime.datetime.fromtimestamp(os.stat(path).st_mtime)
return datetime.datetime.fromtimestamp(os.stat(path).st_mtime) # noqa: DTZ006
except FileNotFoundError:
return None
@@ -280,7 +286,7 @@ def probe_for_check_time(config, borg_repository_id, check, archives_check_id):
(
make_check_time_path(config, borg_repository_id, check, archives_check_id),
make_check_time_path(config, borg_repository_id, check),
)
),
)
)
@@ -312,16 +318,17 @@ def upgrade_check_times(config, borg_repository_id):
{borgmatic_state_directory}/checks/1234567890/archives/all
'''
borgmatic_source_checks_path = os.path.join(
borgmatic.config.paths.get_borgmatic_source_directory(config), 'checks'
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
borgmatic_state_checks_path,
):
logger.debug(
f'Upgrading archives check times directory from {borgmatic_source_checks_path} to {borgmatic_state_checks_path}'
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)
@@ -336,10 +343,8 @@ def upgrade_check_times(config, borg_repository_id):
logger.debug(f'Upgrading archives check time file from {old_path} to {new_path}')
try:
with contextlib.suppress(FileNotFoundError):
shutil.move(old_path, temporary_path)
except FileNotFoundError:
pass
os.mkdir(old_path)
shutil.move(temporary_path, new_path)
@@ -353,41 +358,52 @@ def collect_spot_check_source_paths(
local_path,
remote_path,
borgmatic_runtime_directory,
bootstrap_config_paths,
):
'''
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).
arguments as an argparse.Namespace instance, the local Borg path, the remote Borg path, and the
bootstrap configuration paths as read from an archive's manifest, collect the source paths that
Borg would use in an actual create (but only include files). As part of this, include the
bootstrap configuration paths, so that any configuration files included in the archive to
support bootstrapping are also spot checked.
'''
stream_processes = any(
borgmatic.hooks.dispatch.call_hooks(
'use_streaming',
config,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
).values()
).values(),
)
working_directory = borgmatic.config.paths.get_working_directory(config)
(create_flags, create_positional_arguments, pattern_file) = (
borgmatic.borg.create.make_base_create_command(
dry_run=True,
repository_path=repository['path'],
config=dict(config, list_details=True),
patterns=borgmatic.actions.create.process_patterns(
borgmatic.actions.create.collect_patterns(config),
working_directory,
(create_flags, create_positional_arguments, _) = borgmatic.borg.create.make_base_create_command(
dry_run=True,
repository_path=repository['path'],
# Omit "progress" because it interferes with "list_details".
config=dict(config, progress=False, list_details=True),
patterns=borgmatic.actions.pattern.process_patterns(
borgmatic.actions.pattern.collect_patterns(config, working_directory)
+ tuple(
borgmatic.borg.pattern.Pattern(
config_path,
source=borgmatic.borg.pattern.Pattern_source.INTERNAL,
)
for config_path in bootstrap_config_paths
),
local_borg_version=local_borg_version,
global_arguments=global_arguments,
borgmatic_runtime_directory=borgmatic_runtime_directory,
local_path=local_path,
remote_path=remote_path,
stream_processes=stream_processes,
)
config,
working_directory,
),
local_borg_version=local_borg_version,
global_arguments=global_arguments,
borgmatic_runtime_directory=borgmatic_runtime_directory,
local_path=local_path,
remote_path=remote_path,
stream_processes=stream_processes,
)
working_directory = borgmatic.config.paths.get_working_directory(config)
paths_output = borgmatic.execute.execute_command_and_capture_output(
path_lines = borgmatic.execute.execute_command_and_capture_output(
create_flags + create_positional_arguments,
capture_stderr=True,
environment=borgmatic.borg.environment.make_environment(config),
@@ -396,14 +412,20 @@ def collect_spot_check_source_paths(
borg_exit_codes=config.get('borg_exit_codes'),
)
paths = tuple(
paths = (
path_line.split(' ', 1)[1]
for path_line in paths_output.splitlines()
if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
for path_line in path_lines
if path_line and path_line.startswith(('- ', '+ '))
)
return tuple(
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
# Use dict.fromkeys() to deduplicate file paths, which are present in Borg's dry run output
# when there are overlapping source patterns. For instance, if both "/foo" and
# "/foo/file.txt" are in configured patterns, then "/foo/file.txt" will show up in Borg's
# dry run output twice.
dict.fromkeys(
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
)
)
@@ -433,28 +455,26 @@ def collect_spot_check_archive_paths(
borgmatic_source_directory = borgmatic.config.paths.get_borgmatic_source_directory(config)
return tuple(
path
for line in borgmatic.borg.list.capture_archive_listing(
entry['path']
for entry in borgmatic.borg.list.capture_archive_listing(
repository['path'],
archive,
config,
local_borg_version,
global_arguments,
path_format='{type} {path}{NUL}', # noqa: FS003
local_path=local_path,
remote_path=remote_path,
)
for (file_type, path) in (line.split(' ', 1),)
if file_type not in (BORG_DIRECTORY_FILE_TYPE, BORG_PIPE_FILE_TYPE)
if pathlib.Path('borgmatic') not in pathlib.Path(path).parents
if entry['type'] not in {BORG_DIRECTORY_FILE_TYPE, BORG_PIPE_FILE_TYPE}
if pathlib.Path('borgmatic') not in pathlib.Path(entry['path']).parents
if pathlib.Path(borgmatic_source_directory.lstrip(os.path.sep))
not in pathlib.Path(path).parents
not in pathlib.Path(entry['path']).parents
if pathlib.Path(borgmatic_runtime_directory.lstrip(os.path.sep))
not in pathlib.Path(path).parents
not in pathlib.Path(entry['path']).parents
)
SAMPLE_PATHS_SUBSET_COUNT = 10000
SAMPLE_PATHS_SUBSET_COUNT = 5000
def compare_spot_check_hashes(
@@ -478,9 +498,10 @@ def compare_spot_check_hashes(
# 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
int(len(source_paths) * (min(spot_check_config['data_sample_percentage'], 100) / 100)),
1,
)
source_sample_paths = tuple(random.sample(source_paths, sample_count))
source_sample_paths = tuple(random.SystemRandom().sample(source_paths, sample_count))
working_directory = borgmatic.config.paths.get_working_directory(config)
hashable_source_sample_path = {
source_path
@@ -490,7 +511,7 @@ def compare_spot_check_hashes(
if not os.path.islink(full_source_path)
}
logger.debug(
f'Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check'
f'Sampling {sample_count} source paths (~{spot_check_config["data_sample_percentage"]}%) for spot check',
)
source_sample_paths_iterator = iter(source_sample_paths)
@@ -502,51 +523,102 @@ def compare_spot_check_hashes(
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)
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 hashable_source_sample_path
),
working_directory=working_directory,
hash_paths = tuple(
path for path in source_sample_paths_subset if path in hashable_source_sample_path
)
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. Same thing for filesystem links, since Borg produces empty archive hashes
# for them.
**{
path: ''
for path in source_sample_paths_subset
if path not in hashable_source_sample_path
},
try:
hash_lines = borgmatic.execute.execute_command_and_capture_output(
tuple(
shlex.quote(part)
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
)
+ hash_paths,
working_directory=working_directory,
)
)
source_hashes.update(
**dict(
zip(
# xxh64sum rewrites/escapes the paths that it returns alongside its hashes, for
# instance if they contain special characters. When that happens, they don't
# match the original source paths and therefore hash lookups fail. So when
# building this lookup dict, use the original unaltered paths we provided as
# input to xxh64sum.
hash_paths,
(
# For some reason, xxh64sum prefixes the hash with a backslash if the path
# contains a newline. Work around that.
line.split(' ', 1)[0].lstrip('\\')
for line in hash_lines
),
),
# Represent non-existent files as having empty hashes so the comparison below still
# works. Same thing for filesystem links, since Borg produces empty archive hashes
# for them.
**{
path: ''
for path in source_sample_paths_subset
if path not in hashable_source_sample_path
},
),
)
except subprocess.CalledProcessError:
# This can happen if a file we planned to hash gets deleted right before we try to hash
# it. Falling back to individual file hashing allows us to find and mark just the
# file(s) with problems instead of failing the whole batch.
logger.warning(
'Bulk source path hashing failed for this batch; falling back to individual file hashing'
)
for hash_path in hash_paths:
try:
hash_lines = borgmatic.execute.execute_command_and_capture_output(
(
*(
shlex.quote(part)
for part in shlex.split(
spot_check_config.get('xxh64sum_command', 'xxh64sum')
)
),
hash_path,
),
working_directory=working_directory,
)
source_hashes[hash_path] = next(hash_lines).split(' ', 1)[0].lstrip('\\')
except (subprocess.CalledProcessError, StopIteration): # noqa: PERF203
logger.warning(
f'Source path hashing failed for {hash_path}; treating as missing'
)
source_hashes[hash_path] = ''
# 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}{NUL}', # noqa: FS003
local_path=local_path,
remote_path=remote_path,
)
if line
)
)
for entry 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}{linktarget}{target}',
local_path=local_path,
remote_path=remote_path,
):
if not entry:
continue
# Borg can't get hashes of stored hard links. So if this is a hard link path (and not
# deemed as the "original" by Borg), then skip hashing of it.
if entry.get('linktarget') or entry.get('target'):
source_hashes.pop(os.path.join('/', entry['path']), None)
continue
archive_hashes[entry['path']] = entry['xxh64']
# Compare the source hashes with the archive hashes to see how many match.
failing_paths = []
@@ -562,6 +634,9 @@ def compare_spot_check_hashes(
return tuple(failing_paths)
MAX_SPOT_CHECK_PATHS_LENGTH = 1000
def spot_check(
repository,
config,
@@ -580,8 +655,6 @@ def spot_check(
disk to those stored in the latest archive. If any differences are beyond configured tolerances,
then the check fails.
'''
logger.debug('Running spot check')
try:
spot_check_config = next(
check for check in config.get('checks', ()) if check.get('name') == 'spot'
@@ -591,20 +664,9 @@ def spot_check(
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'
'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,
borgmatic_runtime_directory,
)
logger.debug(f'{len(source_paths)} total source paths for spot check')
archive = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
'latest',
@@ -616,6 +678,25 @@ def spot_check(
)
logger.debug(f'Using archive {archive} for spot check')
source_paths = collect_spot_check_source_paths(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
bootstrap_config_paths=borgmatic.actions.config.bootstrap.load_config_paths_from_archive(
repository['path'],
archive,
config,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
),
)
logger.debug(f'{len(source_paths)} total source paths for spot check')
archive_paths = collect_spot_check_archive_paths(
repository,
archive,
@@ -629,11 +710,14 @@ def spot_check(
logger.debug(f'{len(archive_paths)} total archive paths for spot check')
if len(source_paths) == 0:
logger.debug(
f'Paths in latest archive but not source paths: {", ".join(set(archive_paths)) or "none"}'
truncated_archive_paths = textwrap.shorten(
', '.join(set(archive_paths)) or 'none',
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(f'Paths in latest archive but not source paths: {truncated_archive_paths}')
raise ValueError(
'Spot check failed: There are no source paths to compare against the archive'
'Spot check failed; there are no source paths to compare against the archive',
)
# Calculate the percentage delta between the source paths count and the archive paths count, and
@@ -641,15 +725,19 @@ def spot_check(
count_delta_percentage = abs(len(source_paths) - len(archive_paths)) / len(source_paths) * 100
if count_delta_percentage > spot_check_config['count_tolerance_percentage']:
rootless_source_paths = set(path.lstrip(os.path.sep) for path in source_paths)
logger.debug(
f'Paths in source paths but not latest archive: {", ".join(rootless_source_paths - set(archive_paths)) or "none"}'
rootless_source_paths = {path.lstrip(os.path.sep) for path in source_paths}
truncated_exclusive_source_paths = textwrap.shorten(
', '.join(rootless_source_paths - set(archive_paths)) or 'none',
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(
f'Paths in latest archive but not source paths: {", ".join(set(archive_paths) - rootless_source_paths) or "none"}'
truncated_exclusive_archive_paths = textwrap.shorten(
', '.join(set(archive_paths) - rootless_source_paths) or 'none',
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
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"]}%)'
f'Spot check failed\n{count_delta_percentage:.2f}% file count delta between source paths ({len(source_paths)} total) and latest archive ({len(archive_paths)} total); tolerance is {spot_check_config["count_tolerance_percentage"]}%\nOnly in source paths: {truncated_exclusive_source_paths}\nOnly in latest archive: {truncated_exclusive_archive_paths}',
)
failing_paths = compare_spot_check_hashes(
@@ -669,15 +757,17 @@ def spot_check(
failing_percentage = (len(failing_paths) / len(source_paths)) * 100
if failing_percentage > data_tolerance_percentage:
logger.debug(
f'Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
truncated_failing_paths = textwrap.shorten(
', '.join(failing_paths),
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
raise ValueError(
f'Spot check failed: {failing_percentage:.2f}% of source paths with data not matching the latest archive (tolerance is {data_tolerance_percentage}%)'
f'Spot check failed\n{failing_percentage:.2f}% of source paths ({len(failing_paths)} out of {len(source_paths)} checked) with data not matching the latest archive; tolerance is {data_tolerance_percentage}%\nSource paths with non-matching data: {truncated_failing_paths}',
)
logger.info(
f'Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta'
f'Spot check passed with a {count_delta_percentage:.2f}% file count delta and a {failing_percentage:.2f}% file data delta',
)
@@ -696,11 +786,6 @@ def run_check(
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
):
return
logger.info('Running consistency checks')
repository_id = borgmatic.borg.check.get_repository_id(
@@ -714,7 +799,10 @@ def run_check(
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
local_borg_version,
config,
configured_checks,
check_arguments,
)
archives_check_id = make_archives_check_id(archive_filter_flags)
checks = filter_checks_on_frequency(
@@ -742,6 +830,7 @@ def run_check(
write_check_time(make_check_time_path(config, repository_id, check, archives_check_id))
if 'extract' in checks:
logger.info('Running extract check')
borgmatic.borg.extract.extract_last_archive_dry_run(
config,
local_borg_version,
@@ -754,6 +843,7 @@ def run_check(
write_check_time(make_check_time_path(config, repository_id, 'extract'))
if 'spot' in checks:
logger.info('Running spot check')
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
spot_check(
repository,
@@ -764,4 +854,5 @@ def run_check(
remote_path,
borgmatic_runtime_directory,
)
write_check_time(make_check_time_path(config, repository_id, 'spot'))
-7
View File
@@ -2,8 +2,6 @@ import logging
import borgmatic.borg.compact
import borgmatic.borg.feature
import borgmatic.config.validate
import borgmatic.hooks.command
logger = logging.getLogger(__name__)
@@ -22,11 +20,6 @@ def run_compact(
'''
Run the "compact" action for the given repository.
'''
if compact_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, compact_arguments.repository
):
return
if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.COMPACT, local_borg_version):
logger.info(f'Compacting segments{dry_run_label}')
borgmatic.borg.compact.compact_segments(
+59 -53
View File
@@ -5,8 +5,6 @@ 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__)
@@ -16,78 +14,81 @@ 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,
'borgmatic_source_directory': bootstrap_arguments.borgmatic_source_directory,
'local_path': bootstrap_arguments.local_path,
'remote_path': bootstrap_arguments.remote_path,
# 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,
'ssh_command': bootstrap_arguments.ssh_command,
'user_runtime_directory': bootstrap_arguments.user_runtime_directory,
}
def get_config_paths(archive_name, bootstrap_arguments, global_arguments, local_borg_version):
def load_config_paths_from_archive(
repository_path,
archive_name,
config,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
):
'''
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.
Given a repository path, an archive name, a configuration dict, the local Borg version, the
global arguments as an argparse.Namespace, and the borgmatic runtime directory, return the
config paths from the manifest.json file in the borgmatic source directory or runtime directory
within 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 gets 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},
) 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'
)
for base_directory in (
'borgmatic',
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
borgmatic.config.paths.get_borgmatic_source_directory(config),
):
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()
extract_process = borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
repository_path,
archive_name,
[borgmatic_manifest_path],
config,
local_borg_version,
global_arguments,
local_path=config.get('local_path', 'borg'),
remote_path=config.get('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'
)
if manifest_json:
break
else:
raise ValueError(
'Cannot read configuration paths from archive due to missing archive or 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}'
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'
'Cannot read configuration paths from archive due to invalid bootstrap manifest',
)
@@ -108,19 +109,24 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
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)}")
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
manifest_config_paths = load_config_paths_from_archive(
bootstrap_arguments.repository,
archive_name,
config,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
)
logger.info(f"Bootstrapping configuration 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],
# Only add progress here and not the extract_archive() call above, because progress
# conflicts with extract_to_stdout.
dict(config, progress=bootstrap_arguments.progress or False),
local_borg_version,
global_arguments,
+14 -7
View File
@@ -19,30 +19,37 @@ def run_generate(generate_arguments, global_arguments):
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}'
f'Generating configuration files within: {generate_arguments.destination_path}{dry_run_label}'
if generate_arguments.split
else f'Generating a configuration file at: {generate_arguments.destination_path}{dry_run_label}'
)
borgmatic.config.generate.generate_sample_configuration(
global_arguments.dry_run,
generate_arguments.source_filename,
generate_arguments.destination_filename,
generate_arguments.destination_path,
borgmatic.config.validate.schema_filename(),
overwrite=generate_arguments.overwrite,
split=generate_arguments.split,
)
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}'''
Merged the contents of the source configuration file: {generate_arguments.source_filename}'''
)
if not generate_arguments.split:
logger.answer(
f'''To review the changes made, run:
diff --unified {generate_arguments.source_filename} {generate_arguments.destination_path}''',
)
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'''
If you ever need help: https://torsion.org/borgmatic/#issues''',
)
+44
View File
@@ -0,0 +1,44 @@
import json
import logging
import sys
import borgmatic.config.generate
import borgmatic.logger
logger = logging.getLogger(__name__)
def run_show(show_arguments, configs):
'''
Given the show arguments as an argparse.Namespace instance and a dict of configuration filename
to corresponding parsed configuration, run the "show" action. That consists of rendering and
logging the computed configuration as YAML, separating the configuration for each file with
"---".
If show_arguments.option is set, limit the results to the value of that single option. If
show_arguments.json is True, render the results as JSON with one array element per configuration
file.
'''
borgmatic.logger.add_custom_log_levels()
if show_arguments.json:
sys.stdout.write(
json.dumps(
[
config.get(show_arguments.option) if show_arguments.option else config
for config in configs.values()
]
)
)
return
for config in configs.values():
if len(configs) > 1:
logger.answer('---')
logger.answer(
borgmatic.config.generate.render_configuration(
config.get(show_arguments.option) if show_arguments.option else config
).rstrip()
)
+1 -1
View File
@@ -18,7 +18,7 @@ def run_validate(validate_arguments, configs):
borgmatic.logger.add_custom_log_levels()
if validate_arguments.show:
for config_path, config in configs.items():
for config in configs.values():
if len(configs) > 1:
logger.answer('---')
+144 -307
View File
@@ -1,272 +1,18 @@
import glob
import itertools
import logging
import os
import pathlib
import borgmatic.actions.dump
import borgmatic.actions.json
import borgmatic.borg.create
import borgmatic.borg.pattern
import borgmatic.borg.feature
import borgmatic.borg.rename
import borgmatic.borg.repo_list
import borgmatic.config.paths
import borgmatic.config.validate
import borgmatic.hooks.command
import borgmatic.hooks.dispatch
from borgmatic.actions import pattern
logger = logging.getLogger(__name__)
def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_style.NONE):
'''
Given a Borg pattern as a string, parse it into a borgmatic.borg.pattern.Pattern instance and
return it.
'''
try:
(pattern_type, remainder) = pattern_line.split(' ', maxsplit=1)
except ValueError:
raise ValueError(f'Invalid pattern: {pattern_line}')
try:
(parsed_pattern_style, path) = remainder.split(':', maxsplit=1)
pattern_style = borgmatic.borg.pattern.Pattern_style(parsed_pattern_style)
except ValueError:
pattern_style = default_style
path = remainder
return borgmatic.borg.pattern.Pattern(
path,
borgmatic.borg.pattern.Pattern_type(pattern_type),
borgmatic.borg.pattern.Pattern_style(pattern_style),
source=borgmatic.borg.pattern.Pattern_source.CONFIG,
)
def collect_patterns(config):
'''
Given a configuration dict, produce a single sequence of patterns comprised of the configured
source directories, patterns, excludes, pattern files, and exclude files.
The idea is that Borg has all these different ways of specifying includes, excludes, source
directories, etc., but we'd like to collapse them all down to one common format (patterns) for
ease of manipulation within borgmatic.
'''
try:
return (
tuple(
borgmatic.borg.pattern.Pattern(
source_directory, source=borgmatic.borg.pattern.Pattern_source.CONFIG
)
for source_directory in config.get('source_directories', ())
)
+ tuple(
parse_pattern(pattern_line.strip())
for pattern_line in config.get('patterns', ())
if not pattern_line.lstrip().startswith('#')
if pattern_line.strip()
)
+ tuple(
parse_pattern(
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
for exclude_line in config.get('exclude_patterns', ())
)
+ tuple(
parse_pattern(pattern_line.strip())
for filename in config.get('patterns_from', ())
for pattern_line in open(filename).readlines()
if not pattern_line.lstrip().startswith('#')
if pattern_line.strip()
)
+ tuple(
parse_pattern(
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
for filename in config.get('exclude_from', ())
for exclude_line in open(filename).readlines()
if not exclude_line.lstrip().startswith('#')
if exclude_line.strip()
)
)
except (FileNotFoundError, OSError) as error:
logger.debug(error)
raise ValueError(f'Cannot read patterns_from/exclude_from file: {error.filename}')
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.
Take into account the given working directory so that relative paths are supported.
'''
expanded_directory = os.path.expanduser(directory)
# This would be a lot easier to do with glob(..., root_dir=working_directory), but root_dir is
# only available in Python 3.10+.
normalized_directory = os.path.join(working_directory or '', expanded_directory)
glob_paths = glob.glob(normalized_directory)
if not glob_paths:
return [expanded_directory]
working_directory_prefix = os.path.join(working_directory or '', '')
return [
(
glob_path
# If these are equal, that means we didn't add any working directory prefix above.
if normalized_directory == expanded_directory
# Remove the working directory prefix that we added above in order to make glob() work.
# We can't use os.path.relpath() here because it collapses any use of Borg's slashdot
# hack.
else glob_path.removeprefix(working_directory_prefix)
)
for glob_path in glob_paths
]
def expand_patterns(patterns, working_directory=None, skip_paths=None):
'''
Given a sequence of borgmatic.borg.pattern.Pattern instances and an optional working directory,
expand tildes and globs in each root pattern and expand just tildes in each non-root pattern.
The idea is that non-root patterns may be regular expressions or other pattern styles containing
"*" that borgmatic should not expand as a shell glob.
Return all the resulting patterns as a tuple.
If a set of paths are given to skip, then don't expand any patterns matching them.
'''
if patterns is None:
return ()
return tuple(
itertools.chain.from_iterable(
(
(
borgmatic.borg.pattern.Pattern(
expanded_path,
pattern.type,
pattern.style,
pattern.device,
pattern.source,
)
for expanded_path in expand_directory(pattern.path, working_directory)
)
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
and pattern.path not in (skip_paths or ())
else (
borgmatic.borg.pattern.Pattern(
os.path.expanduser(pattern.path),
pattern.type,
pattern.style,
pattern.device,
pattern.source,
),
)
)
for pattern in patterns
)
)
def device_map_patterns(patterns, working_directory=None):
'''
Given a sequence of borgmatic.borg.pattern.Pattern instances and an optional working directory,
determine the identifier for the device on which the pattern's path resides—or None if the path
doesn't exist or is from a non-root pattern. Return an updated sequence of patterns with the
device field populated. But if the device field is already set, don't bother setting it again.
This is handy for determining whether two different pattern paths are on the same filesystem
(have the same device identifier).
'''
return tuple(
borgmatic.borg.pattern.Pattern(
pattern.path,
pattern.type,
pattern.style,
device=pattern.device
or (
os.stat(full_path).st_dev
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
and os.path.exists(full_path)
else None
),
source=pattern.source,
)
for pattern in patterns
for full_path in (os.path.join(working_directory or '', pattern.path),)
)
def deduplicate_patterns(patterns):
'''
Given a sequence of borgmatic.borg.pattern.Pattern instances, return them with all duplicate
root child patterns removed. For instance, if two root patterns are given with paths "/foo" and
"/foo/bar", return just the one with "/foo". Non-root patterns are passed through without
modification.
The one exception to deduplication is two paths are on different filesystems (devices). In that
case, they won't get deduplicated, in case they both need to be passed to Borg (e.g. the
one_file_system option is true).
The idea is that if Borg is given a root parent pattern, then it doesn't also need to be given
child patterns, because it will naturally spider the contents of the parent pattern's path. And
there are cases where Borg coming across the same file twice will result in duplicate reads and
even hangs, e.g. when a database hook is using a named pipe for streaming database dumps to
Borg.
'''
deduplicated = {} # Use just the keys as an ordered set.
for pattern in patterns:
if pattern.type != borgmatic.borg.pattern.Pattern_type.ROOT:
deduplicated[pattern] = True
continue
parents = pathlib.PurePath(pattern.path).parents
# If another directory in the given list is a parent of current directory (even n levels up)
# and both are on the same filesystem, then the current directory is a duplicate.
for other_pattern in patterns:
if other_pattern.type != borgmatic.borg.pattern.Pattern_type.ROOT:
continue
if any(
pathlib.PurePath(other_pattern.path) == parent
and pattern.device is not None
and other_pattern.device == pattern.device
for parent in parents
):
break
else:
deduplicated[pattern] = True
return tuple(deduplicated.keys())
def process_patterns(patterns, working_directory, skip_expand_paths=None):
'''
Given a sequence of Borg patterns and a configured working directory, expand and deduplicate any
"root" patterns, returning the resulting root and non-root patterns as a list.
If any paths are given to skip, don't expand them.
'''
skip_paths = set(skip_expand_paths or ())
return list(
deduplicate_patterns(
device_map_patterns(
expand_patterns(
patterns,
working_directory=working_directory,
skip_paths=skip_paths,
)
)
)
)
def run_create(
config_filename,
repository,
@@ -284,70 +30,161 @@ def run_create(
If create_arguments.json is True, yield the JSON output from creating the archive.
'''
if create_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, create_arguments.repository
):
return
if config.get('list_details') and config.get('progress'):
raise ValueError(
'With the create action, only one of --list/--files/list_details and --progress/progress can be used.'
'With the create action, only one of --list/--files/list_details and --progress/progress can be used.',
)
if config.get('list_details') and create_arguments.json:
raise ValueError(
'With the create action, only one of --list/--files/list_details and --json can be used.'
'With the create action, only one of --list/--files/list_details and --json can be used.',
)
logger.info(f'Creating archive{dry_run_label}')
working_directory = borgmatic.config.paths.get_working_directory(config)
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
patterns = pattern.process_patterns(
pattern.collect_patterns(config, working_directory),
config,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
working_directory,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
patterns = process_patterns(collect_patterns(config), working_directory)
active_dumps = borgmatic.hooks.dispatch.call_hooks(
'dump_data_sources',
config,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
config_paths,
borgmatic_runtime_directory,
patterns,
global_arguments.dry_run,
)
# Process the patterns again in case any data source hooks updated them. Without this step,
# we could end up with duplicate paths that cause Borg to hang when it tries to read from
# the same named pipe twice.
patterns = process_patterns(patterns, working_directory, skip_expand_paths=config_paths)
stream_processes = [process for processes in active_dumps.values() for process in processes]
original_patterns = list(patterns)
json_output = borgmatic.borg.create.create_archive(
global_arguments.dry_run,
repository['path'],
config,
patterns,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
local_path=local_path,
remote_path=remote_path,
json=create_arguments.json,
stream_processes=stream_processes,
)
# Use the original patterns so as to disregard any modifications made by any data source
# hooks, e.g. via dump_data_sources() below.
with borgmatic.actions.dump.Dump_cleanup(
config, borgmatic_runtime_directory, original_patterns, global_arguments.dry_run
):
active_dumps = borgmatic.hooks.dispatch.call_hooks(
'dump_data_sources',
config,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
config_paths,
borgmatic_runtime_directory,
patterns,
global_arguments.dry_run,
)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
# Process the patterns again in case any data source hooks updated them. Without this step,
# we could end up with duplicate paths that cause Borg to hang when it tries to read from
# the same named pipe twice.
patterns = pattern.process_patterns(
patterns,
config,
working_directory,
borgmatic_runtime_directory,
skip_expand_paths=config_paths,
)
stream_processes = [
process for processes in active_dumps.values() for process in processes
]
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
# If we have stream processes, we first create an archive with .checkpoint suffix. This is
# to make sure we only create a real archive if all the streaming processes completed
# successfully (create_archive will fail if a streaming process fails, but the archive might
# have already been created at this point).
use_checkpoint = bool(stream_processes)
json_output = borgmatic.borg.create.create_archive(
global_arguments.dry_run,
repository['path'],
config,
patterns,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
archive_suffix='.checkpoint' if use_checkpoint else '',
local_path=local_path,
remote_path=remote_path,
json=create_arguments.json,
comment=create_arguments.comment,
stream_processes=stream_processes,
)
if use_checkpoint:
rename_checkpoint_archive(
repository['path'],
global_arguments,
config,
local_borg_version,
local_path,
remote_path,
)
if json_output:
output = borgmatic.actions.json.parse_json(json_output, repository.get('label'))
if use_checkpoint:
# Patch archive name and ID
renamed_archive = borgmatic.borg.repo_list.get_latest_archive(
repository['path'],
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
output['archive']['name'] = renamed_archive['name']
output['archive']['id'] = renamed_archive['id']
yield output
def rename_checkpoint_archive(
repository_path,
global_arguments,
config,
local_borg_version,
local_path,
remote_path,
):
'''
Renames the latest archive to not have a '.checkpoint' suffix.
Raises ValueError if
- there is not latest archive
- the latest archive does not have a '.checkpoint' suffix
Implementation note: We cannot reliably get the just created archive name.
So we resort to listing the archives and picking the last one.
A similar comment applies to retrieving the ID of the renamed archive.
'''
archive = borgmatic.borg.repo_list.get_latest_archive(
repository_path,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
consider_checkpoints=True,
)
archive_name = archive['name']
if not archive_name.endswith('.checkpoint'):
raise ValueError(f'Latest archive did not have a .checkpoint suffix. Got: {archive_name}')
new_archive_name = archive_name.removesuffix('.checkpoint')
logger.info(f'Renaming archive {archive_name} -> {new_archive_name}')
borgmatic.borg.rename.rename_archive(
repository_path,
(
archive['id']
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.ARCHIVE_SERIES, local_borg_version
)
else archive['name']
),
new_archive_name,
global_arguments.dry_run,
config,
local_borg_version,
local_path,
remote_path,
)
+18 -22
View File
@@ -2,7 +2,6 @@ import logging
import borgmatic.actions.arguments
import borgmatic.borg.delete
import borgmatic.borg.repo_delete
import borgmatic.borg.repo_list
logger = logging.getLogger(__name__)
@@ -20,31 +19,28 @@ def run_delete(
'''
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('Deleting archives')
logger.answer('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,
archive_name = (
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
delete_arguments.archive,
config,
local_borg_version,
borgmatic.actions.arguments.update_arguments(delete_arguments, archive=archive_name),
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,
)
+63
View File
@@ -0,0 +1,63 @@
import logging
import borgmatic.actions.pattern
import borgmatic.borg.diff
logger = logging.getLogger(__name__)
def run_diff(
repository,
config,
local_borg_version,
diff_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "diff" action for the given repository.
'''
# Only process patterns if only_patterns flag is set
if diff_arguments.only_patterns:
working_directory = borgmatic.config.paths.get_working_directory(config)
processed_patterns = borgmatic.actions.pattern.process_patterns(
(*borgmatic.actions.pattern.collect_patterns(config, working_directory),),
config,
working_directory,
)
else:
processed_patterns = None
archive = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
diff_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
second_archive = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
diff_arguments.second_archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
borgmatic.borg.diff.diff(
repository['path'],
archive,
second_archive,
config,
local_borg_version,
diff_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
patterns=processed_patterns,
)
+53
View File
@@ -0,0 +1,53 @@
import borgmatic.hooks.dispatch
class Dump_cleanup:
'''
A Python context manager for removing data source dumps before and after wrapped code. Besides
doing this for general filesystem cleanliness reasons, leaving old dumps lying around can
prevent future dumping from working (in the case of filesystem snapshots) or cause Borg hangs
(in the case of database dump named pipes).
Example use as a context manager:
with borgmatic.actions.dump.Dump_cleanup(
config, borgmatic_runtime_directory, patterns, dry_run,
):
do_something_like_perform_a_dump_or_restore()
'''
def __init__(self, config, borgmatic_runtime_directory, patterns, dry_run):
'''
Given a configuration dict, the borgmatic runtime directory, the configured patterns, and
whether this is a dry-run, store these values for use below.
'''
self.config = config
self.borgmatic_runtime_directory = borgmatic_runtime_directory
self.patterns = patterns
self.dry_run = dry_run
def __enter__(self):
'''
Remove all data source dumps that exist prior to the wrapped code running.
'''
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
self.config,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
self.borgmatic_runtime_directory,
self.patterns,
self.dry_run,
)
def __exit__(self, exception_type, exception, traceback):
'''
Remove all data source dumps, including any created by the wrapped code.
'''
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
self.config,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
self.borgmatic_runtime_directory,
self.patterns,
self.dry_run,
)
+10 -14
View File
@@ -1,7 +1,6 @@
import logging
import borgmatic.borg.export_key
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -18,16 +17,13 @@ def run_export_key(
'''
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('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,
)
logger.info('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,
)
+19 -23
View File
@@ -2,7 +2,6 @@ import logging
import borgmatic.borg.export_tar
import borgmatic.borg.repo_list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -19,29 +18,26 @@ def run_export_tar(
'''
Run the "export-tar" action for the given repository.
'''
if export_tar_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, export_tar_arguments.repository
):
logger.info(f'Exporting archive {export_tar_arguments.archive} as tar file')
borgmatic.borg.export_tar.export_tar_archive(
global_arguments.dry_run,
logger.info(f'Exporting archive {export_tar_arguments.archive} as tar file')
borgmatic.borg.export_tar.export_tar_archive(
global_arguments.dry_run,
repository['path'],
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
export_tar_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
),
export_tar_arguments.paths,
export_tar_arguments.destination,
export_tar_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
tar_filter=export_tar_arguments.tar_filter,
strip_components=export_tar_arguments.strip_components,
)
local_path,
remote_path,
),
export_tar_arguments.paths,
export_tar_arguments.destination,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
tar_filter=export_tar_arguments.tar_filter,
strip_components=export_tar_arguments.strip_components,
)
+18 -23
View File
@@ -2,8 +2,6 @@ import logging
import borgmatic.borg.extract
import borgmatic.borg.repo_list
import borgmatic.config.validate
import borgmatic.hooks.command
logger = logging.getLogger(__name__)
@@ -21,28 +19,25 @@ def run_extract(
'''
Run the "extract" action for the given repository.
'''
if extract_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, extract_arguments.repository
):
logger.info(f'Extracting archive {extract_arguments.archive}')
borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
logger.info(f'Extracting archive {extract_arguments.archive}')
borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
repository['path'],
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
extract_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
),
extract_arguments.paths,
extract_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
destination_path=extract_arguments.destination,
strip_components=extract_arguments.strip_components,
)
local_path,
remote_path,
),
extract_arguments.paths,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
destination_path=extract_arguments.destination,
strip_components=extract_arguments.strip_components,
)
+10 -14
View File
@@ -1,7 +1,6 @@
import logging
import borgmatic.borg.import_key
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -18,16 +17,13 @@ def run_import_key(
'''
Run the "key import" action for the given repository.
'''
if import_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, import_arguments.repository
):
logger.info('Importing repository key')
borgmatic.borg.import_key.import_key(
repository['path'],
config,
local_borg_version,
import_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
logger.info('Importing repository key')
borgmatic.borg.import_key.import_key(
repository['path'],
config,
local_borg_version,
import_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
+23 -26
View File
@@ -4,7 +4,6 @@ import borgmatic.actions.arguments
import borgmatic.actions.json
import borgmatic.borg.info
import borgmatic.borg.repo_list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -23,28 +22,26 @@ def run_info(
If info_arguments.json is True, yield the JSON output from the info for the archive.
'''
if info_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, info_arguments.repository
):
if not info_arguments.json:
logger.answer('Displaying archive summary information')
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
info_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
json_output = borgmatic.borg.info.display_archives_info(
repository['path'],
config,
local_borg_version,
borgmatic.actions.arguments.update_arguments(info_arguments, archive=archive_name),
global_arguments,
local_path,
remote_path,
)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
if not info_arguments.json:
logger.answer('Displaying archive summary information')
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
info_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
json_output = borgmatic.borg.info.display_archives_info(
repository['path'],
config,
local_borg_version,
borgmatic.actions.arguments.update_arguments(info_arguments, archive=archive_name),
global_arguments,
local_path,
remote_path,
)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
+25 -29
View File
@@ -3,7 +3,6 @@ import logging
import borgmatic.actions.arguments
import borgmatic.actions.json
import borgmatic.borg.list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -22,32 +21,29 @@ def run_list(
If list_arguments.json is True, yield the JSON output from listing the archive.
'''
if list_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, list_arguments.repository
):
if not list_arguments.json:
if list_arguments.find_paths: # pragma: no cover
logger.answer('Searching archives')
elif not list_arguments.archive: # pragma: no cover
logger.answer('Listing archives')
if not list_arguments.json:
if list_arguments.find_paths: # pragma: no cover
logger.answer('Searching archives')
elif not list_arguments.archive: # pragma: no cover
logger.answer('Listing archives')
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
list_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
json_output = borgmatic.borg.list.list_archive(
repository['path'],
config,
local_borg_version,
borgmatic.actions.arguments.update_arguments(list_arguments, archive=archive_name),
global_arguments,
local_path,
remote_path,
)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
list_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
json_output = borgmatic.borg.list.list_archive(
repository['path'],
config,
local_borg_version,
borgmatic.actions.arguments.update_arguments(list_arguments, archive=archive_name),
global_arguments,
local_path,
remote_path,
)
if json_output:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
+18 -22
View File
@@ -2,7 +2,6 @@ import logging
import borgmatic.borg.mount
import borgmatic.borg.repo_list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -19,29 +18,26 @@ def run_mount(
'''
Run the "mount" action for the given repository.
'''
if mount_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, mount_arguments.repository
):
if mount_arguments.archive:
logger.info(f'Mounting archive {mount_arguments.archive}')
else: # pragma: nocover
logger.info('Mounting repository')
if mount_arguments.archive:
logger.info(f'Mounting archive {mount_arguments.archive}')
else: # pragma: nocover
logger.info('Mounting repository')
borgmatic.borg.mount.mount_archive(
borgmatic.borg.mount.mount_archive(
repository['path'],
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
mount_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
),
mount_arguments,
mount_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
local_path,
remote_path,
),
mount_arguments,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
+330
View File
@@ -0,0 +1,330 @@
import glob
import itertools
import logging
import os
import pathlib
import borgmatic.borg.pattern
logger = logging.getLogger(__name__)
def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_style.NONE):
'''
Given a Borg pattern as a string, parse it into a borgmatic.borg.pattern.Pattern instance and
return it.
'''
try:
(pattern_type, remainder) = pattern_line.split(' ', maxsplit=1)
except ValueError:
raise ValueError(f'Invalid pattern: {pattern_line}')
try:
(parsed_pattern_style, path) = remainder.split(':', maxsplit=1)
pattern_style = borgmatic.borg.pattern.Pattern_style(parsed_pattern_style)
except ValueError:
pattern_style = default_style
path = remainder
return borgmatic.borg.pattern.Pattern(
path,
borgmatic.borg.pattern.Pattern_type(pattern_type),
borgmatic.borg.pattern.Pattern_style(pattern_style),
source=borgmatic.borg.pattern.Pattern_source.CONFIG,
)
def collect_patterns(config, working_directory):
'''
Given a configuration dict and the working directory, produce a single sequence of patterns
comprised of the configured source directories, patterns, excludes, pattern files, and exclude
files.
The idea is that Borg has all these different ways of specifying includes, excludes, source
directories, etc., but we'd like to collapse them all down to one common format (patterns) for
ease of manipulation within borgmatic.
'''
try:
return (
tuple(
borgmatic.borg.pattern.Pattern(
source_directory,
source=borgmatic.borg.pattern.Pattern_source.CONFIG,
)
for source_directory in config.get('source_directories', ())
)
+ tuple(
parse_pattern(pattern_line.strip())
for pattern_line in config.get('patterns', ())
if not pattern_line.lstrip().startswith('#')
if pattern_line.strip()
)
+ tuple(
parse_pattern(
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
for exclude_line in config.get('exclude_patterns', ())
)
+ tuple(
parse_pattern(pattern_line.strip())
for filename in config.get('patterns_from', ())
for expanded_path in expand_directory(filename, working_directory)
for pattern_line in open(expanded_path, encoding='utf-8')
if not pattern_line.lstrip().startswith('#')
if pattern_line.strip()
)
+ tuple(
parse_pattern(
f'{borgmatic.borg.pattern.Pattern_type.NO_RECURSE.value} {exclude_line.strip()}',
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
for filename in config.get('exclude_from', ())
for expanded_path in expand_directory(filename, working_directory)
for exclude_line in open(expanded_path, encoding='utf-8')
if not exclude_line.lstrip().startswith('#')
if exclude_line.strip()
)
)
except (FileNotFoundError, OSError) as error:
logger.debug(error)
raise ValueError(f'Cannot read patterns_from/exclude_from file: {error.filename}')
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.
Take into account the given working directory so that relative paths are supported.
'''
expanded_directory = os.path.expanduser(directory)
# This would be a lot easier to do with glob(..., root_dir=working_directory), but root_dir is
# only available in Python 3.10+.
normalized_directory = os.path.join(working_directory or '', expanded_directory)
glob_paths = glob.glob(normalized_directory)
if not glob_paths:
return [expanded_directory]
working_directory_prefix = os.path.join(working_directory or '', '')
return [
(
glob_path
# If these are equal, that means we didn't add any working directory prefix above.
if normalized_directory == expanded_directory
# Remove the working directory prefix added above in order to make glob() work. We
# can't use os.path.relpath() here because it collapses any use of Borg's slashdot hack.
else glob_path.removeprefix(working_directory_prefix)
)
for glob_path in glob_paths
]
def expand_patterns(patterns, working_directory=None, skip_paths=None):
'''
Given a sequence of borgmatic.borg.pattern.Pattern instances and an optional working directory,
expand tildes and globs in each root pattern and expand just tildes in each non-root pattern.
The idea is that non-root patterns may be regular expressions or other pattern styles containing
"*" that borgmatic should not expand as a shell glob.
Return all the resulting patterns as a tuple.
If a set of paths are given to skip, then don't expand any patterns matching them.
'''
if patterns is None:
return ()
return tuple(
itertools.chain.from_iterable(
(
(
borgmatic.borg.pattern.Pattern(
expanded_path,
pattern.type,
pattern.style,
pattern.device,
pattern.source,
)
for expanded_path in expand_directory(pattern.path, working_directory)
)
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
and pattern.path not in (skip_paths or ())
else (
borgmatic.borg.pattern.Pattern(
os.path.expanduser(pattern.path),
pattern.type,
pattern.style,
pattern.device,
pattern.source,
),
)
)
for pattern in patterns
),
)
def get_existent_path_or_parent(path):
'''
Given a path, return it if it exists. Otherwise, return the longest parent directory of the path
that exists. Return None if none of these paths exist.
This is used below for finding an existent path prefix of pattern's path, which is necessary if
the path contain globs or other special characters that we don't want to try to interpret
(because we want to leave that responsibility to Borg).
'''
if path.startswith('/e2e/'):
return None
try:
return next(
candidate_path
for candidate_path in (
path,
*tuple(str(parent) for parent in pathlib.PurePath(path).parents),
)
if os.path.exists(candidate_path)
)
except StopIteration:
return None
def device_map_patterns(patterns, working_directory=None):
'''
Given a sequence of borgmatic.borg.pattern.Pattern instances and an optional working directory,
determine the identifier for the device on which the pattern's path resides—or None if the path
doesn't exist or is from a non-root pattern. Return an updated sequence of patterns with the
device field populated. But if the device field is already set, don't bother setting it again.
This is handy for determining whether two different pattern paths are on the same filesystem
(have the same device identifier).
This function only considers the start of a pattern's path—from the start of the path up until
there's a path component with a glob or other non-literal character. If there are no such
characters, the whole path is considered. The rationale is that it's not feasible for borgmatic
to interpret Borg's patterns to see which actual files (and therefore devices) they map to. So
for instance, a pattern with a path of "/var/log/*/data" would end up with its device set to the
device of "/var/log"—ignoring the "/*/data" part due to that glob.
The one exception is that if a regular expression pattern path starts with "^", that will get
stripped off for purposes of determining its device.
'''
return tuple(
borgmatic.borg.pattern.Pattern(
pattern.path,
pattern.type,
pattern.style,
device=pattern.device or (os.stat(existent_path).st_dev if existent_path else None),
source=pattern.source,
)
for pattern in patterns
for existent_path in (
get_existent_path_or_parent(
os.path.join(working_directory or '', pattern.path.lstrip('^')),
),
)
)
def deduplicate_runtime_directory_patterns(patterns, config, borgmatic_runtime_directory=None):
'''
Given a sequence of borgmatic.borg.pattern.Pattern instances, the borgmatic runtime directory,
and a configuration dict, return them without any duplicate root child patterns that contain the
runtime directory. For instance, if two root patterns are given with paths "/foo" and
"/foo/bar", and the runtime directory is "/foo/bar", return just the "/foo" pattern. Non-root
patterns and patterns not containing the runtime directory are passed through without
modification.
One exception to deduplication is if two paths are on different filesystems (devices) and
"one_file_system" is True in the given configuration. In that case, the paths won't get
deduplicated, because Borg won't cross filesystem boundaries when "one_file_system" is True.
The idea is that if Borg is given a root parent pattern containing the borgmatic runtime
directory, then Borg doesn't also need to be given child patterns, because it will naturally
spider the contents of the parent pattern's path. Additionally, there are cases where Borg
coming across the same file twice will result in duplicate reads and even hangs, e.g. when a
database hook in the borgmatic runtime directory is using a named pipe for streaming database
dumps to Borg.
This deduplication is limited to the borgmatic runtime directory (where borgmatic's named pipes
exist), because there are other legitimate use cases for parent and child patterns to both exist
in patterns. For instance, with some snapshotted filesystems, snapshots don't traverse from a
parent filesystem to a child and therefore both need to remain in patterns.
And for the case of named pipes outside of the borgmatic runtime directory, there is code
elsewhere (in the "create" action) that auto-excludes special files to prevent Borg hangs.
'''
if borgmatic_runtime_directory is None:
return patterns
deduplicated = {} # Use just the keys as an ordered set.
runtime_directory_parents = set(pathlib.PurePath(borgmatic_runtime_directory).parents).union(
{pathlib.PurePath(borgmatic_runtime_directory)}
)
for pattern in patterns:
if pattern.type != borgmatic.borg.pattern.Pattern_type.ROOT:
deduplicated[pattern] = True
continue
pattern_parents = pathlib.PurePath(pattern.path).parents
# If:
#
# 1. another pattern is a parent of the current pattern (even n levels up),
# 2. both patterns are parents of the runtime directory (even n levels up),
# 3. and both patterns are on the same filesystem (or one_file_system is not set)
#
# ... then consider the current pattern as a duplicate.
for other_pattern in patterns:
if other_pattern.type != borgmatic.borg.pattern.Pattern_type.ROOT:
continue
device_matches = pattern.device is not None and (
other_pattern.device == pattern.device or config.get('one_file_system') is not True
)
if (
pathlib.PurePath(other_pattern.path) in pattern_parents
and pathlib.PurePosixPath(other_pattern.path) in runtime_directory_parents
and pathlib.PurePosixPath(pattern.path) in runtime_directory_parents
and device_matches
):
break
else:
deduplicated[pattern] = True
return tuple(deduplicated.keys())
def process_patterns(
patterns, config, working_directory, borgmatic_runtime_directory=None, skip_expand_paths=None
):
'''
Given a sequence of Borg patterns, a configuration dict, a configured working directory, the
borgmatic runtime directory, and a sequence of paths to skip path expansion for, expand and
deduplicate any "root" patterns, returning the resulting root and non-root patterns as a list.
If the borgmatic runtime directory is None, then don't deduplicate patterns. Deduplication is
really only necessary for the "create" action when the runtime directory might contain named
pipes for database dumps.
'''
skip_paths = set(skip_expand_paths or ())
return list(
deduplicate_runtime_directory_patterns(
device_map_patterns(
expand_patterns(
patterns,
working_directory=working_directory,
skip_paths=skip_paths,
),
),
config,
borgmatic_runtime_directory,
),
)
-7
View File
@@ -1,8 +1,6 @@
import logging
import borgmatic.borg.prune
import borgmatic.config.validate
import borgmatic.hooks.command
logger = logging.getLogger(__name__)
@@ -21,11 +19,6 @@ def run_prune(
'''
Run the "prune" action for the given repository.
'''
if prune_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, prune_arguments.repository
):
return
logger.info(f'Pruning archives{dry_run_label}')
borgmatic.borg.prune.prune_archives(
global_arguments.dry_run,
+62 -21
View File
@@ -1,48 +1,76 @@
import logging
import subprocess
import borgmatic.actions.pattern
import borgmatic.borg.pattern
import borgmatic.borg.recreate
import borgmatic.config.validate
from borgmatic.actions.create import collect_patterns, process_patterns
import borgmatic.borg.repo_list
logger = logging.getLogger(__name__)
BORG_EXIT_CODE_ARCHIVE_ALREADY_EXISTS = 30
def run_recreate(
repository,
config,
local_borg_version,
recreate_arguments,
global_arguments,
dry_run_label,
local_path,
remote_path,
):
'''
Run the "recreate" action for the given repository.
'''
if recreate_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, recreate_arguments.repository
):
if recreate_arguments.archive:
logger.answer(f'Recreating archive {recreate_arguments.archive}')
else:
logger.answer('Recreating repository')
if recreate_arguments.archive:
logger.answer(f'Recreating archive {recreate_arguments.archive}{dry_run_label}')
else:
logger.answer(f'Recreating repository{dry_run_label}')
# Collect and process patterns.
processed_patterns = process_patterns(
collect_patterns(config), borgmatic.config.paths.get_working_directory(config)
working_directory = borgmatic.config.paths.get_working_directory(config)
# Collect and process patterns.
processed_patterns = borgmatic.actions.pattern.process_patterns(
(
*borgmatic.actions.pattern.collect_patterns(config, working_directory),
# Also add borgmatic-specific paths, so they don't get excluded from the recreated
# archive. Note that this doesn't currently work for archives created with Borg 1.2 or
# below.
borgmatic.borg.pattern.Pattern(
'/borgmatic', source=borgmatic.borg.pattern.Pattern_source.INTERNAL
),
),
config,
working_directory,
)
archive = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
recreate_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
if archive and archive.endswith('.recreate'):
if recreate_arguments.archive == 'latest':
raise ValueError(
f'The latest archive "{archive}" is leftover from a prior recreate. Delete it first or select a different archive.',
)
raise ValueError(
f'The archive "{recreate_arguments.archive}" is leftover from a prior recreate. Select a different archive.',
)
try:
borgmatic.borg.recreate.recreate_archive(
repository['path'],
borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
recreate_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
),
archive,
config,
local_borg_version,
recreate_arguments,
@@ -51,3 +79,16 @@ def run_recreate(
remote_path=remote_path,
patterns=processed_patterns,
)
except subprocess.CalledProcessError as error:
if error.returncode == BORG_EXIT_CODE_ARCHIVE_ALREADY_EXISTS:
if recreate_arguments.target:
raise ValueError(
f'The archive "{recreate_arguments.target}" already exists. Delete it first or set a different target archive name.',
)
if archive:
raise ValueError(
f'The archive "{archive}.recreate" is leftover from a prior recreate. Delete it first or select a different archive.',
)
raise
+1 -7
View File
@@ -1,7 +1,6 @@
import logging
import borgmatic.borg.repo_create
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -18,18 +17,13 @@ def run_repo_create(
'''
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('Creating repository')
encryption_mode = repo_create_arguments.encryption_mode or repository.get('encryption')
if not encryption_mode:
raise ValueError(
'With the repo-create action, either the --encryption flag or the repository encryption option is required.'
'With the repo-create action, either the --encryption flag or the repository encryption option is required.',
)
borgmatic.borg.repo_create.create_repository(
+12 -15
View File
@@ -17,19 +17,16 @@ def run_repo_delete(
'''
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(
'Deleting repository' + (' cache' if repo_delete_arguments.cache_only else '')
)
logger.answer(
'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,
)
borgmatic.borg.repo_delete.delete_repository(
repository,
config,
local_borg_version,
repo_delete_arguments,
global_arguments,
local_path,
remote_path,
)
+13 -17
View File
@@ -2,7 +2,6 @@ import logging
import borgmatic.actions.json
import borgmatic.borg.repo_info
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -21,20 +20,17 @@ def run_repo_info(
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('Displaying repository summary information')
if not repo_info_arguments.json:
logger.answer('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'))
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'))
+13 -17
View File
@@ -2,7 +2,6 @@ import logging
import borgmatic.actions.json
import borgmatic.borg.repo_list
import borgmatic.config.validate
logger = logging.getLogger(__name__)
@@ -21,20 +20,17 @@ def run_repo_list(
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('Listing repository')
if not repo_list_arguments.json:
logger.answer('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'))
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'))
+230 -137
View File
@@ -1,16 +1,17 @@
import collections
import locale
import logging
import os
import pathlib
import shutil
import tempfile
import borgmatic.actions.dump
import borgmatic.actions.pattern
import borgmatic.borg.extract
import borgmatic.borg.list
import borgmatic.borg.mount
import borgmatic.borg.repo_list
import borgmatic.config.paths
import borgmatic.config.validate
import borgmatic.hooks.data_source.dump
import borgmatic.hooks.dispatch
@@ -22,8 +23,8 @@ UNSPECIFIED = object()
Dump = collections.namedtuple(
'Dump',
('hook_name', 'data_source_name', 'hostname', 'port'),
defaults=('localhost', None),
('hook_name', 'data_source_name', 'hostname', 'port', 'label', 'container'),
defaults=(None, None, None, None),
)
@@ -33,17 +34,24 @@ def dumps_match(first, second, default_port=None):
indicates that the field should match any value. If a default port is given, then consider any
dump having that port to match with a dump having a None port.
'''
for field_name in first._fields:
# label kinda counts as an unique id, if they match ignore host/container/port
if first.label not in {None, UNSPECIFIED} and first.label == second.label:
field_list = ('hook_name', 'data_source_name')
else:
field_list = Dump._fields
for field_name in field_list:
first_value = getattr(first, field_name)
second_value = getattr(second, field_name)
if default_port is not None and field_name == 'port':
if first_value == default_port and second_value is None:
continue
if second_value == default_port and first_value is None:
continue
if first_value == UNSPECIFIED or second_value == UNSPECIFIED:
if first_value == UNSPECIFIED or second_value == UNSPECIFIED: # noqa: PLR1714
continue
if first_value != second_value:
@@ -56,16 +64,19 @@ def render_dump_metadata(dump):
'''
Given a Dump instance, make a display string describing it for use in log messages.
'''
label = dump.label or UNSPECIFIED
name = 'unspecified' if dump.data_source_name is UNSPECIFIED else dump.data_source_name
hostname = dump.hostname or UNSPECIFIED
host = dump.container or dump.hostname or UNSPECIFIED
port = None if dump.port is UNSPECIFIED else dump.port
if port:
metadata = f'{name}@:{port}' if hostname is UNSPECIFIED else f'{name}@{hostname}:{port}'
if label is not UNSPECIFIED:
metadata = f'{name}@{label}'
elif port:
metadata = f'{name}@:{port}' if host is UNSPECIFIED else f'{name}@{host}:{port}'
else:
metadata = f'{name}' if hostname is UNSPECIFIED else f'{name}@{hostname}'
metadata = f'{name}' if host is UNSPECIFIED else f'{name}@{host}'
if dump.hook_name not in (None, UNSPECIFIED):
if dump.hook_name not in {None, UNSPECIFIED}:
return f'{metadata} ({dump.hook_name})'
return metadata
@@ -98,8 +109,10 @@ def get_configured_data_source(config, restore_dump):
Dump(
hook_name,
hook_data_source.get('name'),
hook_data_source.get('hostname', 'localhost'),
hook_data_source.get('hostname'),
hook_data_source.get('port'),
hook_data_source.get('label') or UNSPECIFIED,
hook_data_source.get('container'),
),
restore_dump,
default_port,
@@ -111,20 +124,21 @@ def get_configured_data_source(config, restore_dump):
if len(matching_dumps) > 1:
raise ValueError(
f'Cannot restore data source {render_dump_metadata(restore_dump)} because there are multiple matching data sources configured'
f'Cannot restore data source {render_dump_metadata(restore_dump)} because there are multiple matching data sources configured',
)
return matching_dumps[0]
def strip_path_prefix_from_extracted_dump_destination(
destination_path, borgmatic_runtime_directory
extract_path,
borgmatic_runtime_directory,
):
'''
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.
that depends how the files were stored in the archive. So, given the 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:
@@ -138,15 +152,16 @@ def strip_path_prefix_from_extracted_dump_destination(
/run/user/0/borgmatic/postgresql_databases/test/...
'''
for subdirectory_path, _, _ in os.walk(destination_path):
for subdirectory_path, _, _ in os.walk(extract_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)
)
destination_path = os.path.join(borgmatic_runtime_directory, databases_directory)
shutil.rmtree(destination_path, ignore_errors=True)
shutil.move(subdirectory_path, destination_path)
break
@@ -169,7 +184,14 @@ def restore_single_dump(
that data source from the archive.
'''
dump_metadata = render_dump_metadata(
Dump(hook_name, data_source['name'], data_source.get('hostname'), data_source.get('port'))
Dump(
hook_name,
data_source['name'],
data_source.get('hostname'),
data_source.get('port'),
data_source.get('label') or UNSPECIFIED,
data_source.get('container'),
),
)
logger.info(f'Restoring data source {dump_metadata}')
@@ -180,6 +202,10 @@ def restore_single_dump(
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
data_source['name'],
data_source.get('hostname'),
data_source.get('port'),
data_source.get('container'),
data_source.get('label'),
)[hook_name.split('_databases', 1)[0]]
destination_path = (
@@ -197,8 +223,8 @@ def restore_single_dump(
archive=archive_name,
paths=[
borgmatic.hooks.data_source.dump.convert_glob_patterns_to_borg_pattern(
dump_patterns
)
dump_patterns,
),
],
config=config,
local_borg_version=local_borg_version,
@@ -213,7 +239,8 @@ def restore_single_dump(
if destination_path and not global_arguments.dry_run:
strip_path_prefix_from_extracted_dump_destination(
destination_path, borgmatic_runtime_directory
destination_path,
borgmatic_runtime_directory,
)
finally:
if destination_path and not global_arguments.dry_run:
@@ -246,16 +273,12 @@ def collect_dumps_from_archive(
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 dumps it contains
and return them as a set of Dump instances.
and return them as a tuple of Dump instances.
'''
borgmatic_source_directory = str(
pathlib.Path(borgmatic.config.paths.get_borgmatic_source_directory(config))
)
dumps_from_archive = {} # Use a dict as an ordered set.
# Probe for the data source dumps in multiple locations, as the default location has moved to
# the borgmatic runtime directory (which gets 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(
# There is (at most) one dump metadata file per data source hook. Load each.
for dumps_metadata_entry in borgmatic.borg.list.capture_archive_listing(
repository,
archive,
config,
@@ -264,7 +287,69 @@ def collect_dumps_from_archive(
list_paths=[
'sh:'
+ borgmatic.hooks.data_source.dump.make_data_source_dump_path(
base_directory, '*_databases/*/*'
base_directory,
'*_databases/dumps.json',
)
# Probe for dump metadata files in multiple locations, as the default location is
# "/borgmatic/*_databases/dumps.json" with Borg 1.4+, but instead begins with the
# borgmatic runtime directory for older versions of Borg.
for base_directory in (
'borgmatic',
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
)
],
local_path=local_path,
remote_path=remote_path,
):
if not dumps_metadata_entry.get('path'):
continue
for dump in borgmatic.hooks.data_source.dump.parse_data_source_dumps_metadata(
borgmatic.borg.extract.extract_archive(
global_arguments.dry_run,
repository,
archive,
[dumps_metadata_entry['path']],
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
extract_to_stdout=True,
)
.stdout.read()
.decode(locale.getpreferredencoding()),
dumps_metadata_entry['path'],
):
dumps_from_archive[dump] = None
# If we've successfully loaded any dumps metadata, we're done.
if dumps_from_archive:
logger.debug('Collecting database dumps from archive data source dumps metadata files')
return tuple(dumps_from_archive.keys())
# No dumps metadata files were found, so for backwards compatibility, fall back to parsing the
# paths of dumps found in the archive to get their respective dump metadata.
logger.debug('Collecting database dumps from archive data source dump paths (fallback)')
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 gets stored as just "/borgmatic" with Borg 1.4+). But
# we still want to support reading dumps from previously created archives as well.
dump_entries = borgmatic.borg.list.capture_archive_listing(
repository,
archive,
config,
local_borg_version,
global_arguments,
list_paths=[
'sh:'
+ borgmatic.hooks.data_source.dump.make_data_source_dump_path(
base_directory,
'*_databases/*/*',
)
for base_directory in (
'borgmatic',
@@ -276,10 +361,9 @@ def collect_dumps_from_archive(
remote_path=remote_path,
)
# Parse the paths of dumps found in the archive to get their respective dump metadata.
dumps_from_archive = set()
for dump_entry in dump_entries:
dump_path = dump_entry.get('path')
for dump_path in dump_paths:
if not dump_path:
continue
@@ -291,7 +375,8 @@ def collect_dumps_from_archive(
):
try:
(hook_name, host_and_port, data_source_name) = dump_path.split(
base_directory + os.path.sep, 1
base_directory + os.path.sep,
1,
)[1].split(os.path.sep)[0:3]
except (ValueError, IndexError):
continue
@@ -308,29 +393,34 @@ def collect_dumps_from_archive(
except (ValueError, TypeError):
port = None
dumps_from_archive.add(Dump(hook_name, data_source_name, hostname, port))
dumps_from_archive[
Dump(
hook_name, data_source_name, None if hostname == 'localhost' else hostname, port
)
] = None
# We've successfully parsed the dump path, so need to probe any further.
break
else:
logger.warning(
f'Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
f'Ignoring invalid data source dump path "{dump_path}" in archive {archive}',
)
return dumps_from_archive
return tuple(dumps_from_archive.keys())
def get_dumps_to_restore(restore_arguments, dumps_from_archive):
'''
Given restore arguments as an argparse.Namespace instance indicating which dumps to restore and
a set of Dump instances representing the dumps found in an archive, return a set of specific
a tuple of Dump instances representing the dumps found in an archive, return a tuple of specific
Dump instances from the archive to restore. As part of this, replace any Dump having a data
source name of "all" with multiple named Dump instances as appropriate.
Raise ValueError if any of the requested data source names cannot be found in the archive or if
there are multiple archive dump matches for a given requested dump.
'''
requested_dumps = (
requested_dumps = tuple(
# Use a dict comprehension as an ordered set.
{
Dump(
hook_name=(
@@ -345,28 +435,35 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive):
data_source_name=name,
hostname=restore_arguments.original_hostname or UNSPECIFIED,
port=restore_arguments.original_port,
)
label=restore_arguments.original_label or UNSPECIFIED,
container=restore_arguments.original_container or UNSPECIFIED,
): None
for name in restore_arguments.data_sources or (UNSPECIFIED,)
}
}.keys()
if restore_arguments.hook
or restore_arguments.data_sources
or restore_arguments.original_hostname
or restore_arguments.original_port
else {
or restore_arguments.original_label
or restore_arguments.original_container
else (
Dump(
hook_name=UNSPECIFIED,
data_source_name='all',
hostname=UNSPECIFIED,
port=UNSPECIFIED,
)
}
label=UNSPECIFIED,
container=UNSPECIFIED,
),
)
)
missing_dumps = set()
dumps_to_restore = set()
dumps_to_restore = {} # Use a dict as an ordered set.
# If there's a requested "all" dump, add every dump from the archive to the dumps to restore.
if any(dump for dump in requested_dumps if dump.data_source_name == 'all'):
dumps_to_restore.update(dumps_from_archive)
for dump in dumps_from_archive:
dumps_to_restore[dump] = None
# If any archive dump matches a requested dump, add the archive dump to the dumps to restore.
for requested_dump in requested_dumps:
@@ -382,10 +479,10 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive):
if len(matching_dumps) == 0:
missing_dumps.add(requested_dump)
elif len(matching_dumps) == 1:
dumps_to_restore.add(matching_dumps[0])
dumps_to_restore[matching_dumps[0]] = None
else:
raise ValueError(
f'Cannot restore data source {render_dump_metadata(requested_dump)} because there are multiple matching dumps in the archive. Try adding flags to disambiguate.'
f'Cannot restore data source {render_dump_metadata(requested_dump)} because there are multiple matching dumps in the archive. Try adding flags to disambiguate.',
)
if missing_dumps:
@@ -394,30 +491,31 @@ def get_dumps_to_restore(restore_arguments, dumps_from_archive):
)
raise ValueError(
f"Cannot restore data source dump{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from archive"
f"Cannot restore data source dump{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from archive",
)
return dumps_to_restore
return tuple(dumps_to_restore.keys())
def ensure_requested_dumps_restored(dumps_to_restore, dumps_actually_restored):
'''
Given a set of requested dumps to restore and a set of dumps actually restored, raise ValueError
if any requested dumps to restore weren't restored, indicating that they were missing from the
configuration.
Given a tuple of requested dumps to restore and a set of dumps actually restored, raise
ValueError if any requested dumps to restore weren't restored, indicating that they were missing
from the configuration.
'''
if not dumps_actually_restored:
if not dumps_to_restore:
raise ValueError('No data source dumps were found to restore')
missing_dumps = sorted(
dumps_to_restore - dumps_actually_restored, key=lambda dump: dump.data_source_name
set(dumps_to_restore) - dumps_actually_restored,
key=lambda dump: dump.data_source_name,
)
if missing_dumps:
rendered_dumps = ', '.join(f'{render_dump_metadata(dump)}' for dump in missing_dumps)
raise ValueError(
f"Cannot restore data source{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from borgmatic's configuration"
f"Cannot restore data source{'s' if len(missing_dumps) > 1 else ''} {rendered_dumps} missing from borgmatic's configuration",
)
@@ -431,101 +529,96 @@ def run_restore(
remote_path,
):
'''
Run the "restore" action for the given repository, but only if the repository matches the
requested repository in restore arguments.
Run the "restore" action for the given repository.
Raise ValueError if a configured data source could not be found to restore or there's no
matching dump in the archive.
'''
if restore_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, restore_arguments.repository
):
return
logger.info(f'Restoring data sources from archive {restore_arguments.archive}')
working_directory = borgmatic.config.paths.get_working_directory(config)
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
patterns = borgmatic.actions.pattern.process_patterns(
borgmatic.actions.pattern.collect_patterns(config, working_directory),
config,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
working_directory,
)
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
restore_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
dumps_from_archive = collect_dumps_from_archive(
repository['path'],
archive_name,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
)
dumps_to_restore = get_dumps_to_restore(restore_arguments, dumps_from_archive)
dumps_actually_restored = set()
connection_params = {
'hostname': restore_arguments.hostname,
'port': restore_arguments.port,
'username': restore_arguments.username,
'password': restore_arguments.password,
'restore_path': restore_arguments.restore_path,
}
# Restore each dump.
for restore_dump in dumps_to_restore:
found_data_source = get_configured_data_source(
config,
restore_dump,
)
# For a dump that wasn't found via an exact match in the configuration, try to fallback
# to an "all" data source.
if not found_data_source:
found_data_source = get_configured_data_source(
config,
Dump(restore_dump.hook_name, 'all', restore_dump.hostname, restore_dump.port),
)
if not found_data_source:
continue
found_data_source = dict(found_data_source)
found_data_source['name'] = restore_dump.data_source_name
dumps_actually_restored.add(restore_dump)
restore_single_dump(
repository,
with borgmatic.actions.dump.Dump_cleanup(
config, borgmatic_runtime_directory, patterns, global_arguments.dry_run
):
archive_name = borgmatic.borg.repo_list.resolve_archive_name(
repository['path'],
restore_arguments.archive,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
dumps_from_archive = collect_dumps_from_archive(
repository['path'],
archive_name,
restore_dump.hook_name,
dict(found_data_source, **{'schemas': restore_arguments.schemas}),
connection_params,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
)
dumps_to_restore = get_dumps_to_restore(restore_arguments, dumps_from_archive)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
config,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
dumps_actually_restored = set()
connection_params = {
'container': restore_arguments.container,
'hostname': restore_arguments.hostname,
'port': restore_arguments.port,
'username': restore_arguments.username,
'password': restore_arguments.password,
'restore_path': restore_arguments.restore_path,
}
# Restore each dump.
for restore_dump in dumps_to_restore:
found_data_source = get_configured_data_source(
config,
restore_dump,
)
# For a dump that wasn't found via an exact match in the configuration, try to fallback
# to an "all" data source.
if not found_data_source:
found_data_source = get_configured_data_source(
config,
Dump(
restore_dump.hook_name,
'all',
restore_dump.hostname,
restore_dump.port,
restore_dump.label,
restore_dump.container,
),
)
if not found_data_source:
continue
found_data_source = dict(found_data_source)
found_data_source['name'] = restore_dump.data_source_name
dumps_actually_restored.add(restore_dump)
restore_single_dump(
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
archive_name,
restore_dump.hook_name,
dict(found_data_source, schemas=restore_arguments.schemas),
connection_params,
borgmatic_runtime_directory,
)
ensure_requested_dumps_restored(dumps_to_restore, dumps_actually_restored)
+1 -1
View File
@@ -19,7 +19,7 @@ def run_transfer(
'''
if transfer_arguments.archive and config.get('match_archives'):
raise ValueError(
'With the transfer action, only one of --archive and --match-archives/match_archives can be used.'
'With the transfer action, only one of --archive and --match-archives/match_archives can be used.',
)
logger.info('Transferring archives to repository')
+5 -7
View File
@@ -39,9 +39,9 @@ def run_arbitrary_borg(
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():
if borg_command and borg_command[0] in borgmatic.commands.arguments.ACTION_ALIASES:
logger.warning(
f"Borg's {borg_command[0]} subcommand is supported natively by borgmatic. Try this instead: borgmatic {borg_command[0]}"
f"Borg's {borg_command[0]} subcommand is supported natively by borgmatic. Try this instead: borgmatic {borg_command[0]}",
)
except IndexError:
borg_command = ()
@@ -57,16 +57,14 @@ def run_arbitrary_borg(
+ command_options
)
return execute_command(
return execute_command( # noqa: S604
tuple(shlex.quote(part) for part in full_command),
output_file=DO_NOT_CAPTURE,
shell=True,
environment=dict(
(environment.make_environment(config) or {}),
**{
'BORG_REPO': repository_path,
'ARCHIVE': archive if archive else '',
},
BORG_REPO=repository_path,
ARCHIVE=archive if archive else '',
),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
+4 -1
View File
@@ -1,4 +1,5 @@
import logging
import shlex
import borgmatic.config.paths
from borgmatic.borg import environment, flags
@@ -22,15 +23,17 @@ def break_lock(
'''
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
extra_borg_options = config.get('extra_borg_options', {}).get('break_lock', '')
full_command = (
(local_path, 'break-lock')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if config.get('log_json') else ())
+ ('--log-json',)
+ (('--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 ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
+5 -3
View File
@@ -1,4 +1,5 @@
import logging
import shlex
import borgmatic.config.paths
import borgmatic.execute
@@ -25,15 +26,16 @@ def change_passphrase(
borgmatic.logger.add_custom_log_levels()
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
extra_borg_options = config.get('extra_borg_options', {}).get('key_change_passphrase', '')
full_command = (
(local_path, 'key', 'change-passphrase')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if config.get('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 ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_flags(
repository_path,
local_borg_version,
@@ -49,7 +51,7 @@ def change_passphrase(
config_without_passphrase = {
option_name: value
for (option_name, value) in config.items()
if option_name not in ('encryption_passphrase', 'encryption_passcommand')
if option_name not in {'encryption_passphrase', 'encryption_passcommand'}
}
borgmatic.execute.execute_command(
@@ -63,5 +65,5 @@ def change_passphrase(
)
logger.answer(
f"{repository_path}: Don't forget to update your encryption_passphrase option (if needed)"
"Don't forget to update your encryption_passphrase option (if needed)",
)
+25 -8
View File
@@ -1,6 +1,7 @@
import argparse
import json
import logging
import shlex
import borgmatic.config.paths
from borgmatic.borg import environment, feature, flags, repo_info
@@ -41,11 +42,12 @@ def make_archive_filter_flags(local_borg_version, config, checks, check_argument
if check_last:
logger.warning(
'Ignoring check_last option, as "archives" or "data" are not in consistency checks'
'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'
'Ignoring consistency prefix option, as "archives" or "data" are not in consistency checks',
)
return ()
@@ -75,13 +77,18 @@ def make_check_name_flags(checks, archive_filter_flags):
return common_flags
return (
tuple(f'--{check}-only' 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 get_repository_id(
repository_path, config, local_borg_version, global_arguments, local_path, remote_path
repository_path,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, global
@@ -99,7 +106,7 @@ def get_repository_id(
global_arguments,
local_path,
remote_path,
)
),
)['repository']['id']
except (json.JSONDecodeError, KeyError):
raise ValueError(f'Cannot determine Borg repository ID for {repository_path}')
@@ -126,8 +133,10 @@ def check_archives(
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')
@@ -140,8 +149,10 @@ def check_archives(
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
# If not configured, elevate Borg's exit code 1 (an ostensible warning) to error, because Borg
# returns exit code 1 for repository check errors!
borg_exit_codes = [*config.get('borg_exit_codes', []), *[{'code': 1, 'treat_as': 'error'}]]
umask = config.get('umask')
borg_exit_codes = config.get('borg_exit_codes')
working_directory = borgmatic.config.paths.get_working_directory(config)
if 'data' in checks:
@@ -167,11 +178,17 @@ def check_archives(
+ make_check_name_flags(checks_subset, archive_filter_flags)
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if config.get('log_json') else ())
+ (
('--log-json',)
if (
config.get('log_json') or not (check_arguments.repair or config.get('progress'))
)
else ()
)
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags
+ (('--progress',) if config.get('progress') else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
+11 -5
View File
@@ -1,7 +1,8 @@
import logging
import shlex
import borgmatic.config.paths
from borgmatic.borg import environment, flags
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import execute_command
logger = logging.getLogger(__name__)
@@ -30,19 +31,24 @@ def compact_segments(
(local_path, 'compact')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if config.get('log_json') else ())
+ (('--log-json',) if (config.get('log_json') or not config.get('progress')) else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--progress',) if config.get('progress') else ())
+ (('--cleanup-commits',) if cleanup_commits else ())
+ (('--threshold', str(threshold)) if threshold else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ (
('--dry-run',)
if dry_run and feature.available(feature.Feature.DRY_RUN_COMPACT, local_borg_version)
else ()
)
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
if dry_run:
logging.info('Skipping compact (dry run)')
if dry_run and not feature.available(feature.Feature.DRY_RUN_COMPACT, local_borg_version):
logger.info('Skipping compact (dry run)')
return
execute_command(
+205 -205
View File
@@ -1,9 +1,8 @@
import itertools
import logging
import os
import pathlib
import shlex
import stat
import tempfile
import textwrap
import borgmatic.borg.pattern
@@ -20,76 +19,6 @@ from borgmatic.execute import (
logger = logging.getLogger(__name__)
def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=None):
'''
Given a sequence of patterns as borgmatic.borg.pattern.Pattern instances, write them to a named
temporary file in the given borgmatic runtime directory and return the file object so it can
continue to exist on disk as long as the caller needs it.
If an optional open pattern file is given, append to it instead of making a new temporary file.
Return None if no patterns are provided.
'''
if not patterns:
return None
if patterns_file is None:
patterns_file = tempfile.NamedTemporaryFile('w', dir=borgmatic_runtime_directory)
operation_name = 'Writing'
else:
patterns_file.write('\n')
operation_name = 'Appending'
patterns_output = '\n'.join(
f'{pattern.type.value} {pattern.style.value}{":" if pattern.style.value else ""}{pattern.path}'
for pattern in patterns
)
logger.debug(f'{operation_name} patterns to {patterns_file.name}:\n{patterns_output}')
patterns_file.write(patterns_output)
patterns_file.flush()
return patterns_file
def make_exclude_flags(config):
'''
Given a configuration dict with various exclude options, return the corresponding Borg flags as
a tuple.
'''
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 config.get('exclude_if_present', ())
)
)
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 caches_flag + if_present_flags + keep_exclude_tags_flags + exclude_nodump_flags
def make_list_filter_flags(local_borg_version, dry_run):
'''
Given the local Borg version and whether this is a dry run, return the corresponding flags for
passing to "--list --filter". The general idea is that excludes are shown for a dry run or when
the verbosity is debug.
'''
base_flags = 'AME'
show_excludes = logger.isEnabledFor(logging.DEBUG)
if feature.available(feature.Feature.EXCLUDED_FILES_MINUS, local_borg_version):
if show_excludes or dry_run:
return f'{base_flags}+-'
else:
return base_flags
if show_excludes:
return f'{base_flags}x-'
else:
return f'{base_flags}-'
def special_file(path, working_directory=None):
'''
Return whether the given path is a special file (character device, block device, or named pipe
@@ -116,35 +45,46 @@ def any_parent_directories(path, candidate_parents):
return False
def collect_special_file_paths(
def validate_planned_backup_paths(
dry_run,
create_command,
config,
patterns,
local_path,
working_directory,
borgmatic_runtime_directory,
find_special_files=False,
):
'''
Given a dry-run flag, a Borg create command as a tuple, a configuration dict, a local Borg path,
a working directory, and the borgmatic runtime directory, 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.
a working directory, and the borgmatic runtime directory, perform a "borg create --dry-run" to
determine whether Borg's planned paths to include in a backup look good. Specifically, if the
given runtime directory exists, validate that it will be included in a backup and hasn't been
excluded.
Skip looking for special files in the given borgmatic runtime directory, as borgmatic creates
its own special files there for database dumps and we don't want those omitted.
If find special files is True, then return the subset of planned backup paths that are special
files. Otherwise, return an empty tuple.
Additionally, if the borgmatic runtime directory is not contained somewhere in the files Borg
plans to backup, that means the user must have excluded the runtime directory (e.g. via
"exclude_patterns" or similar). Therefore, raise, because this means Borg won't be able to
consume any database dumps and therefore borgmatic will hang when it tries to do so.
Raise ValueError if the runtime directory has been excluded via "exclude_patterns" or similar,
because any features that rely on the runtime directory getting backed up will break. For
instance, without the runtime directory, Borg can't consume any database dumps and borgmatic may
hang waiting for them to be consumed.
'''
# Omit "--exclude-nodump" from the Borg dry run command, because that flag causes Borg to open
# files including any named pipe we've created. And omit "--filter" because that can break the
# paths output parsing below such that path lines no longer start with th expected "- ".
paths_output = execute_command_and_capture_output(
flags.omit_flag_and_value(flags.omit_flag(create_command, '--exclude-nodump'), '--filter')
+ ('--dry-run', '--list'),
# paths output parsing below such that path lines no longer start with the expected "- ".
path_lines = execute_command_and_capture_output(
(
*flags.omit_flag_and_value(
flags.omit_flag(
flags.omit_flag(create_command, '--exclude-nodump'),
'--log-json',
),
'--filter',
),
'--dry-run',
'--list',
),
capture_stderr=True,
working_directory=working_directory,
environment=environment.make_environment(config),
@@ -154,56 +94,68 @@ def collect_special_file_paths(
# These are all the individual files that Borg is planning to backup as determined by the Borg
# create dry run above.
paths = tuple(
paths = (
path_line.split(' ', 1)[1]
for path_line in paths_output.split('\n')
if path_line and path_line.startswith('- ') or path_line.startswith('+ ')
for path_line in path_lines
if path_line and path_line.startswith(('- ', '+ '))
)
# These are the subset of those files that contain the borgmatic runtime directory.
paths_containing_runtime_directory = {}
if os.path.exists(borgmatic_runtime_directory):
paths_containing_runtime_directory = {
path for path in paths if any_parent_directories(path, (borgmatic_runtime_directory,))
}
# If no paths to backup contain the runtime directory, it must've been excluded.
if not paths_containing_runtime_directory and not dry_run:
raise ValueError(
f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes or patterns with excludes. Please ensure the runtime directory is not excluded.'
)
return tuple(
path
for path in paths
if special_file(path, working_directory)
if path not in paths_containing_runtime_directory
)
def check_all_root_patterns_exist(patterns):
'''
Given a sequence of borgmatic.borg.pattern.Pattern instances, check that all root pattern
paths exist. If any don't, raise an exception.
'''
missing_paths = [
include_pattern_paths = {
pattern.path
for pattern in patterns
if pattern.type == borgmatic.borg.pattern.Pattern_type.INCLUDE
}
runtime_directory_root_patterns = tuple(
pattern
for pattern in patterns
if any_parent_directories(pattern.path, (borgmatic_runtime_directory,))
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
if not os.path.exists(pattern.path)
]
# Skip root patterns that have corresponding include patterns, because those will "punch
# through" any subsequent excludes.
if pattern.path not in include_pattern_paths
)
if missing_paths:
raise ValueError(
f"Source directories or root pattern paths do not exist: {', '.join(missing_paths)}"
special_paths = []
validate_runtime_directory = bool(
not dry_run
and os.path.exists(borgmatic_runtime_directory)
and runtime_directory_root_patterns
)
runtime_directory_in_path = False
# Do everything in this one loop because we only want to consume the paths generator once.
for path in paths:
# If all root patterns in the runtime directory are missing from the paths Borg is planning
# to backup, then they must've gotten excluded, e.g. by user-configured excludes. Warn
# accordingly (below).
if validate_runtime_directory and any(
any_parent_directories(path, (pattern.path,))
for pattern in runtime_directory_root_patterns
):
runtime_directory_in_path = True
# Return the subset of output paths that are special files but *not* contained within the
# borgmatic runtime directory. The intent is to skip runtime paths that borgmatic uses for its
# own bookkeeping, instead focusing on user-configured paths.
if (
find_special_files
and not any_parent_directories(path, (borgmatic_runtime_directory,))
and special_file(path, working_directory)
):
special_paths.append(path)
if validate_runtime_directory and not runtime_directory_in_path:
logger.warning(
f'The runtime directory {os.path.normpath(borgmatic_runtime_directory)} overlaps with the configured excludes (or the snapshotted source directories are empty). Please ensure the runtime directory is not excluded.'
)
return tuple(special_paths)
MAX_SPECIAL_FILE_PATHS_LENGTH = 1000
def make_base_create_command(
def make_base_create_command( # noqa: PLR0912
dry_run,
repository_path,
config,
@@ -211,22 +163,31 @@ def make_base_create_command(
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
archive_suffix='',
local_path='borg',
remote_path=None,
json=False,
comment=None,
stream_processes=None,
):
'''
Given verbosity/dry-run flags, a local or remote repository path, a configuration dict, a
sequence of patterns as borgmatic.borg.pattern.Pattern instances, 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).
sequence of patterns as borgmatic.borg.pattern.Pattern instances, the local Borg version, global
arguments as an argparse.Namespace instance, the borgmatic runtime directory, a string suffix to
add to the archive name, the local Borg path, the remote Borg path, whether to output JSON,
comment text to add to the created archive, and a sequence of processes streaming data to Borg,
return a tuple of (base Borg create command flags, Borg create command positional arguments,
open pattern file handle).
'''
if config.get('source_directories_must_exist', False):
check_all_root_patterns_exist(patterns)
if config.get('source_directories_must_exist', True):
borgmatic.borg.pattern.check_all_root_patterns_exist(
patterns, borgmatic.config.paths.get_working_directory(config)
)
patterns_file = write_patterns_file(patterns, borgmatic_runtime_directory)
patterns_file = borgmatic.borg.pattern.write_patterns_file(
patterns,
borgmatic_runtime_directory,
)
checkpoint_interval = config.get('checkpoint_interval', None)
checkpoint_volume = config.get('checkpoint_volume', None)
chunker_params = config.get('chunker_params', None)
@@ -235,10 +196,12 @@ def make_base_create_command(
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)
list_filter_flags = flags.make_list_filter_flags(local_borg_version, dry_run)
files_changed = config.get('files_changed')
files_cache = config.get('files_cache')
archive_name_format = config.get(
'archive_name_format', flags.get_default_archive_name_format(local_borg_version)
archive_name_format = (
config.get('archive_name_format', flags.get_default_archive_name_format(local_borg_version))
+ archive_suffix
)
extra_borg_options = config.get('extra_borg_options', {}).get('create', '')
@@ -270,7 +233,8 @@ def make_base_create_command(
tuple(local_path.split(' '))
+ ('create',)
+ (('--patterns-from', patterns_file.name) if patterns_file else ())
+ make_exclude_flags(config)
+ flags.make_exclude_flags(config)
+ (('--comment', comment) if comment else ())
+ (('--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 ())
@@ -284,67 +248,80 @@ def make_base_create_command(
+ (('--nobirthtime',) if config.get('birthtime') is False else ())
+ (('--read-special',) if config.get('read_special') or stream_processes else ())
+ noflags_flags
+ (('--files-changed', files_changed) if files_changed else ())
+ (('--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 config.get('log_json') else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--log-json',) if (config.get('log_json') or not config.get('progress')) else ())
+ (
('--list', '--filter', list_filter_flags)
if config.get('list_details') and not json and not config.get('progress')
else ()
)
+ (('--dry-run',) if dry_run else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
)
create_positional_arguments = flags.make_repository_archive_flags(
repository_path, archive_name_format, local_borg_version
repository_path,
archive_name_format,
local_borg_version,
)
working_directory = borgmatic.config.paths.get_working_directory(config)
if config.get('unsafe_skip_path_validation_before_create'):
logger.debug(
'Skipping pre-backup path validation due to "unsafe_skip_path_validation_before_create" option.'
)
return (create_flags, create_positional_arguments, patterns_file)
logger.debug('Checking file paths Borg plans to include')
special_file_paths = validate_planned_backup_paths(
dry_run,
create_flags + create_positional_arguments,
config,
patterns,
local_path,
working_directory,
borgmatic_runtime_directory=borgmatic_runtime_directory,
find_special_files=bool(stream_processes),
)
if stream_processes and config.get('read_special') is False:
logger.warning(
'Ignoring configured "read_special" value of false, as true is needed for database hooks.',
)
# 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'):
if special_file_paths:
truncated_special_file_paths = textwrap.shorten(
', '.join(special_file_paths),
width=MAX_SPECIAL_FILE_PATHS_LENGTH,
placeholder=' ...',
)
logger.warning(
'Ignoring configured "read_special" value of false, as true is needed for database hooks.'
f'Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}',
)
working_directory = borgmatic.config.paths.get_working_directory(config)
logger.debug('Collecting special file paths')
special_file_paths = collect_special_file_paths(
dry_run,
create_flags + create_positional_arguments,
config,
local_path,
working_directory,
borgmatic_runtime_directory=borgmatic_runtime_directory,
patterns_file = borgmatic.borg.pattern.write_patterns_file(
tuple(
borgmatic.borg.pattern.Pattern(
special_file_path,
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
borgmatic.borg.pattern.Pattern_style.FNMATCH,
source=borgmatic.borg.pattern.Pattern_source.INTERNAL,
)
for special_file_path in special_file_paths
),
borgmatic_runtime_directory,
patterns_file=patterns_file,
)
if special_file_paths:
truncated_special_file_paths = textwrap.shorten(
', '.join(special_file_paths),
width=MAX_SPECIAL_FILE_PATHS_LENGTH,
placeholder=' ...',
)
logger.warning(
f'Excluding special files to prevent Borg from hanging: {truncated_special_file_paths}'
)
patterns_file = write_patterns_file(
tuple(
borgmatic.borg.pattern.Pattern(
special_file_path,
borgmatic.borg.pattern.Pattern_type.NO_RECURSE,
borgmatic.borg.pattern.Pattern_style.FNMATCH,
source=borgmatic.borg.pattern.Pattern_source.INTERNAL,
)
for special_file_path in special_file_paths
),
borgmatic_runtime_directory,
patterns_file=patterns_file,
)
if '--patterns-from' not in create_flags:
create_flags += ('--patterns-from', patterns_file.name)
if '--patterns-from' not in create_flags:
create_flags += ('--patterns-from', patterns_file.name)
return (create_flags, create_positional_arguments, patterns_file)
@@ -357,15 +334,20 @@ def create_archive(
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
archive_suffix='',
local_path='borg',
remote_path=None,
json=False,
comment=None,
stream_processes=None,
):
'''
Given verbosity/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).
sequence of loaded configuration paths, the local Borg version, global arguments as an
argparse.Namespace instance, the borgmatic runtime directory, a string suffix to add to the
archive name, the local Borg path, the remote Borg path, whether to output JSON, and comment
text to add to the created archive, and a sequence of processes streaming data to Borg, 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.
@@ -374,7 +356,7 @@ def create_archive(
working_directory = borgmatic.config.paths.get_working_directory(config)
(create_flags, create_positional_arguments, patterns_file) = make_base_create_command(
(create_flags, create_positional_arguments, _) = make_base_create_command(
dry_run,
repository_path,
config,
@@ -382,15 +364,19 @@ def create_archive(
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
archive_suffix,
local_path,
remote_path,
json,
comment,
stream_processes,
)
if json:
output_log_level = None
elif config.get('list_details') or (config.get('statistics') and not dry_run):
elif config.get('list_details') or (
(config.get('statistics') or config.get('quick_statistics')) and not dry_run
):
output_log_level = logging.ANSWER
else:
output_log_level = logging.INFO
@@ -402,6 +388,11 @@ def create_archive(
create_flags += (
(('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ())
+ (('--stats',) if config.get('statistics') and not json and not dry_run else ())
+ (
('--quick-stats',)
if config.get('quick_statistics') and not json and not dry_run
else ()
)
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ())
+ (('--progress',) if config.get('progress') else ())
+ (('--json',) if json else ())
@@ -409,31 +400,40 @@ def create_archive(
borg_exit_codes = config.get('borg_exit_codes')
if stream_processes:
return execute_command_with_processes(
create_flags + create_positional_arguments,
stream_processes,
output_log_level,
output_file,
working_directory=working_directory,
environment=environment.make_environment(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
output = '\n'.join(
execute_command_with_processes(
create_flags + create_positional_arguments,
stream_processes,
output_log_level,
output_file,
working_directory=working_directory,
environment=environment.make_environment(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
)
elif output_log_level is None:
return execute_command_and_capture_output(
create_flags + create_positional_arguments,
working_directory=working_directory,
environment=environment.make_environment(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
else:
execute_command(
create_flags + create_positional_arguments,
output_log_level,
output_file,
working_directory=working_directory,
environment=environment.make_environment(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
return output if json else None
if output_log_level is None:
output = '\n'.join(
execute_command_and_capture_output(
create_flags + create_positional_arguments,
working_directory=working_directory,
environment=environment.make_environment(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
)
return output if json else None
execute_command(
create_flags + create_positional_arguments,
output_log_level,
output_file,
working_directory=working_directory,
environment=environment.make_environment(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
return None
+15 -4
View File
@@ -1,5 +1,6 @@
import argparse
import logging
import shlex
import borgmatic.borg.environment
import borgmatic.borg.feature
@@ -11,6 +12,9 @@ import borgmatic.execute
logger = logging.getLogger(__name__)
FORCE_HARDER_FLAG_COUNT = 2
def make_delete_command(
repository,
config,
@@ -25,6 +29,8 @@ def make_delete_command(
arguments to the delete action as an argparse.Namespace, and global arguments, return a command
as a tuple to delete archives from the repository.
'''
extra_borg_options = config.get('extra_borg_options', {}).get('delete', '')
return (
(local_path, 'delete')
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
@@ -32,11 +38,14 @@ def make_delete_command(
+ 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', config.get('log_json'))
+ ('--log-json',)
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
+ borgmatic.borg.flags.make_flags('list', config.get('list_details'))
+ (
(('--force',) + (('--force',) if delete_arguments.force >= 2 else ()))
(
('--force',)
+ (('--force',) if delete_arguments.force >= FORCE_HARDER_FLAG_COUNT else ())
)
if delete_arguments.force
else ()
)
@@ -60,6 +69,7 @@ def make_delete_command(
'repository',
),
)
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
)
@@ -98,10 +108,11 @@ def delete_archives(
for argument_name in ARCHIVE_RELATED_ARGUMENT_NAMES
):
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.REPO_DELETE, local_borg_version
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.'
'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(
+90
View File
@@ -0,0 +1,90 @@
import logging
import shlex
import borgmatic.borg.environment
import borgmatic.borg.feature
import borgmatic.config.paths
import borgmatic.execute
from borgmatic.borg import flags
from borgmatic.borg.pattern import write_patterns_file
logger = logging.getLogger(__name__)
def diff(
repository,
archive,
second_archive,
config,
local_borg_version,
diff_arguments,
global_arguments,
local_path,
remote_path=None,
patterns=None,
):
'''
Given a local or remote repository path, two archive names, a configuration dict, the local Borg
version string, an argparse.Namespace of diff arguments, an argparse.Namespace of global
arguments, optional local and remote Borg paths, executes the diff command with the given
arguments.
'''
borgmatic.logger.add_custom_log_levels()
lock_wait = config.get('lock_wait', None)
exclude_flags = flags.make_exclude_flags(config)
extra_borg_options = config.get('extra_borg_options', {}).get('diff', '')
if diff_arguments.only_patterns:
# Write patterns to a temporary file and use that file with --patterns-from.
patterns_file = write_patterns_file(
patterns,
borgmatic.config.paths.get_working_directory(config),
)
else:
patterns_file = None
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.NUMERIC_IDS, local_borg_version
):
numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else ()
else:
numeric_ids_flags = ('--numeric-owner',) if config.get('numeric_ids') else ()
diff_command = (
(local_path, 'diff')
+ (('--remote-path', remote_path) if remote_path else ())
+ ('--log-json',)
+ (('--lock-wait', str(lock_wait)) if lock_wait is not None else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (
('--patterns-from', patterns_file.name)
if patterns_file and diff_arguments.only_patterns
else ()
)
+ exclude_flags
+ numeric_ids_flags
+ (('--same-chunker-params',) if diff_arguments.same_chunker_params else ())
+ (('--sort-by', ','.join(diff_arguments.sort_keys)) if diff_arguments.sort_keys else ())
+ (('--content-only',) if diff_arguments.content_only else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ (
(*flags.make_repository_flags(repository, local_borg_version), archive)
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.SEPARATE_REPOSITORY_ARCHIVE,
local_borg_version,
)
else flags.make_repository_archive_flags(repository, archive, local_borg_version)
)
+ (second_archive,)
)
borgmatic.execute.execute_command(
full_command=diff_command,
output_log_level=logging.ANSWER,
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'),
)
+16 -9
View File
@@ -10,17 +10,22 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
'borg_security_directory': 'BORG_SECURITY_DIR',
'borg_keys_directory': 'BORG_KEYS_DIR',
'borg_key_file': 'BORG_KEY_FILE',
'ssh_command': 'BORG_RSH',
'temporary_directory': 'TMPDIR',
}
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_UNCONDITIONAL_ENVIRONMENT_VARIABLE = {
'check_i_know_what_i_am_doing': 'BORG_CHECK_I_KNOW_WHAT_I_AM_DOING',
}
DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE = {
'check_i_know_what_i_am_doing': 'BORG_CHECK_I_KNOW_WHAT_I_AM_DOING',
DEFAULT_BOOL_OPTION_TO_ENVIRONMENT_VARIABLE = {
'debug_passphrase': 'BORG_DEBUG_PASSPHRASE',
'display_passphrase': 'BORG_DISPLAY_PASSPHRASE',
'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK',
'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK',
'use_chunks_archive': 'BORG_USE_CHUNKS_ARCHIVE',
'msgpack_version_check': 'BORG_MSGPACK_VERSION_CHECK',
}
@@ -61,7 +66,8 @@ def make_environment(config):
environment.pop('BORG_PASSCOMMAND', None)
passphrase = borgmatic.hooks.credential.parse.resolve_credential(
config.get('encryption_passphrase'), config
config.get('encryption_passphrase'),
config,
)
if passphrase is None:
@@ -82,15 +88,16 @@ def make_environment(config):
for (
option_name,
environment_variable_name,
) in DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE.items():
) in DEFAULT_BOOL_OPTION_TO_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'
if value is not None:
environment[environment_variable_name] = 'YES' if value else 'NO'
for (
option_name,
environment_variable_name,
) in DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE.items():
) in DEFAULT_BOOL_OPTION_TO_UNCONDITIONAL_ENVIRONMENT_VARIABLE.items():
value = config.get(option_name)
if value is not None:
environment[environment_variable_name] = 'YES' if value else 'NO'
+5 -2
View File
@@ -1,5 +1,6 @@
import logging
import os
import shlex
import borgmatic.config.paths
import borgmatic.logger
@@ -31,11 +32,12 @@ def export_key(
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
working_directory = borgmatic.config.paths.get_working_directory(config)
extra_borg_options = config.get('extra_borg_options', {}).get('key_export', '')
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.'
f'Destination path {export_arguments.path} already exists. Aborting.',
)
output_file = None
@@ -46,12 +48,13 @@ def export_key(
(local_path, 'key', 'export')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if config.get('log_json') else ())
+ (('--log-json',) if output_file is None 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)
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_flags(
repository_path,
local_borg_version,
+6 -6
View File
@@ -1,4 +1,5 @@
import logging
import shlex
import borgmatic.config.paths
import borgmatic.logger
@@ -34,12 +35,13 @@ def export_tar_archive(
borgmatic.logger.add_custom_log_levels()
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
extra_borg_options = config.get('extra_borg_options', {}).get('export_tar', '')
full_command = (
(local_path, 'export-tar')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if config.get('log_json') else ())
+ (('--log-json',) if destination_path != '-' else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--list',) if config.get('list_details') else ())
@@ -47,6 +49,7 @@ def export_tar_archive(
+ (('--dry-run',) if dry_run else ())
+ (('--tar-filter', tar_filter) if tar_filter else ())
+ (('--strip-components', str(strip_components)) if strip_components else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_archive_flags(
repository_path,
archive,
@@ -56,13 +59,10 @@ def export_tar_archive(
+ (tuple(paths) if paths else ())
)
if config.get('list_details'):
output_log_level = logging.ANSWER
else:
output_log_level = logging.INFO
output_log_level = logging.ANSWER if config.get('list_details') else logging.INFO
if dry_run:
logging.info('Skipping export to tar file (dry run)')
logger.info('Skipping export to tar file (dry run)')
return
execute_command(
+42 -25
View File
@@ -1,5 +1,6 @@
import logging
import os
import shlex
import subprocess
import borgmatic.config.paths
@@ -23,6 +24,7 @@ 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.
'''
extra_borg_options = config.get('extra_borg_options', {}).get('extract', '')
verbosity_flags = ()
if logger.isEnabledFor(logging.DEBUG):
verbosity_flags = ('--debug', '--show-rc')
@@ -47,22 +49,36 @@ def extract_last_archive_dry_run(
full_extract_command = (
(local_path, 'extract', '--dry-run')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--log-json',) if config.get('log_json') else ())
+ (('--log-json',) if not config.get('progress') else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags
+ (('--progress',) if config.get('progress') else ())
+ list_flag
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_archive_flags(
repository_path, last_archive_name, local_borg_version
repository_path,
last_archive_name,
local_borg_version,
)
)
execute_command(
full_extract_command,
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'),
)
if config.get('progress'):
execute_command(
full_extract_command,
output_file=DO_NOT_CAPTURE,
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'),
)
else:
execute_command(
full_extract_command,
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'),
)
def extract_archive(
@@ -90,9 +106,7 @@ def extract_archive(
'''
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
if config.get('progress') and extract_to_stdout:
raise ValueError('progress and extract to stdout cannot both be set')
extra_borg_options = config.get('extra_borg_options', {}).get('extract', '')
if feature.available(feature.Feature.NUMERIC_IDS, local_borg_version):
numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else ()
@@ -121,14 +135,15 @@ def extract_archive(
+ (('--remote-path', remote_path) if remote_path else ())
+ numeric_ids_flags
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if config.get('log_json') else ())
+ (('--log-json',) if (config.get('log_json') or not config.get('progress')) else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--dry-run',) if dry_run else ())
+ (('--strip-components', str(strip_components)) if strip_components else ())
+ (('--progress',) if config.get('progress') else ())
+ (('--progress',) if config.get('progress') and not extract_to_stdout else ())
+ (('--stdout',) if extract_to_stdout else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_archive_flags(
# 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
@@ -145,6 +160,17 @@ def extract_archive(
os.path.join(working_directory or '', destination_path) if destination_path else None
)
if extract_to_stdout:
return execute_command(
full_command,
output_file=subprocess.PIPE,
run_to_completion=False,
environment=environment.make_environment(config),
working_directory=full_destination_path,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
# The progress output isn't compatible with captured and logged output, as progress messes with
# the terminal directly.
if config.get('progress'):
@@ -158,17 +184,6 @@ def extract_archive(
)
return None
if extract_to_stdout:
return execute_command(
full_command,
output_file=subprocess.PIPE,
run_to_completion=False,
environment=environment.make_environment(config),
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(
@@ -178,3 +193,5 @@ def extract_archive(
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
return None
+2
View File
@@ -18,6 +18,7 @@ class Feature(Enum):
EXCLUDED_FILES_MINUS = 12
ARCHIVE_SERIES = 13
NO_PRUNE_STATS = 14
DRY_RUN_COMPACT = 15
FEATURE_TO_MINIMUM_BORG_VERSION = {
@@ -35,6 +36,7 @@ FEATURE_TO_MINIMUM_BORG_VERSION = {
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
Feature.NO_PRUNE_STATS: parse('2.0.0b10'), # prune --stats is not available
Feature.DRY_RUN_COMPACT: parse('1.2.9'), # borg compact --dry-run support
}
+60 -31
View File
@@ -1,5 +1,4 @@
import itertools
import json
import logging
import re
@@ -34,7 +33,7 @@ def make_flags_from_arguments(arguments, excludes=()):
make_flags(name, value=getattr(arguments, name))
for name in sorted(vars(arguments))
if name not in excludes and not name.startswith('_')
)
),
)
@@ -50,7 +49,7 @@ def make_repository_flags(repository_path, local_borg_version):
) + (repository_path,)
ARCHIVE_HASH_PATTERN = re.compile('[0-9a-fA-F]{8,}$')
ARCHIVE_HASH_PATTERN = re.compile(r'[0-9a-fA-F]{8,}$')
def make_repository_archive_flags(repository_path, archive, local_borg_version):
@@ -76,8 +75,8 @@ def make_repository_archive_flags(repository_path, archive, local_borg_version):
)
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
DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES = '{hostname}'
def get_default_archive_name_format(local_borg_version):
@@ -90,7 +89,7 @@ def get_default_archive_name_format(local_borg_version):
return DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES
def make_match_archives_flags(
def make_match_archives_flags( # noqa: PLR0911
match_archives,
archive_name_format,
local_borg_version,
@@ -115,8 +114,8 @@ def make_match_archives_flags(
return ('--match-archives', f'aid:{match_archives}')
return ('--match-archives', match_archives)
else:
return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
derived_match_archives = re.sub(
r'\{(now|utcnow|pid)([:%\w\.-]*)\}',
@@ -131,31 +130,22 @@ def make_match_archives_flags(
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}')
return ('--glob-archives', f'{derived_match_archives}')
def warn_for_aggressive_archive_flags(json_command, json_output):
def warn_for_aggressive_archive_flags(command, output_lines):
'''
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.
Given an archives command and the resulting output lines from running it, 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')
if {'--glob-archives', '--match-archives'}.intersection(set(command)) and len(
tuple(line for line in output_lines if not line.startswith('terminating with '))
) == 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.',
)
def omit_flag(arguments, flag):
@@ -193,7 +183,46 @@ def omit_flag_and_value(arguments, flag):
# its value.
return tuple(
argument
for (previous_argument, argument) in zip((None,) + arguments, arguments)
if flag not in (previous_argument, argument)
for (previous_argument, argument) in zip((None, *arguments), arguments)
if flag not in {previous_argument, argument}
if not argument.startswith(f'{flag}=')
)
def make_exclude_flags(config):
'''
Given a configuration dict with various exclude options, return the corresponding Borg flags as
a tuple.
'''
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 config.get('exclude_if_present', ())
),
)
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 caches_flag + if_present_flags + keep_exclude_tags_flags + exclude_nodump_flags
def make_list_filter_flags(local_borg_version, dry_run):
'''
Given the local Borg version and whether this is a dry run, return the corresponding flags for
passing to "--list --filter". The general idea is that excludes are shown for a dry run or when
the verbosity is debug.
'''
base_flags = 'AME'
show_excludes = logger.isEnabledFor(logging.DEBUG)
if feature.available(feature.Feature.EXCLUDED_FILES_MINUS, local_borg_version):
if show_excludes or dry_run:
return f'{base_flags}+-'
return base_flags
if show_excludes:
return f'{base_flags}x-'
return f'{base_flags}-'
+12 -12
View File
@@ -1,10 +1,10 @@
import logging
import os
import shlex
import borgmatic.config.paths
import borgmatic.logger
from borgmatic.borg import environment, flags
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
from borgmatic.execute import execute_command
logger = logging.getLogger(__name__)
@@ -30,29 +30,30 @@ def import_key(
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
working_directory = borgmatic.config.paths.get_working_directory(config)
extra_borg_options = config.get('extra_borg_options', {}).get('key_import', '')
if import_arguments.path and import_arguments.path != '-':
if not os.path.exists(os.path.join(working_directory or '', import_arguments.path)):
raise ValueError(f'Path {import_arguments.path} does not exist. Aborting.')
input_file = None
else:
input_file = DO_NOT_CAPTURE
if (
import_arguments.path
and import_arguments.path != '-'
and not os.path.exists(os.path.join(working_directory or '', import_arguments.path))
):
raise ValueError(f'Path {import_arguments.path} does not exist. Aborting.')
full_command = (
(local_path, 'key', 'import')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if config.get('log_json') else ())
+ ('--log-json',)
+ (('--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', import_arguments.paper)
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_flags(
repository_path,
local_borg_version,
)
+ ((import_arguments.path,) if input_file is None else ())
+ (import_arguments.path or '-',)
)
if global_arguments.dry_run:
@@ -61,7 +62,6 @@ def import_key(
execute_command(
full_command,
input_file=input_file,
output_log_level=logging.INFO,
environment=environment.make_environment(config),
working_directory=working_directory,
+30 -21
View File
@@ -1,10 +1,11 @@
import argparse
import logging
import shlex
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
from borgmatic.execute import execute_command_and_capture_output
logger = logging.getLogger(__name__)
@@ -23,6 +24,8 @@ def make_info_command(
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.
'''
extra_borg_options = config.get('extra_borg_options', {}).get('info', '')
return (
(local_path, 'info')
+ (
@@ -37,7 +40,7 @@ def make_info_command(
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', config.get('log_json'))
+ ('--log-json',)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ (
(
@@ -55,8 +58,10 @@ def make_info_command(
)
)
+ flags.make_flags_from_arguments(
info_arguments, excludes=('repository', 'archive', 'prefix', 'match_archives')
info_arguments,
excludes=('repository', 'archive', 'prefix', 'match_archives'),
)
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
@@ -98,24 +103,28 @@ def display_archives_info(
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,
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 json_info
return '\n'.join(
execute_command_and_capture_output(
json_command,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
)
flags.warn_for_aggressive_archive_flags(json_command, json_info)
execute_command(
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
output_lines = tuple(
execute_command_and_capture_output(
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
)
flags.warn_for_aggressive_archive_flags(main_command, output_lines)
return None
+44 -40
View File
@@ -1,7 +1,9 @@
import argparse
import copy
import json
import logging
import re
import shlex
import borgmatic.config.paths
import borgmatic.logger
@@ -17,7 +19,10 @@ MAKE_FLAGS_EXCLUDES = (
'archive',
'paths',
'find_paths',
) + ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST
'format',
'json',
*ARCHIVE_FILTER_FLAGS_MOVED_TO_REPO_LIST,
)
def make_list_command(
@@ -34,6 +39,8 @@ def make_list_command(
and local and remote Borg paths, return a command as a tuple to list archives or paths within an
archive.
'''
extra_borg_options = config.get('extra_borg_options', {}).get('list', '')
return (
(local_path, 'list')
+ (
@@ -48,12 +55,17 @@ def make_list_command(
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', config.get('log_json'))
+ ('--log-json',)
+ flags.make_flags('json-lines', list_arguments.json)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ flags.make_flags('format', list_arguments.format or config.get('file_list_format'))
+ flags.make_flags_from_arguments(list_arguments, excludes=MAKE_FLAGS_EXCLUDES)
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ (
flags.make_repository_archive_flags(
repository_path, list_arguments.archive, local_borg_version
repository_path,
list_arguments.archive,
local_borg_version,
)
if list_arguments.archive
else flags.make_repository_flags(repository_path, local_borg_version)
@@ -100,14 +112,15 @@ def capture_archive_listing(
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,
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.
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 indicating keys to include in the output, and local
and remote Borg paths, capture the output of listing that archive and return it as a generator
of dicts, one per path.
'''
return tuple(
execute_command_and_capture_output(
return (
json.loads(entry)
for entry in execute_command_and_capture_output(
make_list_command(
repository_path,
config,
@@ -115,10 +128,10 @@ def capture_archive_listing(
argparse.Namespace(
repository=repository_path,
archive=archive,
paths=[path for path in list_paths] if list_paths else None,
paths=list(list_paths) if list_paths else None,
find_paths=None,
json=None,
format=path_format or '{path}{NUL}', # noqa: FS003
json=True,
format=path_format or None,
),
global_arguments,
local_path,
@@ -129,8 +142,6 @@ def capture_archive_listing(
borg_local_path=local_path,
borg_exit_codes=config.get('borg_exit_codes'),
)
.strip('\0')
.split('\0')
)
@@ -156,7 +167,7 @@ def list_archive(
if not list_arguments.archive and not list_arguments.find_paths:
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 repo-list action instead.'
'Omitting the --archive flag on the list action is deprecated when using Borg 2.x+. Use the repo-list action instead.',
)
repo_list_arguments = argparse.Namespace(
@@ -184,14 +195,9 @@ def list_archive(
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."
f"The --{name.replace('_', '-')} flag on the list action is ignored when using the --archive flag.",
)
if list_arguments.json:
raise ValueError(
'The --json flag on the list action is not supported when using the --archive/--find flags.'
)
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
@@ -210,24 +216,20 @@ def list_archive(
)
# Ask Borg to list archives. Capture its output for use below.
archive_lines = tuple(
execute_command_and_capture_output(
repo_list.make_repo_list_command(
repository_path,
config,
local_borg_version,
repo_list_arguments,
global_arguments,
local_path,
remote_path,
),
environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
.strip('\n')
.splitlines()
archive_lines = execute_command_and_capture_output(
repo_list.make_repo_list_command(
repository_path,
config,
local_borg_version,
repo_list_arguments,
global_arguments,
local_path,
remote_path,
),
environment=environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
else:
archive_lines = (list_arguments.archive,)
@@ -262,3 +264,5 @@ def list_archive(
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
return None
+4 -1
View File
@@ -1,4 +1,5 @@
import logging
import shlex
import borgmatic.config.paths
from borgmatic.borg import environment, feature, flags
@@ -25,12 +26,13 @@ def mount_archive(
'''
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
extra_borg_options = config.get('extra_borg_options', {}).get('mount', '')
full_command = (
(local_path, 'mount')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if config.get('log_json') else ())
+ (('--log-json',) if not mount_arguments.foreground 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 ())
@@ -39,6 +41,7 @@ def mount_archive(
excludes=('repository', 'archive', 'mount_point', 'paths', 'options'),
)
+ (('-o', mount_arguments.options) if mount_arguments.options else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ (
(
flags.make_repository_flags(repository_path, local_borg_version)
+5 -3
View File
@@ -17,9 +17,11 @@ def run_passcommand(passcommand, working_directory):
Cache the results so that the passcommand only needs to run—and potentially prompt the user—once
per borgmatic invocation.
'''
return borgmatic.execute.execute_command_and_capture_output(
shlex.split(passcommand),
working_directory=working_directory,
return '\n'.join(
borgmatic.execute.execute_command_and_capture_output(
shlex.split(passcommand),
working_directory=working_directory,
)
)
+56
View File
@@ -1,5 +1,10 @@
import collections
import enum
import logging
import os
import tempfile
logger = logging.getLogger(__name__)
# See https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns
@@ -48,3 +53,54 @@ Pattern = collections.namedtuple(
Pattern_source.HOOK,
),
)
def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=None):
'''
Given a sequence of patterns as Pattern instances, write them to a named temporary file in the
given borgmatic runtime directory and return the file object so it can continue to exist on disk
as long as the caller needs it.
If an optional open pattern file is given, append to it instead of making a new temporary file.
Return None if no patterns are provided.
'''
if not patterns:
return None
if patterns_file is None:
patterns_file = tempfile.NamedTemporaryFile(
'w', dir=borgmatic_runtime_directory, encoding='utf-8'
)
operation_name = 'Writing'
else:
patterns_file.write('\n')
operation_name = 'Appending'
patterns_output = '\n'.join(
f'{pattern.type.value} {pattern.style.value}{":" if pattern.style.value else ""}{pattern.path}'
for pattern in patterns
)
logger.debug(f'{operation_name} patterns to {patterns_file.name}:\n{patterns_output}')
patterns_file.write(patterns_output)
patterns_file.flush()
return patterns_file
def check_all_root_patterns_exist(patterns, working_directory):
'''
Given a sequence of Pattern instances and the current working directory, check that all root
pattern paths exist. If any don't, raise an exception.
'''
missing_paths = [
pattern.path
for pattern in patterns
if pattern.type == Pattern_type.ROOT
if not os.path.exists(os.path.join(working_directory or '', pattern.path))
]
if missing_paths:
raise ValueError(
f"Source directories or root pattern paths do not exist: {', '.join(missing_paths)}",
)
+3 -2
View File
@@ -1,4 +1,5 @@
import logging
import shlex
import borgmatic.config.paths
import borgmatic.logger
@@ -71,9 +72,9 @@ def prune_archives(
full_command = (
(local_path, 'prune')
+ make_prune_flags(config, prune_arguments, local_borg_version)
+ ('--log-json',)
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if config.get('log_json') else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (
('--stats',)
@@ -90,7 +91,7 @@ def prune_archives(
+ (('--list',) if config.get('list_details') 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 ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
+12 -10
View File
@@ -6,7 +6,7 @@ import borgmatic.borg.feature
import borgmatic.config.paths
import borgmatic.execute
from borgmatic.borg import flags
from borgmatic.borg.create import make_exclude_flags, make_list_filter_flags, write_patterns_file
from borgmatic.borg.pattern import write_patterns_file
logger = logging.getLogger(__name__)
@@ -29,21 +29,24 @@ def recreate_archive(
arguments.
'''
lock_wait = config.get('lock_wait', None)
exclude_flags = make_exclude_flags(config)
exclude_flags = flags.make_exclude_flags(config)
compression = config.get('compression', None)
chunker_params = config.get('chunker_params', None)
extra_borg_options = config.get('extra_borg_options', {}).get('recreate', '')
# Available recompress MODES: "if-different", "always", "never" (default)
recompress = config.get('recompress', None)
# Write patterns to a temporary file and use that file with --patterns-from.
patterns_file = write_patterns_file(
patterns, borgmatic.config.paths.get_working_directory(config)
patterns,
borgmatic.config.paths.get_working_directory(config),
)
recreate_command = (
(local_path, 'recreate')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--log-json',) if config.get('log_json') else ())
+ ('--log-json',)
+ (('--lock-wait', str(lock_wait)) if lock_wait is not None else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
@@ -52,7 +55,7 @@ def recreate_archive(
(
'--list',
'--filter',
make_list_filter_flags(local_borg_version, global_arguments.dry_run),
flags.make_list_filter_flags(local_borg_version, global_arguments.dry_run),
)
if config.get('list_details')
else ()
@@ -69,6 +72,8 @@ def recreate_archive(
+ (('--chunker-params', chunker_params) if chunker_params else ())
+ (('--recompress', recompress) if recompress else ())
+ exclude_flags
+ (('--dry-run',) if global_arguments.dry_run else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ (
(
flags.make_repository_flags(repository, local_borg_version)
@@ -79,7 +84,8 @@ def recreate_archive(
)
)
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version
borgmatic.borg.feature.Feature.SEPARATE_REPOSITORY_ARCHIVE,
local_borg_version,
)
else (
flags.make_repository_archive_flags(repository, archive, local_borg_version)
@@ -89,10 +95,6 @@ def recreate_archive(
)
)
if global_arguments.dry_run:
logger.info('Skipping the archive recreation (dry run)')
return
borgmatic.execute.execute_command(
full_command=recreate_command,
output_log_level=logging.INFO,
+69
View File
@@ -0,0 +1,69 @@
import logging
import shlex
import borgmatic.borg.environment
import borgmatic.borg.flags
logger = logging.getLogger(__name__)
def make_rename_command(
dry_run,
repository_name,
old_archive_name,
new_archive_name,
config,
local_borg_version,
local_path,
remote_path,
):
extra_borg_options = config.get('extra_borg_options', {}).get('rename', '')
return (
(local_path, 'rename')
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ borgmatic.borg.flags.make_flags('dry-run', dry_run)
+ borgmatic.borg.flags.make_flags('remote-path', remote_path)
+ borgmatic.borg.flags.make_flags('umask', config.get('umask'))
+ ('--log-json',)
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ borgmatic.borg.flags.make_repository_archive_flags(
repository_name,
old_archive_name,
local_borg_version,
)
+ (new_archive_name,)
)
def rename_archive(
repository_name,
old_archive_name,
new_archive_name,
dry_run,
config,
local_borg_version,
local_path,
remote_path,
):
command = make_rename_command(
dry_run,
repository_name,
old_archive_name,
new_archive_name,
config,
local_borg_version,
local_path,
remote_path,
)
borgmatic.execute.execute_command(
command,
output_log_level=logging.INFO,
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'),
)
+12 -5
View File
@@ -1,6 +1,7 @@
import argparse
import json
import logging
import shlex
import subprocess
import borgmatic.config.paths
@@ -48,13 +49,13 @@ def create_repository(
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}"'
f'Requested encryption mode "{encryption_mode}" does not match existing repository encryption mode "{repository_encryption_mode}"',
)
logger.info('Repository already exists. Skipping creation.')
@@ -65,8 +66,14 @@ def create_repository(
lock_wait = config.get('lock_wait')
umask = config.get('umask')
extra_borg_options_from_init = config.get('extra_borg_options', {}).get('init', '')
extra_borg_options = config.get('extra_borg_options', {}).get('repo-create', '')
if extra_borg_options_from_init:
logger.warning(
'The "init" option in "extra_borg_options" is deprecated and will be removed from a future release; use "repo_create" instead.'
)
repo_create_command = (
(local_path,)
+ (
@@ -82,16 +89,16 @@ def create_repository(
+ (('--make-parent-dirs',) if make_parent_directories else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug',) if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--log-json',) if config.get('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 ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ (tuple(shlex.split(extra_borg_options_from_init)) if extra_borg_options_from_init else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
if dry_run:
logging.info('Skipping repository creation (dry run)')
logger.info('Skipping repository creation (dry run)')
return
# Do not capture output here, so as to support interactive prompts.
+37 -13
View File
@@ -1,4 +1,5 @@
import logging
import shlex
import borgmatic.borg.environment
import borgmatic.borg.feature
@@ -9,6 +10,9 @@ import borgmatic.execute
logger = logging.getLogger(__name__)
FORCE_HARDER_FLAG_COUNT = 2
def make_repo_delete_command(
repository,
config,
@@ -17,18 +21,30 @@ def make_repo_delete_command(
global_arguments,
local_path,
remote_path,
output_file,
):
'''
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.
arguments to the repo_delete action as an argparse.Namespace, and global arguments, the Borg
local path, the Borg remote path, and an optional output file, return a command as a tuple to
repo_delete the entire repository.
'''
extra_borg_options = config.get('extra_borg_options', {}).get(
'repo_delete'
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.REPO_DELETE, local_borg_version
)
else 'delete',
'',
)
return (
(local_path,)
+ (
('repo-delete',)
if borgmatic.borg.feature.available(
borgmatic.borg.feature.Feature.REPO_DELETE, local_borg_version
borgmatic.borg.feature.Feature.REPO_DELETE,
local_borg_version,
)
else ('delete',)
)
@@ -37,17 +53,22 @@ def make_repo_delete_command(
+ 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', config.get('log_json'))
+ (('--log-json',) if output_file is None else ())
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
+ borgmatic.borg.flags.make_flags('list', config.get('list_details'))
+ (
(('--force',) + (('--force',) if repo_delete_arguments.force >= 2 else ()))
(
('--force',)
+ (('--force',) if repo_delete_arguments.force >= FORCE_HARDER_FLAG_COUNT else ())
)
if repo_delete_arguments.force
else ()
)
+ borgmatic.borg.flags.make_flags_from_arguments(
repo_delete_arguments, excludes=('list_details', 'force', 'repository')
repo_delete_arguments,
excludes=('list_details', 'force', 'repository'),
)
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ borgmatic.borg.flags.make_repository_flags(repository['path'], local_borg_version)
)
@@ -68,6 +89,14 @@ def delete_repository(
'''
borgmatic.logger.add_custom_log_levels()
# 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
)
command = make_repo_delete_command(
repository,
config,
@@ -76,18 +105,13 @@ def delete_repository(
global_arguments,
local_path,
remote_path,
output_file,
)
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
),
output_file=output_file,
environment=borgmatic.borg.environment.make_environment(config),
working_directory=borgmatic.config.paths.get_working_directory(config),
borg_local_path=local_path,
+27 -16
View File
@@ -1,4 +1,5 @@
import logging
import shlex
import borgmatic.config.paths
import borgmatic.logger
@@ -24,6 +25,10 @@ def display_repository_info(
'''
borgmatic.logger.add_custom_log_levels()
lock_wait = config.get('lock_wait', None)
extra_borg_options = config.get('extra_borg_options', {}).get(
'repo_info' if feature.available(feature.Feature.REPO_INFO, local_borg_version) else 'info',
'',
)
full_command = (
(local_path,)
@@ -32,6 +37,7 @@ def display_repository_info(
if feature.available(feature.Feature.REPO_INFO, local_borg_version)
else ('info',)
)
+ (('--critical',) if repo_info_arguments.json else ())
+ (
('--info',)
if logger.getEffectiveLevel() == logging.INFO and not repo_info_arguments.json
@@ -44,9 +50,10 @@ def display_repository_info(
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', config.get('log_json'))
+ flags.make_flags('lock-wait', lock_wait)
+ ('--log-json',)
+ (('--json',) if repo_info_arguments.json else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
@@ -54,19 +61,23 @@ def display_repository_info(
borg_exit_codes = config.get('borg_exit_codes')
if repo_info_arguments.json:
return execute_command_and_capture_output(
full_command,
environment=environment.make_environment(config),
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,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
return '\n'.join(
execute_command_and_capture_output(
full_command,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
)
execute_command(
full_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
return None
+114 -46
View File
@@ -1,10 +1,12 @@
import argparse
import json
import logging
import shlex
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
from borgmatic.execute import execute_command_and_capture_output
logger = logging.getLogger(__name__)
@@ -22,49 +24,102 @@ def resolve_archive_name(
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.
repository for the latest archive and return its name or ID, depending on whether the version of
Borg in use supports archive series—different archives that share the same name but have unique
IDs.
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', config.get('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)
latest_archive = get_latest_archive(
repository_path,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
output = execute_command_and_capture_output(
full_command,
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'),
return (
latest_archive['id']
if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version)
else latest_archive['name']
)
def get_latest_archive(
repository_path,
config,
local_borg_version,
global_arguments,
local_path='borg',
remote_path=None,
consider_checkpoints=False,
):
'''
Returns a dict with information about the latest archive of a repository.
Raises ValueError if there are no archives in the repository.
'''
extra_borg_options = config.get('extra_borg_options', {}).get(
'repo_list' if feature.available(feature.Feature.REPO_LIST, local_borg_version) else 'list',
'',
)
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')),
*('--log-json',),
*flags.make_flags('lock-wait', config.get('lock_wait')),
*(
flags.make_match_archives_flags(
config.get('match_archives'),
config.get('archive_name_format'),
local_borg_version,
)
),
*(
flags.make_flags('consider-checkpoints', consider_checkpoints)
if not feature.available(feature.Feature.REPO_LIST, local_borg_version)
else ()
),
*flags.make_flags('last', 1),
'--json',
*(tuple(shlex.split(extra_borg_options)) if extra_borg_options else ()),
*flags.make_repository_flags(repository_path, local_borg_version),
)
json_output = '\n'.join(
execute_command_and_capture_output(
full_command,
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'),
)
)
archives = json.loads(json_output)['archives']
try:
latest_archive = output.strip().splitlines()[-1]
latest_archive = archives[-1]
except IndexError:
raise ValueError('No archives found in the repository')
logger.debug(f'Latest archive is {latest_archive}')
logger.debug(f'Latest archive is {latest_archive["name"]} ({latest_archive["id"]})')
return latest_archive
MAKE_FLAGS_EXCLUDES = ('repository', 'prefix', 'match_archives')
MAKE_FLAGS_EXCLUDES = ('repository', 'format', 'prefix', 'match_archives')
def make_repo_list_command(
@@ -81,6 +136,11 @@ def make_repo_list_command(
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.
'''
extra_borg_options = config.get('extra_borg_options', {}).get(
'repo_list' if feature.available(feature.Feature.REPO_LIST, local_borg_version) else 'list',
'',
)
return (
(
local_path,
@@ -102,7 +162,7 @@ def make_repo_list_command(
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', config.get('log_json'))
+ ('--log-json',)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ (
(
@@ -119,7 +179,11 @@ def make_repo_list_command(
)
)
)
+ flags.make_flags(
'format', repo_list_arguments.format or config.get('archive_list_format')
)
+ flags.make_flags_from_arguments(repo_list_arguments, excludes=MAKE_FLAGS_EXCLUDES)
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
@@ -162,24 +226,28 @@ def list_repository(
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,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
if repo_list_arguments.json:
return json_listing
return '\n'.join(
execute_command_and_capture_output(
json_command,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
)
flags.warn_for_aggressive_archive_flags(json_command, json_listing)
execute_command(
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
output_lines = tuple(
execute_command_and_capture_output(
main_command,
output_log_level=logging.ANSWER,
environment=environment.make_environment(config),
working_directory=working_directory,
borg_local_path=local_path,
borg_exit_codes=borg_exit_codes,
)
)
flags.warn_for_aggressive_archive_flags(main_command, output_lines)
return None
+1 -1
View File
@@ -1 +1 @@
DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic'
DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic' # pragma: nocover
+4 -1
View File
@@ -1,4 +1,5 @@
import logging
import shlex
import borgmatic.config.paths
import borgmatic.logger
@@ -24,6 +25,7 @@ def transfer_archives(
instance, transfer archives to the given repository.
'''
borgmatic.logger.add_custom_log_levels()
extra_borg_options = config.get('extra_borg_options', {}).get('transfer', '')
full_command = (
(local_path, 'transfer')
@@ -31,7 +33,7 @@ def transfer_archives(
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('umask', config.get('umask'))
+ flags.make_flags('log-json', config.get('log_json'))
+ (('--log-json',) if (config.get('log_json') or not config.get('progress')) else ())
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ flags.make_flags('progress', config.get('progress'))
+ (
@@ -53,6 +55,7 @@ def transfer_archives(
)
)
)
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ 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)
+4
View File
@@ -1,4 +1,5 @@
import logging
import shlex
import borgmatic.config.paths
from borgmatic.execute import execute_command
@@ -11,10 +12,13 @@ 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.
'''
extra_borg_options = config.get('extra_borg_options', {}).get('umount', '')
full_command = (
(local_path, 'umount')
+ ('--log-json',)
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ (mount_point,)
)
+10 -6
View File
@@ -16,15 +16,19 @@ def local_borg_version(config, local_path='borg'):
'''
full_command = (
(local_path, '--version')
+ ('--log-json',)
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
)
output = execute_command_and_capture_output(
full_command,
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'),
output = '\n'.join(
execute_command_and_capture_output(
full_command,
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:
+354 -100
View File
@@ -31,8 +31,10 @@ ACTION_ALIASES = {
'transfer': [],
'break-lock': [],
'key': [],
'borg': [],
'recreate': [],
'diff': [],
'browse': [],
'borg': [],
}
@@ -60,7 +62,7 @@ def get_subactions_for_actions(action_parsers):
action: tuple(
subaction_name
for group_action in action_parser._subparsers._group_actions
for subaction_name in group_action.choices.keys()
for subaction_name in group_action.choices
)
for action, action_parser in action_parsers.items()
if action_parser._subparsers
@@ -77,21 +79,25 @@ def omit_values_colliding_with_action_names(unparsed_arguments, parsed_arguments
'''
remaining_arguments = list(unparsed_arguments)
for action_name, parsed in parsed_arguments.items():
for parsed in parsed_arguments.values():
for value in vars(parsed).values():
if isinstance(value, str):
if value in ACTION_ALIASES.keys() and value in remaining_arguments:
if value in ACTION_ALIASES and value in remaining_arguments:
remaining_arguments.remove(value)
elif isinstance(value, list):
for item in value:
if item in ACTION_ALIASES.keys() and item in remaining_arguments:
if item in ACTION_ALIASES and item in remaining_arguments:
remaining_arguments.remove(item)
return tuple(remaining_arguments)
def parse_and_record_action_arguments(
unparsed_arguments, parsed_arguments, action_parser, action_name, canonical_name=None
unparsed_arguments,
parsed_arguments,
action_parser,
action_name,
canonical_name=None,
):
'''
Given unparsed arguments as a sequence of strings, parsed arguments as a dict from action name
@@ -102,7 +108,8 @@ def parse_and_record_action_arguments(
given action doesn't apply to the given unparsed arguments.
'''
filtered_arguments = omit_values_colliding_with_action_names(
unparsed_arguments, parsed_arguments
unparsed_arguments,
parsed_arguments,
)
if action_name not in filtered_arguments:
@@ -186,12 +193,12 @@ def get_unparsable_arguments(remaining_action_arguments):
itertools.chain.from_iterable(
argument_group
for argument_group in dict.fromkeys(
itertools.chain.from_iterable(grouped_action_arguments)
).keys()
itertools.chain.from_iterable(grouped_action_arguments),
)
if all(
argument_group in action_arguments for action_arguments in grouped_action_arguments
)
)
),
)
@@ -244,7 +251,7 @@ def parse_arguments_for_actions(unparsed_arguments, action_parsers, global_parse
subaction_name,
)
if argument != action_name
)
),
)
if subaction_name in arguments:
@@ -256,14 +263,18 @@ def parse_arguments_for_actions(unparsed_arguments, action_parsers, global_parse
sys.exit(0)
else:
raise ValueError(
f"Missing sub-action after {action_name} action. Expected one of: {', '.join(get_subactions_for_actions(action_parsers)[action_name])}"
f"Missing sub-action after {action_name} action. Expected one of: {', '.join(get_subactions_for_actions(action_parsers)[action_name])}",
)
# Otherwise, parse with the main action parser.
else:
remaining_action_arguments.append(
parse_and_record_action_arguments(
unparsed_arguments, arguments, action_parser, action_name, canonical_name
)
unparsed_arguments,
arguments,
action_parser,
action_name,
canonical_name,
),
)
# If no actions were explicitly requested, assume defaults.
@@ -272,11 +283,11 @@ def parse_arguments_for_actions(unparsed_arguments, action_parsers, global_parse
default_action_parser = action_parsers[default_action_name]
remaining_action_arguments.append(
parse_and_record_action_arguments(
tuple(unparsed_arguments) + (default_action_name,),
(*unparsed_arguments, default_action_name),
arguments,
default_action_parser,
default_action_name,
)
),
)
arguments['global'], remaining = global_parser.parse_known_args(unparsed_arguments)
@@ -288,7 +299,14 @@ def parse_arguments_for_actions(unparsed_arguments, action_parsers, global_parse
)
OMITTED_FLAG_NAMES = {'match-archives', 'progress', 'statistics', 'list-details'}
OMITTED_FLAG_NAMES = {
'match-archives',
'progress',
'statistics',
'list-details',
'file-list-format',
'archive-list-format',
}
def make_argument_description(schema, flag_name):
@@ -304,7 +322,7 @@ def make_argument_description(schema, flag_name):
if '[0]' in flag_name:
pieces.append(
' To specify a different list element, replace the "[0]" with another array index ("[1]", "[2]", etc.).'
' To specify a different list element, replace the "[0]" with another array index ("[1]", "[2]", etc.).',
)
if example and schema_type in ('array', 'object'):
@@ -387,7 +405,7 @@ def add_array_element_arguments(arguments_group, unparsed_arguments, flag_name):
if not pattern.match(unparsed_flag_name) or unparsed_flag_name == existing_flag_name:
continue
if action_registry_name in ('store_true', 'store_false'):
if action_registry_name in {'store_true', 'store_false'}:
arguments_group.add_argument(
unparsed_flag_name,
action=action_registry_name,
@@ -408,7 +426,7 @@ def add_array_element_arguments(arguments_group, unparsed_arguments, flag_name):
)
def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names=None):
def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names=None): # noqa: PLR0912
'''
Given an argparse._ArgumentGroup instance, a configuration schema dict, and a sequence of
unparsed argument strings, convert the entire schema into corresponding command-line flags and
@@ -466,7 +484,10 @@ def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names
if properties:
for name, child in properties.items():
add_arguments_from_schema(
arguments_group, child, unparsed_arguments, names + (name,)
arguments_group,
child,
unparsed_arguments,
(*names, name),
)
return
@@ -483,12 +504,15 @@ def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names
arguments_group,
child,
unparsed_arguments,
names[:-1] + (f'{names[-1]}[0]',) + (name,),
(*names[:-1], f'{names[-1]}[0]', name),
)
# If there aren't any children, then this is an array of scalars. Recurse accordingly.
else:
add_arguments_from_schema(
arguments_group, items, unparsed_arguments, names[:-1] + (f'{names[-1]}[0]',)
arguments_group,
items,
unparsed_arguments,
(*names[:-1], f'{names[-1]}[0]'),
)
flag_name = '.'.join(names).replace('_', '-')
@@ -515,9 +539,9 @@ def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names
)
if names[-1].startswith('no_'):
no_flag_name = '.'.join(names[:-1] + (names[-1][len('no_') :],)).replace('_', '-')
no_flag_name = '.'.join((*names[:-1], names[-1][len('no_') :])).replace('_', '-')
else:
no_flag_name = '.'.join(names[:-1] + ('no-' + names[-1],)).replace('_', '-')
no_flag_name = '.'.join((*names[:-1], 'no-' + names[-1])).replace('_', '-')
arguments_group.add_argument(
f'--{no_flag_name}',
@@ -545,7 +569,7 @@ def add_arguments_from_schema(arguments_group, schema, unparsed_arguments, names
add_array_element_arguments(arguments_group, unparsed_arguments, flag_name)
def make_parsers(schema, unparsed_arguments):
def make_parsers(schema, unparsed_arguments): # noqa: PLR0915
'''
Given a configuration schema dict and unparsed arguments as a sequence of strings, build a
global arguments parser, individual action parsers, and a combined parser containing both.
@@ -553,8 +577,7 @@ def make_parsers(schema, unparsed_arguments):
ignoring actions, and the combined parser is handy for displaying help that includes everything:
global flags, a list of actions, etc.
'''
config_paths = collect.get_default_config_paths(expand_home=True)
unexpanded_config_paths = collect.get_default_config_paths(expand_home=False)
config_paths = collect.get_default_config_paths()
# Using allow_abbrev=False here prevents the global parser from erroring about "ambiguous"
# options like --encryption. Such options are intended for an action parser rather than the
@@ -567,7 +590,7 @@ def make_parsers(schema, unparsed_arguments):
'--config',
dest='config_paths',
action='append',
help=f"Configuration filename or directory, can specify flag multiple times, defaults to: -c {' -c '.join(unexpanded_config_paths)}",
help='Configuration filename or directory, can specify flag multiple times, defaults to /etc/borgmatic/config.yaml, /etc/borgmatic.d, $XDG_CONFIG_HOME/borgmatic/config.yaml, and $XDG_CONFIG_HOME/borgmatic.d, where $XDG_CONFIG_HOME defaults to $HOME/.config',
)
global_group.add_argument(
'-n',
@@ -587,7 +610,7 @@ def make_parsers(schema, unparsed_arguments):
'--no-environment-interpolation',
dest='resolve_env',
action='store_false',
help='Do not resolve environment variables in configuration file',
help='Do not resolve environment variables in configuration files',
)
global_group.add_argument(
'--bash-completion',
@@ -626,8 +649,8 @@ def make_parsers(schema, unparsed_arguments):
repo_create_parser = action_parsers.add_parser(
'repo-create',
aliases=ACTION_ALIASES['repo-create'],
help='Create a new, empty Borg repository',
description='Create a new, empty Borg repository',
help='Create a new, empty Borg repository (also known as "init")',
description='Create a new, empty Borg repository (also known as "init")',
add_help=False,
)
repo_create_group = repo_create_parser.add_argument_group('repo-create arguments')
@@ -667,10 +690,13 @@ def make_parsers(schema, unparsed_arguments):
dest='make_parent_directories',
default=None,
action='store_true',
help='Create any missing parent directories of the repository directory',
help='Create any missing parent directories of the repository directory [Borg 1.x only]',
)
repo_create_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
transfer_parser = action_parsers.add_parser(
@@ -712,7 +738,9 @@ def make_parsers(schema, unparsed_arguments):
help='Only transfer archives with names, hashes, or series matching this pattern',
)
transfer_group.add_argument(
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
'--sort-by',
metavar='KEYS',
help='Comma-separated list of sorting keys',
)
transfer_group.add_argument(
'--first',
@@ -720,7 +748,9 @@ def make_parsers(schema, unparsed_arguments):
help='Only transfer first N archives after other filters are applied',
)
transfer_group.add_argument(
'--last', metavar='N', help='Only transfer last N archives after other filters are applied'
'--last',
metavar='N',
help='Only transfer last N archives after other filters are applied',
)
transfer_group.add_argument(
'--oldest',
@@ -743,7 +773,10 @@ def make_parsers(schema, unparsed_arguments):
help='Transfer archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
)
transfer_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
prune_parser = action_parsers.add_parser(
@@ -833,7 +866,10 @@ def make_parsers(schema, unparsed_arguments):
help='Minimum saved space percentage threshold for compacting a segment, defaults to 10',
)
compact_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
create_parser = action_parsers.add_parser(
@@ -861,6 +897,13 @@ def make_parsers(schema, unparsed_arguments):
action='store_true',
help='Display statistics of archive',
)
create_group.add_argument(
'--quick-stats',
dest='quick_statistics',
default=None,
action='store_true',
help='Display statistics of archive, skipping repository-wide "All archives" and chunk index statistics [Borg 1.4.5+ only]',
)
create_group.add_argument(
'--list',
'--files',
@@ -870,7 +913,16 @@ def make_parsers(schema, unparsed_arguments):
help='Show per-file details',
)
create_group.add_argument(
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
'--json',
dest='json',
default=False,
action='store_true',
help='Output results as JSON',
)
create_group.add_argument(
'--comment',
metavar='COMMENT',
help='Add a comment text to the archive',
)
create_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
@@ -991,13 +1043,19 @@ def make_parsers(schema, unparsed_arguments):
help='Only delete archives with names, hashes, or series matching this pattern',
)
delete_group.add_argument(
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
'--sort-by',
metavar='KEYS',
help='Comma-separated list of sorting keys',
)
delete_group.add_argument(
'--first', metavar='N', help='Delete first N archives after other filters are applied'
'--first',
metavar='N',
help='Delete first N archives after other filters are applied',
)
delete_group.add_argument(
'--last', metavar='N', help='Delete last N archives after other filters are applied'
'--last',
metavar='N',
help='Delete last N archives after other filters are applied',
)
delete_group.add_argument(
'--oldest',
@@ -1034,7 +1092,9 @@ def make_parsers(schema, unparsed_arguments):
help='Path of repository to extract, defaults to the configured repository if there is only one, quoted globs supported',
)
extract_group.add_argument(
'--archive', help='Name or hash of a single archive to extract (or "latest")', required=True
'--archive',
help='Name or hash of a single archive to extract (or "latest")',
required=True,
)
extract_group.add_argument(
'--path',
@@ -1063,7 +1123,10 @@ def make_parsers(schema, unparsed_arguments):
help='Display progress for each file as it is extracted',
)
extract_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
config_parser = action_parsers.add_parser(
@@ -1088,7 +1151,7 @@ def make_parsers(schema, unparsed_arguments):
add_help=False,
)
config_bootstrap_group = config_bootstrap_parser.add_argument_group(
'config bootstrap arguments'
'config bootstrap arguments',
)
config_bootstrap_group.add_argument(
'--repository',
@@ -1143,7 +1206,10 @@ def make_parsers(schema, unparsed_arguments):
help='Command to use instead of "ssh"',
)
config_bootstrap_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
config_generate_parser = config_parsers.add_parser(
@@ -1162,9 +1228,9 @@ def make_parsers(schema, unparsed_arguments):
config_generate_group.add_argument(
'-d',
'--destination',
dest='destination_filename',
dest='destination_path',
default=config_paths[0],
help=f'Destination configuration file, default: {unexpanded_config_paths[0]}',
help='Destination configuration file (or directory if using --split), default: /etc/borgmatic/config.yaml',
)
config_generate_group.add_argument(
'--overwrite',
@@ -1173,7 +1239,15 @@ def make_parsers(schema, unparsed_arguments):
help='Whether to overwrite any existing destination file, defaults to false',
)
config_generate_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'--split',
action='store_true',
help='Assuming the destination is a directory instead of a file, split the configuration into separate files within it, one per option, useful for documentation',
)
config_generate_group.add_argument(
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
config_validate_parser = config_parsers.add_parser(
@@ -1190,7 +1264,33 @@ def make_parsers(schema, unparsed_arguments):
help='Show the validated configuration after all include merging has occurred',
)
config_validate_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
config_show_parser = config_parsers.add_parser(
'show',
help='Show the computed configuration for each file specified with --config (see borgmatic --help)',
description='Show the computed configuration for each file specified with --config (see borgmatic --help)',
add_help=False,
)
config_show_group = config_show_parser.add_argument_group('config show arguments')
config_show_group.add_argument(
'--option',
help='Show the value of a single named configuration option instead of the entire configuration',
)
config_show_group.add_argument(
'--json',
action='store_true',
help='Show the configuration as JSON with one array element per configuration file',
)
config_show_group.add_argument(
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
export_tar_parser = action_parsers.add_parser(
@@ -1206,7 +1306,9 @@ def make_parsers(schema, unparsed_arguments):
help='Path of repository to export from, defaults to the configured repository if there is only one, quoted globs supported',
)
export_tar_group.add_argument(
'--archive', help='Name or hash of a single archive to export (or "latest")', required=True
'--archive',
help='Name or hash of a single archive to export (or "latest")',
required=True,
)
export_tar_group.add_argument(
'--path',
@@ -1223,7 +1325,8 @@ def make_parsers(schema, unparsed_arguments):
required=True,
)
export_tar_group.add_argument(
'--tar-filter', help='Name of filter program to pipe data through'
'--tar-filter',
help='Name of filter program to pipe data through',
)
export_tar_group.add_argument(
'--list',
@@ -1241,7 +1344,10 @@ def make_parsers(schema, unparsed_arguments):
help='Number of leading path components to remove from each exported path. Skip paths with fewer elements',
)
export_tar_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
mount_parser = action_parsers.add_parser(
@@ -1257,7 +1363,8 @@ def make_parsers(schema, unparsed_arguments):
help='Path of repository to use, defaults to the configured repository if there is only one, quoted globs supported',
)
mount_group.add_argument(
'--archive', help='Name or hash of a single archive to mount (or "latest")'
'--archive',
help='Name or hash of a single archive to mount (or "latest")',
)
mount_group.add_argument(
'--mount-point',
@@ -1286,7 +1393,9 @@ def make_parsers(schema, unparsed_arguments):
help='Mount first N archives after other filters are applied',
)
mount_group.add_argument(
'--last', metavar='N', help='Mount last N archives after other filters are applied'
'--last',
metavar='N',
help='Mount last N archives after other filters are applied',
)
mount_group.add_argument(
'--oldest',
@@ -1363,7 +1472,10 @@ def make_parsers(schema, unparsed_arguments):
help='Do not delete the local security info when deleting a repository',
)
repo_delete_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
restore_parser = action_parsers.add_parser(
@@ -1406,6 +1518,10 @@ def make_parsers(schema, unparsed_arguments):
'--port',
help='Database port to restore to. Defaults to the "restore_port" option in borgmatic\'s configuration',
)
restore_group.add_argument(
'--container',
help='Container to restore to. Defaults to the "restore_container" option in borgmatic\'s configuration',
)
restore_group.add_argument(
'--username',
help='Username with which to connect to the database. Defaults to the "restore_username" option in borgmatic\'s configuration',
@@ -1418,10 +1534,18 @@ def make_parsers(schema, unparsed_arguments):
'--restore-path',
help='Path to restore SQLite database dumps to. Defaults to the "restore_path" option in borgmatic\'s configuration',
)
restore_group.add_argument(
'--original-label',
help='The label where the dump to restore came from, only necessary if you need to disambiguate dumps',
)
restore_group.add_argument(
'--original-hostname',
help='The hostname where the dump to restore came from, only necessary if you need to disambiguate dumps',
)
restore_group.add_argument(
'--original-container',
help='The container where the dump to restore came from, only necessary if you need to disambiguate dumps',
)
restore_group.add_argument(
'--original-port',
type=int,
@@ -1432,7 +1556,10 @@ def make_parsers(schema, unparsed_arguments):
help='The name of the data source hook for the dump to restore, only necessary if you need to disambiguate dumps',
)
restore_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
repo_list_parser = action_parsers.add_parser(
@@ -1448,14 +1575,22 @@ def make_parsers(schema, unparsed_arguments):
help='Path of repository to list, defaults to the configured repositories, quoted globs supported',
)
repo_list_group.add_argument(
'--short', default=False, action='store_true', help='Output only archive names'
'--short',
default=False,
action='store_true',
help='Output only archive names',
)
repo_list_group.add_argument('--format', help='Format for archive listing')
repo_list_group.add_argument('--format', help='Borg format for the archive listing')
repo_list_group.add_argument(
'--json', default=False, action='store_true', help='Output results as JSON'
'--json',
default=False,
action='store_true',
help='Output results as JSON',
)
repo_list_group.add_argument(
'-P', '--prefix', help='Deprecated. Only list archive names starting with this prefix'
'-P',
'--prefix',
help='Deprecated. Only list archive names starting with this prefix',
)
repo_list_group.add_argument(
'-a',
@@ -1465,13 +1600,19 @@ def make_parsers(schema, unparsed_arguments):
help='Only list archive names, hashes, or series matching this pattern',
)
repo_list_group.add_argument(
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
'--sort-by',
metavar='KEYS',
help='Comma-separated list of sorting keys',
)
repo_list_group.add_argument(
'--first', metavar='N', help='List first N archives after other filters are applied'
'--first',
metavar='N',
help='List first N archives after other filters are applied',
)
repo_list_group.add_argument(
'--last', metavar='N', help='List last N archives after other filters are applied'
'--last',
metavar='N',
help='List last N archives after other filters are applied',
)
repo_list_group.add_argument(
'--oldest',
@@ -1500,7 +1641,10 @@ def make_parsers(schema, unparsed_arguments):
help="List only deleted archives that haven't yet been compacted [Borg 2.x+ only]",
)
repo_list_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
list_parser = action_parsers.add_parser(
@@ -1516,7 +1660,8 @@ def make_parsers(schema, unparsed_arguments):
help='Path of repository containing archive to list, defaults to the configured repositories, quoted globs supported',
)
list_group.add_argument(
'--archive', help='Name or hash of a single archive to list (or "latest")'
'--archive',
help='Name or hash of a single archive to list (or "latest")',
)
list_group.add_argument(
'--path',
@@ -1533,14 +1678,22 @@ def make_parsers(schema, unparsed_arguments):
help='Partial path or pattern to search for and list across multiple archives, can specify flag multiple times',
)
list_group.add_argument(
'--short', default=False, action='store_true', help='Output only path names'
'--short',
default=False,
action='store_true',
help='Output only path names',
)
list_group.add_argument('--format', help='Format for file listing')
list_group.add_argument('--format', help='Borg format for the file listing')
list_group.add_argument(
'--json', default=False, action='store_true', help='Output results as JSON'
'--json',
default=False,
action='store_true',
help='Output results as JSON',
)
list_group.add_argument(
'-P', '--prefix', help='Deprecated. Only list archive names starting with this prefix'
'-P',
'--prefix',
help='Deprecated. Only list archive names starting with this prefix',
)
list_group.add_argument(
'-a',
@@ -1550,19 +1703,30 @@ def make_parsers(schema, unparsed_arguments):
help='Only list archive names matching this pattern',
)
list_group.add_argument(
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
'--sort-by',
metavar='KEYS',
help='Comma-separated list of sorting keys',
)
list_group.add_argument(
'--first', metavar='N', help='List first N archives after other filters are applied'
'--first',
metavar='N',
help='List first N archives after other filters are applied',
)
list_group.add_argument(
'--last', metavar='N', help='List last N archives after other filters are applied'
'--last',
metavar='N',
help='List last N archives after other filters are applied',
)
list_group.add_argument(
'-e', '--exclude', metavar='PATTERN', help='Exclude paths matching the pattern'
'-e',
'--exclude',
metavar='PATTERN',
help='Exclude paths matching the pattern',
)
list_group.add_argument(
'--exclude-from', metavar='FILENAME', help='Exclude paths from exclude file, one per line'
'--exclude-from',
metavar='FILENAME',
help='Exclude paths from exclude file, one per line',
)
list_group.add_argument('--pattern', help='Include or exclude paths matching a pattern')
list_group.add_argument(
@@ -1585,10 +1749,17 @@ def make_parsers(schema, unparsed_arguments):
help='Path of repository to show info for, defaults to the configured repository if there is only one, quoted globs supported',
)
repo_info_group.add_argument(
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
'--json',
dest='json',
default=False,
action='store_true',
help='Output results as JSON',
)
repo_info_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
info_parser = action_parsers.add_parser(
@@ -1604,10 +1775,15 @@ def make_parsers(schema, unparsed_arguments):
help='Path of repository containing archive to show info for, defaults to the configured repository if there is only one, quoted globs supported',
)
info_group.add_argument(
'--archive', help='Archive name, hash, or series to show info for (or "latest")'
'--archive',
help='Archive name, hash, or series to show info for (or "latest")',
)
info_group.add_argument(
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
'--json',
dest='json',
default=False,
action='store_true',
help='Output results as JSON',
)
info_group.add_argument(
'-P',
@@ -1622,7 +1798,9 @@ def make_parsers(schema, unparsed_arguments):
help='Only show info for archive names, hashes, or series matching this pattern',
)
info_group.add_argument(
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
'--sort-by',
metavar='KEYS',
help='Comma-separated list of sorting keys',
)
info_group.add_argument(
'--first',
@@ -1630,7 +1808,9 @@ def make_parsers(schema, unparsed_arguments):
help='Show info for first N archives after other filters are applied',
)
info_group.add_argument(
'--last', metavar='N', help='Show info for last N archives after other filters are applied'
'--last',
metavar='N',
help='Show info for last N archives after other filters are applied',
)
info_group.add_argument(
'--oldest',
@@ -1667,7 +1847,10 @@ def make_parsers(schema, unparsed_arguments):
help='Path of repository to break the lock for, defaults to the configured repository if there is only one, quoted globs supported',
)
break_lock_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
key_parser = action_parsers.add_parser(
@@ -1712,7 +1895,10 @@ def make_parsers(schema, unparsed_arguments):
help='Path to export the key to, defaults to stdout (but be careful about dirtying the output with --verbosity)',
)
key_export_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
key_import_parser = key_parsers.add_parser(
@@ -1737,7 +1923,10 @@ def make_parsers(schema, unparsed_arguments):
help='Path to import the key from backup, defaults to stdin',
)
key_import_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
key_change_passphrase_parser = key_parsers.add_parser(
@@ -1747,14 +1936,17 @@ def make_parsers(schema, unparsed_arguments):
add_help=False,
)
key_change_passphrase_group = key_change_passphrase_parser.add_argument_group(
'key change-passphrase arguments'
'key change-passphrase arguments',
)
key_change_passphrase_group.add_argument(
'--repository',
help='Path of repository to change the passphrase for, defaults to the configured repository if there is only one, quoted globs supported',
)
key_change_passphrase_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
recreate_parser = action_parsers.add_parser(
@@ -1771,7 +1963,7 @@ def make_parsers(schema, unparsed_arguments):
)
recreate_group.add_argument(
'--archive',
help='Archive name, hash, or series to recreate',
help='Archive name, hash, or series to recreate, defaults to all archives in the repository (if specified), or all archives across all repositories',
)
recreate_group.add_argument(
'--list',
@@ -1804,9 +1996,66 @@ def make_parsers(schema, unparsed_arguments):
help='Only consider archive names, hashes, or series matching this pattern [Borg 2.x+ only]',
)
recreate_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
'-h',
'--help',
action='help',
help='Show this help message and exit',
)
diff_parser = action_parsers.add_parser(
'diff',
aliases=ACTION_ALIASES['diff'],
help='Find differences (file contents, user/group/mode) between archives',
description='Find differences (file contents, user/group/mode) between archives',
add_help=False,
)
diff_group = diff_parser.add_argument_group('diff arguments')
diff_group.add_argument(
'--repository',
help='Path of repository containing archive to diff, defaults to the configured repository if there is only one, quoted globs supported',
)
diff_group.add_argument(
'--archive',
help='Archive name, hash, or series to diff',
required=True,
)
diff_group.add_argument(
'--second-archive',
help='Second archive name, hash, or series to diff',
required=True,
)
diff_group.add_argument(
'--same-chunker-params', action='store_true', help='Override check of chunker parameters'
)
diff_group.add_argument(
'--sort-by',
metavar='KEY',
dest='sort_keys',
action='append',
help='Advanced sorting: specify field(s) to sort by. Prefix with > for descending or < for ascending (default)',
)
diff_group.add_argument(
'--content-only',
action='store_true',
help='Only compare differences in content (exclude metadata differences)',
)
diff_group.add_argument(
'--only-patterns',
action='store_true',
help='Run the diff according to borgmatic configured patterns (ie do not diff entire archives)',
)
diff_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
browse_parser = action_parsers.add_parser(
'browse',
aliases=ACTION_ALIASES['browse'],
help='Browse repositories, archives, and files in a console UI',
description='Browse repositories, archives, and files in a console UI',
add_help=False,
)
browse_group = browse_parser.add_argument_group('browse arguments')
browse_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
borg_parser = action_parsers.add_parser(
'borg',
aliases=ACTION_ALIASES['borg'],
@@ -1820,7 +2069,8 @@ def make_parsers(schema, unparsed_arguments):
help='Path of repository to pass to Borg, defaults to the configured repositories, quoted globs supported',
)
borg_group.add_argument(
'--archive', help='Archive name, hash, or series to pass to Borg (or "latest")'
'--archive',
help='Archive name, hash, or series to pass to Borg (or "latest")',
)
borg_group.add_argument(
'--',
@@ -1834,6 +2084,9 @@ def make_parsers(schema, unparsed_arguments):
return global_parser, action_parsers, global_plus_action_parser
HIGHLANDER_ACTION_ARGUMENTS_COUNT = 2 # 1 for "global" + 1 for the action
def parse_arguments(schema, *unparsed_arguments):
'''
Given a configuration schema dict and the command-line arguments with which this script was
@@ -1844,21 +2097,22 @@ def parse_arguments(schema, *unparsed_arguments):
Raise SystemExit with an error code of 0 if "--help" was requested.
'''
global_parser, action_parsers, global_plus_action_parser = make_parsers(
schema, unparsed_arguments
schema,
unparsed_arguments,
)
arguments, remaining_action_arguments = parse_arguments_for_actions(
unparsed_arguments, action_parsers.choices, global_parser
unparsed_arguments,
action_parsers.choices,
global_parser,
)
if not arguments['global'].config_paths:
arguments['global'].config_paths = collect.get_default_config_paths(expand_home=True)
arguments['global'].config_paths = collect.get_default_config_paths()
for action_name in ('bootstrap', 'generate', 'validate'):
if (
action_name in arguments.keys() and len(arguments.keys()) > 2
): # 2 = 1 for 'global' + 1 for the action
if action_name in arguments and len(arguments) > HIGHLANDER_ACTION_ARGUMENTS_COUNT:
raise ValueError(
f'The {action_name} action cannot be combined with other actions. Please run it separately.'
f'The {action_name} action cannot be combined with other actions. Please run it separately.',
)
unknown_arguments = get_unparsable_arguments(remaining_action_arguments)
@@ -1870,7 +2124,7 @@ def parse_arguments(schema, *unparsed_arguments):
global_plus_action_parser.print_usage()
raise ValueError(
f"Unrecognized argument{'s' if len(unknown_arguments) > 1 else ''}: {' '.join(unknown_arguments)}"
f"Unrecognized argument{'s' if len(unknown_arguments) > 1 else ''}: {' '.join(unknown_arguments)}",
)
if (
@@ -1882,14 +2136,14 @@ def parse_arguments(schema, *unparsed_arguments):
if 'list' in arguments and (arguments['list'].prefix and arguments['list'].match_archives):
raise ValueError(
'With the list action, only one of --prefix or --match-archives flags can be used.'
'With the list action, only one of --prefix or --match-archives flags can be used.',
)
if 'repo-list' in arguments and (
arguments['repo-list'].prefix and arguments['repo-list'].match_archives
):
raise ValueError(
'With the repo-list action, only one of --prefix or --match-archives flags can be used.'
'With the repo-list action, only one of --prefix or --match-archives flags can be used.',
)
if 'info' in arguments and (
@@ -1898,7 +2152,7 @@ def parse_arguments(schema, *unparsed_arguments):
or (arguments['info'].prefix and arguments['info'].match_archives)
):
raise ValueError(
'With the info action, only one of --archive, --prefix, or --match-archives flags can be used.'
'With the info action, only one of --archive, --prefix, or --match-archives flags can be used.',
)
if 'borg' in arguments and arguments['global'].dry_run:
+327 -213
View File
@@ -12,14 +12,17 @@ import ruamel.yaml
import borgmatic.actions.borg
import borgmatic.actions.break_lock
import borgmatic.actions.browse.run
import borgmatic.actions.change_passphrase
import borgmatic.actions.check
import borgmatic.actions.compact
import borgmatic.actions.config.bootstrap
import borgmatic.actions.config.generate
import borgmatic.actions.config.show
import borgmatic.actions.config.validate
import borgmatic.actions.create
import borgmatic.actions.delete
import borgmatic.actions.diff
import borgmatic.actions.export_key
import borgmatic.actions.export_tar
import borgmatic.actions.extract
@@ -95,7 +98,9 @@ class Monitoring_hooks:
self.config_filename = config_filename
self.config = config
self.dry_run = global_arguments.dry_run
self.monitoring_log_level = verbosity_to_log_level(config.get('monitoring_verbosity'))
self.monitoring_log_level = verbosity_to_log_level(
get_verbosity({config_filename: config}, 'monitoring_verbosity'),
)
self.monitoring_hooks_are_activated = (
using_primary_action and self.monitoring_log_level != DISABLED
)
@@ -180,7 +185,7 @@ class Monitoring_hooks:
)
def run_configuration(config_filename, config, config_paths, arguments):
def run_configuration(config_filename, config, config_paths, arguments): # noqa: PLR0912, PLR0915
'''
Given a config filename, the corresponding parsed config dict, a sequence of loaded
configuration paths, and command-line arguments as a dict from subparser name to a namespace of
@@ -204,12 +209,13 @@ def run_configuration(config_filename, config, config_paths, arguments):
if skip_actions:
logger.debug(
f"Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions"
f"Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions",
)
try:
with Monitoring_hooks(config_filename, config, arguments, global_arguments):
with borgmatic.hooks.command.Before_after_hooks(
with (
Monitoring_hooks(config_filename, config, arguments, global_arguments),
borgmatic.hooks.command.Before_after_hooks(
command_hooks=config.get('commands'),
before_after='configuration',
umask=config.get('umask'),
@@ -218,69 +224,80 @@ def run_configuration(config_filename, config, config_paths, arguments):
action_names=arguments.keys(),
configuration_filename=config_filename,
log_file=config.get('log_file', ''),
):
try:
local_borg_version = borg_version.local_borg_version(config, local_path)
logger.debug(f'Borg {local_borg_version}')
except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records(
f'{config_filename}: Error getting local Borg version', error
)
return
),
):
try:
local_borg_version = borg_version.local_borg_version(config, local_path)
logger.debug(f'Borg {local_borg_version}')
except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records(
f'{config_filename}: Error getting local Borg version',
error,
)
raise
for repo in config['repositories']:
repo_queue.put(
(repo, 0),
)
for repo in config['repositories']:
repo_queue.put(
(repo, 0),
)
while not repo_queue.empty():
repository, retry_num = repo_queue.get()
while not repo_queue.empty():
repository, retry_num = repo_queue.get()
with Log_prefix(repository.get('label', repository['path'])):
logger.debug('Running actions for repository')
timeout = retry_num * retry_wait
if timeout:
logger.warning(f'Sleeping {timeout}s before next retry')
time.sleep(timeout)
try:
yield from run_actions(
arguments=arguments,
config_filename=config_filename,
config=config,
config_paths=config_paths,
local_path=local_path,
remote_path=remote_path,
local_borg_version=local_borg_version,
repository=repository,
with Log_prefix(repository.get('label', repository['path'])):
logger.debug('Running actions for repository')
timeout = retry_num * retry_wait
if timeout:
logger.warning(f'Sleeping {timeout}s before next retry')
time.sleep(timeout)
try:
yield from run_actions(
arguments=arguments,
config_filename=config_filename,
config=config,
config_paths=config_paths,
local_path=local_path,
remote_path=remote_path,
local_borg_version=local_borg_version,
repository=repository,
)
except (OSError, CalledProcessError, ValueError) as error:
if retry_num < retries:
repo_queue.put(
(repository, retry_num + 1),
)
except (OSError, CalledProcessError, ValueError) as error:
if retry_num < retries:
repo_queue.put(
(repository, retry_num + 1),
)
tuple( # Consume the generator so as to trigger logging.
log_error_records(
'Error running actions for repository',
error,
levelno=logging.WARNING,
log_command_error_output=True,
)
)
logger.warning(f'Retrying... attempt {retry_num + 1}/{retries}')
continue
if command.considered_soft_failure(error):
continue
yield from log_error_records(
'Error running actions for repository',
error,
tuple( # Consume the generator so as to trigger logging.
log_error_records(
'Error running actions for repository',
error,
levelno=logging.WARNING,
log_command_error_output=True,
),
)
encountered_error = error
error_repository = repository
logger.warning(f'Retrying... attempt {retry_num + 1}/{retries}')
continue
if command.considered_soft_failure(error):
continue
yield from log_error_records(
'Error running actions for repository',
error,
)
encountered_error = error
error_repository = repository
# Re-raise any error, so that the Monitoring_hooks context manager wrapping this
# code can see the error and act accordingly. Do this here rather than as soon as
# the error is encountered so that an error with one repository doesn't prevent
# other repositories from running.
if encountered_error:
raise encountered_error
except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records('Error running configuration', error)
yield from log_error_records('Error running configuration')
encountered_error = error
@@ -290,7 +307,10 @@ def run_configuration(config_filename, config, config_paths, arguments):
try:
command.execute_hooks(
command.filter_hooks(
config.get('commands'), after='error', action_names=arguments.keys()
config.get('commands'),
after='error',
action_names=arguments.keys(),
state_names=['fail'],
),
config.get('umask'),
borgmatic.config.paths.get_working_directory(config),
@@ -309,7 +329,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
yield from log_error_records(f'{config_filename}: Error running after error hook', error)
def run_actions(
def run_actions( # noqa: PLR0912, PLR0915
*,
arguments,
config_filename,
@@ -323,7 +343,7 @@ def run_actions(
'''
Given parsed command-line arguments as an argparse.ArgumentParser instance, the configuration
filename, a configuration dict, a sequence of loaded configuration paths, local and remote paths
to Borg, a local Borg version string, and a repository name, run all actions from the
to Borg, a local Borg version string, and a repository dict, run all actions from the
command-line arguments on the given repository.
Yield JSON output strings from executing any actions that produce JSON.
@@ -345,6 +365,22 @@ def run_actions(
'repository': repository_path,
}
skip_actions = set(get_skip_actions(config, arguments))
requested_repository = next(
(
repository
for action_arguments in arguments.values()
for repository in (getattr(action_arguments, 'repository', None),)
if repository is not None
),
None,
)
if requested_repository and not borgmatic.config.validate.repositories_match(
repository,
requested_repository,
):
logger.debug('Skipping actions because the requested --repository does not match')
return
with borgmatic.hooks.command.Before_after_hooks(
command_hooks=config.get('commands'),
@@ -356,7 +392,7 @@ def run_actions(
**hook_context,
):
for action_name, action_arguments in arguments.items():
if action_name == 'global':
if action_name == 'global' or action_name in skip_actions:
continue
with borgmatic.hooks.command.Before_after_hooks(
@@ -365,10 +401,10 @@ def run_actions(
umask=config.get('umask'),
working_directory=borgmatic.config.paths.get_working_directory(config),
dry_run=global_arguments.dry_run,
action_names=arguments.keys(),
action_names=(action_name,),
**hook_context,
):
if action_name == 'repo-create' and action_name not in skip_actions:
if action_name == 'repo-create':
borgmatic.actions.repo_create.run_repo_create(
repository,
config,
@@ -378,7 +414,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'transfer' and action_name not in skip_actions:
elif action_name == 'transfer':
borgmatic.actions.transfer.run_transfer(
repository,
config,
@@ -388,7 +424,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'create' and action_name not in skip_actions:
elif action_name == 'create':
yield from borgmatic.actions.create.run_create(
config_filename,
repository,
@@ -401,17 +437,18 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'recreate' and action_name not in skip_actions:
elif action_name == 'recreate':
borgmatic.actions.recreate.run_recreate(
repository,
config,
local_borg_version,
action_arguments,
global_arguments,
dry_run_label,
local_path,
remote_path,
)
elif action_name == 'prune' and action_name not in skip_actions:
elif action_name == 'prune':
borgmatic.actions.prune.run_prune(
config_filename,
repository,
@@ -423,7 +460,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'compact' and action_name not in skip_actions:
elif action_name == 'compact':
borgmatic.actions.compact.run_compact(
config_filename,
repository,
@@ -435,7 +472,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'check' and action_name not in skip_actions:
elif action_name == 'check':
if checks.repository_enabled_for_checks(repository, config):
borgmatic.actions.check.run_check(
config_filename,
@@ -447,7 +484,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'extract' and action_name not in skip_actions:
elif action_name == 'extract':
borgmatic.actions.extract.run_extract(
config_filename,
repository,
@@ -458,7 +495,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'export-tar' and action_name not in skip_actions:
elif action_name == 'export-tar':
borgmatic.actions.export_tar.run_export_tar(
repository,
config,
@@ -468,7 +505,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'mount' and action_name not in skip_actions:
elif action_name == 'mount':
borgmatic.actions.mount.run_mount(
repository,
config,
@@ -478,7 +515,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'restore' and action_name not in skip_actions:
elif action_name == 'restore':
borgmatic.actions.restore.run_restore(
repository,
config,
@@ -488,7 +525,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'repo-list' and action_name not in skip_actions:
elif action_name == 'repo-list':
yield from borgmatic.actions.repo_list.run_repo_list(
repository,
config,
@@ -498,7 +535,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'list' and action_name not in skip_actions:
elif action_name == 'list':
yield from borgmatic.actions.list.run_list(
repository,
config,
@@ -508,7 +545,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'repo-info' and action_name not in skip_actions:
elif action_name == 'repo-info':
yield from borgmatic.actions.repo_info.run_repo_info(
repository,
config,
@@ -518,7 +555,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'info' and action_name not in skip_actions:
elif action_name == 'info':
yield from borgmatic.actions.info.run_info(
repository,
config,
@@ -528,7 +565,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'break-lock' and action_name not in skip_actions:
elif action_name == 'break-lock':
borgmatic.actions.break_lock.run_break_lock(
repository,
config,
@@ -538,7 +575,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'export' and action_name not in skip_actions:
elif action_name == 'export':
borgmatic.actions.export_key.run_export_key(
repository,
config,
@@ -548,7 +585,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'import' and action_name not in skip_actions:
elif action_name == 'import':
borgmatic.actions.import_key.run_import_key(
repository,
config,
@@ -558,7 +595,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'change-passphrase' and action_name not in skip_actions:
elif action_name == 'change-passphrase':
borgmatic.actions.change_passphrase.run_change_passphrase(
repository,
config,
@@ -568,7 +605,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'delete' and action_name not in skip_actions:
elif action_name == 'delete':
borgmatic.actions.delete.run_delete(
repository,
config,
@@ -578,7 +615,7 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'repo-delete' and action_name not in skip_actions:
elif action_name == 'repo-delete':
borgmatic.actions.repo_delete.run_repo_delete(
repository,
config,
@@ -588,7 +625,17 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'borg' and action_name not in skip_actions:
elif action_name == 'diff':
borgmatic.actions.diff.run_diff(
repository,
config,
local_borg_version,
action_arguments,
global_arguments,
local_path,
remote_path,
)
elif action_name == 'borg':
borgmatic.actions.borg.run_borg(
repository,
config,
@@ -627,9 +674,10 @@ def load_configurations(config_filenames, arguments, overrides=None, resolve_env
levelno=logging.DEBUG,
levelname='DEBUG',
msg=f'{config_filename}: Loading configuration file',
)
name=logger.name,
),
),
]
],
)
try:
configs[config_filename], paths, parse_logs = validate.parse_configuration(
@@ -646,12 +694,13 @@ def load_configurations(config_filenames, arguments, overrides=None, resolve_env
[
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg=f'{config_filename}: Insufficient permissions to read configuration file',
)
name=logger.name,
),
),
]
],
)
except (ValueError, OSError, validate.Validation_error) as error:
logs.extend(
@@ -661,12 +710,18 @@ def load_configurations(config_filenames, arguments, overrides=None, resolve_env
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg=f'{config_filename}: Error parsing configuration file',
)
name=logger.name,
),
),
logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=str(error))
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg=str(error),
name=logger.name,
),
),
]
],
)
return (configs, sorted(config_paths), logs)
@@ -677,7 +732,7 @@ def log_record(suppress_log=False, **kwargs):
Create a log record based on the given makeLogRecord() arguments, one of which must be
named "levelno". Log the record (unless suppress log is set) and return it.
'''
record = logging.makeLogRecord(kwargs)
record = logging.makeLogRecord(dict(kwargs, name=logger.name))
if suppress_log:
return record
@@ -689,7 +744,10 @@ BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE = 62
def log_error_records(
message, error=None, levelno=logging.CRITICAL, log_command_error_output=False
message,
error=None,
levelno=logging.CRITICAL,
log_command_error_output=False,
):
'''
Given error message text, an optional exception object, an optional log level, and whether to
@@ -707,14 +765,14 @@ def log_error_records(
try:
raise error
except CalledProcessError as error:
except CalledProcessError as called_process_error:
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
if error.output:
if called_process_error.output:
try:
output = error.output.decode('utf-8')
output = called_process_error.output.decode('utf-8')
except (UnicodeDecodeError, AttributeError):
output = error.output
output = called_process_error.output
# Suppress these logs for now and save the error output for the log summary at the end.
# Log a separate record per line, as some errors can be really verbose and overflow the
@@ -727,18 +785,18 @@ def log_error_records(
suppress_log=True,
)
yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
yield log_record(levelno=levelno, levelname=level_name, msg=str(called_process_error))
if error.returncode == BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE:
if called_process_error.returncode == BORG_REPOSITORY_ACCESS_ABORTED_EXIT_CODE:
yield log_record(
levelno=levelno,
levelname=level_name,
msg='\nTo work around this, set either the "relocated_repo_access_is_ok" or "unknown_unencrypted_repo_access_is_ok" option to "true", as appropriate.',
)
except (ValueError, OSError) as error:
except (ValueError, OSError) as other_error:
yield log_record(levelno=levelno, levelname=level_name, msg=str(message))
yield log_record(levelno=levelno, levelname=level_name, msg=str(error))
except: # noqa: E722
yield log_record(levelno=levelno, levelname=level_name, msg=str(other_error))
except: # noqa: E722, S110
# Raising above only as a means of determining the error type. Swallow the exception here
# because we don't want the exception to propagate out of this function.
pass
@@ -765,70 +823,63 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
'''
add_custom_log_levels()
if 'bootstrap' in arguments:
try:
# No configuration file is needed for bootstrap.
local_borg_version = borg_version.local_borg_version(
{}, arguments['bootstrap'].local_path
)
except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records('Error getting local Borg version', error)
return
try:
if 'bootstrap' in arguments:
try:
local_borg_version = borg_version.local_borg_version(
# No configuration file is needed for bootstrap.
{},
arguments['bootstrap'].local_path,
)
except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records('Error getting local Borg version', error)
return
try:
borgmatic.actions.config.bootstrap.run_bootstrap(
arguments['bootstrap'], arguments['global'], local_borg_version
arguments['bootstrap'],
arguments['global'],
local_borg_version,
)
yield logging.makeLogRecord(
dict(
levelno=logging.ANSWER,
levelname='ANSWER',
msg='Bootstrap successful',
)
name=logger.name,
),
)
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
return
return
if 'generate' in arguments:
try:
if 'generate' in arguments:
borgmatic.actions.config.generate.run_generate(
arguments['generate'], arguments['global']
arguments['generate'],
arguments['global'],
)
yield logging.makeLogRecord(
dict(
levelno=logging.ANSWER,
levelname='ANSWER',
msg='Generate successful',
)
)
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
return
if 'validate' in arguments:
if configuration_parse_errors:
yield logging.makeLogRecord(
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg='Configuration validation failed',
)
name=logger.name,
),
)
return
try:
if 'validate' in arguments:
if configuration_parse_errors:
yield logging.makeLogRecord(
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg='Configuration validation failed',
name=logger.name,
),
)
return
borgmatic.actions.config.validate.run_validate(arguments['validate'], configs)
yield logging.makeLogRecord(
@@ -836,19 +887,33 @@ def collect_highlander_action_summary_logs(configs, arguments, configuration_par
levelno=logging.ANSWER,
levelname='ANSWER',
msg='All configuration files are valid',
)
name=logger.name,
),
)
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
return
return
if 'show' in arguments:
borgmatic.actions.config.show.run_show(arguments['show'], configs)
return
if 'browse' in arguments:
borgmatic.actions.browse.run.run_browse(
arguments['browse'],
arguments['global'],
configs,
)
except (
CalledProcessError,
ValueError,
OSError,
) as error:
yield from log_error_records(error)
def collect_configuration_run_summary_logs(configs, config_paths, arguments, log_file_path):
def collect_configuration_run_summary_logs(configs, config_paths, arguments, log_file_path): # noqa: PLR0912
'''
Given a dict of configuration filename to corresponding parsed configuration, a sequence of
loaded configuration paths, parsed command-line arguments as a dict from subparser name to a
@@ -861,9 +926,9 @@ def collect_configuration_run_summary_logs(configs, config_paths, arguments, log
# Run cross-file validation checks.
repository = None
for action_name, action_arguments in arguments.items():
for action_arguments in arguments.values():
if hasattr(action_arguments, 'repository'):
repository = getattr(action_arguments, 'repository')
repository = action_arguments.repository
break
try:
@@ -879,23 +944,36 @@ def collect_configuration_run_summary_logs(configs, config_paths, arguments, log
return
try:
seen_command_hooks = []
for config_filename, config in configs.items():
command.execute_hooks(
command.filter_hooks(
config.get('commands'), before='everything', action_names=arguments.keys()
command_hooks = command.filter_hooks(
tuple(
command_hook
for command_hook in config.get('commands', ())
if command_hook not in seen_command_hooks
),
config.get('umask'),
borgmatic.config.paths.get_working_directory(config),
arguments['global'].dry_run,
configuration_filename=config_filename,
log_file=log_file_path or '',
before='everything',
action_names=arguments.keys(),
)
if command_hooks:
command.execute_hooks(
command_hooks,
config.get('umask'),
borgmatic.config.paths.get_working_directory(config),
arguments['global'].dry_run,
configuration_filename=config_filename,
log_file=log_file_path or '',
)
seen_command_hooks += list(command_hooks)
except (CalledProcessError, ValueError, OSError) as error:
yield from log_error_records('Error running before everything hook', error)
return
# Execute the actions corresponding to each configuration file.
json_results = []
encountered_error = False
for config_filename, config in configs.items():
with Log_prefix(config_filename):
@@ -906,6 +984,7 @@ def collect_configuration_run_summary_logs(configs, config_paths, arguments, log
)
if error_logs:
encountered_error = True
yield from log_error_records('An error occurred')
yield from error_logs
else:
@@ -914,7 +993,8 @@ def collect_configuration_run_summary_logs(configs, config_paths, arguments, log
levelno=logging.INFO,
levelname='INFO',
msg=f'{config_filename}: Successfully ran configuration file',
)
name=logger.name,
),
)
if results:
json_results.extend(results)
@@ -928,23 +1008,37 @@ def collect_configuration_run_summary_logs(configs, config_paths, arguments, log
local_path=get_local_path(configs),
)
except (CalledProcessError, OSError) as error:
encountered_error = True
yield from log_error_records('Error unmounting mount point', error)
if json_results:
sys.stdout.write(json.dumps(json_results))
try:
seen_command_hooks = []
for config_filename, config in configs.items():
command.execute_hooks(
command.filter_hooks(
config.get('commands'), after='everything', action_names=arguments.keys()
command_hooks = command.filter_hooks(
tuple(
command_hook
for command_hook in config.get('commands', ())
if command_hook not in seen_command_hooks
),
config.get('umask'),
borgmatic.config.paths.get_working_directory(config),
arguments['global'].dry_run,
configuration_filename=config_filename,
log_file=log_file_path or '',
after='everything',
action_names=arguments.keys(),
state_names=['fail' if encountered_error else 'finish'],
)
if command_hooks:
command.execute_hooks(
command_hooks,
config.get('umask'),
borgmatic.config.paths.get_working_directory(config),
arguments['global'].dry_run,
configuration_filename=config_filename,
log_file=log_file_path or '',
)
seen_command_hooks += list(command_hooks)
except (CalledProcessError, ValueError, OSError) as error:
yield from log_error_records('Error running after everything hook', error)
@@ -958,16 +1052,17 @@ def exit_with_help_link(): # pragma: no cover
sys.exit(1)
def check_and_show_help_on_no_args(configs):
def check_and_show_help_on_no_args(configs, schema):
'''
Given a dict of configuration filename to corresponding parsed configuration, check if the
borgmatic command is run without any arguments. If the configuration option "default_actions" is
set to False, show the help message. Otherwise, trigger the default backup behavior.
Given a dict of configuration filename to corresponding parsed configuration and the
configuration schema as a dict, check if the borgmatic command was run without any arguments. If
the configuration option "default_actions" is set to False, then show the help message an exit.
'''
if len(sys.argv) == 1: # No arguments provided
if len(sys.argv) == 1: # No arguments provided.
default_actions = any(config.get('default_actions', True) for config in configs.values())
if not default_actions:
parse_arguments('--help')
if configs and not default_actions:
parse_arguments(schema, '--help')
sys.exit(0)
@@ -989,17 +1084,34 @@ def get_singular_option_value(configs, option_name):
configure_logging(logging.CRITICAL)
joined_values = ', '.join(str(value) for value in distinct_values)
logger.critical(
f'The {option_name} option has conflicting values across configuration files: {joined_values}'
f'The {option_name} option has conflicting values across configuration files: {joined_values}',
)
exit_with_help_link()
try:
return tuple(distinct_values)[0]
except IndexError:
return next(iter(distinct_values))
except StopIteration:
return None
def main(extra_summary_logs=[]): # pragma: no cover
def display_summary(summary_logs, log_json): # pragma: no cover
summary_logs_max_level = max(log.levelno for log in summary_logs)
for message in ('summary:',) if log_json else ('', 'summary:'):
log_record(
levelno=summary_logs_max_level,
levelname=logging.getLevelName(summary_logs_max_level),
msg=message,
)
for log in summary_logs:
logger.handle(log)
if summary_logs_max_level >= logging.CRITICAL:
exit_with_help_link()
def main(extra_summary_logs=()): # pragma: no cover
configure_signals()
configure_delayed_logging()
schema_filename = validate.schema_filename()
@@ -1019,20 +1131,24 @@ def main(extra_summary_logs=[]): # pragma: no cover
exit_with_help_link()
except SystemExit as error:
if error.code == 0:
raise error
raise
configure_logging(logging.CRITICAL)
logger.critical(f"Error parsing arguments: {' '.join(sys.argv)}")
exit_with_help_link()
global_arguments = arguments['global']
if global_arguments.version:
print(importlib.metadata.version('borgmatic'))
print(importlib.metadata.version('borgmatic')) # noqa: T201
sys.exit(0)
if global_arguments.bash_completion:
print(borgmatic.commands.completion.bash.bash_completion())
print(borgmatic.commands.completion.bash.bash_completion()) # noqa: T201
sys.exit(0)
if global_arguments.fish_completion:
print(borgmatic.commands.completion.fish.fish_completion())
print(borgmatic.commands.completion.fish.fish_completion()) # noqa: T201
sys.exit(0)
config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
@@ -1040,11 +1156,10 @@ def main(extra_summary_logs=[]): # pragma: no cover
config_filenames,
arguments,
global_arguments.overrides,
resolve_env=global_arguments.resolve_env and not validate,
resolve_env=global_arguments.resolve_env and not arguments.get('validate'),
)
# Use the helper function to check and show help on no arguments, passing the preloaded configs
check_and_show_help_on_no_args(configs)
check_and_show_help_on_no_args(configs, schema)
configuration_parse_errors = (
(max(log.levelno for log in parse_logs) >= logging.CRITICAL) if parse_logs else False
@@ -1054,6 +1169,7 @@ def main(extra_summary_logs=[]): # pragma: no cover
getattr(sub_arguments, 'json', False) for sub_arguments in arguments.values()
)
log_file_path = get_singular_option_value(configs, 'log_file')
log_json = get_singular_option_value(configs, 'log_json')
try:
configure_logging(
@@ -1063,7 +1179,8 @@ def main(extra_summary_logs=[]): # pragma: no cover
verbosity_to_log_level(get_verbosity(configs, 'monitoring_verbosity')),
log_file_path,
get_singular_option_value(configs, 'log_file_format'),
color_enabled=should_do_markup(configs, any_json_flags),
log_json,
color_enabled=should_do_markup(configs, any_json_flags or log_json),
)
except (FileNotFoundError, PermissionError) as error:
configure_logging(logging.CRITICAL)
@@ -1071,32 +1188,29 @@ def main(extra_summary_logs=[]): # pragma: no cover
exit_with_help_link()
summary_logs = (
extra_summary_logs
list(extra_summary_logs)
+ parse_logs
+ (
list(
collect_highlander_action_summary_logs(
configs, arguments, configuration_parse_errors
)
configs,
arguments,
configuration_parse_errors,
),
)
or list(
collect_configuration_run_summary_logs(
configs, config_paths, arguments, log_file_path
)
configs,
config_paths,
arguments,
log_file_path,
),
)
)
)
summary_logs_max_level = max(log.levelno for log in summary_logs)
for message in ('', 'summary:'):
log_record(
levelno=summary_logs_max_level,
levelname=logging.getLevelName(summary_logs_max_level),
msg=message,
)
display_summary(summary_logs, log_json)
for log in summary_logs:
logger.handle(log)
if summary_logs_max_level >= logging.CRITICAL:
exit_with_help_link()
if __name__ == '__main__': # pragma: no cover
main()
+4 -4
View File
@@ -22,15 +22,15 @@ def available_actions(subparsers, current_action=None):
action of "config" but not "list".
'''
action_to_subactions = borgmatic.commands.arguments.get_subactions_for_actions(
subparsers.choices
subparsers.choices,
)
current_subactions = action_to_subactions.get(current_action)
if current_subactions:
return current_subactions
all_subactions = set(
all_subactions = {
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)
return tuple(action for action in subparsers.choices if action not in all_subactions)
+9 -10
View File
@@ -23,7 +23,7 @@ def bash_completion():
borgmatic's command-line argument parsers.
'''
(
unused_global_parser,
_,
action_parsers,
global_plus_action_parser,
) = borgmatic.commands.arguments.make_parsers(
@@ -33,12 +33,13 @@ def bash_completion():
global_flags = parser_flags(global_plus_action_parser)
# Avert your eyes.
# fmt: off
return '\n'.join(
(
'check_version() {',
' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"',
' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"',
' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];'
' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];',
f''' then cat << EOF\n{borgmatic.commands.completion.actions.upgrade_message(
'bash',
'sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"',
@@ -47,24 +48,22 @@ def bash_completion():
' fi',
'}',
'complete_borgmatic() {',
)
+ tuple(
*tuple(
''' if [[ " ${COMP_WORDS[*]} " =~ " %s " ]]; then
COMPREPLY=($(compgen -W "%s %s %s" -- "${COMP_WORDS[COMP_CWORD]}"))
return 0
fi'''
fi''' # noqa: UP031
% (
action,
parser_flags(action_parser),
' '.join(
borgmatic.commands.completion.actions.available_actions(action_parsers, action)
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]}"))' # noqa: FS003
),
' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))' # noqa: UP031
% (
' '.join(borgmatic.commands.completion.actions.available_actions(action_parsers)),
global_flags,
@@ -72,5 +71,5 @@ def bash_completion():
' (check_version &)',
'}',
'\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic',
)
),
)
+23 -22
View File
@@ -11,10 +11,10 @@ def has_file_options(action: Action):
'''
Given an argparse.Action instance, return True if it takes a file argument.
'''
return action.metavar in (
return action.metavar in {
'FILENAME',
'PATH',
) or action.dest in ('config_paths',)
} or action.dest in {'config_paths'}
def has_choice_options(action: Action):
@@ -36,11 +36,11 @@ def has_unknown_required_param_options(action: Action):
return (
action.required is True
or action.nargs
in (
in {
'+',
'*',
)
or action.metavar in ('PATTERN', 'KEYS', 'N')
}
or action.metavar in {'PATTERN', 'KEYS', 'N'}
or (action.type is not None and action.default is None)
)
@@ -77,7 +77,7 @@ def exact_options_completion(action: 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'
f'Unexpected action: {action} passes has_exact_options but has no choices produced',
)
@@ -96,7 +96,7 @@ def fish_completion():
borgmatic's command-line argument parsers.
'''
(
unused_global_parser,
_,
action_parsers,
global_plus_action_parser,
) = borgmatic.commands.arguments.make_parsers(
@@ -104,7 +104,7 @@ def fish_completion():
unparsed_arguments=(),
)
all_action_parsers = ' '.join(action for action in action_parsers.choices.keys())
all_action_parsers = ' '.join(action for action in action_parsers.choices)
exact_option_args = tuple(
' '.join(action.option_strings)
@@ -119,8 +119,9 @@ def fish_completion():
)
# Avert your eyes.
return '\n'.join(
dedent_strip_as_tuple(
# fmt: off
return '\n'.join((
*dedent_strip_as_tuple(
f'''
function __borgmatic_check_version
set -fx this_filename (status current-filename)
@@ -157,27 +158,27 @@ def fish_completion():
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(
''',
),
'\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# 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(
),
'\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 ())
)
)
),
))
+1 -1
View File
@@ -5,7 +5,7 @@ def variants(flag_name):
"--foo[9].bar".
'''
if '[0]' in flag_name:
for index in range(0, 10):
for index in range(10):
yield flag_name.replace('[0]', f'[{index}]')
return
+2 -2
View File
@@ -10,8 +10,8 @@ def main():
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.',
)
),
)
sys.argv = ['borgmatic', 'config', 'generate'] + sys.argv[1:]
sys.argv = ['borgmatic', 'config', 'generate', *sys.argv[1:]]
borgmatic.commands.borgmatic.main([warning_log])
+2 -2
View File
@@ -10,8 +10,8 @@ def main():
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.',
)
),
)
sys.argv = ['borgmatic', 'config', 'validate'] + sys.argv[1:]
sys.argv = ['borgmatic', 'config', 'validate', *sys.argv[1:]]
borgmatic.commands.borgmatic.main([warning_log])
+4 -4
View File
@@ -146,16 +146,16 @@ def prepare_arguments_for_config(global_arguments, schema):
keys = tuple(argument_name.split('.'))
option_type = type_for_option(schema, keys)
# The argument doesn't correspond to any option in the schema, so ignore it. It's
# probably a flag that borgmatic has on the command-line but not in configuration.
if option_type is None:
# The argument doesn't correspond to any option in the schema, or it is a complex argument, so ignore it.
# It's probably a flag that borgmatic has on the command-line but not in configuration.
if option_type in {'object', None}:
continue
prepared_values.append(
(
keys,
convert_value_type(value, option_type),
)
),
)
return tuple(prepared_values)
+8 -10
View File
@@ -1,17 +1,14 @@
import os
def get_default_config_paths(expand_home=True):
def get_default_config_paths():
'''
Based on the value of the XDG_CONFIG_HOME and HOME environment variables, return a list of
default configuration paths. This includes both system-wide configuration and configuration in
the current user's home directory.
Don't expand the home directory ($HOME) if the expand home flag is False.
Return a list of default configuration paths. This includes both system-wide
configuration and configuration in the current user's home directory.
'''
user_config_directory = os.getenv('XDG_CONFIG_HOME') or os.path.join('$HOME', '.config')
if expand_home:
user_config_directory = os.path.expandvars(user_config_directory)
user_config_directory = os.path.expandvars(
os.getenv('XDG_CONFIG_HOME') or os.path.join('$HOME', '.config')
)
return [
'/etc/borgmatic/config.yaml',
@@ -49,6 +46,7 @@ def collect_config_filenames(config_paths):
for filename in sorted(os.listdir(path)):
full_filename = os.path.join(path, filename)
matching_filetype = full_filename.endswith('.yaml') or full_filename.endswith('.yml')
matching_filetype = full_filename.endswith(('.yaml', '.yml'))
if matching_filetype and not os.path.isdir(full_filename):
yield os.path.abspath(full_filename)
+62 -23
View File
@@ -1,3 +1,6 @@
import contextlib
import functools
import re
import shlex
@@ -6,20 +9,58 @@ def coerce_scalar(value):
Given a configuration value, coerce it to an integer or a boolean as appropriate and return the
result.
'''
try:
with contextlib.suppress(TypeError, ValueError):
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
try:
return {
'true': True,
'True': True,
'false': False,
'False': False,
}.get(value, value)
except TypeError: # e.g. for an unhashable type
return value
def apply_constants(value, constants, shell_escape=False):
CONSTANT_PATTERN = re.compile(r'(?P<left_escape>\\)?\{(?P<name>[\w]+)(?P<right_escape>\\)?\}')
def resolve_constant(match, constants, command_hook):
'''
Given a re.Match instance of CONSTANT_PATTERN representing a matched constant name to be
interpolated, a constants dict, and whether this is for a command hook, lookup the matched
constant name within the given constants and return its value.
If the match is escaped with backslashes, then instead of resolving the variable's value, strip
off the backslashing and return the literal value.
If the variable name isn't found in the given constants, then return the literal value.
'''
name = match.group('name')
# The would-be variable is escaped, so strip off the escaping and return the result without
# resolving the name—unless this is for a command hook, in which case just return the literal
# string. That way, subsequent variable interpolation will still see the string as escaped
# instead of trying to interpolate it.
if match.group('left_escape') and match.group('right_escape'):
if command_hook:
return match.group(0)
return '{' + name + '}'
value = constants.get(name)
# The matched variable is in the constants, so return its value. And if this is for a command
# hook, then shell escape the value so as to prevent shell injection attacks.
if value is not None:
return shlex.quote(str(value)) if command_hook else str(value)
# The matched variable name isn't in the constants. Return the whole string unaltered.
return match.group(0)
def apply_constants(value, constants, command_hook=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
@@ -37,27 +78,25 @@ def apply_constants(value, constants, shell_escape=False):
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)
value = coerce_scalar(
CONSTANT_PATTERN.sub(
functools.partial(resolve_constant, constants=constants, command_hook=command_hook),
value,
)
)
elif isinstance(value, list):
for index, list_value in enumerate(value):
value[index] = apply_constants(list_value, constants, shell_escape)
value[index] = apply_constants(list_value, constants, command_hook)
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'
command_hook=(
command_hook
or option_name.startswith(('before_', 'after_'))
or option_name in {'on_error', 'run'}
),
)
+1 -1
View File
@@ -2,7 +2,7 @@ import os
import re
VARIABLE_PATTERN = re.compile(
r'(?P<escape>\\)?(?P<variable>\$\{(?P<name>[A-Za-z0-9_]+)((:?-)(?P<default>[^}]+))?\})'
r'(?P<escape>\\)?(?P<variable>\$\{(?P<name>[A-Za-z0-9_]+)((:?-)(?P<default>[^}]+))?\})',
)

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