Compare commits

..
80 Commits
Author SHA1 Message Date
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
89 changed files with 2922 additions and 673 deletions
+3
View File
@@ -0,0 +1,3 @@
[bandit]
exclude=tests
skips=S105,S404
+1
View File
@@ -17,6 +17,7 @@ jobs:
docs:
needs: [test]
runs-on: host
if: gitea.event_name == 'push'
env:
IMAGE_NAME: projects.torsion.org/borgmatic-collective/borgmatic:docs
+12
View File
@@ -0,0 +1,12 @@
## Hold up, GitHub users
Thanks for your contribution!
If you're submitting this PR on **projects.torsion.org**: Please ignore the following. You can safely delete this text before submitting your PR.
If you're submitting this PR on **GitHub**: 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.
+70 -4
View File
@@ -1,3 +1,69 @@
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/
@@ -46,7 +112,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 "."
@@ -296,7 +362,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
@@ -352,7 +418,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.
@@ -622,7 +688,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".
+9 -8
View File
@@ -138,7 +138,8 @@ issues.
### Social
Follow [borgmatic on Mastodon](https://fosstodon.org/@borgmatic).
Follow <a rel="me" href="https://floss.social/@borgmatic">borgmatic on
Mastodon</a>.
### Chat
@@ -159,21 +160,21 @@ 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
+40 -10
View File
@@ -6,7 +6,9 @@ import logging
import os
import pathlib
import random
import shlex
import shutil
import textwrap
import borgmatic.actions.pattern
import borgmatic.borg.check
@@ -372,7 +374,11 @@ def collect_spot_check_source_paths(
borgmatic.borg.create.make_base_create_command(
dry_run=True,
repository_path=repository['path'],
config=dict(config, list_details=True),
# Omit "progress" because it interferes with "list_details".
config=dict(
{option: value for option, value in config.items() if option != 'progress'},
list_details=True,
),
patterns=borgmatic.actions.pattern.process_patterns(
borgmatic.actions.pattern.collect_patterns(config),
working_directory,
@@ -454,7 +460,7 @@ def collect_spot_check_archive_paths(
)
SAMPLE_PATHS_SUBSET_COUNT = 10000
SAMPLE_PATHS_SUBSET_COUNT = 5000
def compare_spot_check_hashes(
@@ -480,7 +486,7 @@ def compare_spot_check_hashes(
sample_count = max(
int(len(source_paths) * (min(spot_check_config['data_sample_percentage'], 100) / 100)), 1
)
source_sample_paths = tuple(random.sample(source_paths, sample_count))
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
@@ -508,7 +514,10 @@ def compare_spot_check_hashes(
break
hash_output = borgmatic.execute.execute_command_and_capture_output(
(spot_check_config.get('xxh64sum_command', 'xxh64sum'),)
tuple(
shlex.quote(part)
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
)
+ tuple(
path for path in source_sample_paths_subset if path in hashable_source_sample_path
),
@@ -562,6 +571,9 @@ def compare_spot_check_hashes(
return tuple(failing_paths)
MAX_SPOT_CHECK_PATHS_LENGTH = 1000
def spot_check(
repository,
config,
@@ -629,9 +641,12 @@ 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'
)
@@ -642,11 +657,21 @@ def spot_check(
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"}'
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"}'
f'Paths in source paths but not latest archive: {truncated_exclusive_source_paths}'
)
truncated_exclusive_archive_paths = textwrap.shorten(
', '.join(set(archive_paths) - rootless_source_paths) or 'none',
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(
f'Paths in latest archive but not source paths: {truncated_exclusive_archive_paths}'
)
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"]}%)'
@@ -669,8 +694,13 @@ def spot_check(
failing_percentage = (len(failing_paths) / len(source_paths)) * 100
if failing_percentage > data_tolerance_percentage:
truncated_failing_paths = textwrap.shorten(
', '.join(failing_paths),
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(
f'Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
f'Source paths with data not matching the latest archive: {truncated_failing_paths}'
)
raise ValueError(
f'Spot check failed: {failing_percentage:.2f}% of source paths with data not matching the latest archive (tolerance is {data_tolerance_percentage}%)'
+87 -1
View File
@@ -2,6 +2,8 @@ import logging
import borgmatic.actions.json
import borgmatic.borg.create
import borgmatic.borg.rename
import borgmatic.borg.repo_list
import borgmatic.config.paths
import borgmatic.config.validate
import borgmatic.hooks.dispatch
@@ -72,6 +74,12 @@ def run_create(
)
stream_processes = [process for processes in active_dumps.values() for process in processes]
# 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'],
@@ -80,14 +88,41 @@ def run_create(
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:
yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
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
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps',
@@ -96,3 +131,54 @@ def run_create(
borgmatic_runtime_directory,
global_arguments.dry_run,
)
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_name,
new_archive_name,
global_arguments.dry_run,
config,
local_borg_version,
local_path,
remote_path,
)
+39 -8
View File
@@ -165,6 +165,29 @@ def expand_patterns(patterns, working_directory=None, skip_paths=None):
)
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,
@@ -174,23 +197,31 @@ def device_map_patterns(patterns, working_directory=None):
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(full_path).st_dev
if pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT
and os.path.exists(full_path)
else None
),
device=pattern.device or (os.stat(existent_path).st_dev if existent_path else None),
source=pattern.source,
)
for pattern in patterns
for full_path in (os.path.join(working_directory or '', pattern.path),)
for existent_path in (
get_existent_path_or_parent(
os.path.join(working_directory or '', pattern.path.lstrip('^'))
),
)
)
+1 -1
View File
@@ -60,7 +60,7 @@ def run_arbitrary_borg(
return execute_command(
tuple(shlex.quote(part) for part in full_command),
output_file=DO_NOT_CAPTURE,
shell=True,
shell=True, # noqa: S604
environment=dict(
(environment.make_environment(config) or {}),
**{
+2 -1
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
@@ -171,7 +172,7 @@ def check_archives(
+ (('--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)
)
+9 -3
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__)
@@ -37,11 +38,16 @@ def compact_segments(
+ (('--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:
if dry_run and not feature.available(feature.Feature.DRY_RUN_COMPACT, local_borg_version):
logging.info('Skipping compact (dry run)')
return
+24 -10
View File
@@ -1,6 +1,7 @@
import logging
import os
import pathlib
import shlex
import stat
import textwrap
@@ -69,7 +70,7 @@ def collect_special_file_paths(
'''
# 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 parsing below such that path lines no longer start with the expected "- ".
paths_output = execute_command_and_capture_output(
flags.omit_flag_and_value(flags.omit_flag(create_command, '--exclude-nodump'), '--filter')
+ ('--dry-run', '--list'),
@@ -121,17 +122,21 @@ 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):
borgmatic.borg.pattern.check_all_root_patterns_exist(patterns)
@@ -149,8 +154,9 @@ def make_base_create_command(
lock_wait = config.get('lock_wait', None)
list_filter_flags = flags.make_list_filter_flags(local_borg_version, dry_run)
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', '')
@@ -183,6 +189,7 @@ def make_base_create_command(
+ ('create',)
+ (('--patterns-from', patterns_file.name) if patterns_file else ())
+ 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 ())
@@ -207,7 +214,7 @@ def make_base_create_command(
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(
@@ -269,15 +276,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.
@@ -294,9 +306,11 @@ def create_archive(
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
archive_suffix,
local_path,
remote_path,
json,
comment,
stream_processes,
)
+11 -8
View File
@@ -14,13 +14,16 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
'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',
}
@@ -82,15 +85,15 @@ 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'
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'
+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.4.1'), # borg compact --dry-run support
}
+2 -1
View File
@@ -1,4 +1,5 @@
import logging
import shlex
import borgmatic.config.paths
import borgmatic.logger
@@ -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)
)
+62
View File
@@ -0,0 +1,62 @@
import logging
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,
):
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'))
+ borgmatic.borg.flags.make_flags('log-json', config.get('log_json'))
+ borgmatic.borg.flags.make_flags('lock-wait', config.get('lock_wait'))
+ 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'),
)
+2 -1
View File
@@ -1,6 +1,7 @@
import argparse
import json
import logging
import shlex
import subprocess
import borgmatic.config.paths
@@ -86,7 +87,7 @@ def create_repository(
+ (('--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 ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
+36 -4
View File
@@ -1,4 +1,5 @@
import argparse
import json
import logging
import borgmatic.config.paths
@@ -29,6 +30,33 @@ def resolve_archive_name(
if archive != 'latest':
return archive
latest_archive = get_latest_archive(
repository_path,
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
return 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.
'''
full_command = (
(
local_path,
@@ -42,24 +70,28 @@ def resolve_archive_name(
+ 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('consider-checkpoints', consider_checkpoints)
+ flags.make_flags('last', 1)
+ ('--short',)
+ ('--json',)
+ flags.make_repository_flags(repository_path, local_borg_version)
)
output = execute_command_and_capture_output(
json_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'),
)
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"]}')
return latest_archive
+6 -1
View File
@@ -667,7 +667,7 @@ 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'
@@ -872,6 +872,11 @@ def make_parsers(schema, unparsed_arguments):
create_group.add_argument(
'--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')
check_parser = action_parsers.add_parser(
+61 -21
View File
@@ -95,7 +95,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
)
@@ -226,7 +228,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
yield from log_error_records(
f'{config_filename}: Error getting local Borg version', error
)
return
raise
for repo in config['repositories']:
repo_queue.put(
@@ -279,8 +281,15 @@ def run_configuration(config_filename, config, config_paths, arguments):
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 +299,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),
@@ -738,7 +750,7 @@ def log_error_records(
except (ValueError, OSError) as 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
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
@@ -879,23 +891,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 +931,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:
@@ -928,23 +954,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)
+27 -5
View File
@@ -51,7 +51,7 @@ def schema_to_sample_configuration(schema, source_config=None, level=0, parent_i
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
elif borgmatic.config.schema.compare_types(schema_type, {'object'}):
if source_config and isinstance(source_config, list) and isinstance(source_config[0], dict):
source_config = dict(collections.ChainMap(*source_config))
source_config = source_config[0]
config = (
ruamel.yaml.comments.CommentedMap(
@@ -71,7 +71,7 @@ def schema_to_sample_configuration(schema, source_config=None, level=0, parent_i
)
indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
add_comments_to_configuration_object(
config, schema, source_config, indent=indent, skip_first=parent_is_sequence
config, schema, source_config, indent=indent, skip_first_field=parent_is_sequence
)
elif borgmatic.config.schema.compare_types(schema_type, SCALAR_SCHEMA_TYPES, match=all):
return example
@@ -108,17 +108,30 @@ def comment_out_optional_configuration(rendered_config):
'''
lines = []
optional = False
indent_characters = None
indent_characters_at_sentinel = None
for line in rendered_config.split('\n'):
indent_characters = len(line) - len(line.lstrip())
# Upon encountering an optional configuration option, comment out lines until the next blank
# line.
if line.strip().startswith(f'# {COMMENTED_OUT_SENTINEL}'):
optional = True
indent_characters_at_sentinel = indent_characters
continue
# Hit a blank line, so reset commenting.
if not line.strip():
optional = False
indent_characters_at_sentinel = None
# Dedented, so reset commenting.
elif (
indent_characters_at_sentinel is not None
and indent_characters < indent_characters_at_sentinel
):
optional = False
indent_characters_at_sentinel = None
lines.append(comment_out_line(line) if optional else line)
@@ -198,25 +211,28 @@ COMMENTED_OUT_SENTINEL = 'COMMENT_OUT'
def add_comments_to_configuration_object(
config, schema, source_config=None, indent=0, skip_first=False
config, schema, source_config=None, indent=0, skip_first_field=False
):
'''
Using descriptions from a schema as a source, add those descriptions as comments to the given
configuration dict, putting them before each field. Indent the comment the given number of
characters.
If skip_first_field is True, omit the comment for the initial field. This is useful for
sequences, where the comment for the first field goes before the sequence itself.
And a sentinel for commenting out options that are neither in DEFAULT_KEYS nor the the given
source configuration dict. The idea is that any options used in the source configuration should
stay active in the generated configuration.
'''
for index, field_name in enumerate(config.keys()):
if skip_first and index == 0:
if skip_first_field and index == 0:
continue
field_schema = borgmatic.config.schema.get_properties(schema).get(field_name, {})
description = field_schema.get('description', '').strip()
# If this isn't a default key, add an indicator to the comment flagging it to be commented
# If this isn't a default key, add an indicator to the comment, flagging it to be commented
# out from the sample configuration. This sentinel is consumed by downstream processing that
# does the actual commenting out.
if field_name not in DEFAULT_KEYS and (
@@ -299,6 +315,12 @@ def generate_sample_configuration(
source_config = load.load_configuration(source_filename)
normalize.normalize(source_filename, source_config)
# The borgmatic.config.normalize.normalize() function tacks on an empty "bootstrap" if
# needed, so the hook gets used by default. But we don't want it to end up in the generated
# config unless the user has set it explicitly, as an empty "bootstrap:" won't validate.
if source_config and source_config.get('bootstrap') == {}:
del source_config['bootstrap']
destination_config = merge_source_configuration_into_destination(
schema_to_sample_configuration(schema, source_config), source_config
)
+4 -2
View File
@@ -159,7 +159,7 @@ def normalize(config_filename, config):
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The borgmatic_source_directory option is deprecated and will be removed from a future release. Use borgmatic_runtime_directory and borgmatic_state_directory instead.',
msg=f'{config_filename}: The borgmatic_source_directory option is deprecated and will be removed from a future release. Use user_runtime_directory and user_state_directory instead.',
)
)
)
@@ -356,6 +356,8 @@ def normalize(config_filename, config):
repository_path.startswith('ssh://')
or repository_path.startswith('sftp://')
or repository_path.startswith('rclone:')
or repository_path.startswith('s3:')
or repository_path.startswith('b2:')
):
config['repositories'].append(repository_dict)
else:
@@ -365,7 +367,7 @@ def normalize(config_filename, config):
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: Remote repository paths without ssh:// or rclone: syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
msg=f'{config_filename}: Remote repository paths without ssh://, sftp://, rclone:, s3:, or b2:, syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
)
)
)
+3 -1
View File
@@ -103,7 +103,9 @@ class Runtime_directory:
self.temporary_directory = None
else:
base_directory = os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp'
base_directory = (
os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp' # noqa: S108
)
if not base_directory.startswith(os.path.sep):
raise ValueError('The temporary directory must be an absolute path')
+84 -1
View File
@@ -72,7 +72,8 @@ properties:
description: |
Whether any missing parent directories of the repository
path should be created, only used for the repo-create
action. Defaults to false.
action. Defaults to false. (This option is supported
for Borg 1.x only)
example: true
description: |
A required list of local or remote repositories with paths and
@@ -389,6 +390,13 @@ properties:
Path for Borg cache files. Defaults to
$borg_base_directory/.cache/borg
example: /path/to/base/cache
use_chunks_archive:
type: boolean
description: |
Enables or disables the use of chunks.archive.d for faster cache
resyncs in Borg. If true, value is set to "yes" (default) else
it's set to "no", reducing disk usage but slowing resyncs.
example: true
borg_files_cache_ttl:
type: integer
description: |
@@ -483,6 +491,19 @@ properties:
Bypass Borg error about a previously unknown unencrypted repository.
Defaults to false.
example: true
debug_passphrase:
type: boolean
description: |
When set true, display debugging information that includes
passphrases used and passphrase related environment variables set.
Defaults to false.
example: true
display_passphrase:
type: boolean
description: |
When set true, always shows passphrase and its hex UTF-8 byte
sequence. Defaults to false.
example: true
check_i_know_what_i_am_doing:
type: boolean
description: |
@@ -558,6 +579,14 @@ properties:
type: integer
description: Number of yearly archives to keep.
example: 1
keep_13weekly:
type: integer
description: Number of quarterly archives to keep (13 week strategy).
example: 13
keep_3monthly:
type: integer
description: Number of quarterly archives to keep (3 month strategy).
example: 3
prefix:
type: string
description: |
@@ -1225,6 +1254,26 @@ properties:
particular actions listed here. Defaults to
running for all actions.
example: [create, prune, compact, check]
states:
type: array
items:
type: string
enum:
- finish
- fail
description: |
Only trigger the hook if borgmatic encounters one
of the states (execution results) listed here,
where:
* "finish": No errors occurred.
* "fail": An error occurred.
This state is evaluated only for the scope of the
configured "action", "repository", etc., rather
than for the entire borgmatic run. Only available
for "after" hooks. Defaults to running the hook
for all states.
example:
- finish
run:
type: array
items:
@@ -1525,6 +1574,23 @@ properties:
Defaults to the "password" option. Supports the
"{credential ...}" syntax.
example: trustsome1
password_transport:
type: string
enum:
- pipe
- environment
description: |
How to transmit database passwords from borgmatic to the
MariaDB client, one of:
* "pipe": Securely transmit passwords via anonymous
pipe. Only works if the database client is on the
same host as borgmatic. (The server can be
somewhere else.) This is the default value.
* "environment": Transmit passwords via environment
variable. Potentially less secure than a pipe, but
necessary when the database client is elsewhere, e.g.
when "mariadb_dump_command" is configured to "exec"
into a container and run a client there.
tls:
type: boolean
description: |
@@ -1673,6 +1739,23 @@ properties:
Defaults to the "password" option. Supports the
"{credential ...}" syntax.
example: trustsome1
password_transport:
type: string
enum:
- pipe
- environment
description: |
How to transmit database passwords from borgmatic to the
MySQL client, one of:
* "pipe": Securely transmit passwords via anonymous
pipe. Only works if the database client is on the
same host as borgmatic. (The server can be
somewhere else.) This is the default value.
* "environment": Transmit passwords via environment
variable. Potentially less secure than a pipe, but
necessary when the database client is elsewhere, e.g.
when "mysql_dump_command" is configured to "exec"
into a container and run a client there.
tls:
type: boolean
description: |
+9 -9
View File
@@ -288,6 +288,7 @@ def execute_command(
borg_local_path=None,
borg_exit_codes=None,
run_to_completion=True,
close_fds=False, # Necessary for passing credentials via anonymous pipe.
):
'''
Execute the given command (a sequence of command/argument strings) and log its output at the
@@ -312,11 +313,10 @@ def execute_command(
stdin=input_file,
stdout=None if do_not_capture else (output_file or subprocess.PIPE),
stderr=None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT),
shell=shell,
shell=shell, # noqa: S602
env=environment,
cwd=working_directory,
# Necessary for passing credentials via anonymous pipe.
close_fds=False,
close_fds=close_fds,
)
if not run_to_completion:
return process
@@ -340,6 +340,7 @@ def execute_command_and_capture_output(
working_directory=None,
borg_local_path=None,
borg_exit_codes=None,
close_fds=False, # Necessary for passing credentials via anonymous pipe.
):
'''
Execute the given command (a sequence of command/argument strings), capturing and returning its
@@ -362,11 +363,10 @@ def execute_command_and_capture_output(
command,
stdin=input_file,
stderr=subprocess.STDOUT if capture_stderr else None,
shell=shell,
shell=shell, # noqa: S602
env=environment,
cwd=working_directory,
# Necessary for passing credentials via anonymous pipe.
close_fds=False,
close_fds=close_fds,
)
except subprocess.CalledProcessError as error:
if (
@@ -390,6 +390,7 @@ def execute_command_with_processes(
working_directory=None,
borg_local_path=None,
borg_exit_codes=None,
close_fds=False, # Necessary for passing credentials via anonymous pipe.
):
'''
Execute the given command (a sequence of command/argument strings) and log its output at the
@@ -422,11 +423,10 @@ def execute_command_with_processes(
stderr=(
None if do_not_capture else (subprocess.PIPE if output_file else subprocess.STDOUT)
),
shell=shell,
shell=shell, # noqa: S602
env=environment,
cwd=working_directory,
# Necessary for passing credentials via anonymous pipe.
close_fds=False,
close_fds=close_fds,
)
except (subprocess.CalledProcessError, OSError):
# Something has gone wrong. So vent each process' output buffer to prevent it from hanging.
+35 -17
View File
@@ -13,6 +13,20 @@ logger = logging.getLogger(__name__)
SOFT_FAIL_EXIT_CODE = 75
BORG_PLACEHOLDERS = {
'{hostname}',
'{fqdn}',
'{reverse-fqdn}',
'{now}',
'{utcnow}',
'{unixtime}',
'{user}',
'{pid}',
'{borgversion}',
'{borgmajor}',
'{borgminor}',
'{borgpatch}',
}
def interpolate_context(hook_description, command, context):
@@ -23,10 +37,13 @@ def interpolate_context(hook_description, command, context):
for name, value in context.items():
command = command.replace(f'{{{name}}}', shlex.quote(str(value)))
for unsupported_variable in re.findall(r'{\w+}', command):
logger.warning(
f"Variable '{unsupported_variable}' is not supported in {hook_description} hook"
)
for unsupported_variable in re.findall(r'\{\w+\}', command):
# Warn about variables unknown to borgmatic, but don't warn if the variable name happens to
# be a Borg placeholder, as Borg should hopefully consume it.
if unsupported_variable not in BORG_PLACEHOLDERS:
logger.warning(
f'Variable "{unsupported_variable}" is not supported in the {hook_description} hook'
)
return command
@@ -47,21 +64,25 @@ def make_environment(current_environment, sys_module=sys):
return environment
def filter_hooks(command_hooks, before=None, after=None, hook_name=None, action_names=None):
def filter_hooks(command_hooks, before=None, after=None, action_names=None, state_names=None):
'''
Given a sequence of command hook dicts from configuration and one or more filters (before name,
after name, calling hook name, or a sequence of action names), filter down the command hooks to
just the ones that match the given filters.
after name, a sequence of action names, and/or a sequence of execution result state names),
filter down the command hooks to just the ones that match the given filters.
'''
return tuple(
hook_config
for hook_config in command_hooks or ()
for config_action_names in (hook_config.get('when'),)
for config_state_names in (hook_config.get('states'),)
if before is None or hook_config.get('before') == before
if after is None or hook_config.get('after') == after
if action_names is None
or config_action_names is None
or set(config_action_names or ()).intersection(set(action_names))
if state_names is None
or config_state_names is None
or set(config_state_names or ()).intersection(set(state_names))
)
@@ -123,7 +144,7 @@ def execute_hooks(command_hooks, umask, working_directory, dry_run, **context):
output_log_level=(
logging.ERROR if hook_config.get('after') == 'error' else logging.ANSWER
),
shell=True,
shell=True, # noqa: S604
environment=make_environment(os.environ),
working_directory=working_directory,
)
@@ -143,7 +164,7 @@ class Before_after_hooks:
before_after='do_stuff',
umask=config.get('umask'),
dry_run=dry_run,
hook_name='myhook',
action_names=['create'],
):
do()
some()
@@ -160,22 +181,20 @@ class Before_after_hooks:
umask,
working_directory,
dry_run,
hook_name=None,
action_names=None,
**context,
):
'''
Given a sequence of command hook configuration dicts, the before/after name, a umask to run
commands with, a working directory to run commands with, a dry run flag, the name of the
calling hook, a sequence of action names, and any context for the executed commands, save
those data points for use below.
commands with, a working directory to run commands with, a dry run flag, a sequence of
action names, and any context for the executed commands, save those data points for use
below.
'''
self.command_hooks = command_hooks
self.before_after = before_after
self.umask = umask
self.working_directory = working_directory
self.dry_run = dry_run
self.hook_name = hook_name
self.action_names = action_names
self.context = context
@@ -188,7 +207,6 @@ class Before_after_hooks:
borgmatic.hooks.command.filter_hooks(
self.command_hooks,
before=self.before_after,
hook_name=self.hook_name,
action_names=self.action_names,
),
self.umask,
@@ -202,7 +220,7 @@ class Before_after_hooks:
# Trigger the after hook manually, since raising here will prevent it from being run
# otherwise.
self.__exit__(None, None, None)
self.__exit__(exception_type=type(error), exception=error, traceback=None)
raise ValueError(f'Error running before {self.before_after} hook: {error}')
@@ -215,8 +233,8 @@ class Before_after_hooks:
borgmatic.hooks.command.filter_hooks(
self.command_hooks,
after=self.before_after,
hook_name=self.hook_name,
action_names=self.action_names,
state_names=['fail' if exception_type else 'finish'],
),
self.umask,
self.working_directory,
+92 -26
View File
@@ -1,5 +1,6 @@
import collections
import glob
import itertools
import json
import logging
import os
@@ -21,9 +22,44 @@ def use_streaming(hook_config, config): # pragma: no cover
return False
def get_subvolume_mount_points(findmnt_command):
def get_contained_subvolume_paths(btrfs_command, subvolume_path):
'''
Given a findmnt command to run, get all sorted Btrfs subvolume mount points.
Given the path of a Btrfs subvolume, return it in a sequence along with the paths of its
contained subvolumes.
If the btrfs command errors, log that error and return an empty sequence.
'''
try:
btrfs_output = borgmatic.execute.execute_command_and_capture_output(
tuple(btrfs_command.split(' '))
+ (
'subvolume',
'list',
subvolume_path,
),
close_fds=True,
)
except subprocess.CalledProcessError as error:
logger.debug(
f'Ignoring Btrfs subvolume {subvolume_path} because of error listing its subvolumes: {error}'
)
return ()
return (subvolume_path,) + tuple(
os.path.join(subvolume_path, line.split(' ')[-1])
for line in btrfs_output.splitlines()
if line.strip()
)
FINDMNT_BTRFS_ROOT_SUBVOLUME_OPTION = 'subvolid=5'
def get_all_subvolume_paths(btrfs_command, findmnt_command):
'''
Given btrfs and findmnt commands to run, get the sorted paths for all Btrfs subvolumes on the
system.
'''
findmnt_output = borgmatic.execute.execute_command_and_capture_output(
tuple(findmnt_command.split(' '))
@@ -32,12 +68,28 @@ def get_subvolume_mount_points(findmnt_command):
'btrfs',
'--json',
'--list', # Request a flat list instead of a nested subvolume hierarchy.
)
),
close_fds=True,
)
try:
return tuple(
sorted(filesystem['target'] for filesystem in json.loads(findmnt_output)['filesystems'])
sorted(
itertools.chain.from_iterable(
# If findmnt gave us a Btrfs root filesystem, list the subvolumes within it.
# This is necessary because findmnt only returns a subvolume's mount point
# rather than its original subvolume path (which can differ). For instance,
# a subvolume might exist at /mnt/subvolume but be mounted at /home/myuser.
# findmnt is still useful though because it's a global way to discover all
# Btrfs subvolumes—even if we have to do some additional legwork ourselves.
(
get_contained_subvolume_paths(btrfs_command, filesystem['target'])
if FINDMNT_BTRFS_ROOT_SUBVOLUME_OPTION in filesystem['options'].split(',')
else (filesystem['target'],)
)
for filesystem in json.loads(findmnt_output)['filesystems']
)
)
)
except json.JSONDecodeError as error:
raise ValueError(f'Invalid {findmnt_command} JSON output: {error}')
@@ -49,6 +101,12 @@ Subvolume = collections.namedtuple('Subvolume', ('path', 'contained_patterns'),
def get_subvolume_property(btrfs_command, subvolume_path, property_name):
'''
Given a btrfs command, a subvolume path, and a property name to lookup, return the value of the
corresponding property.
Raise subprocess.CalledProcessError if the btrfs command errors.
'''
output = borgmatic.execute.execute_command_and_capture_output(
tuple(btrfs_command.split(' '))
+ (
@@ -59,6 +117,7 @@ def get_subvolume_property(btrfs_command, subvolume_path, property_name):
subvolume_path,
property_name,
),
close_fds=True,
)
try:
@@ -72,19 +131,24 @@ def get_subvolume_property(btrfs_command, subvolume_path, property_name):
}.get(value, value)
def omit_read_only_subvolume_mount_points(btrfs_command, subvolume_paths):
def omit_read_only_subvolume_paths(btrfs_command, subvolume_paths):
'''
Given a Btrfs command to run and a sequence of Btrfs subvolume mount points, filter them down to
just those that are read-write. The idea is that Btrfs can't actually snapshot a read-only
subvolume, so we should just ignore them.
Given a Btrfs command to run and a sequence of Btrfs subvolume paths, filter them down to just
those that are read-write. The idea is that Btrfs can't actually snapshot a read-only subvolume,
so we should just ignore them.
'''
retained_subvolume_paths = []
for subvolume_path in subvolume_paths:
if get_subvolume_property(btrfs_command, subvolume_path, 'ro'):
logger.debug(f'Ignoring Btrfs subvolume {subvolume_path} because it is read-only')
else:
retained_subvolume_paths.append(subvolume_path)
try:
if get_subvolume_property(btrfs_command, subvolume_path, 'ro'):
logger.debug(f'Ignoring Btrfs subvolume {subvolume_path} because it is read-only')
else:
retained_subvolume_paths.append(subvolume_path)
except subprocess.CalledProcessError as error:
logger.debug(
f'Error determining read-only status of Btrfs subvolume {subvolume_path}: {error}'
)
return tuple(retained_subvolume_paths)
@@ -92,32 +156,32 @@ def omit_read_only_subvolume_mount_points(btrfs_command, subvolume_paths):
def get_subvolumes(btrfs_command, findmnt_command, patterns=None):
'''
Given a Btrfs command to run and a sequence of configured patterns, find the intersection
between the current Btrfs filesystem and subvolume mount points and the paths of any patterns.
The idea is that these pattern paths represent the requested subvolumes to snapshot.
between the current Btrfs filesystem and subvolume paths and the paths of any patterns. The
idea is that these pattern paths represent the requested subvolumes to snapshot.
Only include subvolumes that contain at least one root pattern sourced from borgmatic
configuration (as opposed to generated elsewhere in borgmatic). But if patterns is None, then
return all subvolumes instead, sorted by path.
Return the result as a sequence of matching subvolume mount points.
Return the result as a sequence of matching Subvolume instances.
'''
candidate_patterns = set(patterns or ())
subvolumes = []
# For each subvolume mount point, match it against the given patterns to find the subvolumes to
# For each subvolume path, match it against the given patterns to find the subvolumes to
# backup. Sort the subvolumes from longest to shortest mount points, so longer mount points get
# a whack at the candidate pattern piñata before their parents do. (Patterns are consumed during
# this process, so no two subvolumes end up with the same contained patterns.)
for mount_point in reversed(
omit_read_only_subvolume_mount_points(
btrfs_command, get_subvolume_mount_points(findmnt_command)
for subvolume_path in reversed(
omit_read_only_subvolume_paths(
btrfs_command, get_all_subvolume_paths(btrfs_command, findmnt_command)
)
):
subvolumes.extend(
Subvolume(mount_point, contained_patterns)
Subvolume(subvolume_path, contained_patterns)
for contained_patterns in (
borgmatic.hooks.data_source.snapshot.get_contained_patterns(
mount_point, candidate_patterns
subvolume_path, candidate_patterns
),
)
if patterns is None
@@ -227,6 +291,7 @@ def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma:
snapshot_path,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -241,9 +306,9 @@ def dump_data_sources(
'''
Given a Btrfs configuration dict, a configuration dict, the borgmatic configuration file paths,
the borgmatic runtime directory, the configured patterns, and whether this is a dry run,
auto-detect and snapshot any Btrfs subvolume mount points listed in the given patterns. Also
update those patterns, replacing subvolume mount points with corresponding snapshot directories
so they get stored in the Borg archive instead.
auto-detect and snapshot any Btrfs subvolume paths listed in the given patterns. Also update
those patterns, replacing subvolume paths with corresponding snapshot directories so they get
stored in the Borg archive instead.
Return an empty sequence, since there are no ongoing dump processes from this hook.
@@ -298,6 +363,7 @@ def delete_snapshot(btrfs_command, snapshot_path): # pragma: no cover
snapshot_path,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -324,8 +390,8 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
logger.debug(error)
return
# Reversing the sorted subvolumes ensures that we remove longer mount point paths of child
# subvolumes before the shorter mount point paths of parent subvolumes.
# Reversing the sorted subvolumes ensures that we remove longer paths of child subvolumes before
# the shorter paths of parent subvolumes.
for subvolume in reversed(all_subvolumes):
subvolume_snapshots_glob = borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
os.path.normpath(make_snapshot_path(subvolume.path)),
+16 -9
View File
@@ -50,7 +50,8 @@ def get_logical_volumes(lsblk_command, patterns=None):
'name,path,mountpoint,type',
'--json',
'--list',
)
),
close_fds=True,
)
)
except json.JSONDecodeError as error:
@@ -109,6 +110,7 @@ def snapshot_logical_volume(
logical_volume_device,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -129,6 +131,7 @@ def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path): # prag
snapshot_mount_path,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -227,12 +230,13 @@ def dump_data_sources(
)
# Get the device path for the snapshot we just created.
try:
snapshot = get_snapshots(
hook_config.get('lvs_command', 'lvs'), snapshot_name=snapshot_name
)[0]
except IndexError:
raise ValueError(f'Cannot find LVM snapshot {snapshot_name}')
if not dry_run:
try:
snapshot = get_snapshots(
hook_config.get('lvs_command', 'lvs'), snapshot_name=snapshot_name
)[0]
except IndexError:
raise ValueError(f'Cannot find LVM snapshot {snapshot_name}')
# Mount the snapshot into a particular named temporary directory so that the snapshot ends
# up in the Borg archive at the "original" logical volume mount point path.
@@ -277,6 +281,7 @@ def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
borgmatic.execute.execute_command(
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -291,6 +296,7 @@ def remove_snapshot(lvremove_command, snapshot_device_path): # pragma: no cover
snapshot_device_path,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -318,7 +324,8 @@ def get_snapshots(lvs_command, snapshot_name=None):
'lv_name,lv_path',
'--select',
'lv_attr =~ ^s', # Filter to just snapshots.
)
),
close_fds=True,
)
)
except json.JSONDecodeError as error:
@@ -407,7 +414,7 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, d
continue
if not dry_run:
shutil.rmtree(snapshots_directory)
shutil.rmtree(snapshots_directory, ignore_errors=True)
# Delete snapshots.
lvremove_command = hook_config.get('lvremove_command', 'lvremove')
+33 -5
View File
@@ -120,13 +120,19 @@ def database_names_to_dump(database, config, username, password, environment, dr
shlex.quote(part) for part in shlex.split(database.get('mariadb_command') or 'mariadb')
)
extra_options, defaults_extra_filename = parse_extra_options(database.get('list_options'))
password_transport = database.get('password_transport', 'pipe')
show_command = (
mariadb_show_command
+ make_defaults_file_options(username, password, defaults_extra_filename)
+ (
make_defaults_file_options(username, password, defaults_extra_filename)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if database.get('tls') is True else ())
+ (('--skip-ssl',) if database.get('tls') is False else ())
+ ('--skip-column-names', '--batch')
@@ -184,14 +190,20 @@ def execute_dump_command(
for part in shlex.split(database.get('mariadb_dump_command') or 'mariadb-dump')
)
extra_options, defaults_extra_filename = parse_extra_options(database.get('options'))
password_transport = database.get('password_transport', 'pipe')
dump_command = (
mariadb_dump_command
+ make_defaults_file_options(username, password, defaults_extra_filename)
+ (
make_defaults_file_options(username, password, defaults_extra_filename)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ (('--add-drop-database',) if database.get('add_drop_database', True) else ())
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if database.get('tls') is True else ())
+ (('--skip-ssl',) if database.get('tls') is False else ())
+ ('--databases',)
@@ -255,7 +267,14 @@ def dump_data_sources(
password = borgmatic.hooks.credential.parse.resolve_credential(
database.get('password'), config
)
environment = dict(os.environ)
environment = dict(
os.environ,
**(
{'MYSQL_PWD': password}
if password and database.get('password_transport') == 'environment'
else {}
),
)
dump_database_names = database_names_to_dump(
database, config, username, password, environment, dry_run
)
@@ -383,18 +402,27 @@ def restore_data_source_dump(
shlex.quote(part) for part in shlex.split(data_source.get('mariadb_command') or 'mariadb')
)
extra_options, defaults_extra_filename = parse_extra_options(data_source.get('restore_options'))
password_transport = data_source.get('password_transport', 'pipe')
restore_command = (
mariadb_restore_command
+ make_defaults_file_options(username, password, defaults_extra_filename)
+ (
make_defaults_file_options(username, password, defaults_extra_filename)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ ('--batch',)
+ (('--host', hostname) if hostname else ())
+ (('--port', str(port)) if port else ())
+ (('--protocol', 'tcp') if hostname or port else ())
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if tls is True else ())
+ (('--skip-ssl',) if tls is False else ())
)
environment = dict(os.environ)
environment = dict(
os.environ,
**({'MYSQL_PWD': password} if password and password_transport == 'environment' else {}),
)
logger.debug(f"Restoring MariaDB database {data_source['name']}{dry_run_label}")
if dry_run:
+4 -3
View File
@@ -74,11 +74,12 @@ def dump_data_sources(
if dump_format == 'directory':
dump.create_parent_directory_for_dump(dump_filename)
execute_command(command, shell=True)
execute_command(command, shell=True) # noqa: S604
else:
dump.create_named_pipe_for_dump(dump_filename)
processes.append(execute_command(command, shell=True, run_to_completion=False))
processes.append(
execute_command(command, shell=True, run_to_completion=False) # noqa: S604
)
if not dry_run:
patterns.append(
borgmatic.borg.pattern.Pattern(
+36 -8
View File
@@ -45,15 +45,21 @@ def database_names_to_dump(database, config, username, password, environment, dr
extra_options, defaults_extra_filename = (
borgmatic.hooks.data_source.mariadb.parse_extra_options(database.get('list_options'))
)
password_transport = database.get('password_transport', 'pipe')
show_command = (
mysql_show_command
+ borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
+ (
borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if database.get('tls') is True else ())
+ (('--skip-ssl',) if database.get('tls') is False else ())
+ ('--skip-column-names', '--batch')
@@ -109,16 +115,22 @@ def execute_dump_command(
extra_options, defaults_extra_filename = (
borgmatic.hooks.data_source.mariadb.parse_extra_options(database.get('options'))
)
password_transport = database.get('password_transport', 'pipe')
dump_command = (
mysql_dump_command
+ borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
+ (
borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ (('--add-drop-database',) if database.get('add_drop_database', True) else ())
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if database.get('tls') is True else ())
+ (('--skip-ssl',) if database.get('tls') is False else ())
+ ('--databases',)
@@ -182,7 +194,14 @@ def dump_data_sources(
password = borgmatic.hooks.credential.parse.resolve_credential(
database.get('password'), config
)
environment = dict(os.environ)
environment = dict(
os.environ,
**(
{'MYSQL_PWD': password}
if password and database.get('password_transport') == 'environment'
else {}
),
)
dump_database_names = database_names_to_dump(
database, config, username, password, environment, dry_run
)
@@ -312,20 +331,29 @@ def restore_data_source_dump(
extra_options, defaults_extra_filename = (
borgmatic.hooks.data_source.mariadb.parse_extra_options(data_source.get('restore_options'))
)
password_transport = data_source.get('password_transport', 'pipe')
restore_command = (
mysql_restore_command
+ borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
+ (
borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ ('--batch',)
+ (('--host', hostname) if hostname else ())
+ (('--port', str(port)) if port else ())
+ (('--protocol', 'tcp') if hostname or port else ())
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if tls is True else ())
+ (('--skip-ssl',) if tls is False else ())
)
environment = dict(os.environ)
environment = dict(
os.environ,
**({'MYSQL_PWD': password} if password and password_transport == 'environment' else {}),
)
logger.debug(f"Restoring MySQL database {data_source['name']}{dry_run_label}")
if dry_run:
+2 -2
View File
@@ -224,7 +224,7 @@ def dump_data_sources(
dump.create_parent_directory_for_dump(dump_filename)
execute_command(
command,
shell=True,
shell=True, # noqa: S604
environment=environment,
)
else:
@@ -232,7 +232,7 @@ def dump_data_sources(
processes.append(
execute_command(
command,
shell=True,
shell=True, # noqa: S604
environment=environment,
run_to_completion=False,
)
+5 -5
View File
@@ -8,9 +8,9 @@ def get_contained_patterns(parent_directory, candidate_patterns):
'''
Given a parent directory and a set of candidate patterns potentially inside it, get the subset
of contained patterns for which the parent directory is actually the parent, a grandparent, the
very same directory, etc. The idea is if, say, /var/log and /var/lib are candidate pattern
paths, but there's a parent directory (logical volume, dataset, subvolume, etc.) at /var, then
/var is what we want to snapshot.
very same directory, etc. The idea is if, say, "/var/log" and "/var/lib" are candidate pattern
paths, but there's a parent directory (logical volume, dataset, subvolume, etc.) at "/var", then
"/var" is what we want to snapshot.
If a parent directory and a candidate pattern are on different devices, skip the pattern. That's
because any snapshot of a parent directory won't actually include "contained" directories if
@@ -18,8 +18,8 @@ def get_contained_patterns(parent_directory, candidate_patterns):
For this function to work, a candidate pattern path can't have any globs or other non-literal
characters in the initial portion of the path that matches the parent directory. For instance, a
parent directory of /var would match a candidate pattern path of /var/log/*/data, but not a
pattern path like /v*/log/*/data.
parent directory of "/var" would match a candidate pattern path of "/var/log/*/data", but not a
pattern path like "/v*/log/*/data".
The one exception is that if a regular expression pattern path starts with "^", that will get
stripped off for purposes of matching against a parent directory.
+3 -1
View File
@@ -88,7 +88,9 @@ def dump_data_sources(
continue
dump.create_named_pipe_for_dump(dump_filename)
processes.append(execute_command(command, shell=True, run_to_completion=False))
processes.append(
execute_command(command, shell=True, run_to_completion=False) # noqa: S604
)
if not dry_run:
patterns.append(
+10 -3
View File
@@ -53,7 +53,8 @@ def get_datasets_to_backup(zfs_command, patterns):
'filesystem',
'-o',
f'name,mountpoint,canmount,{BORGMATIC_USER_PROPERTY}',
)
),
close_fds=True,
)
try:
@@ -131,7 +132,8 @@ def get_all_dataset_mount_points(zfs_command):
'filesystem',
'-o',
'mountpoint',
)
),
close_fds=True,
)
return tuple(
@@ -158,6 +160,7 @@ def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover
full_snapshot_name,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -180,6 +183,7 @@ def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # p
snapshot_mount_path,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -310,6 +314,7 @@ def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
borgmatic.execute.execute_command(
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -325,6 +330,7 @@ def destroy_snapshot(zfs_command, full_snapshot_name): # pragma: no cover
full_snapshot_name,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -342,7 +348,8 @@ def get_all_snapshots(zfs_command):
'snapshot',
'-o',
'name',
)
),
close_fds=True,
)
return tuple(line.rstrip() for line in list_output.splitlines())
+2 -1
View File
@@ -11,6 +11,7 @@ MONITOR_STATE_TO_CRONHUB = {
monitor.State.FINISH: 'finish',
monitor.State.FAIL: 'fail',
}
TIMEOUT_SECONDS = 10
def initialize_monitor(
@@ -45,7 +46,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
try:
response = requests.get(ping_url)
response = requests.get(ping_url, timeout=TIMEOUT_SECONDS)
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
+2 -1
View File
@@ -11,6 +11,7 @@ MONITOR_STATE_TO_CRONITOR = {
monitor.State.FINISH: 'complete',
monitor.State.FAIL: 'fail',
}
TIMEOUT_SECONDS = 10
def initialize_monitor(
@@ -40,7 +41,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
try:
response = requests.get(ping_url)
response = requests.get(ping_url, timeout=TIMEOUT_SECONDS)
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
+5 -1
View File
@@ -17,6 +17,7 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
DEFAULT_PING_BODY_LIMIT_BYTES = 100000
HANDLER_IDENTIFIER = 'healthchecks'
TIMEOUT_SECONDS = 10
def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
@@ -84,7 +85,10 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
logging.getLogger('urllib3').setLevel(logging.ERROR)
try:
response = requests.post(
ping_url, data=payload.encode('utf-8'), verify=hook_config.get('verify_tls', True)
ping_url,
data=payload.encode('utf-8'),
verify=hook_config.get('verify_tls', True),
timeout=TIMEOUT_SECONDS,
)
if not response.ok:
response.raise_for_status()
+4 -1
View File
@@ -15,6 +15,7 @@ MONITOR_STATE_TO_LOKI = {
monitor.State.FINISH: 'Finished',
monitor.State.FAIL: 'Failed',
}
TIMEOUT_SECONDS = 10
# Threshold at which logs get flushed to loki
MAX_BUFFER_LINES = 100
@@ -69,7 +70,9 @@ class Loki_log_buffer:
request_header = {'Content-Type': 'application/json'}
try:
result = requests.post(self.url, headers=request_header, data=request_body, timeout=5)
result = requests.post(
self.url, headers=request_header, data=request_body, timeout=TIMEOUT_SECONDS
)
result.raise_for_status()
except requests.RequestException:
logger.warning('Failed to upload logs to loki')
+6 -1
View File
@@ -7,6 +7,9 @@ import borgmatic.hooks.credential.parse
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10
def initialize_monitor(
ping_url, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
@@ -82,7 +85,9 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
try:
response = requests.post(f'{base_url}/{topic}', headers=headers, auth=auth)
response = requests.post(
f'{base_url}/{topic}', headers=headers, auth=auth, timeout=TIMEOUT_SECONDS
)
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
+4 -1
View File
@@ -14,6 +14,7 @@ logger = logging.getLogger(__name__)
EVENTS_API_URL = 'https://events.pagerduty.com/v2/enqueue'
DEFAULT_LOGS_PAYLOAD_LIMIT_BYTES = 10000
HANDLER_IDENTIFIER = 'pagerduty'
TIMEOUT_SECONDS = 10
def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
@@ -94,7 +95,9 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
logging.getLogger('urllib3').setLevel(logging.ERROR)
try:
response = requests.post(EVENTS_API_URL, data=payload.encode('utf-8'))
response = requests.post(
EVENTS_API_URL, data=payload.encode('utf-8'), timeout=TIMEOUT_SECONDS
)
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
+2
View File
@@ -8,6 +8,7 @@ logger = logging.getLogger(__name__)
EMERGENCY_PRIORITY = 2
TIMEOUT_SECONDS = 10
def initialize_monitor(
@@ -79,6 +80,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
'https://api.pushover.net/1/messages.json',
headers={'Content-type': 'application/x-www-form-urlencoded'},
data=data,
timeout=TIMEOUT_SECONDS,
)
if not response.ok:
response.raise_for_status()
+4 -1
View File
@@ -6,6 +6,9 @@ import requests
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10
def initialize_monitor(
ping_url, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
@@ -61,7 +64,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
logging.getLogger('urllib3').setLevel(logging.ERROR)
try:
response = requests.post(f'{cron_url}?status={status}')
response = requests.post(f'{cron_url}?status={status}', timeout=TIMEOUT_SECONDS)
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
+8 -1
View File
@@ -5,6 +5,9 @@ import requests
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10
def initialize_monitor(
push_url, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
@@ -37,7 +40,11 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
logging.getLogger('urllib3').setLevel(logging.ERROR)
try:
response = requests.get(f'{push_url}?{query}', verify=hook_config.get('verify_tls', True))
response = requests.get(
f'{push_url}?{query}',
verify=hook_config.get('verify_tls', True),
timeout=TIMEOUT_SECONDS,
)
if not response.ok:
response.raise_for_status()
except requests.exceptions.RequestException as error:
+4 -1
View File
@@ -7,6 +7,9 @@ import borgmatic.hooks.credential.parse
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10
def initialize_monitor(
ping_url, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
@@ -28,7 +31,7 @@ def send_zabbix_request(server, headers, data):
logger.debug(f'Sending a "{data["method"]}" request to the Zabbix server')
try:
response = requests.post(server, headers=headers, json=data)
response = requests.post(server, headers=headers, json=data, timeout=TIMEOUT_SECONDS)
if not response.ok:
response.raise_for_status()
@@ -55,6 +55,25 @@ commands:
- "echo Backup: start"
```
By default, an `after` command hook runs even if an error occurs in the
corresponding `before` hook or between those two hooks. This allows you to
perform cleanup steps that correspond to `before` preparation commands—even when
something goes wrong. You may notice that this is a departure from the way that
the deprecated `after_*` hooks worked in borgmatic prior to version 2.0.0.
<span class="minilink minilink-addedin">New in version 2.0.3</span> You can
customize this behavior with the `states` option. For instance, here's an
example of an `after` hook that only triggers on success and not on error:
```yaml
commands:
- after: action
when: [create]
states: [finish]
run:
- echo "After successful create!"
```
Each command in the `commands:` list has the following options:
* `before` or `after`: Name for the point in borgmatic's execution that the commands should be run before or after, one of:
@@ -64,17 +83,21 @@ Each command in the `commands:` list has the following options:
* `everything` runs before or after all configuration files. Errors here do not trigger `error` hooks or the `fail` state in monitoring hooks. This replaces the deprecated `before_everything` and `after_everything`.
* `error` runs after an error occurs—and it's only available for `after`. This replaces the deprecated `on_error` hook.
* `when`: Only trigger the hook when borgmatic is run with particular actions (`create`, `prune`, etc.) listed here. Defaults to running for all actions.
* `states`: <span class="minilink minilink-addedin">New in version 2.0.3</span> Only trigger the hook if borgmatic encounters one of the states (execution results) listed here. This state is evaluated only for the scope of the configured `action`, `repository`, etc., rather than for the entire borgmatic run. Only available for `after` hooks. Defaults to running the hook for all states. One or more of:
* `finish`: No errors occurred.
* `fail`: An error occurred.
* `run`: List of one or more shell commands or scripts to run when this command hook is triggered.
An `after` command hook runs even if an error occurs in the corresponding
`before` hook or between those two hooks. This allows you to perform cleanup
steps that correspond to `before` preparation commands—even when something goes
wrong. This is a departure from the way that the deprecated `after_*` hooks
worked in borgmatic prior to version 2.0.0.
When command hooks run, they respect the `working_directory` option if it is
configured, meaning that the hook commands are run in that directory.
Additionally, when command hooks run, they respect the `working_directory`
option if it is configured, meaning that the hook commands are run in that
directory.
<span class="minilink minilink-addedin">New in version 2.0.4</span>If the exact
same `everything` command hook is present in multiple configuration files,
borgmatic only runs it once.
borgmatic's `--repository` flag does not impact which command hooks get run. But
you can use the `--config` flag to limit the configuration files (and thus
command hooks) used.
### Order of execution
@@ -97,11 +120,24 @@ borgmatic for the `create` and `prune` actions. Here's the order of execution:
* Run `after: action` hooks for `prune`.
* Run `after: repository` hooks (for the first repository).
* Run `after: configuration` hooks (from the first configuration file).
* Run `after: error` hooks (if an error occurs).
* Run `after: everything` hooks (from all configuration files).
This same order of execution extends to multiple repositories and/or
configuration files.
Based on the above, you can see the difference between, say, an `after: action`
hook with `states: [fail]` and an `after: error` hook. The `after: action hook`
runs immediately after the create action fails for a particular repository—so
before any subsequent actions for that repository or other repositories even
have a chance to run. Whereas the `after: error` hook doesn't run until all
actions for—and repositories in—a configuration file have had a chance to
execute.
And if there are multiple hooks defined for a particular step (e.g. `before:
action` for `create`), then those hooks are run in the order they're defined in
configuration.
### Deprecated command hooks
+51 -23
View File
@@ -191,7 +191,7 @@ mysql_databases:
format: sql
```
### Containers
### Database containers
If your database server is running within a container and borgmatic is too, no
problem—configure borgmatic to connect to the container's name on its exposed
@@ -209,6 +209,9 @@ postgresql_databases:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
these options in the `hooks:` section of your configuration.
#### Database client on the host
But what if borgmatic is running on the host? You can still connect to a
database server container if its ports are properly exposed to the host. For
instance, when running the database container, you can specify `--publish
@@ -228,17 +231,19 @@ And then you can configure borgmatic running on the host to connect to the
database:
```yaml
hooks:
postgresql_databases:
- name: users
hostname: 127.0.0.1
port: 5433
username: postgres
password: trustsome1
postgresql_databases:
- name: users
hostname: 127.0.0.1
port: 5433
username: postgres
password: trustsome1
```
Alter the ports in these examples to suit your particular database system.
#### Database client in a running container
Normally, borgmatic dumps a database by running a database dump command (e.g.
`pg_dump`) on the host or wherever borgmatic is running, and this command
connects to your containerized database via the given `hostname` and `port`. But
@@ -250,14 +255,13 @@ to run the dump command.
For instance, if using Docker and PostgreSQL, something like this might work:
```yaml
hooks:
postgresql_databases:
- name: users
hostname: 127.0.0.1
port: 5433
username: postgres
password: trustsome1
pg_dump_command: docker exec my_pg_container pg_dump
postgresql_databases:
- name: users
hostname: 127.0.0.1
port: 5433
username: postgres
password: trustsome1
pg_dump_command: docker exec my_pg_container pg_dump
```
... where `my_pg_container` is the name of your running database container.
@@ -282,19 +286,43 @@ services:
- /run/user/1000:/run/user/1000
```
And here's an example of using a MariaDB database client within a running Docker
container:
```yaml
mariadb_databases:
- name: users
hostname: 127.0.0.1
username: example
password: trustsome1
password_transport: environment
mariadb_dump_command: docker exec --env MYSQL_PWD my_mariadb_container mariadb-dump
```
The `password_transport: environment` option tells borgmatic to transmit the
password via environment variable instead of the default behavior of using an
anonymous pipe. The environment variable transport is potentially less secure
than the pipe, but it's necessary when the database client is running in a
container separate from borgmatic.
A similar approach can work with MySQL, using `mysql_dump_command` instead of
`mariadb_dump_command` to run `mysqldump` in a container.
#### Database client in a temporary container
Another variation: If you're running borgmatic on the host but want to spin up a
temporary `pg_dump` container whenever borgmatic dumps a database, for
instance to make use of a `pg_dump` version not present on the host, try
something like this:
```yaml
hooks:
postgresql_databases:
- name: users
hostname: your-database-hostname
username: postgres
password: trustsome1
pg_dump_command: docker run --rm --env PGPASSWORD postgres:17-alpine pg_dump
postgresql_databases:
- name: users
hostname: your-database-hostname
username: postgres
password: trustsome1
pg_dump_command: docker run --rm --env PGPASSWORD postgres:17-alpine pg_dump
```
The `--env PGPASSWORD` is necessary here for borgmatic to provide your database
+43 -46
View File
@@ -12,52 +12,18 @@ numeric ID indicating success, warning, or error. borgmatic consumes this exit
code to decide how to respond. Normally, a Borg error results in a borgmatic
error, while a Borg warning or success doesn't.
But if that default behavior isn't sufficient for your needs, you can
customize how borgmatic interprets [Borg's exit
<span class="minilink minilink-addedin">With Borg version 1.4+</span> If the
default behavior isn't sufficient for your needs, you can customize how
borgmatic interprets [Borg's exit
codes](https://borgbackup.readthedocs.io/en/stable/usage/general.html#return-codes).
For instance, to elevate Borg warnings to errors, thereby causing borgmatic to
error on them, use the following borgmatic configuration:
```yaml
borg_exit_codes:
- exit_code: 1
treat_as: error
```
Be aware though that Borg exits with a warning code for a variety of benign
situations such as files changing while they're being read, so this example
may not meet your needs. Keep reading though for more granular exit code
configuration.
Here's an example that squashes Borg errors to warnings:
```yaml
borg_exit_codes:
- exit_code: 2
treat_as: warning
```
Be careful with this example though, because it prevents borgmatic from
erroring when Borg errors, which may not be desirable.
### More granular configuration
<span class="minilink minilink-addedin">New in Borg version 1.4</span> Borg
support for [more granular exit
codes](https://borgbackup.readthedocs.io/en/1.4-maint/usage/general.html#return-codes)
means that you can configure borgmatic to respond to specific Borg conditions.
See the full list of [Borg 1.4 error and warning exit
codes](https://borgbackup.readthedocs.io/en/stable/internals/frontends.html#message-ids).
The `rc:` numeric value there tells you the exit code for each.
For instance, this borgmatic configuration elevates all Borg backup file
permission warnings (exit code `105`)—and only those warnings—to errors:
```yaml
borg_exit_codes:
- exit_code: 105
treat_as: error
- code: 105
treat_as: error
```
The following configuration does that *and* elevates backup file not found
@@ -65,16 +31,20 @@ warnings (exit code `107`) to errors as well:
```yaml
borg_exit_codes:
- exit_code: 105
treat_as: error
- exit_code: 107
treat_as: error
- code: 105
treat_as: error
- code: 107
treat_as: error
```
See the full list of [Borg 1.4 error and warning exit
codes](https://borgbackup.readthedocs.io/en/stable/internals/frontends.html#message-ids).
The `rc:` numeric value there tells you the exit code for each.
If you don't know the exit code for a particular Borg error or warning you're
experiencing, you can usually find it in your borgmatic output when
`--verbosity 2` is enabled. For instance, here's a snippet of that output when
a backup file is not found:
experiencing, you can usually find it in your borgmatic output when `--verbosity
2` is enabled. For instance, here's a snippet of that output when a backup file
is not found:
```
/noexist: stat: [Errno 2] No such file or directory: '/noexist'
@@ -84,3 +54,30 @@ terminating with warning status, rc 107
So if you want to configure borgmatic to treat this as an error instead of a
warning, the exit status to use is `107`.
<span class="minilink minilink-addedin">With Borg version 1.2 and earlier</span>
Older versions of Borg didn't support granular exit codes, but still
distinguished between Borg errors and warnings. For instance, to elevate Borg
warnings to errors, thereby causing borgmatic to error on them, use the
following borgmatic configuration with Borg 1.2 or below:
```yaml
borg_exit_codes:
- code: 1
treat_as: error
```
Be aware though that Borg exits with a warning code for a variety of benign
situations such as files changing while they're being read, so this example
may not meet your needs.
Here's another Borg 1.2 example that squashes Borg errors to warnings:
```yaml
borg_exit_codes:
- code: 2
treat_as: warning
```
Be careful with this example though, because it prevents borgmatic from
erroring when Borg errors, which may not be desirable.
+1 -1
View File
@@ -141,7 +141,7 @@ for more information.
<span class="minilink minilink-addedin">With Borg version 2.x</span>See the
[match archives
documentation](https://borgbackup.readthedocs.io/en/2.0.0b13/usage/help.html#borg-help-match-archives).
documentation](https://borgbackup.readthedocs.io/en/2.0.0b16/usage/help.html#borg-help-match-archives).
Some borgmatic command-line actions also have a `--match-archives` flag that
overrides both the auto-matching behavior and the `match_archives`
+20 -17
View File
@@ -144,9 +144,8 @@ class="minilink minilink-addedin">Beta feature</span> borgmatic supports taking
snapshots with the [Btrfs filesystem](https://btrfs.readthedocs.io/) and sending
those snapshots to Borg for backup.
To use this feature, first you need one or more Btrfs subvolumes on mounted
filesystems. Then, enable Btrfs within borgmatic by adding the following line to
your configuration file:
To use this feature, first you need one or more Btrfs subvolumes. Then, enable
Btrfs within borgmatic by adding the following line to your configuration file:
```yaml
btrfs:
@@ -169,13 +168,18 @@ feedback](https://torsion.org/borgmatic/#issues) you have on this feature.
#### Subvolume discovery
For any read-write subvolume you'd like backed up, add its mount point path to
For any read-write subvolume you'd like backed up, add its subvolume path to
borgmatic's `source_directories` option. Btrfs does not support snapshotting
read-only subvolumes.
<span class="minilink minilink-addedin">New in version 1.9.6</span> Or include
the mount point as a root pattern with borgmatic's `patterns` or `patterns_from`
options.
<span class="minilink minilink-addedin">New in version 2.0.7</span> The path can
be either the path of the subvolume itself or the mount point where the
subvolume is mounted. Prior to version 2.0.7, the subvolume path could not be
used if the subvolume was mounted elsewhere; only the mount point could be used.
<span class="minilink minilink-addedin">New in version 1.9.6</span> Instead of
using `source_directories`, you can include the subvolume path as a root pattern
with borgmatic's `patterns` or `patterns_from` options.
During a backup, borgmatic snapshots these subvolumes (non-recursively) and
includes the snapshotted files in the paths sent to Borg. borgmatic is also
@@ -184,25 +188,24 @@ responsible for cleaning up (deleting) these snapshots after a backup completes.
borgmatic is smart enough to look at the parent (and grandparent, etc.)
directories of each of your `source_directories` to discover any subvolumes. For
instance, let's say you add `/var/log` and `/var/lib` to your source
directories, but `/var` is a subvolume mount point. borgmatic will discover that
and snapshot `/var` accordingly. This also works even with nested subvolumes;
directories, but `/var` is a subvolume path. borgmatic will discover that and
snapshot `/var` accordingly. This also works even with nested subvolumes;
borgmatic selects the subvolume that's the "closest" parent to your source
directories.
<span class="minilink minilink-addedin">New in version 1.9.6</span> When using
[patterns](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns),
the initial portion of a pattern's path that you intend borgmatic to match
against a subvolume mount point can't have globs or other non-literal characters
in it—or it won't actually match. For instance, a subvolume mount point of
`/var` would match a pattern of `+ fm:/var/*/data`, but borgmatic isn't
currently smart enough to match `/var` to a pattern like `+ fm:/v*/lib/data`.
against a subvolume path can't have globs or other non-literal characters in
it—or it won't actually match. For instance, a subvolume path of `/var` would
match a pattern of `+ fm:/var/*/data`, but borgmatic isn't currently smart
enough to match `/var` to a pattern like `+ fm:/v*/lib/data`.
Additionally, borgmatic rewrites the snapshot file paths so that they appear at
their original subvolume locations in a Borg archive. For instance, if your
subvolume is mounted at `/var/subvolume`, then the snapshotted files will appear
in an archive at `/var/subvolume` as well—even if borgmatic has to mount the
snapshot somewhere in `/var/subvolume/.borgmatic-snapshot-1234/` to perform the
backup.
subvolume path is `/var/subvolume`, then the snapshotted files will appear in an
archive at `/var/subvolume` as well—even if borgmatic has to mount the snapshot
somewhere in `/var/subvolume/.borgmatic-snapshot-1234/` to perform the backup.
<span class="minilink minilink-addedin">With Borg version 1.2 and
earlier</span>Snapshotted files are instead stored at a path dependent on the
+1 -1
View File
@@ -193,7 +193,7 @@ confirmation of success—or tells you if something hasn't been transferred yet.
Note that by omitting the `--upgrader` flag, you can also do archive transfers
between related Borg 2 repositories without upgrading, even down to individual
archives. For more on that functionality, see the [Borg transfer
documentation](https://borgbackup.readthedocs.io/en/2.0.0b13/usage/transfer.html).
documentation](https://borgbackup.readthedocs.io/en/2.0.0b16/usage/transfer.html).
That's it! Now you can use your new Borg 2 repository as normal with
borgmatic. If you've got multiple repositories, repeat the above process for
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "borgmatic"
version = "2.0.2"
version = "2.0.7"
authors = [
{ name="Dan Helfman", email="witten@torsion.org" },
]
+22 -19
View File
@@ -1,32 +1,35 @@
appdirs==1.4.4
apprise==1.8.0
attrs==23.2.0
black==24.4.2
certifi==2024.7.4
apprise==1.9.3
attrs==25.3.0
bandit==1.8.5
black==25.1.0
certifi==2025.6.15
chardet==5.2.0
click==8.1.7
codespell==2.2.6
coverage==7.5.1
flake8==7.0.0
click==8.2.1
codespell==2.4.1
coverage==7.9.1
flake8==7.3.0
flake8-bandit==4.1.1
flake8-quotes==3.4.0
flake8-use-fstring==1.4
flake8-variables-names==0.0.6
flexmock==0.12.1
idna==3.7
isort==5.13.2
jsonschema==4.22.0
Markdown==3.6
idna==3.10
isort==6.0.1
jsonschema==4.24.0
Markdown==3.8.2
mccabe==0.7.0
packaging==24.0
packaging==25.0
pathspec==0.12.1
pluggy==1.5.0
pbr==6.1.1
pluggy==1.6.0
py==1.11.0
pycodestyle==2.11.1
pyflakes==3.2.0
pytest==8.2.1
pytest-cov==5.0.0
pycodestyle==2.14.0
pyflakes==3.4.0
pytest==8.4.1
pytest-cov==6.2.1
PyYAML>5.0.0
regex
requests==2.32.2
requests==2.32.4
ruamel.yaml>0.15.0
toml==0.10.2
+1 -1
View File
@@ -53,7 +53,7 @@ def save_snapshots(snapshot_paths):
def print_subvolume_list(arguments, snapshot_paths):
assert arguments.subvolume_path == '/mnt/subvolume'
assert arguments.subvolume_path == '/e2e/mnt/subvolume'
if not arguments.snapshots_only:
for line in BUILTIN_SUBVOLUME_LIST_LINES:
+1 -1
View File
@@ -14,7 +14,7 @@ def parse_arguments(*unparsed_arguments):
BUILTIN_FILESYSTEM_MOUNT_OUTPUT = '''{
"filesystems": [
{
"target": "/mnt/subvolume",
"target": "/e2e/mnt/subvolume",
"source": "/dev/loop0",
"fstype": "btrfs",
"options": "rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/"
+1 -1
View File
@@ -20,7 +20,7 @@ BUILTIN_BLOCK_DEVICES = {
{
'name': 'vgroup-lvolume',
'path': '/dev/mapper/vgroup-lvolume',
'mountpoint': '/mnt/lvolume',
'mountpoint': '/e2e/mnt/lvolume',
'type': 'lvm',
},
{
+4 -4
View File
@@ -23,20 +23,20 @@ def parse_arguments(*unparsed_arguments):
BUILTIN_DATASETS = (
{
'name': 'pool',
'name': 'e2e/pool',
'used': '256K',
'avail': '23.7M',
'refer': '25K',
'canmount': 'on',
'mountpoint': '/pool',
'mountpoint': '/e2e/pool',
},
{
'name': 'pool/dataset',
'name': 'e2e/pool/dataset',
'used': '256K',
'avail': '23.7M',
'refer': '25K',
'canmount': 'on',
'mountpoint': '/pool/dataset',
'mountpoint': '/e2e/pool/dataset',
},
)
@@ -16,10 +16,10 @@ def generate_configuration(config_path, repository_path):
open(config_path)
.read()
.replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
.replace('- path: /mnt/backup', '')
.replace('- path: /e2e/mnt/backup', '')
.replace('label: local', '')
.replace('- /home', f'- {config_path}')
.replace('- /etc', '- /mnt/subvolume/subdir')
.replace('- /etc', '- /e2e/mnt/subvolume/subdir')
.replace('- /var/log/syslog*', '')
+ 'encryption_passphrase: "test"\n'
+ 'btrfs:\n'
@@ -51,11 +51,11 @@ def test_btrfs_create_and_list():
f'borgmatic --config {config_path} list --archive latest'.split(' ')
).decode(sys.stdout.encoding)
assert 'mnt/subvolume/subdir/file.txt' in output
assert 'e2e/mnt/subvolume/subdir/file.txt' in output
# Assert that the snapshot has been deleted.
assert not subprocess.check_output(
'python3 /app/tests/end-to-end/commands/fake_btrfs.py subvolume list -s /mnt/subvolume'.split(
'python3 /app/tests/end-to-end/commands/fake_btrfs.py subvolume list -s /e2e/mnt/subvolume'.split(
' '
)
)
@@ -17,10 +17,10 @@ def generate_configuration(config_path, repository_path):
open(config_path)
.read()
.replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
.replace('- path: /mnt/backup', '')
.replace('- path: /e2e/mnt/backup', '')
.replace('label: local', '')
.replace('- /home', f'- {config_path}')
.replace('- /etc', '- /mnt/lvolume/subdir')
.replace('- /etc', '- /e2e/mnt/lvolume/subdir')
.replace('- /var/log/syslog*', '')
+ 'encryption_passphrase: "test"\n'
+ 'lvm:\n'
@@ -56,7 +56,7 @@ def test_lvm_create_and_list():
f'borgmatic --config {config_path} list --archive latest'.split(' ')
).decode(sys.stdout.encoding)
assert 'mnt/lvolume/subdir/file.txt' in output
assert 'e2e/mnt/lvolume/subdir/file.txt' in output
# Assert that the snapshot has been deleted.
assert not json.loads(
@@ -19,7 +19,7 @@ def generate_configuration(config_path, repository_path):
.replace('- path: /mnt/backup', '')
.replace('label: local', '')
.replace('- /home', f'- {config_path}')
.replace('- /etc', '- /pool/dataset/subdir')
.replace('- /etc', '- /e2e/pool/dataset/subdir')
.replace('- /var/log/syslog*', '')
+ 'encryption_passphrase: "test"\n'
+ 'zfs:\n'
@@ -45,14 +45,14 @@ def test_zfs_create_and_list():
)
# Run a create action to exercise ZFS snapshotting and backup.
subprocess.check_call(f'borgmatic --config {config_path} create'.split(' '))
subprocess.check_call(f'borgmatic -v 2 --config {config_path} create'.split(' '))
# List the resulting archive and assert that the snapshotted files are there.
output = subprocess.check_output(
f'borgmatic --config {config_path} list --archive latest'.split(' ')
).decode(sys.stdout.encoding)
assert 'pool/dataset/subdir/file.txt' in output
assert 'e2e/pool/dataset/subdir/file.txt' in output
# Assert that the snapshot has been deleted.
assert not subprocess.check_output(
+120
View File
@@ -0,0 +1,120 @@
import logging
from borgmatic.borg import rename as module
from tests.unit.test_verbosity import insert_logging_mock
def test_make_rename_command_includes_log_info():
insert_logging_mock(logging.INFO)
command = module.make_rename_command(
dry_run=False,
repository_name='repo',
old_archive_name='old_archive',
new_archive_name='new_archive',
config={},
local_borg_version='1.2.3',
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rename', '--info', 'repo::old_archive', 'new_archive')
def test_make_rename_command_includes_log_debug():
insert_logging_mock(logging.DEBUG)
command = module.make_rename_command(
dry_run=False,
repository_name='repo',
old_archive_name='old_archive',
new_archive_name='new_archive',
config={},
local_borg_version='1.2.3',
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rename', '--debug', '--show-rc', 'repo::old_archive', 'new_archive')
def test_make_rename_command_includes_dry_run():
command = module.make_rename_command(
dry_run=True,
repository_name='repo',
old_archive_name='old_archive',
new_archive_name='new_archive',
config={},
local_borg_version='1.2.3',
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rename', '--dry-run', 'repo::old_archive', 'new_archive')
def test_make_rename_command_includes_remote_path():
command = module.make_rename_command(
dry_run=False,
repository_name='repo',
old_archive_name='old_archive',
new_archive_name='new_archive',
config={},
local_borg_version='1.2.3',
local_path='borg',
remote_path='borg1',
)
assert command == (
'borg',
'rename',
'--remote-path',
'borg1',
'repo::old_archive',
'new_archive',
)
def test_make_rename_command_includes_umask():
command = module.make_rename_command(
dry_run=False,
repository_name='repo',
old_archive_name='old_archive',
new_archive_name='new_archive',
config={'umask': '077'},
local_borg_version='1.2.3',
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rename', '--umask', '077', 'repo::old_archive', 'new_archive')
def test_make_rename_command_includes_log_json():
command = module.make_rename_command(
dry_run=False,
repository_name='repo',
old_archive_name='old_archive',
new_archive_name='new_archive',
config={'log_json': True},
local_borg_version='1.2.3',
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rename', '--log-json', 'repo::old_archive', 'new_archive')
def test_make_rename_command_includes_lock_wait():
command = module.make_rename_command(
dry_run=False,
repository_name='repo',
old_archive_name='old_archive',
new_archive_name='new_archive',
config={'lock_wait': 5},
local_borg_version='1.2.3',
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'rename', '--lock-wait', '5', 'repo::old_archive', 'new_archive')
@@ -2,6 +2,7 @@ import subprocess
from flexmock import flexmock
import borgmatic.hooks.command
from borgmatic.commands import borgmatic as module
@@ -12,3 +13,81 @@ def test_borgmatic_version_matches_news_version():
news_version = open('NEWS').readline()
assert borgmatic_version == news_version
def test_run_configuration_without_error_pings_monitoring_hooks_start_and_finish():
config = {'repositories': [{'path': 'foo'}]}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module).should_receive('run_actions').and_return([])
flexmock(module.dispatch).should_receive('call_hooks')
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'ping_monitor',
config,
module.dispatch.Hook_type.MONITORING,
'test.yaml',
module.monitor.State.START,
object,
object,
).once()
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'ping_monitor',
config,
module.dispatch.Hook_type.MONITORING,
'test.yaml',
module.monitor.State.FINISH,
object,
object,
).once()
list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments))
def test_run_configuration_with_borg_version_error_pings_after_command_hook_with_fail_state():
config = {
'repositories': [{'path': 'foo'}],
'commands': ({'after': 'configuration', 'run': ['echo after'], 'states': ['fail']},),
}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
flexmock(module.borg_version).should_receive('local_borg_version').and_raise(ValueError)
flexmock(module).should_receive('run_actions').and_return([])
flexmock(module.dispatch).should_receive('call_hooks')
flexmock(borgmatic.hooks.command).should_receive('execute_hooks')
flexmock(borgmatic.hooks.command).should_receive('execute_hooks').with_args(
config['commands'],
umask=object,
working_directory=object,
dry_run=False,
log_file=object,
configuration_filename=object,
).once()
list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments))
def test_run_configuration_with_action_error_pings_monioring_hooks_start_and_fail():
config = {'repositories': [{'path': 'foo'}]}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module).should_receive('run_actions').and_raise(OSError)
flexmock(module.dispatch).should_receive('call_hooks')
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'ping_monitor',
config,
module.dispatch.Hook_type.MONITORING,
'test.yaml',
module.monitor.State.START,
object,
object,
).once()
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'ping_monitor',
config,
module.dispatch.Hook_type.MONITORING,
'test.yaml',
module.monitor.State.FAIL,
object,
object,
).once()
list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments))
+91 -9
View File
@@ -126,6 +126,45 @@ def test_schema_to_sample_configuration_comments_out_non_source_config_options_i
assert 'field3' not in config[0].ca.items
def test_schema_to_sample_configuration_comments_out_non_source_config_options_in_sequence_of_maps_with_different_subschemas():
schema = {
'type': 'array',
'items': {
'type': 'object',
'oneOf': [
{
'properties': dict(
[
('field1', {'type': 'string', 'example': 'Example 1'}),
('field2', {'type': 'string', 'example': 'Example 2'}),
]
)
},
{
'properties': dict(
[
('field2', {'type': 'string', 'example': 'Example 2'}),
('field3', {'type': 'string', 'example': 'Example 3'}),
]
)
},
],
},
}
source_config = [{'field1': 'value'}, {'field3': 'value'}]
config = module.schema_to_sample_configuration(schema, source_config)
assert config == [
dict([('field1', 'Example 1'), ('field2', 'Example 2'), ('field3', 'Example 3')])
]
# The first field in a sequence does not get commented.
assert 'field1' not in config[0].ca.items
assert 'COMMENT_OUT' in config[0].ca.items['field2'][1][-1]._value
assert 'COMMENT_OUT' in config[0].ca.items['field3'][1][-1]._value
def test_comment_out_line_skips_blank_line():
line = ' \n'
@@ -169,8 +208,11 @@ foo:
- quux
repositories:
- one
- two
- path: foo
# COMMENT_OUT
label: bar
- path: baz
label: quux
# This comment should be kept.
# COMMENT_OUT
@@ -185,8 +227,10 @@ other: thing
# - quux
repositories:
- one
- two
- path: foo
# label: bar
- path: baz
label: quux
# This comment should be kept.
# other: thing
@@ -297,14 +341,28 @@ def test_add_comments_to_configuration_comments_out_non_source_config_options():
assert 'baz' not in config.ca.items
def test_add_comments_to_configuration_object_with_skip_first_does_not_comment_out_first_option():
def test_add_comments_to_configuration_object_with_skip_first_field_does_not_comment_out_first_option():
config = module.ruamel.yaml.comments.CommentedMap([('foo', 33), ('bar', 44), ('baz', 55)])
schema = {
'type': 'object',
'properties': {'foo': {'description': 'Foo'}, 'bar': {'description': 'Bar'}},
}
module.add_comments_to_configuration_object(config, schema, skip_first=True)
module.add_comments_to_configuration_object(config, schema, skip_first_field=True)
assert 'foo' not in config.ca.items
assert 'COMMENT_OUT' in config.ca.items['bar'][1][-1]._value
assert 'baz' not in config.ca.items
def test_add_comments_to_configuration_object_with_skip_first_field_does_not_comment_out_first_option():
config = module.ruamel.yaml.comments.CommentedMap([('foo', 33), ('bar', 44), ('baz', 55)])
schema = {
'type': 'object',
'properties': {'foo': {'description': 'Foo'}, 'bar': {'description': 'Bar'}},
}
module.add_comments_to_configuration_object(config, schema, skip_first_field=True)
assert 'foo' not in config.ca.items
assert 'COMMENT_OUT' in config.ca.items['bar'][1][-1]._value
@@ -326,15 +384,39 @@ def test_generate_sample_configuration_does_not_raise():
module.generate_sample_configuration(False, None, 'dest.yaml', 'schema.yaml')
def test_generate_sample_configuration_with_source_filename_does_not_raise():
def test_generate_sample_configuration_with_source_filename_omits_empty_bootstrap_field():
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.ruamel.yaml).should_receive('YAML').and_return(
flexmock(load=lambda filename: {})
)
flexmock(module.load).should_receive('load_configuration')
flexmock(module.load).should_receive('load_configuration').and_return(
{'bootstrap': {}, 'foo': 'bar'}
)
flexmock(module.normalize).should_receive('normalize')
flexmock(module).should_receive('schema_to_sample_configuration')
flexmock(module).should_receive('schema_to_sample_configuration').with_args(
object, {'foo': 'bar'}
).once()
flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('render_configuration')
flexmock(module).should_receive('comment_out_optional_configuration')
flexmock(module).should_receive('write_configuration')
module.generate_sample_configuration(False, 'source.yaml', 'dest.yaml', 'schema.yaml')
def test_generate_sample_configuration_with_source_filename_keeps_non_empty_bootstrap_field():
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.ruamel.yaml).should_receive('YAML').and_return(
flexmock(load=lambda filename: {})
)
source_config = {'bootstrap': {'stuff': 'here'}, 'foo': 'bar'}
flexmock(module.load).should_receive('load_configuration').and_return(source_config)
flexmock(module.normalize).should_receive('normalize')
flexmock(module).should_receive('schema_to_sample_configuration').with_args(
object, source_config
).once()
flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('render_configuration')
flexmock(module).should_receive('comment_out_optional_configuration')
+69 -20
View File
@@ -601,6 +601,53 @@ def test_collect_spot_check_source_paths_parses_borg_output():
) == ('/etc/path', '/etc/other')
def test_collect_spot_check_source_paths_omits_progress_from_create_dry_run_command():
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': False}
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
flexmock()
)
flexmock(module.borgmatic.actions.pattern).should_receive('collect_patterns').and_return(
flexmock()
)
flexmock(module.borgmatic.actions.pattern).should_receive('process_patterns').and_return(
[Pattern('foo'), Pattern('bar')]
)
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
dry_run=True,
repository_path='repo',
config={'working_directory': '/', 'list_details': True},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version=object,
global_arguments=object,
borgmatic_runtime_directory='/run/borgmatic',
local_path=object,
remote_path=object,
stream_processes=False,
).and_return((('borg', 'create'), ('repo::archive',), flexmock()))
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
flexmock()
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'warning: stuff\n- /etc/path\n+ /etc/other\n? /nope',
)
flexmock(module.os.path).should_receive('isfile').and_return(True)
assert module.collect_spot_check_source_paths(
repository={'path': 'repo'},
config={'working_directory': '/', 'progress': True},
local_borg_version=flexmock(),
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
) == ('/etc/path', '/etc/other')
def test_collect_spot_check_source_paths_passes_through_stream_processes_false():
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': False}
@@ -891,8 +938,8 @@ def test_collect_spot_check_source_paths_uses_working_directory():
def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
flexmock(module.random).should_receive('sample').replace_with(
lambda population, count: population[:count]
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count])
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
@@ -932,8 +979,8 @@ def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes():
flexmock(module.random).should_receive('sample').replace_with(
lambda population, count: population[:count]
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count])
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
@@ -973,8 +1020,8 @@ def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes(
def test_compare_spot_check_hashes_handles_data_sample_percentage_above_100():
flexmock(module.random).should_receive('sample').replace_with(
lambda population, count: population[:count]
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count])
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
@@ -1014,8 +1061,8 @@ def test_compare_spot_check_hashes_handles_data_sample_percentage_above_100():
def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
flexmock(module.random).should_receive('sample').replace_with(
lambda population, count: population[:count]
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count])
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
@@ -1024,7 +1071,9 @@ def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
flexmock(module.os.path).should_receive('islink').and_return(False)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).with_args(('/usr/local/bin/xxh64sum', '/foo', '/bar'), working_directory=None).and_return(
).with_args(
('/usr/local/bin/xxhsum', '-H64', '/foo', '/bar'), working_directory=None
).and_return(
'hash1 /foo\nhash2 /bar'
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
@@ -1039,7 +1088,7 @@ def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
{
'name': 'spot',
'data_sample_percentage': 50,
'xxh64sum_command': '/usr/local/bin/xxh64sum',
'xxh64sum_command': '/usr/local/bin/xxhsum -H64',
},
]
},
@@ -1052,8 +1101,8 @@ def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
def test_compare_spot_check_hashes_considers_path_missing_from_archive_as_not_matching():
flexmock(module.random).should_receive('sample').replace_with(
lambda population, count: population[:count]
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count])
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
@@ -1089,8 +1138,8 @@ def test_compare_spot_check_hashes_considers_path_missing_from_archive_as_not_ma
def test_compare_spot_check_hashes_considers_symlink_path_as_not_matching():
flexmock(module.random).should_receive('sample').replace_with(
lambda population, count: population[:count]
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count])
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
@@ -1125,8 +1174,8 @@ def test_compare_spot_check_hashes_considers_symlink_path_as_not_matching():
def test_compare_spot_check_hashes_considers_non_existent_path_as_not_matching():
flexmock(module.random).should_receive('sample').replace_with(
lambda population, count: population[:count]
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count])
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
@@ -1162,8 +1211,8 @@ def test_compare_spot_check_hashes_considers_non_existent_path_as_not_matching()
def test_compare_spot_check_hashes_with_too_many_paths_feeds_them_to_commands_in_chunks():
flexmock(module).SAMPLE_PATHS_SUBSET_COUNT = 2
flexmock(module.random).should_receive('sample').replace_with(
lambda population, count: population[:count]
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count])
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
@@ -1208,8 +1257,8 @@ def test_compare_spot_check_hashes_with_too_many_paths_feeds_them_to_commands_in
def test_compare_spot_check_hashes_uses_working_directory_to_access_source_paths():
flexmock(module.random).should_receive('sample').replace_with(
lambda population, count: population[:count]
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count])
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
'/working/dir',
+197
View File
@@ -1,3 +1,4 @@
import json
import os
import pytest
@@ -25,6 +26,7 @@ def test_run_create_executes_and_calls_hooks_for_configured_repository():
progress=flexmock(),
statistics=flexmock(),
json=False,
comment=None,
list_details=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
@@ -66,6 +68,7 @@ def test_run_create_runs_with_selected_repository():
progress=flexmock(),
statistics=flexmock(),
json=False,
comment=None,
list_details=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
@@ -98,6 +101,7 @@ def test_run_create_bails_if_repository_does_not_match():
progress=flexmock(),
statistics=flexmock(),
json=False,
comment=None,
list_details=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
@@ -130,6 +134,7 @@ def test_run_create_with_both_list_and_json_errors():
progress=flexmock(),
statistics=flexmock(),
json=True,
comment=None,
list_details=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
@@ -163,6 +168,7 @@ def test_run_create_with_both_list_and_progress_errors():
progress=flexmock(),
statistics=flexmock(),
json=False,
comment=None,
list_details=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
@@ -209,6 +215,7 @@ def test_run_create_produces_json():
progress=flexmock(),
statistics=flexmock(),
json=True,
comment=None,
list_details=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
@@ -227,3 +234,193 @@ def test_run_create_produces_json():
remote_path=None,
)
) == [parsed_json]
def test_run_create_with_active_dumps_roundtrips_via_checkpoint_archive():
mock_dump_process = flexmock()
mock_dump_process.should_receive('poll').and_return(None).and_return(0)
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
flexmock()
)
flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'dump': mock_dump_process}
)
flexmock(module.borgmatic.hooks.dispatch).should_receive(
'call_hooks_even_if_unconfigured'
).and_return({})
flexmock(module.borgmatic.actions.pattern).should_receive('collect_patterns').and_return(())
flexmock(module.borgmatic.actions.pattern).should_receive('process_patterns').and_return([])
flexmock(os.path).should_receive('join').and_return('/run/borgmatic/bootstrap')
flexmock(module.borgmatic.borg.repo_list).should_receive('get_latest_archive').and_return(
{'id': 'id1', 'name': 'archive.checkpoint'}
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
flexmock(module).should_receive('rename_checkpoint_archive').with_args(
repository_path='repo',
global_arguments=global_arguments,
config={},
local_borg_version=None,
local_path=None,
remote_path=None,
).once()
create_arguments = flexmock(
repository=None,
progress=flexmock(),
statistics=flexmock(),
json=False,
comment=None,
list_details=flexmock(),
)
list(
module.run_create(
config_filename='test.yaml',
repository={'path': 'repo'},
config={},
config_paths=['/tmp/test.yaml'],
local_borg_version=None,
create_arguments=create_arguments,
global_arguments=global_arguments,
dry_run_label='',
local_path=None,
remote_path=None,
)
)
def test_run_create_with_active_dumps_json_updates_archive_info():
mock_dump_process = flexmock()
mock_dump_process.should_receive('poll').and_return(None).and_return(0)
borg_create_result = {
'archive': {
'command_line': ['foo'],
'name': 'archive.checkpoint',
'id': 'id1',
},
'cache': {},
'repository': {
'id': 'repo-id',
},
}
expected_create_result = {
'archive': {
'command_line': ['foo'],
'name': 'archive',
'id': 'id2',
},
'cache': {},
'repository': {'id': 'repo-id', 'label': ''},
}
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
flexmock()
)
flexmock(module.borgmatic.borg.create).should_receive('create_archive').and_return(
json.dumps(borg_create_result)
).once()
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'dump': mock_dump_process}
)
flexmock(module.borgmatic.hooks.dispatch).should_receive(
'call_hooks_even_if_unconfigured'
).and_return({})
flexmock(module.borgmatic.actions.pattern).should_receive('collect_patterns').and_return(())
flexmock(module.borgmatic.actions.pattern).should_receive('process_patterns').and_return([])
flexmock(os.path).should_receive('join').and_return('/run/borgmatic/bootstrap')
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
flexmock(module).should_receive('rename_checkpoint_archive').with_args(
repository_path='repo',
global_arguments=global_arguments,
config={},
local_borg_version=None,
local_path=None,
remote_path=None,
).once()
flexmock(module.borgmatic.borg.repo_list).should_receive('get_latest_archive').and_return(
{'id': 'id2', 'name': 'archive'},
)
create_arguments = flexmock(
repository=None,
progress=flexmock(),
statistics=flexmock(),
json=True,
comment=None,
list_details=flexmock(),
)
assert list(
module.run_create(
config_filename='test.yaml',
repository={'path': 'repo'},
config={},
config_paths=['/tmp/test.yaml'],
local_borg_version=None,
create_arguments=create_arguments,
global_arguments=global_arguments,
dry_run_label='',
local_path=None,
remote_path=None,
)
) == [expected_create_result]
def test_rename_checkpoint_archive_renames_archive():
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
flexmock(module.borgmatic.borg.repo_list).should_receive('get_latest_archive').and_return(
{'id': 'id1', 'name': 'archive.checkpoint'}
)
flexmock(module.borgmatic.borg.rename).should_receive('rename_archive').with_args(
repository_name='path',
old_archive_name='archive.checkpoint',
new_archive_name='archive',
dry_run=False,
config={},
local_borg_version=None,
local_path=None,
remote_path=None,
)
module.rename_checkpoint_archive(
repository_path='path',
global_arguments=global_arguments,
config={},
local_borg_version=None,
local_path=None,
remote_path=None,
)
def test_rename_checkpoint_archive_checks_suffix():
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
flexmock(module.borgmatic.borg.repo_list).should_receive('get_latest_archive').and_return(
{'id': 'id1', 'name': 'unexpected-archive'}
)
with pytest.raises(
ValueError,
match='Latest archive did not have a .checkpoint suffix. Got: unexpected-archive',
):
module.rename_checkpoint_archive(
repository_path='path',
global_arguments=global_arguments,
config={},
local_borg_version=None,
local_path=None,
remote_path=None,
)
+52 -19
View File
@@ -245,36 +245,69 @@ def test_expand_patterns_expands_only_tildes_in_non_root_patterns():
assert paths == (Pattern('/root/bar/*', Pattern_type.INCLUDE),)
def test_device_map_patterns_gives_device_id_per_path():
def test_get_existent_path_or_parent_passes_through_existent_path():
flexmock(module.os.path).should_receive('exists').and_return(True)
assert module.get_existent_path_or_parent('/foo/bar/baz') == '/foo/bar/baz'
def test_get_existent_path_or_parent_with_non_existent_path_returns_none():
flexmock(module.os.path).should_receive('exists').and_return(False)
assert module.get_existent_path_or_parent('/foo/bar/baz') is None
def test_get_existent_path_or_parent_with_non_existent_path_returns_existent_parent():
flexmock(module.os.path).should_receive('exists').with_args('/foo/bar/baz*').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/foo/bar').and_return(True)
flexmock(module.os.path).should_receive('exists').with_args('/foo').never()
flexmock(module.os.path).should_receive('exists').with_args('/').never()
assert module.get_existent_path_or_parent('/foo/bar/baz*') == '/foo/bar'
def test_get_existent_path_or_parent_with_non_existent_path_returns_existent_grandparent():
flexmock(module.os.path).should_receive('exists').with_args('/foo/bar/baz*').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/foo/bar').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/foo').and_return(True)
flexmock(module.os.path).should_receive('exists').with_args('/').never()
assert module.get_existent_path_or_parent('/foo/bar/baz*') == '/foo'
def test_get_existent_path_or_parent_with_end_to_end_test_prefix_returns_none():
flexmock(module.os.path).should_receive('exists').never()
assert module.get_existent_path_or_parent('/e2e/foo/bar/baz') is None
def test_device_map_patterns_gives_device_id_per_path():
flexmock(module).should_receive('get_existent_path_or_parent').replace_with(lambda path: path)
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
flexmock(module.os).should_receive('stat').with_args('/bar').and_return(flexmock(st_dev=66))
device_map = module.device_map_patterns((Pattern('/foo'), Pattern('/bar')))
assert device_map == (
Pattern('/foo', device=55),
Pattern('/bar', device=66),
)
def test_device_map_patterns_only_considers_root_patterns():
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
flexmock(module.os).should_receive('stat').with_args('/bar*').never()
device_map = module.device_map_patterns(
(Pattern('/foo'), Pattern('/bar*', Pattern_type.INCLUDE))
(
Pattern('/foo'),
Pattern('^/bar', type=Pattern_type.INCLUDE, style=Pattern_style.REGULAR_EXPRESSION),
)
)
assert device_map == (
Pattern('/foo', device=55),
Pattern('/bar*', Pattern_type.INCLUDE),
Pattern(
'^/bar', type=Pattern_type.INCLUDE, style=Pattern_style.REGULAR_EXPRESSION, device=66
),
)
def test_device_map_patterns_with_missing_path_does_not_error():
flexmock(module.os.path).should_receive('exists').and_return(True).and_return(False)
flexmock(module).should_receive('get_existent_path_or_parent').with_args('/foo').and_return(
'/foo'
)
flexmock(module).should_receive('get_existent_path_or_parent').with_args('/bar').and_return(
None
)
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
flexmock(module.os).should_receive('stat').with_args('/bar').never()
@@ -287,7 +320,7 @@ def test_device_map_patterns_with_missing_path_does_not_error():
def test_device_map_patterns_uses_working_directory_to_construct_path():
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module).should_receive('get_existent_path_or_parent').replace_with(lambda path: path)
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
flexmock(module.os).should_receive('stat').with_args('/working/dir/bar').and_return(
flexmock(st_dev=66)
@@ -304,7 +337,7 @@ def test_device_map_patterns_uses_working_directory_to_construct_path():
def test_device_map_patterns_with_existing_device_id_does_not_overwrite_it():
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module).should_receive('get_existent_path_or_parent').replace_with(lambda path: path)
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
flexmock(module.os).should_receive('stat').with_args('/bar').and_return(flexmock(st_dev=100))
+4 -2
View File
@@ -911,12 +911,14 @@ def test_check_archives_with_retention_prefix():
def test_check_archives_with_extra_borg_options_passes_through_to_borg():
config = {'extra_borg_options': {'check': '--extra --options'}}
config = {'extra_borg_options': {'check': '--extra --options "value with space"'}}
flexmock(module).should_receive('make_check_name_flags').with_args(
{'repository'}, ()
).and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
insert_execute_command_mock(('borg', 'check', '--extra', '--options', 'repo'))
insert_execute_command_mock(
('borg', 'check', '--extra', '--options', 'value with space', 'repo')
)
module.check_archives(
repository_path='repo',
+30 -3
View File
@@ -68,8 +68,15 @@ def test_compact_segments_with_log_debug_calls_borg_with_debug_flag():
)
def test_compact_segments_with_dry_run_skips_borg_call():
def test_compact_segments_with_dry_run_skips_borg_call_when_feature_unavailable():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.feature).should_receive('available').with_args(
module.feature.Feature.DRY_RUN_COMPACT, '1.2.3'
).and_return(False)
flexmock(module.environment).should_receive('make_environment').never()
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').never()
flexmock(module).should_receive('execute_command').never()
flexmock(logging).should_receive('info').with_args('Skipping compact (dry run)').once()
module.compact_segments(
repository_path='repo',
@@ -80,6 +87,24 @@ def test_compact_segments_with_dry_run_skips_borg_call():
)
def test_compact_segments_with_dry_run_executes_borg_call_when_feature_available():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.feature).should_receive('available').with_args(
module.feature.Feature.DRY_RUN_COMPACT, '1.4.1'
).and_return(True)
flexmock(module.environment).should_receive('make_environment').once()
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').once()
flexmock(module).should_receive('execute_command').once()
module.compact_segments(
repository_path='repo',
config={},
local_borg_version='1.4.1',
global_arguments=flexmock(),
dry_run=True,
)
def test_compact_segments_with_local_path_calls_borg_via_local_path():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
insert_execute_command_mock(('borg1',) + COMPACT_COMMAND[1:] + ('repo',), logging.INFO)
@@ -207,12 +232,14 @@ def test_compact_segments_with_lock_wait_calls_borg_with_lock_wait_flags():
def test_compact_segments_with_extra_borg_options_calls_borg_with_extra_options():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
insert_execute_command_mock(COMPACT_COMMAND + ('--extra', '--options', 'repo'), logging.INFO)
insert_execute_command_mock(
COMPACT_COMMAND + ('--extra', '--options', 'value with space', 'repo'), logging.INFO
)
module.compact_segments(
dry_run=False,
repository_path='repo',
config={'extra_borg_options': {'compact': '--extra --options'}},
config={'extra_borg_options': {'compact': '--extra --options "value with space"'}},
local_borg_version='1.2.3',
global_arguments=flexmock(),
)
+98 -2
View File
@@ -392,6 +392,39 @@ def test_make_base_create_command_includes_dry_run_in_borg_command():
assert not pattern_file
def test_make_base_create_command_includes_comment_in_borg_command():
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
'{hostname}'
)
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(
(f'repo::{DEFAULT_ARCHIVE_NAME}',)
)
(create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command(
dry_run=False,
repository_path='repo',
config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': ['exclude'],
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
global_arguments=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
comment='a comment',
)
assert create_flags == ('borg', 'create', '--comment', 'a comment')
assert create_positional_arguments == REPO_ARCHIVE
assert not pattern_file
def test_make_base_create_command_includes_local_path_in_borg_command():
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
@@ -786,6 +819,34 @@ def test_make_base_create_command_includes_repository_and_archive_name_format_wi
assert not pattern_file
def test_make_base_create_command_includes_archive_suffix_in_borg_command():
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('FOO')
flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
DEFAULT_ARCHIVE_NAME
)
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
(create_flags, create_positional_arguments, pattern_file) = module.make_base_create_command(
dry_run=False,
repository_path='repo',
config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
global_arguments=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
archive_suffix='.checkpoint',
)
assert create_flags == ('borg', 'create')
assert create_positional_arguments == (f'repo::{DEFAULT_ARCHIVE_NAME}.checkpoint',)
assert not pattern_file
def test_make_base_create_command_includes_extra_borg_options_in_borg_command():
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
@@ -805,7 +866,7 @@ def test_make_base_create_command_includes_extra_borg_options_in_borg_command():
config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'extra_borg_options': {'create': '--extra --options'},
'extra_borg_options': {'create': '--extra --options "value with space"'},
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
@@ -813,7 +874,7 @@ def test_make_base_create_command_includes_extra_borg_options_in_borg_command():
borgmatic_runtime_directory='/run/borgmatic',
)
assert create_flags == ('borg', 'create', '--extra', '--options')
assert create_flags == ('borg', 'create', '--extra', '--options', 'value with space')
assert create_positional_arguments == REPO_ARCHIVE
assert not pattern_file
@@ -1414,6 +1475,41 @@ def test_create_archive_with_stats_and_json_calls_borg_without_stats_flag():
assert json_output == '[]'
def test_create_archive_with_comment_calls_borg_with_comment_flag():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module).should_receive('make_base_create_command').and_return(
(('borg', 'create', '--comment', 'a comment'), REPO_ARCHIVE, flexmock())
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--comment', 'a comment') + REPO_ARCHIVE,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
borg_exit_codes=None,
working_directory=None,
environment=None,
)
module.create_archive(
dry_run=False,
repository_path='repo',
config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version='1.2.3',
global_arguments=flexmock(),
borgmatic_runtime_directory='/borgmatic/run',
json=False,
comment='a comment',
)
def test_create_archive_calls_borg_with_working_directory():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
+73 -6
View File
@@ -90,8 +90,11 @@ def test_make_environment_without_configuration_sets_certain_environment_variabl
assert environment == {
'USER': 'root',
'BORG_EXIT_CODES': 'modern',
'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'no',
'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'no',
'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'NO',
'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'NO',
'BORG_USE_CHUNKS_ARCHIVE': 'NO',
'BORG_DEBUG_PASSPHRASE': 'NO',
'BORG_DISPLAY_PASSPHRASE': 'NO',
}
@@ -101,6 +104,9 @@ def test_make_environment_without_configuration_passes_through_default_environme
'USER': 'root',
'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'yup',
'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'nah',
'BORG_USE_CHUNKS_ARCHIVE': 'yup',
'BORG_DEBUG_PASSPHRASE': 'nah',
'BORG_DISPLAY_PASSPHRASE': 'yup',
}
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
@@ -113,11 +119,14 @@ def test_make_environment_without_configuration_passes_through_default_environme
'USER': 'root',
'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'yup',
'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'nah',
'BORG_USE_CHUNKS_ARCHIVE': 'yup',
'BORG_DEBUG_PASSPHRASE': 'nah',
'BORG_DISPLAY_PASSPHRASE': 'yup',
'BORG_EXIT_CODES': 'modern',
}
def test_make_environment_with_relocated_repo_access_true_should_set_environment_yes():
def test_make_environment_with_relocated_repo_access_true_should_set_environment_YES():
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
@@ -125,10 +134,10 @@ def test_make_environment_with_relocated_repo_access_true_should_set_environment
flexmock(module.os).should_receive('pipe').never()
environment = module.make_environment({'relocated_repo_access_is_ok': True})
assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'yes'
assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'YES'
def test_make_environment_with_relocated_repo_access_false_should_set_environment_no():
def test_make_environment_with_relocated_repo_access_false_should_set_environment_NO():
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
@@ -136,7 +145,7 @@ def test_make_environment_with_relocated_repo_access_false_should_set_environmen
flexmock(module.os).should_receive('pipe').never()
environment = module.make_environment({'relocated_repo_access_is_ok': False})
assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'no'
assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'NO'
def test_make_environment_check_i_know_what_i_am_doing_true_should_set_environment_YES():
@@ -161,6 +170,50 @@ def test_make_environment_check_i_know_what_i_am_doing_false_should_set_environm
assert environment.get('BORG_CHECK_I_KNOW_WHAT_I_AM_DOING') == 'NO'
def test_make_environment_debug_passphrase_true_should_set_environment_YES():
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).and_return(None)
flexmock(module.os).should_receive('pipe').never()
environment = module.make_environment({'debug_passphrase': True})
assert environment.get('BORG_DEBUG_PASSPHRASE') == 'YES'
def test_make_environment_debug_passphrase_false_should_set_environment_NO():
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).and_return(None)
flexmock(module.os).should_receive('pipe').never()
environment = module.make_environment({'debug_passphrase': False})
assert environment.get('BORG_DEBUG_PASSPHRASE') == 'NO'
def test_make_environment_display_passphrase_true_should_set_environment_YES():
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).and_return(None)
flexmock(module.os).should_receive('pipe').never()
environment = module.make_environment({'display_passphrase': True})
assert environment.get('BORG_DISPLAY_PASSPHRASE') == 'YES'
def test_make_environment_display_passphrase_false_should_set_environment_NO():
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).and_return(None)
flexmock(module.os).should_receive('pipe').never()
environment = module.make_environment({'display_passphrase': False})
assert environment.get('BORG_DISPLAY_PASSPHRASE') == 'NO'
def test_make_environment_with_integer_variable_value():
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
@@ -170,3 +223,17 @@ def test_make_environment_with_integer_variable_value():
environment = module.make_environment({'borg_files_cache_ttl': 40})
assert environment.get('BORG_FILES_CACHE_TTL') == '40'
def test_make_environment_with_use_chunks_archive_should_set_correct_environment_value():
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).and_return(None)
flexmock(module.os).should_receive('pipe').never()
environment = module.make_environment({'use_chunks_archive': True})
assert environment.get('BORG_USE_CHUNKS_ARCHIVE') == 'YES'
environment = module.make_environment({'use_chunks_archive': False})
assert environment.get('BORG_USE_CHUNKS_ARCHIVE') == 'NO'
+26 -2
View File
@@ -43,6 +43,28 @@ def test_make_prune_flags_returns_flags_from_config():
assert result == BASE_PRUNE_FLAGS
def test_make_prune_flags_with_keep_13weekly_and_keep_3monthly():
config = {
'keep_13weekly': 4,
'keep_3monthly': 5,
}
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
result = module.make_prune_flags(
config, flexmock(match_archives=None), local_borg_version='1.2.3'
)
expected = (
'--keep-13weekly',
'4',
'--keep-3monthly',
'5',
)
assert result == expected
def test_make_prune_flags_accepts_prefix_with_placeholders():
config = {
'keep_daily': 1,
@@ -450,13 +472,15 @@ def test_prune_archives_with_extra_borg_options_calls_borg_with_extra_options():
flexmock(module.feature).should_receive('available').with_args(
module.feature.Feature.NO_PRUNE_STATS, '1.2.3'
).and_return(False)
insert_execute_command_mock(PRUNE_COMMAND + ('--extra', '--options', 'repo'), logging.INFO)
insert_execute_command_mock(
PRUNE_COMMAND + ('--extra', '--options', 'value with space', 'repo'), logging.INFO
)
prune_arguments = flexmock(statistics=False, list_details=False)
module.prune_archives(
dry_run=False,
repository_path='repo',
config={'extra_borg_options': {'prune': '--extra --options'}},
config={'extra_borg_options': {'prune': '--extra --options "value with space"'}},
local_borg_version='1.2.3',
global_arguments=flexmock(),
prune_arguments=prune_arguments,
+37
View File
@@ -0,0 +1,37 @@
import logging
from flexmock import flexmock
from borgmatic.borg import rename as module
def test_rename_archive_calls_borg_rename():
environment = flexmock()
# Note: make_rename_command is tested as integration test.
flexmock(module).should_receive('make_rename_command').and_return(('borg', 'fake-command'))
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
environment
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
'/working/dir'
)
flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
('borg', 'fake-command'),
output_log_level=logging.INFO,
environment=environment,
working_directory='/working/dir',
borg_local_path='borg',
borg_exit_codes=None,
).once()
module.rename_archive(
dry_run=False,
repository_name='repo',
old_archive_name='old_archive',
new_archive_name='new_archive',
config={},
local_borg_version='1.2.3',
local_path='borg',
remote_path=None,
)
+2 -2
View File
@@ -484,7 +484,7 @@ def test_create_repository_with_umask_calls_borg_with_umask_flag():
def test_create_repository_with_extra_borg_options_calls_borg_with_extra_options():
insert_repo_info_command_not_found_mock()
insert_repo_create_command_mock(
REPO_CREATE_COMMAND + ('--extra', '--options', '--repo', 'repo')
REPO_CREATE_COMMAND + ('--extra', '--options', 'value with space', '--repo', 'repo')
)
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_repository_flags').and_return(
@@ -497,7 +497,7 @@ def test_create_repository_with_extra_borg_options_calls_borg_with_extra_options
module.create_repository(
dry_run=False,
repository_path='repo',
config={'extra_borg_options': {'repo-create': '--extra --options'}},
config={'extra_borg_options': {'repo-create': '--extra --options "value with space"'}},
local_borg_version='2.3.4',
global_arguments=flexmock(),
encryption_mode='repokey',
+79 -54
View File
@@ -1,4 +1,5 @@
import argparse
import json
import logging
import pytest
@@ -11,7 +12,7 @@ from ..test_verbosity import insert_logging_mock
BORG_LIST_LATEST_ARGUMENTS = (
'--last',
'1',
'--short',
'--json',
'repo',
)
@@ -31,8 +32,43 @@ def test_resolve_archive_name_passes_through_non_latest_archive_name():
)
def test_resolve_archive_name_calls_borg_with_flags():
def test_resolve_archive_name_calls_get_latest_archive():
expected_archive = 'archive-name'
repository_path = flexmock()
config = flexmock()
local_borg_version = flexmock()
global_arguments = flexmock()
local_path = flexmock()
remote_path = flexmock()
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('get_latest_archive').with_args(
repository_path,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
).and_return({'name': expected_archive})
assert (
module.resolve_archive_name(
repository_path,
'latest',
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
)
== expected_archive
)
def test_get_latest_archive_calls_borg_with_flags():
expected_archive = {'name': 'archive-name'}
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
@@ -41,12 +77,11 @@ def test_resolve_archive_name_calls_borg_with_flags():
borg_exit_codes=None,
environment=None,
working_directory=None,
).and_return(expected_archive + '\n')
).and_return(json.dumps({'archives': [expected_archive]}))
assert (
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={},
local_borg_version='1.2.3',
global_arguments=flexmock(),
@@ -55,8 +90,8 @@ def test_resolve_archive_name_calls_borg_with_flags():
)
def test_resolve_archive_name_with_log_info_calls_borg_without_info_flag():
expected_archive = 'archive-name'
def test_get_latest_archive_with_log_info_calls_borg_without_info_flag():
expected_archive = {'name': 'archive-name'}
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
@@ -65,13 +100,12 @@ def test_resolve_archive_name_with_log_info_calls_borg_without_info_flag():
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).and_return(expected_archive + '\n')
).and_return(json.dumps({'archives': [expected_archive]}))
insert_logging_mock(logging.INFO)
assert (
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={},
local_borg_version='1.2.3',
global_arguments=flexmock(),
@@ -80,8 +114,8 @@ def test_resolve_archive_name_with_log_info_calls_borg_without_info_flag():
)
def test_resolve_archive_name_with_log_debug_calls_borg_without_debug_flag():
expected_archive = 'archive-name'
def test_get_latest_archive_with_log_debug_calls_borg_without_debug_flag():
expected_archive = {'name': 'archive-name'}
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
@@ -90,13 +124,12 @@ def test_resolve_archive_name_with_log_debug_calls_borg_without_debug_flag():
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).and_return(expected_archive + '\n')
).and_return(json.dumps({'archives': [expected_archive]}))
insert_logging_mock(logging.DEBUG)
assert (
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={},
local_borg_version='1.2.3',
global_arguments=flexmock(),
@@ -105,8 +138,8 @@ def test_resolve_archive_name_with_log_debug_calls_borg_without_debug_flag():
)
def test_resolve_archive_name_with_local_path_calls_borg_via_local_path():
expected_archive = 'archive-name'
def test_get_latest_archive_with_local_path_calls_borg_via_local_path():
expected_archive = {'name': 'archive-name'}
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
@@ -115,12 +148,11 @@ def test_resolve_archive_name_with_local_path_calls_borg_via_local_path():
working_directory=None,
borg_local_path='borg1',
borg_exit_codes=None,
).and_return(expected_archive + '\n')
).and_return(json.dumps({'archives': [expected_archive]}))
assert (
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={},
local_borg_version='1.2.3',
global_arguments=flexmock(),
@@ -130,8 +162,8 @@ def test_resolve_archive_name_with_local_path_calls_borg_via_local_path():
)
def test_resolve_archive_name_with_exit_codes_calls_borg_using_them():
expected_archive = 'archive-name'
def test_get_latest_archive_with_exit_codes_calls_borg_using_them():
expected_archive = {'name': 'archive-name'}
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
borg_exit_codes = flexmock()
@@ -141,12 +173,11 @@ def test_resolve_archive_name_with_exit_codes_calls_borg_using_them():
working_directory=None,
borg_local_path='borg',
borg_exit_codes=borg_exit_codes,
).and_return(expected_archive + '\n')
).and_return(json.dumps({'archives': [expected_archive]}))
assert (
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={'borg_exit_codes': borg_exit_codes},
local_borg_version='1.2.3',
global_arguments=flexmock(),
@@ -155,8 +186,8 @@ def test_resolve_archive_name_with_exit_codes_calls_borg_using_them():
)
def test_resolve_archive_name_with_remote_path_calls_borg_with_remote_path_flags():
expected_archive = 'archive-name'
def test_get_latest_archive_with_remote_path_calls_borg_with_remote_path_flags():
expected_archive = {'name': 'archive-name'}
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
@@ -165,12 +196,11 @@ def test_resolve_archive_name_with_remote_path_calls_borg_with_remote_path_flags
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).and_return(expected_archive + '\n')
).and_return(json.dumps({'archives': [expected_archive]}))
assert (
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={},
local_borg_version='1.2.3',
global_arguments=flexmock(),
@@ -180,8 +210,8 @@ def test_resolve_archive_name_with_remote_path_calls_borg_with_remote_path_flags
)
def test_resolve_archive_name_with_umask_calls_borg_with_umask_flags():
expected_archive = 'archive-name'
def test_get_latest_archive_with_umask_calls_borg_with_umask_flags():
expected_archive = {'name': 'archive-name'}
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
@@ -190,12 +220,11 @@ def test_resolve_archive_name_with_umask_calls_borg_with_umask_flags():
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).and_return(expected_archive + '\n')
).and_return(json.dumps({'archives': [expected_archive]}))
assert (
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={'umask': '077'},
local_borg_version='1.2.3',
global_arguments=flexmock(),
@@ -204,7 +233,7 @@ def test_resolve_archive_name_with_umask_calls_borg_with_umask_flags():
)
def test_resolve_archive_name_without_archives_raises():
def test_get_latest_archive_without_archives_raises():
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
@@ -213,20 +242,19 @@ def test_resolve_archive_name_without_archives_raises():
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).and_return('')
).and_return(json.dumps({'archives': []}))
with pytest.raises(ValueError):
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={},
local_borg_version='1.2.3',
global_arguments=flexmock(),
)
def test_resolve_archive_name_with_log_json_calls_borg_with_log_json_flags():
expected_archive = 'archive-name'
def test_get_latest_archive_with_log_json_calls_borg_with_log_json_flags():
expected_archive = {'name': 'archive-name'}
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -236,12 +264,11 @@ def test_resolve_archive_name_with_log_json_calls_borg_with_log_json_flags():
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).and_return(expected_archive + '\n')
).and_return(json.dumps({'archives': [expected_archive]}))
assert (
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={'log_json': True},
local_borg_version='1.2.3',
global_arguments=flexmock(),
@@ -250,8 +277,8 @@ def test_resolve_archive_name_with_log_json_calls_borg_with_log_json_flags():
)
def test_resolve_archive_name_with_lock_wait_calls_borg_with_lock_wait_flags():
expected_archive = 'archive-name'
def test_get_latest_archive_with_lock_wait_calls_borg_with_lock_wait_flags():
expected_archive = {'name': 'archive-name'}
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
@@ -261,12 +288,11 @@ def test_resolve_archive_name_with_lock_wait_calls_borg_with_lock_wait_flags():
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
).and_return(expected_archive + '\n')
).and_return(json.dumps({'archives': [expected_archive]}))
assert (
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={'lock_wait': 'okay'},
local_borg_version='1.2.3',
global_arguments=flexmock(),
@@ -275,8 +301,8 @@ def test_resolve_archive_name_with_lock_wait_calls_borg_with_lock_wait_flags():
)
def test_resolve_archive_name_calls_borg_with_working_directory():
expected_archive = 'archive-name'
def test_get_latest_archive_calls_borg_with_working_directory():
expected_archive = {'name': 'archive-name'}
flexmock(module.environment).should_receive('make_environment')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
'/working/dir',
@@ -287,12 +313,11 @@ def test_resolve_archive_name_calls_borg_with_working_directory():
borg_exit_codes=None,
environment=None,
working_directory='/working/dir',
).and_return(expected_archive + '\n')
).and_return(json.dumps({'archives': [expected_archive]}))
assert (
module.resolve_archive_name(
module.get_latest_archive(
'repo',
'latest',
config={'working_directory': '/working/dir'},
local_borg_version='1.2.3',
global_arguments=flexmock(),
+167 -35
View File
@@ -28,6 +28,7 @@ def test_get_skip_actions_uses_config_and_arguments(config, arguments, expected_
def test_monitoring_hooks_with_monioring_disabled_bails():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(module.DISABLED)
flexmock(module.dispatch).should_receive('call_hooks').never()
@@ -41,6 +42,7 @@ def test_monitoring_hooks_with_monioring_disabled_bails():
def test_monitoring_hooks_with_non_primary_action_bails():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').never()
@@ -54,6 +56,7 @@ def test_monitoring_hooks_with_non_primary_action_bails():
def test_monitoring_hooks_pings_monitors():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -117,6 +120,7 @@ def test_monitoring_hooks_pings_monitors():
def test_monitoring_hooks_with_start_ping_error_raises():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -172,6 +176,7 @@ def test_monitoring_hooks_with_start_ping_error_raises():
def test_monitoring_hooks_with_log_ping_error_raises():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -227,6 +232,7 @@ def test_monitoring_hooks_with_log_ping_error_raises():
def test_monitoring_hooks_with_finish_ping_error_raises():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -281,7 +287,8 @@ def test_monitoring_hooks_with_finish_ping_error_raises():
pass
def test_monitoring_with_wrapped_code_error_pings_fail():
def test_monitoring_hooks_with_wrapped_code_error_pings_fail():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -345,7 +352,8 @@ def test_monitoring_with_wrapped_code_error_pings_fail():
raise OSError()
def test_monitoring_with_fail_ping_error_raise_original_error():
def test_monitoring_hooks_with_fail_ping_error_raise_original_error():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -465,8 +473,10 @@ def test_run_configuration_logs_actions_error():
flexmock(module).should_receive('Monitoring_hooks').and_return(flexmock())
flexmock(module.command).should_receive('Before_after_hooks').and_return(flexmock())
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
expected_results = [flexmock()]
flexmock(module).should_receive('log_error_records').and_return(expected_results)
expected_results = [flexmock(), flexmock()]
flexmock(module).should_receive('log_error_records').and_return(
expected_results[:1]
).and_return(expected_results[1:])
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_actions').and_raise(OSError)
flexmock(module.command).should_receive('filter_hooks')
@@ -532,7 +542,7 @@ def test_run_configuration_logs_on_error_hook_error():
expected_results = [flexmock(), flexmock()]
flexmock(module).should_receive('log_error_records').and_return(
expected_results[:1]
).and_return(expected_results[1:])
).and_return(expected_results[1:2]).and_return(expected_results[2:])
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_actions').and_raise(OSError)
config = {'repositories': [{'path': 'foo'}]}
@@ -601,8 +611,10 @@ def test_run_configuration_bails_for_on_error_hook_soft_failure():
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('execute_hooks').and_raise(error)
expected_results = [flexmock()]
flexmock(module).should_receive('log_error_records').and_return(expected_results)
expected_results = [flexmock(), flexmock()]
flexmock(module).should_receive('log_error_records').and_return(
expected_results[:1]
).and_return(expected_results[1:])
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_actions').and_raise(OSError)
config = {'repositories': [{'path': 'foo'}]}
@@ -654,11 +666,14 @@ def test_run_configuration_retries_hard_error():
levelno=logging.WARNING,
log_command_error_output=True,
).and_return([flexmock()])
error_logs = [flexmock()]
error_logs = [flexmock(), flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'Error running actions for repository',
OSError,
).and_return(error_logs)
).and_return(error_logs[:1]).ordered()
flexmock(module).should_receive('log_error_records').with_args(
'Error running configuration',
).and_return(error_logs[1:]).ordered()
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('execute_hooks')
config = {'repositories': [{'path': 'foo'}], 'retries': 1}
@@ -680,13 +695,16 @@ def test_run_configuration_retries_repositories_in_order():
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_actions').and_raise(OSError).times(2)
expected_results = [flexmock(), flexmock()]
expected_results = [flexmock(), flexmock(), flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'Error running actions for repository', OSError
).and_return(expected_results[:1]).ordered()
flexmock(module).should_receive('log_error_records').with_args(
'Error running actions for repository', OSError
).and_return(expected_results[1:]).ordered()
).and_return(expected_results[1:2]).ordered()
flexmock(module).should_receive('log_error_records').with_args(
'Error running configuration',
).and_return(expected_results[2:]).ordered()
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('execute_hooks')
config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}]}
@@ -728,6 +746,10 @@ def test_run_configuration_retries_round_robin():
flexmock(module).should_receive('log_error_records').with_args(
'Error running actions for repository', OSError
).and_return(bar_error_logs).ordered()
config_error_logs = [flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'Error running configuration',
).and_return(config_error_logs).ordered()
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('execute_hooks')
config = {
@@ -741,7 +763,7 @@ def test_run_configuration_retries_round_robin():
results = list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments))
assert results == foo_error_logs + bar_error_logs
assert results == foo_error_logs + bar_error_logs + config_error_logs
def test_run_configuration_with_one_retry():
@@ -766,10 +788,13 @@ def test_run_configuration_with_one_retry():
levelno=logging.WARNING,
log_command_error_output=True,
).and_return(flexmock()).ordered()
error_logs = [flexmock()]
error_logs = [flexmock(), flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'Error running actions for repository', OSError
).and_return(error_logs).ordered()
).and_return(error_logs[:1]).ordered()
flexmock(module).should_receive('log_error_records').with_args(
'Error running configuration',
).and_return(error_logs[1:]).ordered()
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('execute_hooks')
config = {
@@ -818,10 +843,13 @@ def test_run_configuration_with_retry_wait_does_backoff_after_each_retry():
).and_return([flexmock()]).ordered()
flexmock(time).should_receive('sleep').with_args(30).and_return().ordered()
error_logs = [flexmock()]
error_logs = [flexmock(), flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'Error running actions for repository', OSError
).and_return(error_logs).ordered()
).and_return(error_logs[:1]).ordered()
flexmock(module).should_receive('log_error_records').with_args(
'Error running configuration',
).and_return(error_logs[1:]).ordered()
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('execute_hooks')
config = {
@@ -867,10 +895,13 @@ def test_run_configuration_with_multiple_repositories_retries_with_timeout():
# Sleep before retrying bar (and failing)
flexmock(time).should_receive('sleep').with_args(10).and_return().ordered()
error_logs = [flexmock()]
error_logs = [flexmock(), flexmock()]
flexmock(module).should_receive('log_error_records').with_args(
'Error running actions for repository', OSError
).and_return(error_logs).ordered()
).and_return(error_logs[:1]).ordered()
flexmock(module).should_receive('log_error_records').with_args(
'Error running configuration',
).and_return(error_logs[1:]).ordered()
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('execute_hooks')
config = {
@@ -1909,7 +1940,12 @@ def test_collect_highlander_action_summary_logs_error_on_run_validate_failure():
def test_collect_configuration_run_summary_logs_info_for_success():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
object, before='everything', action_names=object
)
flexmock(module.command).should_receive('filter_hooks').with_args(
object, after='everything', action_names=object, state_names=['finish']
)
flexmock(module.command).should_receive('execute_hooks')
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_configuration').and_return([])
@@ -1928,9 +1964,17 @@ def test_collect_configuration_run_summary_logs_info_for_success():
def test_collect_configuration_run_summary_executes_hooks_for_create():
before_everything_hook = {'before': 'everything', 'run': ['echo hi']}
after_everything_hook = {'after': 'everything', 'run': ['echo hi']}
command_hooks = (before_everything_hook, after_everything_hook)
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('execute_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
command_hooks, before='everything', action_names=object
).and_return([before_everything_hook])
flexmock(module.command).should_receive('filter_hooks').with_args(
command_hooks, after='everything', action_names=object, state_names=['finish']
).and_return([after_everything_hook])
flexmock(module.command).should_receive('execute_hooks').twice()
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_configuration').and_return([])
arguments = {
@@ -1940,7 +1984,7 @@ def test_collect_configuration_run_summary_executes_hooks_for_create():
logs = tuple(
module.collect_configuration_run_summary_logs(
{'test.yaml': {}},
{'test.yaml': {'commands': command_hooks}},
config_paths=['/tmp/test.yaml'],
arguments=arguments,
log_file_path=None,
@@ -1950,9 +1994,51 @@ def test_collect_configuration_run_summary_executes_hooks_for_create():
assert {log.levelno for log in logs} == {logging.INFO}
def test_collect_configuration_run_summary_deduplicates_everything_hooks_across_config_files():
before_everything_hook = {'before': 'everything', 'run': ['echo hi']}
after_everything_hook = {'after': 'everything', 'run': ['echo hi']}
command_hooks = (before_everything_hook, after_everything_hook)
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks').with_args(
command_hooks, before='everything', action_names=object
).and_return([before_everything_hook]).once()
flexmock(module.command).should_receive('filter_hooks').with_args(
(after_everything_hook,), before='everything', action_names=object
).and_return([]).once()
flexmock(module.command).should_receive('filter_hooks').with_args(
command_hooks, after='everything', action_names=object, state_names=['finish']
).and_return([after_everything_hook]).once()
flexmock(module.command).should_receive('filter_hooks').with_args(
(before_everything_hook,), after='everything', action_names=object, state_names=['finish']
).and_return([]).once()
flexmock(module.command).should_receive('execute_hooks').twice()
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_configuration').and_return([])
arguments = {
'create': flexmock(),
'global': flexmock(monitoring_verbosity=1, dry_run=False),
}
logs = tuple(
module.collect_configuration_run_summary_logs(
{'test.yaml': {'commands': command_hooks}, 'other.yaml': {'commands': command_hooks}},
config_paths=['/tmp/test.yaml', '/tmp/other.yaml'],
arguments=arguments,
log_file_path=None,
)
)
assert {log.levelno for log in logs} == {logging.INFO}
def test_collect_configuration_run_summary_logs_info_for_success_with_extract():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
object, before='everything', action_names=object
)
flexmock(module.command).should_receive('filter_hooks').with_args(
object, after='everything', action_names=object, state_names=['finish']
)
flexmock(module.command).should_receive('execute_hooks')
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_configuration').and_return([])
@@ -1995,7 +2081,12 @@ def test_collect_configuration_run_summary_logs_extract_with_repository_error():
def test_collect_configuration_run_summary_logs_info_for_success_with_mount():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
object, before='everything', action_names=object
)
flexmock(module.command).should_receive('filter_hooks').with_args(
object, after='everything', action_names=object, state_names=['finish']
)
flexmock(module.command).should_receive('execute_hooks')
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_configuration').and_return([])
@@ -2041,7 +2132,12 @@ def test_collect_configuration_run_summary_logs_mount_with_repository_error():
def test_collect_configuration_run_summary_logs_missing_configs_error():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
object, before='everything', action_names=object
)
flexmock(module.command).should_receive('filter_hooks').with_args(
object, after='everything', action_names=object, state_names=['fail']
)
flexmock(module.command).should_receive('execute_hooks')
arguments = {'global': flexmock(config_paths=[])}
expected_logs = (flexmock(),)
@@ -2056,9 +2152,17 @@ def test_collect_configuration_run_summary_logs_missing_configs_error():
assert logs == expected_logs
def test_collect_configuration_run_summary_logs_pre_hook_error():
def test_collect_configuration_run_summary_logs_before_hook_error():
before_everything_hook = {'before': 'everything', 'run': ['echo hi']}
after_everything_hook = {'after': 'everything', 'run': ['echo hi']}
command_hooks = (before_everything_hook, after_everything_hook)
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
object, before='everything', action_names=object
).and_return([before_everything_hook])
flexmock(module.command).should_receive('filter_hooks').with_args(
object, after='everything', action_names=object, state_names=['fail']
).and_return([after_everything_hook])
flexmock(module.command).should_receive('execute_hooks').and_raise(ValueError)
expected_logs = (flexmock(),)
flexmock(module).should_receive('log_error_records').and_return(expected_logs)
@@ -2069,7 +2173,7 @@ def test_collect_configuration_run_summary_logs_pre_hook_error():
logs = tuple(
module.collect_configuration_run_summary_logs(
{'test.yaml': {}},
{'test.yaml': {'commands': command_hooks}},
config_paths=['/tmp/test.yaml'],
arguments=arguments,
log_file_path=None,
@@ -2079,9 +2183,17 @@ def test_collect_configuration_run_summary_logs_pre_hook_error():
assert logs == expected_logs
def test_collect_configuration_run_summary_logs_post_hook_error():
def test_collect_configuration_run_summary_logs_after_hook_error():
before_everything_hook = {'before': 'everything', 'run': ['echo hi']}
after_everything_hook = {'after': 'everything', 'run': ['echo hi']}
command_hooks = (before_everything_hook, after_everything_hook)
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
object, before='everything', action_names=object
).and_return([before_everything_hook])
flexmock(module.command).should_receive('filter_hooks').with_args(
object, after='everything', action_names=object, state_names=['finish']
).and_return([after_everything_hook])
flexmock(module.command).should_receive('execute_hooks').and_return(None).and_raise(ValueError)
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_configuration').and_return([])
@@ -2094,7 +2206,7 @@ def test_collect_configuration_run_summary_logs_post_hook_error():
logs = tuple(
module.collect_configuration_run_summary_logs(
{'test.yaml': {}},
{'test.yaml': {'commands': command_hooks}},
config_paths=['/tmp/test.yaml'],
arguments=arguments,
log_file_path=None,
@@ -2129,7 +2241,12 @@ def test_collect_configuration_run_summary_logs_for_list_with_archive_and_reposi
def test_collect_configuration_run_summary_logs_info_for_success_with_list():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
object, before='everything', action_names=object
)
flexmock(module.command).should_receive('filter_hooks').with_args(
object, after='everything', action_names=object, state_names=['finish']
)
flexmock(module.command).should_receive('execute_hooks')
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_configuration').and_return([])
@@ -2152,7 +2269,12 @@ def test_collect_configuration_run_summary_logs_info_for_success_with_list():
def test_collect_configuration_run_summary_logs_run_configuration_error_logs():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
object, before='everything', action_names=object
)
flexmock(module.command).should_receive('filter_hooks').with_args(
object, after='everything', action_names=object, state_names=['fail']
)
flexmock(module.command).should_receive('execute_hooks')
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_configuration').and_return(
@@ -2175,7 +2297,12 @@ def test_collect_configuration_run_summary_logs_run_configuration_error_logs():
def test_collect_configuration_run_summary_logs_run_umount_error():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
object, before='everything', action_names=object
)
flexmock(module.command).should_receive('filter_hooks').with_args(
object, after='everything', action_names=object, state_names=['fail']
)
flexmock(module.command).should_receive('execute_hooks')
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_configuration').and_return([])
@@ -2202,7 +2329,12 @@ def test_collect_configuration_run_summary_logs_run_umount_error():
def test_collect_configuration_run_summary_logs_outputs_merged_json_results():
flexmock(module.validate).should_receive('guard_configuration_contains_repository')
flexmock(module.command).should_receive('filter_hooks')
flexmock(module.command).should_receive('filter_hooks').with_args(
object, before='everything', action_names=object
)
flexmock(module.command).should_receive('filter_hooks').with_args(
object, after='everything', action_names=object, state_names=['finish']
)
flexmock(module.command).should_receive('execute_hooks')
flexmock(module).should_receive('Log_prefix').and_return(flexmock())
flexmock(module).should_receive('run_configuration').and_return(['foo', 'bar']).and_return(
+10
View File
@@ -334,6 +334,16 @@ def test_normalize_commands_moves_individual_command_hooks_to_unified_commands(
{'repositories': [{'path': 'rclone:host:repo'}]},
True,
),
(
{'repositories': ['s3:stuff']},
{'repositories': [{'path': 's3:stuff'}]},
True,
),
(
{'repositories': ['b2:stuff']},
{'repositories': [{'path': 'b2:stuff'}]},
True,
),
(
{'repositories': ['file:///repo']},
{'repositories': [{'path': '/repo'}]},
+76 -24
View File
@@ -5,7 +5,31 @@ from borgmatic.borg.pattern import Pattern, Pattern_source, Pattern_style, Patte
from borgmatic.hooks.data_source import btrfs as module
def test_get_subvolume_mount_points_parses_findmnt_output():
def test_get_contained_subvolume_paths_parses_btrfs_output():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).with_args(('btrfs', 'subvolume', 'list', '/mnt0'), close_fds=True).and_return(
'ID 256 gen 28 top level 5 path @sub\nID 258 gen 17 top level 5 path snap\n\n'
)
assert module.get_contained_subvolume_paths('btrfs', '/mnt0') == (
'/mnt0',
'/mnt0/@sub',
'/mnt0/snap',
)
def test_get_contained_subvolume_paths_swallows_called_process_error():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).with_args(('btrfs', 'subvolume', 'list', '/mnt0'), close_fds=True).and_raise(
module.subprocess.CalledProcessError(1, 'btrfs')
)
assert module.get_contained_subvolume_paths('btrfs', '/mnt0') == ()
def test_get_all_subvolume_paths_parses_findmnt_output():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
@@ -22,31 +46,53 @@ def test_get_subvolume_mount_points_parses_findmnt_output():
"source": "/dev/loop0",
"fstype": "btrfs",
"options": "rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/"
},
{
"target": "/mnt2",
"source": "/dev/loop0",
"fstype": "btrfs",
"options": "rw,relatime,ssd,space_cache=v2,subvolid=256,subvol=/"
}
]
}
'''
)
flexmock(module).should_receive('get_contained_subvolume_paths').with_args(
'btrfs', '/mnt0'
).and_return(('/mnt0',))
flexmock(module).should_receive('get_contained_subvolume_paths').with_args(
'btrfs', '/mnt1'
).and_return(('/mnt1', '/mnt1/sub'))
flexmock(module).should_receive('get_contained_subvolume_paths').with_args(
'btrfs', '/mnt2'
).never()
assert module.get_subvolume_mount_points('findmnt') == ('/mnt0', '/mnt1')
assert module.get_all_subvolume_paths('btrfs', 'findmnt') == (
'/mnt0',
'/mnt1',
'/mnt1/sub',
'/mnt2',
)
def test_get_subvolume_mount_points_with_invalid_findmnt_json_errors():
def test_get_all_subvolume_paths_with_invalid_findmnt_json_errors():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return('{')
flexmock(module).should_receive('get_contained_subvolume_paths').never()
with pytest.raises(ValueError):
module.get_subvolume_mount_points('findmnt')
module.get_all_subvolume_paths('btrfs', 'findmnt')
def test_get_subvolume_mount_points_with_findmnt_json_missing_filesystems_errors():
def test_get_all_subvolume_paths_with_findmnt_json_missing_filesystems_errors():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return('{"wtf": "something is wrong here"}')
flexmock(module).should_receive('get_contained_subvolume_paths').never()
with pytest.raises(ValueError):
module.get_subvolume_mount_points('findmnt')
module.get_all_subvolume_paths('btrfs', 'findmnt')
def test_get_subvolume_property_with_invalid_btrfs_output_errors():
@@ -82,7 +128,7 @@ def test_get_subvolume_property_passes_through_general_value():
assert module.get_subvolume_property('btrfs', '/foo', 'thing') == 'value'
def test_omit_read_only_subvolume_mount_points_filters_out_read_only():
def test_omit_read_only_subvolume_paths_filters_out_read_only_subvolumes():
flexmock(module).should_receive('get_subvolume_property').with_args(
'btrfs', '/foo', 'ro'
).and_return(False)
@@ -93,17 +139,29 @@ def test_omit_read_only_subvolume_mount_points_filters_out_read_only():
'btrfs', '/baz', 'ro'
).and_return(False)
assert module.omit_read_only_subvolume_mount_points('btrfs', ('/foo', '/bar', '/baz')) == (
assert module.omit_read_only_subvolume_paths('btrfs', ('/foo', '/bar', '/baz')) == (
'/foo',
'/baz',
)
def test_omit_read_only_subvolume_paths_filters_out_erroring_subvolumes():
flexmock(module).should_receive('get_subvolume_property').with_args(
'btrfs', '/foo', 'ro'
).and_raise(module.subprocess.CalledProcessError(1, 'btrfs'))
flexmock(module).should_receive('get_subvolume_property').with_args(
'btrfs', '/bar', 'ro'
).and_return(True)
flexmock(module).should_receive('get_subvolume_property').with_args(
'btrfs', '/baz', 'ro'
).and_return(False)
assert module.omit_read_only_subvolume_paths('btrfs', ('/foo', '/bar', '/baz')) == ('/baz',)
def test_get_subvolumes_collects_subvolumes_matching_patterns():
flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2'))
flexmock(module).should_receive('omit_read_only_subvolume_mount_points').and_return(
('/mnt1', '/mnt2')
)
flexmock(module).should_receive('get_all_subvolume_paths').and_return(('/mnt1', '/mnt2'))
flexmock(module).should_receive('omit_read_only_subvolume_paths').and_return(('/mnt1', '/mnt2'))
contained_pattern = Pattern(
'/mnt1',
@@ -128,10 +186,8 @@ def test_get_subvolumes_collects_subvolumes_matching_patterns():
def test_get_subvolumes_skips_non_root_patterns():
flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2'))
flexmock(module).should_receive('omit_read_only_subvolume_mount_points').and_return(
('/mnt1', '/mnt2')
)
flexmock(module).should_receive('get_all_subvolume_paths').and_return(('/mnt1', '/mnt2'))
flexmock(module).should_receive('omit_read_only_subvolume_paths').and_return(('/mnt1', '/mnt2'))
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
@@ -162,10 +218,8 @@ def test_get_subvolumes_skips_non_root_patterns():
def test_get_subvolumes_skips_non_config_patterns():
flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2'))
flexmock(module).should_receive('omit_read_only_subvolume_mount_points').and_return(
('/mnt1', '/mnt2')
)
flexmock(module).should_receive('get_all_subvolume_paths').and_return(('/mnt1', '/mnt2'))
flexmock(module).should_receive('omit_read_only_subvolume_paths').and_return(('/mnt1', '/mnt2'))
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
@@ -196,10 +250,8 @@ def test_get_subvolumes_skips_non_config_patterns():
def test_get_subvolumes_without_patterns_collects_all_subvolumes():
flexmock(module).should_receive('get_subvolume_mount_points').and_return(('/mnt1', '/mnt2'))
flexmock(module).should_receive('omit_read_only_subvolume_mount_points').and_return(
('/mnt1', '/mnt2')
)
flexmock(module).should_receive('get_all_subvolume_paths').and_return(('/mnt1', '/mnt2'))
flexmock(module).should_receive('omit_read_only_subvolume_paths').and_return(('/mnt1', '/mnt2'))
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns'
+3 -10
View File
@@ -214,6 +214,7 @@ def test_snapshot_logical_volume_with_percentage_snapshot_name_uses_lvcreate_ext
'/dev/snap',
),
output_log_level=object,
close_fds=True,
)
module.snapshot_logical_volume('lvcreate', 'snap', '/dev/snap', '10%ORIGIN')
@@ -233,6 +234,7 @@ def test_snapshot_logical_volume_with_non_percentage_snapshot_name_uses_lvcreate
'/dev/snap',
),
output_log_level=object,
close_fds=True,
)
module.snapshot_logical_volume('lvcreate', 'snap', '/dev/snap', '10TB')
@@ -554,16 +556,7 @@ def test_dump_data_sources_with_dry_run_skips_snapshots_and_does_not_touch_patte
)
flexmock(module.os).should_receive('getpid').and_return(1234)
flexmock(module).should_receive('snapshot_logical_volume').never()
flexmock(module).should_receive('get_snapshots').with_args(
'lvs', snapshot_name='lvolume1_borgmatic-1234'
).and_return(
(module.Snapshot(name='lvolume1_borgmatic-1234', device_path='/dev/lvolume1_snap'),)
)
flexmock(module).should_receive('get_snapshots').with_args(
'lvs', snapshot_name='lvolume2_borgmatic-1234'
).and_return(
(module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),)
)
flexmock(module).should_receive('get_snapshots').never()
flexmock(module).should_receive('mount_snapshot').never()
assert (
+233 -54
View File
@@ -167,6 +167,38 @@ def test_database_names_to_dump_queries_mariadb_for_database_names():
assert names == ('foo', 'bar')
def test_database_names_to_dump_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
environment = flexmock()
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module).should_receive('parse_extra_options').and_return((), None)
flexmock(module).should_receive('make_defaults_file_options').never()
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
(
'mariadb',
'--user',
'root',
'--skip-column-names',
'--batch',
'--execute',
'show schemas',
),
environment=environment,
).and_return('foo\nbar\nmysql\n').once()
names = module.database_names_to_dump(
{'name': 'all', 'password_transport': 'environment'},
{},
'root',
'trustsome1',
environment,
dry_run=False,
)
assert names == ('foo', 'bar')
def test_database_names_to_dump_runs_mariadb_with_tls():
environment = flexmock()
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
@@ -247,9 +279,22 @@ def test_dump_data_sources_dumps_each_database():
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module).should_receive('database_names_to_dump').with_args(
database=databases[0],
config={},
username=None,
password=None,
environment={'USER': 'root'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('database_names_to_dump').with_args(
database=databases[1],
config={},
username=None,
password=None,
environment={'USER': 'root'},
dry_run=False,
).and_return(('bar',))
for name, process in zip(('foo', 'bar'), processes):
flexmock(module).should_receive('execute_dump_command').with_args(
@@ -285,9 +330,14 @@ def test_dump_data_sources_dumps_with_password():
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module).should_receive('database_names_to_dump').with_args(
database=database,
config={},
username='root',
password='trustsome1',
environment={'USER': 'root'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('execute_dump_command').with_args(
database=database,
@@ -311,6 +361,50 @@ def test_dump_data_sources_dumps_with_password():
) == [process]
def test_dump_data_sources_dumps_with_environment_password_transport_passes_password_environment_variable():
database = {
'name': 'foo',
'username': 'root',
'password': 'trustsome1',
'password_transport': 'environment',
}
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module).should_receive('database_names_to_dump').with_args(
database=database,
config={},
username='root',
password='trustsome1',
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('execute_dump_command').with_args(
database=database,
config={},
username='root',
password='trustsome1',
dump_path=object,
database_names=('foo',),
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
dry_run=object,
dry_run_label=object,
).and_return(process).once()
assert module.dump_data_sources(
[database],
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
) == [process]
def test_dump_data_sources_dumps_all_databases_at_once():
databases = [{'name': 'all'}]
process = flexmock()
@@ -378,6 +472,54 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
)
def test_dump_data_sources_errors_for_missing_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
with pytest.raises(ValueError):
assert module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
)
def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
assert (
module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=True,
)
== []
)
def test_database_names_to_dump_runs_mariadb_with_list_options():
database = {'name': 'all', 'list_options': '--defaults-extra-file=mariadb.cnf --skip-ssl'}
flexmock(module).should_receive('parse_extra_options').and_return(
@@ -479,6 +621,48 @@ def test_execute_dump_command_runs_mariadb_dump():
)
def test_execute_dump_command_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
process = flexmock()
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module).should_receive('parse_extra_options').and_return((), None)
flexmock(module).should_receive('make_defaults_file_options').never()
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
(
'mariadb-dump',
'--add-drop-database',
'--user',
'root',
'--databases',
'foo',
'--result-file',
'dump',
),
environment=None,
run_to_completion=False,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'foo', 'password_transport': 'environment'},
config={},
username='root',
password='trustsome1',
dump_path=flexmock(),
database_names=('foo',),
environment=None,
dry_run=False,
dry_run_label='',
)
== process
)
def test_execute_dump_command_runs_mariadb_dump_without_add_drop_database():
process = flexmock()
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
@@ -849,54 +1033,6 @@ def test_execute_dump_command_with_dry_run_skips_mariadb_dump():
)
def test_dump_data_sources_errors_for_missing_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
with pytest.raises(ValueError):
assert module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
)
def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
assert (
module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=True,
)
== []
)
def test_restore_data_source_dump_runs_mariadb_to_restore():
hook_config = [{'name': 'foo'}, {'name': 'bar'}]
extract_process = flexmock(stdout=flexmock())
@@ -1168,6 +1304,49 @@ def test_restore_data_source_dump_runs_mariadb_with_username_and_password():
)
def test_restore_data_source_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
hook_config = [
{
'name': 'foo',
'username': 'root',
'password': 'trustsome1',
'password_transport': 'environment',
}
]
extract_process = flexmock(stdout=flexmock())
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module).should_receive('parse_extra_options').and_return((), None)
flexmock(module).should_receive('make_defaults_file_options').never()
flexmock(module.os).should_receive('environ').and_return(
{'USER': 'root', 'MYSQL_PWD': 'trustsome1'}
)
flexmock(module).should_receive('execute_command_with_processes').with_args(
('mariadb', '--batch', '--user', 'root'),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
).once()
module.restore_data_source_dump(
hook_config,
{},
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
borgmatic_runtime_directory='/run/borgmatic',
)
def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore():
hook_config = [
{
+245 -54
View File
@@ -60,6 +60,42 @@ def test_database_names_to_dump_queries_mysql_for_database_names():
assert names == ('foo', 'bar')
def test_database_names_to_dump_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
environment = flexmock()
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'parse_extra_options'
).and_return((), None)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'make_defaults_file_options'
).never()
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
(
'mysql',
'--user',
'root',
'--skip-column-names',
'--batch',
'--execute',
'show schemas',
),
environment=environment,
).and_return('foo\nbar\nmysql\n').once()
names = module.database_names_to_dump(
{'name': 'all', 'password_transport': 'environment'},
{},
'root',
'trustsome1',
environment,
dry_run=False,
)
assert names == ('foo', 'bar')
def test_database_names_to_dump_runs_mysql_with_tls():
environment = flexmock()
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
@@ -141,9 +177,22 @@ def test_dump_data_sources_dumps_each_database():
'resolve_credential'
).and_return(None)
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module).should_receive('database_names_to_dump').with_args(
database=databases[0],
config={},
username=None,
password=None,
environment={'USER': 'root'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('database_names_to_dump').with_args(
database=databases[1],
config={},
username=None,
password=None,
environment={'USER': 'root'},
dry_run=False,
).and_return(('bar',))
for name, process in zip(('foo', 'bar'), processes):
flexmock(module).should_receive('execute_dump_command').with_args(
@@ -179,9 +228,14 @@ def test_dump_data_sources_dumps_with_password():
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module).should_receive('database_names_to_dump').with_args(
database=database,
config={},
username='root',
password='trustsome1',
environment={'USER': 'root'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('execute_dump_command').with_args(
database=database,
@@ -205,6 +259,50 @@ def test_dump_data_sources_dumps_with_password():
) == [process]
def test_dump_data_sources_dumps_with_environment_password_transport_passes_password_environment_variable():
database = {
'name': 'foo',
'username': 'root',
'password': 'trustsome1',
'password_transport': 'environment',
}
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module).should_receive('database_names_to_dump').with_args(
database=database,
config={},
username='root',
password='trustsome1',
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('execute_dump_command').with_args(
database=database,
config={},
username='root',
password='trustsome1',
dump_path=object,
database_names=('foo',),
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
dry_run=object,
dry_run_label=object,
).and_return(process).once()
assert module.dump_data_sources(
[database],
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
) == [process]
def test_dump_data_sources_dumps_all_databases_at_once():
databases = [{'name': 'all'}]
process = flexmock()
@@ -272,6 +370,54 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
)
def test_dump_data_sources_errors_for_missing_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
with pytest.raises(ValueError):
assert module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
)
def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
assert (
module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=True,
)
== []
)
def test_database_names_to_dump_runs_mysql_with_list_options():
database = {'name': 'all', 'list_options': '--defaults-extra-file=my.cnf --skip-ssl'}
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
@@ -375,6 +521,52 @@ def test_execute_dump_command_runs_mysqldump():
)
def test_execute_dump_command_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
process = flexmock()
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'parse_extra_options'
).and_return((), None)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'make_defaults_file_options'
).never()
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
(
'mysqldump',
'--add-drop-database',
'--user',
'root',
'--databases',
'foo',
'--result-file',
'dump',
),
environment=None,
run_to_completion=False,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'foo', 'password_transport': 'environment'},
config={},
username='root',
password='trustsome1',
dump_path=flexmock(),
database_names=('foo',),
environment=None,
dry_run=False,
dry_run_label='',
)
== process
)
def test_execute_dump_command_runs_mysqldump_without_add_drop_database():
process = flexmock()
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
@@ -761,54 +953,6 @@ def test_execute_dump_command_with_dry_run_skips_mysqldump():
)
def test_dump_data_sources_errors_for_missing_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
with pytest.raises(ValueError):
assert module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
)
def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
assert (
module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=True,
)
== []
)
def test_restore_data_source_dump_runs_mysql_to_restore():
hook_config = [{'name': 'foo'}, {'name': 'bar'}]
extract_process = flexmock(stdout=flexmock())
@@ -1092,6 +1236,53 @@ def test_restore_data_source_dump_runs_mysql_with_username_and_password():
)
def test_restore_data_source_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
hook_config = [
{
'name': 'foo',
'username': 'root',
'password': 'trustsome1',
'password_transport': 'environment',
}
]
extract_process = flexmock(stdout=flexmock())
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'parse_extra_options'
).and_return((), None)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'make_defaults_file_options'
).never()
flexmock(module.os).should_receive('environ').and_return(
{'USER': 'root', 'MYSQL_PWD': 'trustsome1'}
)
flexmock(module).should_receive('execute_command_with_processes').with_args(
('mysql', '--batch', '--user', 'root'),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
).once()
module.restore_data_source_dump(
hook_config,
{},
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
borgmatic_runtime_directory='/run/borgmatic',
)
def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore():
hook_config = [
{
+5 -5
View File
@@ -6,7 +6,7 @@ from borgmatic.hooks.monitoring import cronhub as module
def test_ping_monitor_rewrites_ping_url_for_start_state():
hook_config = {'ping_url': 'https://example.com/start/abcdef'}
flexmock(module.requests).should_receive('get').with_args(
'https://example.com/start/abcdef'
'https://example.com/start/abcdef', timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -22,7 +22,7 @@ def test_ping_monitor_rewrites_ping_url_for_start_state():
def test_ping_monitor_rewrites_ping_url_and_state_for_start_state():
hook_config = {'ping_url': 'https://example.com/ping/abcdef'}
flexmock(module.requests).should_receive('get').with_args(
'https://example.com/start/abcdef'
'https://example.com/start/abcdef', timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -38,7 +38,7 @@ def test_ping_monitor_rewrites_ping_url_and_state_for_start_state():
def test_ping_monitor_rewrites_ping_url_for_finish_state():
hook_config = {'ping_url': 'https://example.com/start/abcdef'}
flexmock(module.requests).should_receive('get').with_args(
'https://example.com/finish/abcdef'
'https://example.com/finish/abcdef', timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -54,7 +54,7 @@ def test_ping_monitor_rewrites_ping_url_for_finish_state():
def test_ping_monitor_rewrites_ping_url_for_fail_state():
hook_config = {'ping_url': 'https://example.com/start/abcdef'}
flexmock(module.requests).should_receive('get').with_args(
'https://example.com/fail/abcdef'
'https://example.com/fail/abcdef', timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -105,7 +105,7 @@ def test_ping_monitor_with_other_error_logs_warning():
module.requests.exceptions.RequestException
)
flexmock(module.requests).should_receive('get').with_args(
'https://example.com/start/abcdef'
'https://example.com/start/abcdef', timeout=int
).and_return(response)
flexmock(module.logger).should_receive('warning').once()
+8 -8
View File
@@ -5,9 +5,9 @@ from borgmatic.hooks.monitoring import cronitor as module
def test_ping_monitor_hits_ping_url_for_start_state():
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').with_args('https://example.com/run').and_return(
flexmock(ok=True)
)
flexmock(module.requests).should_receive('get').with_args(
'https://example.com/run', timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
hook_config,
@@ -22,7 +22,7 @@ def test_ping_monitor_hits_ping_url_for_start_state():
def test_ping_monitor_hits_ping_url_for_finish_state():
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').with_args(
'https://example.com/complete'
'https://example.com/complete', timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -38,7 +38,7 @@ def test_ping_monitor_hits_ping_url_for_finish_state():
def test_ping_monitor_hits_ping_url_for_fail_state():
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').with_args(
'https://example.com/fail'
'https://example.com/fail', timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -88,9 +88,9 @@ def test_ping_monitor_with_other_error_logs_warning():
response.should_receive('raise_for_status').and_raise(
module.requests.exceptions.RequestException
)
flexmock(module.requests).should_receive('get').with_args('https://example.com/run').and_return(
response
)
flexmock(module.requests).should_receive('get').with_args(
'https://example.com/run', timeout=int
).and_return(response)
flexmock(module.logger).should_receive('warning').once()
module.ping_monitor(
@@ -79,7 +79,7 @@ def test_ping_monitor_hits_ping_url_for_start_state():
).never()
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8'), verify=True
'https://example.com/start', data=''.encode('utf-8'), verify=True, timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -100,7 +100,7 @@ def test_ping_monitor_hits_ping_url_for_finish_state():
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
'https://example.com', data=payload.encode('utf-8'), verify=True
'https://example.com', data=payload.encode('utf-8'), verify=True, timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -121,7 +121,7 @@ def test_ping_monitor_hits_ping_url_for_fail_state():
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/fail', data=payload.encode('utf'), verify=True
'https://example.com/fail', data=payload.encode('utf'), verify=True, timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -142,7 +142,7 @@ def test_ping_monitor_hits_ping_url_for_log_state():
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/log', data=payload.encode('utf'), verify=True
'https://example.com/log', data=payload.encode('utf'), verify=True, timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -166,6 +166,7 @@ def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
f"https://hc-ping.com/{hook_config['ping_url']}",
data=payload.encode('utf-8'),
verify=True,
timeout=int,
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -186,7 +187,7 @@ def test_ping_monitor_skips_ssl_verification_when_verify_tls_false():
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
'https://example.com', data=payload.encode('utf-8'), verify=False
'https://example.com', data=payload.encode('utf-8'), verify=False, timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -207,7 +208,7 @@ def test_ping_monitor_executes_ssl_verification_when_verify_tls_true():
'format_buffered_logs_for_payload'
).and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
'https://example.com', data=payload.encode('utf-8'), verify=True
'https://example.com', data=payload.encode('utf-8'), verify=True, timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -260,7 +261,7 @@ def test_ping_monitor_hits_ping_url_when_states_matching():
).never()
hook_config = {'ping_url': 'https://example.com', 'states': ['start', 'finish']}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8'), verify=True
'https://example.com/start', data=''.encode('utf-8'), verify=True, timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -279,7 +280,7 @@ def test_ping_monitor_adds_create_query_parameter_when_create_slug_true():
).never()
hook_config = {'ping_url': 'https://example.com', 'create_slug': True}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start?create=1', data=''.encode('utf-8'), verify=True
'https://example.com/start?create=1', data=''.encode('utf-8'), verify=True, timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -298,7 +299,7 @@ def test_ping_monitor_does_not_add_create_query_parameter_when_create_slug_false
).never()
hook_config = {'ping_url': 'https://example.com', 'create_slug': False}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8'), verify=True
'https://example.com/start', data=''.encode('utf-8'), verify=True, timeout=int
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -317,6 +318,7 @@ def test_ping_monitor_does_not_add_create_query_parameter_when_ping_url_is_uuid(
f"https://hc-ping.com/{hook_config['ping_url']}",
data=''.encode('utf-8'),
verify=True,
timeout=int,
).and_return(flexmock(ok=True))
module.ping_monitor(
@@ -352,7 +354,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
).never()
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8'), verify=True
'https://example.com/start', data=''.encode('utf-8'), verify=True, timeout=int
).and_raise(module.requests.exceptions.ConnectionError)
flexmock(module.logger).should_receive('warning').once()
@@ -376,7 +378,7 @@ def test_ping_monitor_with_other_error_logs_warning():
module.requests.exceptions.RequestException
)
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/start', data=''.encode('utf-8'), verify=True
'https://example.com/start', data=''.encode('utf-8'), verify=True, timeout=int
).and_return(response)
flexmock(module.logger).should_receive('warning').once()
+11 -1
View File
@@ -43,6 +43,7 @@ def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -67,6 +68,7 @@ def test_ping_monitor_with_access_token_hits_hosted_ntfy_on_fail():
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=module.requests.auth.HTTPBasicAuth('', 'abc123'),
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -93,6 +95,7 @@ def test_ping_monitor_with_username_password_and_access_token_ignores_username_p
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=module.requests.auth.HTTPBasicAuth('', 'abc123'),
timeout=int,
).and_return(flexmock(ok=True)).once()
flexmock(module.logger).should_receive('warning').once()
@@ -119,6 +122,7 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=module.requests.auth.HTTPBasicAuth('testuser', 'fakepassword'),
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -140,6 +144,7 @@ def test_ping_monitor_with_password_but_no_username_warns():
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
timeout=int,
).and_return(flexmock(ok=True)).once()
flexmock(module.logger).should_receive('warning').once()
@@ -162,6 +167,7 @@ def test_ping_monitor_with_username_but_no_password_warns():
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
timeout=int,
).and_return(flexmock(ok=True)).once()
flexmock(module.logger).should_receive('warning').once()
@@ -218,6 +224,7 @@ def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
f'{custom_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -253,7 +260,7 @@ def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}', headers=custom_message_headers, auth=None
f'{default_base_url}/{topic}', headers=custom_message_headers, auth=None, timeout=int
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -275,6 +282,7 @@ def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.START),
auth=None,
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -296,6 +304,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
timeout=int,
).and_raise(module.requests.exceptions.ConnectionError)
flexmock(module.logger).should_receive('warning').once()
@@ -340,6 +349,7 @@ def test_ping_monitor_with_other_error_logs_warning():
f'{default_base_url}/{topic}',
headers=return_default_message_headers(borgmatic.hooks.monitoring.monitor.State.FAIL),
auth=None,
timeout=int,
).and_return(response)
flexmock(module.logger).should_receive('warning').once()
@@ -23,6 +23,7 @@ def test_ping_monitor_config_with_minimum_config_fail_state_backup_successfully_
'user': '983hfe0of902lkjfa2amanfgui',
'message': 'fail',
},
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -81,6 +82,7 @@ def test_ping_monitor_start_state_backup_default_message_successfully_send_to_pu
'user': '983hfe0of902lkjfa2amanfgui',
'message': 'start',
},
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -117,6 +119,7 @@ def test_ping_monitor_start_state_backup_custom_message_successfully_send_to_pus
'user': '983hfe0of902lkjfa2amanfgui',
'message': 'custom start message',
},
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -155,6 +158,7 @@ def test_ping_monitor_start_state_backup_default_message_with_priority_emergency
'retry': 30,
'expire': 600,
},
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -193,6 +197,7 @@ def test_ping_monitor_start_state_backup_default_message_with_priority_emergency
'retry': 30,
'expire': 600,
},
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -231,6 +236,7 @@ def test_ping_monitor_start_state_backup_default_message_with_priority_emergency
'retry': 30,
'expire': 600,
},
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -328,6 +334,7 @@ def test_ping_monitor_start_state_backup_based_on_documentation_advanced_example
'html': 1,
'ttl': 10,
},
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -399,6 +406,7 @@ def test_ping_monitor_fail_state_backup_based_on_documentation_advanced_example_
'url': 'https://ticketing-system.example.com/login',
'url_title': 'Login to ticketing system',
},
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -467,6 +475,7 @@ def test_ping_monitor_finish_state_backup_based_on_documentation_advanced_exampl
'url': 'https://ticketing-system.example.com/login',
'url_title': 'Login to ticketing system',
},
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -550,6 +559,7 @@ def test_ping_monitor_push_post_error_bails():
'user': '983hfe0of902lkjfa2amanfgui',
'message': 'fail',
},
timeout=int,
).and_return(push_response).once()
flexmock(module.logger).should_receive('warning').once()
+4 -2
View File
@@ -29,7 +29,8 @@ def test_ping_monitor_constructs_cron_url_and_pings_it(state, configured_states,
hook_config['states'] = configured_states
flexmock(module.requests).should_receive('post').with_args(
f'https://o294220.ingest.us.sentry.io/api/203069/cron/test/5f80ec/?status={expected_status}'
f'https://o294220.ingest.us.sentry.io/api/203069/cron/test/5f80ec/?status={expected_status}',
timeout=int,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -134,7 +135,8 @@ def test_ping_monitor_with_network_error_does_not_raise():
module.requests.exceptions.ConnectionError
)
flexmock(module.requests).should_receive('post').with_args(
'https://o294220.ingest.us.sentry.io/api/203069/cron/test/5f80ec/?status=in_progress'
'https://o294220.ingest.us.sentry.io/api/203069/cron/test/5f80ec/?status=in_progress',
timeout=int,
).and_return(response).once()
module.ping_monitor(
@@ -10,7 +10,7 @@ CUSTOM_PUSH_URL = 'https://uptime.example.com/api/push/efgh5678'
def test_ping_monitor_hits_default_uptimekuma_on_fail():
hook_config = {}
flexmock(module.requests).should_receive('get').with_args(
f'{DEFAULT_PUSH_URL}?status=down&msg=fail', verify=True
f'{DEFAULT_PUSH_URL}?status=down&msg=fail', verify=True, timeout=int
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -26,7 +26,7 @@ def test_ping_monitor_hits_default_uptimekuma_on_fail():
def test_ping_monitor_hits_custom_uptimekuma_on_fail():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True
f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True, timeout=int
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -42,7 +42,7 @@ def test_ping_monitor_hits_custom_uptimekuma_on_fail():
def test_ping_monitor_custom_uptimekuma_on_start():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=up&msg=start', verify=True
f'{CUSTOM_PUSH_URL}?status=up&msg=start', verify=True, timeout=int
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -58,7 +58,7 @@ def test_ping_monitor_custom_uptimekuma_on_start():
def test_ping_monitor_custom_uptimekuma_on_finish():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=up&msg=finish', verify=True
f'{CUSTOM_PUSH_URL}?status=up&msg=finish', verify=True, timeout=int
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -116,7 +116,7 @@ def test_ping_monitor_does_not_hit_custom_uptimekuma_on_finish_dry_run():
def test_ping_monitor_with_connection_error_logs_warning():
hook_config = {'push_url': CUSTOM_PUSH_URL}
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True
f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True, timeout=int
).and_raise(module.requests.exceptions.ConnectionError)
flexmock(module.logger).should_receive('warning').once()
@@ -137,7 +137,7 @@ def test_ping_monitor_with_other_error_logs_warning():
module.requests.exceptions.RequestException
)
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True
f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True, timeout=int
).and_return(response)
flexmock(module.logger).should_receive('warning').once()
@@ -168,7 +168,7 @@ def test_ping_monitor_with_invalid_run_state():
def test_ping_monitor_skips_ssl_verification_when_verify_tls_false():
hook_config = {'push_url': CUSTOM_PUSH_URL, 'verify_tls': False}
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=False
f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=False, timeout=int
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
@@ -184,7 +184,7 @@ def test_ping_monitor_skips_ssl_verification_when_verify_tls_false():
def test_ping_monitor_executes_ssl_verification_when_verify_tls_true():
hook_config = {'push_url': CUSTOM_PUSH_URL, 'verify_tls': True}
flexmock(module.requests).should_receive('get').with_args(
f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True
f'{CUSTOM_PUSH_URL}?status=down&msg=fail', verify=True, timeout=int
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
+5 -5
View File
@@ -74,7 +74,7 @@ def test_send_zabbix_request_with_post_error_bails():
)
flexmock(module.requests).should_receive('post').with_args(
server, headers=headers, json=data
server, headers=headers, json=data, timeout=int
).and_return(response)
assert module.send_zabbix_request(server, headers, data) is None
@@ -89,7 +89,7 @@ def test_send_zabbix_request_with_invalid_json_response_bails():
response.should_receive('json').and_raise(module.requests.exceptions.JSONDecodeError)
flexmock(module.requests).should_receive('post').with_args(
server, headers=headers, json=data
server, headers=headers, json=data, timeout=int
).and_return(response)
assert module.send_zabbix_request(server, headers, data) is None
@@ -103,7 +103,7 @@ def test_send_zabbix_request_with_success_returns_response_result():
response.should_receive('json').and_return({'result': {'foo': 'bar'}})
flexmock(module.requests).should_receive('post').with_args(
server, headers=headers, json=data
server, headers=headers, json=data, timeout=int
).and_return(response)
assert module.send_zabbix_request(server, headers, data) == {'foo': 'bar'}
@@ -117,7 +117,7 @@ def test_send_zabbix_request_with_success_passes_through_missing_result():
response.should_receive('json').and_return({})
flexmock(module.requests).should_receive('post').with_args(
server, headers=headers, json=data
server, headers=headers, json=data, timeout=int
).and_return(response)
assert module.send_zabbix_request(server, headers, data) is None
@@ -131,7 +131,7 @@ def test_send_zabbix_request_with_error_bails():
response.should_receive('json').and_return({'result': {'data': [{'error': 'oops'}]}})
flexmock(module.requests).should_receive('post').with_args(
server, headers=headers, json=data
server, headers=headers, json=data, timeout=int
).and_return(response)
assert module.send_zabbix_request(server, headers, data) is None
+84 -16
View File
@@ -11,8 +11,16 @@ def test_interpolate_context_passes_through_command_without_variable():
assert module.interpolate_context('pre-backup', 'ls', {'foo': 'bar'}) == 'ls'
def test_interpolate_context_passes_through_command_with_unknown_variable():
def test_interpolate_context_warns_and_passes_through_command_with_unknown_variable():
command = 'ls {baz}' # noqa: FS003
flexmock(module.logger).should_receive('warning').once()
assert module.interpolate_context('pre-backup', command, {'foo': 'bar'}) == command
def test_interpolate_context_does_not_warn_and_passes_through_command_with_unknown_variable_matching_borg_placeholder():
command = 'ls {hostname}' # noqa: FS003
flexmock(module.logger).should_receive('warning').never()
assert module.interpolate_context('pre-backup', command, {'foo': 'bar'}) == command
@@ -167,6 +175,44 @@ def test_make_environment_with_pyinstaller_and_LD_LIBRARY_PATH_ORIG_copies_it_in
},
),
),
(
(
{
'before': 'action',
'states': ['finish'], # Not actually valid; only valid for "after".
'run': ['foo'],
},
{
'after': 'action',
'run': ['bar'],
},
{
'after': 'action',
'states': ['finish'],
'run': ['baz'],
},
{
'after': 'action',
'states': ['fail'],
'run': ['quux'],
},
),
{
'after': 'action',
'state_names': ['finish'],
},
(
{
'after': 'action',
'run': ['bar'],
},
{
'after': 'action',
'states': ['finish'],
'run': ['baz'],
},
),
),
),
)
def test_filter_hooks(hooks, filters, expected_hooks):
@@ -336,14 +382,13 @@ def test_before_after_hooks_calls_command_hooks():
flexmock(module).should_receive('filter_hooks').with_args(
commands,
before='action',
hook_name='myhook',
action_names=['create'],
).and_return(flexmock()).once()
flexmock(module).should_receive('filter_hooks').with_args(
commands,
after='action',
hook_name='myhook',
action_names=['create'],
state_names=['finish'],
).and_return(flexmock()).once()
flexmock(module).should_receive('execute_hooks').twice()
@@ -353,7 +398,6 @@ def test_before_after_hooks_calls_command_hooks():
umask=1234,
working_directory='/working',
dry_run=False,
hook_name='myhook',
action_names=['create'],
context1='stuff',
context2='such',
@@ -369,14 +413,13 @@ def test_before_after_hooks_with_before_error_runs_after_hook_and_raises():
flexmock(module).should_receive('filter_hooks').with_args(
commands,
before='action',
hook_name='myhook',
action_names=['create'],
).and_return(flexmock()).once()
flexmock(module).should_receive('filter_hooks').with_args(
commands,
after='action',
hook_name='myhook',
action_names=['create'],
state_names=['fail'],
).and_return(flexmock()).once()
flexmock(module).should_receive('execute_hooks').and_raise(OSError).and_return(None)
flexmock(module).should_receive('considered_soft_failure').and_return(False)
@@ -388,7 +431,6 @@ def test_before_after_hooks_with_before_error_runs_after_hook_and_raises():
umask=1234,
working_directory='/working',
dry_run=False,
hook_name='myhook',
action_names=['create'],
context1='stuff',
context2='such',
@@ -404,14 +446,13 @@ def test_before_after_hooks_with_before_soft_failure_raises():
flexmock(module).should_receive('filter_hooks').with_args(
commands,
before='action',
hook_name='myhook',
action_names=['create'],
).and_return(flexmock()).once()
flexmock(module).should_receive('filter_hooks').with_args(
commands,
after='action',
hook_name='myhook',
action_names=['create'],
state_names=['finish'],
).never()
flexmock(module).should_receive('execute_hooks').and_raise(OSError)
flexmock(module).should_receive('considered_soft_failure').and_return(True)
@@ -423,7 +464,6 @@ def test_before_after_hooks_with_before_soft_failure_raises():
umask=1234,
working_directory='/working',
dry_run=False,
hook_name='myhook',
action_names=['create'],
context1='stuff',
context2='such',
@@ -431,6 +471,38 @@ def test_before_after_hooks_with_before_soft_failure_raises():
pass
def test_before_after_hooks_with_wrapped_code_error_runs_after_hook_and_raises():
commands = [
{'before': 'repository', 'run': ['foo', 'bar']},
{'after': 'repository', 'run': ['baz']},
]
flexmock(module).should_receive('filter_hooks').with_args(
commands,
before='action',
action_names=['create'],
).and_return(flexmock()).once()
flexmock(module).should_receive('filter_hooks').with_args(
commands,
after='action',
action_names=['create'],
state_names=['fail'],
).and_return(flexmock()).once()
flexmock(module).should_receive('execute_hooks').twice()
with pytest.raises(ValueError):
with module.Before_after_hooks(
command_hooks=commands,
before_after='action',
umask=1234,
working_directory='/working',
dry_run=False,
action_names=['create'],
context1='stuff',
context2='such',
):
raise ValueError()
def test_before_after_hooks_with_after_error_raises():
commands = [
{'before': 'repository', 'run': ['foo', 'bar']},
@@ -439,14 +511,13 @@ def test_before_after_hooks_with_after_error_raises():
flexmock(module).should_receive('filter_hooks').with_args(
commands,
before='action',
hook_name='myhook',
action_names=['create'],
).and_return(flexmock()).once()
flexmock(module).should_receive('filter_hooks').with_args(
commands,
after='action',
hook_name='myhook',
action_names=['create'],
state_names=['finish'],
).and_return(flexmock()).once()
flexmock(module).should_receive('execute_hooks').and_return(None).and_raise(OSError)
flexmock(module).should_receive('considered_soft_failure').and_return(False)
@@ -458,7 +529,6 @@ def test_before_after_hooks_with_after_error_raises():
umask=1234,
working_directory='/working',
dry_run=False,
hook_name='myhook',
action_names=['create'],
context1='stuff',
context2='such',
@@ -474,14 +544,13 @@ def test_before_after_hooks_with_after_soft_failure_raises():
flexmock(module).should_receive('filter_hooks').with_args(
commands,
before='action',
hook_name='myhook',
action_names=['create'],
).and_return(flexmock()).once()
flexmock(module).should_receive('filter_hooks').with_args(
commands,
after='action',
hook_name='myhook',
action_names=['create'],
state_names=['finish'],
).and_return(flexmock()).once()
flexmock(module).should_receive('execute_hooks').and_return(None).and_raise(OSError)
flexmock(module).should_receive('considered_soft_failure').and_return(True)
@@ -493,7 +562,6 @@ def test_before_after_hooks_with_after_soft_failure_raises():
umask=1234,
working_directory='/working',
dry_run=False,
hook_name='myhook',
action_names=['create'],
context1='stuff',
context2='such',