Compare commits

..
88 Commits
Author SHA1 Message Date
Dan Helfman a0323d9d6c Bump version for release. 2023-10-26 22:20:26 -07:00
Dan Helfman 8ad7b473f1 When an archive filter causes no matching archives for the "rlist" or "info" actions, warn (#748). 2023-10-26 22:12:13 -07:00
Dan Helfman 895a0ccb3c Upgrade to tox 4. (Now a minimum requirement.) 2023-10-23 17:39:27 -07:00
Dan Helfman 257ab77bea Disallow the "--dry-run" flag with the "borg" action (#774). 2023-10-23 17:23:04 -07:00
Dan HelfmanandGitHub dccaa4014b Update systemd service example with better filesystem protection options.
Merge pull request #78 from Alphix/update-systemd-service
2023-10-15 08:59:39 -07:00
David Härdeman 2f3c0bec5b Update systemd .service example
First, ProtectSystem=strict will make the entire file system hierarchy (except
/dev, /proc/ and /sys) read-only, so separate ReadOnlyPaths= is not necessary.

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

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

Note that all these options are commented out by default, so this is more of
a documentation change than any real change in functionality.
2023-10-15 11:30:11 +02:00
Dan Helfman 487d8ffd32 Fix normalization of deprecated sections to support empty sections without erroring (#771). 2023-10-14 13:04:18 -07:00
Dan Helfman 30523a7c89 Update home page example of Healthchecks configuration not to use deprecated config. 2023-10-11 12:56:21 -07:00
Dan Helfman 77b1907d03 Update Healthchecks deprecation warning message for clarity. 2023-10-11 12:17:57 -07:00
Dan Helfman 09594c85bf Be more explicit in documentation that you don't have to use an environment variable for passphrases. 2023-10-10 09:34:55 -07:00
Dan Helfman e07efdf68f Add documentation note about using includes for specifying passphrases (#769). 2023-10-10 09:16:58 -07:00
Dan Helfman 1fed44f905 Add documentation note about sudo and sudoers "secure_path" option (#757). 2023-10-09 14:15:54 -07:00
Dan Helfman c687dafdd2 Fix a traceback when an invalid command-line flag or action is used (#768). 2023-10-06 21:00:23 -07:00
Dan Helfman 3eff2c4248 Add Grafana Loki badge to integrations documentation. 2023-10-05 09:06:06 -07:00
Dan Helfman d94fdb6faf Add apprise logo to integrations in readme (#715).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/765
2023-10-05 15:51:04 +00:00
Pim Kunis a83282faf0 add apprise logo to integrations in readme 2023-10-05 15:38:32 +02:00
Dan Helfman e7169f6fb2 Upgrade certifi test dependency to fix security alert. 2023-10-04 22:59:15 -07:00
Dan Helfman 9587fc2366 Update Apprise documentation to use sudo for pipx install (#715). 2023-10-04 22:54:11 -07:00
Dan Helfman 5f06884d5a Fix Apprise/PyYAML end-to-end test breakage (#715). 2023-10-04 22:51:05 -07:00
Dan Helfman f011431463 Apprise hook documentation (#715). 2023-10-04 19:23:53 -07:00
Dan Helfman 9e14f209f1 Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2023-10-04 14:58:48 -07:00
Dan Helfman 9d34d2eec5 Support for Apprise (#759).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/759
2023-10-04 21:58:20 +00:00
Pim Kunis 7a9625cd44 fix PR comments 2023-10-04 13:19:40 +02:00
Pim Kunisandpizzapim 4763c323d0 add unit tests for apprise hook 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim eaa22be3db fix PR comments 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim a587e207f9 pin Apprise dependencies for test requirements 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim db8079b699 fix typo in setup.py
handle if apprise cannot be imported
2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim 5a989826a1 convert map to list for apprise function call
fix apprise config schema
remove apprise from required dependencies
2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim 21f4266273 incorporate PR review comments 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim e7252c7545 remove comments about tags 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim 86011c8418 default apprise notify type per borgmatic state 2023-10-01 16:59:59 +00:00
Pim Kunisandpizzapim f3295ccb4a add support for apprise 2023-10-01 16:59:59 +00:00
Dan Helfman cacb81f086 Bump version for release. 2023-09-30 13:37:10 -07:00
Dan Helfman 06c2154e6a Build docs regardless of Drone "event" (push, etc.). 2023-09-29 19:49:09 -07:00
Dan Helfman ac1e1a9407 Simplify logging logic (#665). 2023-09-29 14:16:47 -07:00
Dan Helfman 10933fd55b Fix for borgmatic not stopping Borg immediately when the user presses ctrl-C (#761). 2023-09-27 08:52:00 -07:00
Dan Helfman af422ad705 Add documentation note about upgrading multiple pipx installations of borgmatic. 2023-09-18 13:46:41 -07:00
Dan Helfman d9d35491fb Fix tense typo. 2023-09-17 23:25:57 -07:00
Dan Helfman b540e63c0e Updated documentation so "sudo borgmatic" works for pipx borgmatic installations (#757). 2023-09-17 22:46:33 -07:00
Dan Helfman 5a56208922 Fix documentation typo. 2023-09-15 10:18:35 -07:00
Dan Helfman 5912769273 Fix error handling to log command output as one record per line (#754). 2023-09-14 21:10:52 -07:00
Dan Helfman bac2aabe66 Attempt to unbreak ticket filing. 2023-09-12 09:50:38 -07:00
Dan Helfman 9f3328781b When "archive_name_format" is not set, filter archives using the default archive name format (#753). 2023-09-06 23:13:40 -07:00
Dan Helfman 0205748db8 Update documentation to recommend installing/upgrading borgmatic with pipx instead of pip. 2023-09-04 16:25:10 -07:00
Dan Helfman d0a8251ad2 Add borgmatic version introducing Loki hook to docs (#743). 2023-08-27 20:30:13 -07:00
Dan Helfman 32019ea8f3 Add documentation for Grafana Loki hook (#743). 2023-08-25 10:52:00 -07:00
Dan Helfman fa9a061033 Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2023-08-25 09:29:02 -07:00
Dan Helfman b3d2560563 Added support for grafana loki (#743).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/747
2023-08-25 16:28:19 +00:00
Dan Helfman 4b4f56da42 Fix another database -> data source instance (#685). 2023-08-24 14:00:29 -07:00
Dan Helfman b96d1898f7 Prep work for eventual container-dumping hooks: Generalize internal database hook "API" (#685). 2023-08-24 13:50:10 -07:00
Tobias Hodapp 099a712e53 Added more documentation to the test
Split tests to integration tests
2023-08-24 13:17:42 +02:00
Tobias Hodapp 9e2674ea5a Added unit tests
Removed useless dry run check
2023-08-23 17:17:23 +02:00
Tobias Hodapp 7e419ec995 Fixed spelling errors
Added documentation
Added log messages for dry run
2023-08-22 23:03:14 +02:00
Tobias Hodapp a3edf757ee Added changes of formatting tools 2023-08-22 13:40:05 +02:00
Tobias Hodapp e576403b64 Added support for grafana loki 2023-08-22 03:13:39 +02:00
Dan Helfman 7313430178 Make warning about sections a little more explicit (#721). 2023-08-19 22:51:20 -07:00
Dan Helfman 962daaa8b9 Bump version for release. 2023-08-14 12:54:38 -07:00
Dan Helfman cd51e9c1ea Fix for database "restore" action not actually restore anything (#738). 2023-08-14 12:43:21 -07:00
Dan Helfman 6dca7c1c15 Add "key export" action to export a copy of the repository key (#345). 2023-08-07 12:28:39 -07:00
Dan Helfman fd8c56c6be Add brief source code reference documentation. 2023-08-06 23:44:31 -07:00
Dan HelfmanandGitHub 065057c966 Fix typos.
Merge pull request #77 from hop/main
2023-08-05 17:19:57 -07:00
Christoph Schindler c04517f843 Fix typos. 2023-08-06 02:16:31 +02:00
Dan Helfman 5d80c366fb Fix "borg create" flags/argument interleaving. 2023-08-04 20:02:09 -07:00
Dan Helfman 193dd93de2 Fork a MariaDB database hook from the MySQL database hook (#727). 2023-08-04 13:22:44 -07:00
Dan Helfman 8a94b9e2f1 Mention "store_config_files" in docs (#725). 2023-08-03 22:11:02 -07:00
Dan Helfman 3c8f6040e2 Bump version for release. 2023-08-03 21:56:26 -07:00
Dan Helfman 14e2a6b89d Omit "--glob-archives" or "--match-archives" Borg flag when its value would be "*" (#734). 2023-08-02 10:23:22 -07:00
Dan Helfman e607de7df1 Include multiple configuration files with a single "!include" (#732).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/737
2023-08-02 04:46:33 +00:00
Dan Helfman e9bd5f4e1d Add documentation and NEWS link (#732). 2023-08-01 21:12:49 -07:00
Dan Helfman 175003ff9b Additional test coverage (#732). 2023-08-01 19:45:01 -07:00
Dan Helfman b8d349d048 Additional test coverage (#732). 2023-08-01 16:27:53 -07:00
Dan Helfman f6f06551f0 Multiple configuration includes with a single "!include" (#732). 2023-08-01 14:12:35 -07:00
Dan Helfman 69771fe7ce Add "store_config_files" option to NEWS (#725). 2023-07-31 14:04:00 -07:00
Dan Helfman c5c3e2e0ce Code formatting (#725). 2023-07-31 14:01:20 -07:00
Dan HelfmanandGitHub 8491b2c416 Feat: optionally disable config bootstrap (#725).
Merge pull request #75 from diivi/feat/disable-config-bootstrap
2023-07-31 14:00:00 -07:00
Divyansh Singh 962267b3c5 add test for checking manifest creation 2023-08-01 02:26:36 +05:30
Divyansh Singh 562f4a281b add test 2023-08-01 02:01:15 +05:30
Divyansh Singh 6b09ca8022 exclude configs too and not just the manifest 2023-08-01 01:51:37 +05:30
Divyansh Singh f2ce2f387f feat: optionally disable config bootstrap 2023-08-01 01:32:06 +05:30
Dan Helfman 782a9bb70a Add new documentation to NEWS (#326, #697). 2023-07-31 12:19:19 -07:00
Dan HelfmanandGitHub 88adb5b3de Add docs for database restore params and config bootstrap (#326, #697).
Merge pull request #74 from diivi/docs/database-restore-params-and-config-bootstrap
2023-07-31 11:56:32 -07:00
Divyansh SinghandGitHub 59465b256d Apply suggestions from code review 2023-07-31 23:50:46 +05:30
Divyansh Singh adfb89ee65 improve docs - wittens suggestions 2023-07-29 00:16:04 +05:30
Dan Helfman c11dcdef0a Fix some "borgmatic" capitalization issues. 2023-07-26 23:38:08 -07:00
Dan Helfman 8a2514915c Fix for Borg's interactive prompt on the "check --repair" action automatically getting answered "NO" (#730). 2023-07-22 15:25:55 -07:00
Dan Helfman 4d7a2876a5 Fix for "prune" action error when using the "keep_exclude_tags" option in configuration (#728). 2023-07-22 10:26:52 -07:00
Dan Helfman 309f67e860 Fix documentation comma grammar issues. 2023-07-18 23:27:45 -07:00
Divyansh Singh 1f415a24b8 add docs for database restore params and config bootstrap 2023-07-15 12:27:54 +05:30
87 changed files with 5097 additions and 1800 deletions
+20 -11
View File
@@ -13,19 +13,30 @@ services:
environment:
POSTGRES_PASSWORD: test2
POSTGRES_DB: test
POSTGRES_USER: postgres2
commands:
- docker-entrypoint.sh -p 5433
- name: mysql
image: docker.io/mariadb:10.5
- name: mariadb
image: docker.io/mariadb:10.11.4
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
- name: mysql2
image: docker.io/mariadb:10.5
MARIADB_ROOT_PASSWORD: test
MARIADB_DATABASE: test
- name: mariadb2
image: docker.io/mariadb:10.11.4
environment:
MYSQL_ROOT_PASSWORD: test2
MYSQL_DATABASE: test
MARIADB_ROOT_PASSWORD: test2
MARIADB_DATABASE: test
commands:
- docker-entrypoint.sh --port=3307
- name: not-actually-mysql
image: docker.io/mariadb:10.11.4
environment:
MARIADB_ROOT_PASSWORD: test
MARIADB_DATABASE: test
- name: not-actually-mysql2
image: docker.io/mariadb:10.11.4
environment:
MARIADB_ROOT_PASSWORD: test2
MARIADB_DATABASE: test
commands:
- docker-entrypoint.sh --port=3307
- name: mongodb
@@ -82,5 +93,3 @@ trigger:
- borgmatic-collective/borgmatic
branch:
- main
event:
- push
+1 -1
View File
@@ -1 +1 @@
blank_issues_enabled: false
blank_issues_enabled: true
+64 -4
View File
@@ -1,3 +1,63 @@
1.8.4
* #715: Add a monitoring hook for sending backup status to a variety of monitoring services via the
Apprise library. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook
* #748: When an archive filter causes no matching archives for the "rlist" or "info"
actions, warn the user and suggest how to remove the filter.
* #768: Fix a traceback when an invalid command-line flag or action is used.
* #771: Fix normalization of deprecated sections ("location:", "storage:", "hooks:", etc.) to
support empty sections without erroring.
* #774: Disallow the "--dry-run" flag with the "borg" action, as borgmatic can't guarantee the Borg
command won't have side effects.
1.8.3
* #665: BREAKING: Simplify logging logic as follows: Syslog verbosity is now disabled by
default, but setting the "--syslog-verbosity" flag enables it regardless of whether you're at an
interactive console. Additionally, "--log-file-verbosity" and "--monitoring-verbosity" now
default to 1 (info about steps borgmatic is taking) instead of 0. And both syslog logging and
file logging can be enabled simultaneously.
* #743: Add a monitoring hook for sending backup status and logs to Grafana Loki. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook
* #753: When "archive_name_format" is not set, filter archives using the default archive name
format.
* #754: Fix error handling to log command output as one record per line instead of truncating
too-long output and swallowing the end of some Borg error messages.
* #757: Update documentation so "sudo borgmatic" works for pipx borgmatic installations.
* #761: Fix for borgmatic not stopping Borg immediately when the user presses ctrl-C.
* Update documentation to recommend installing/upgrading borgmatic with pipx instead of pip. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation
https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-borgmatic
1.8.2
* #345: Add "key export" action to export a copy of the repository key for safekeeping in case
the original goes missing or gets damaged.
* #727: Add a MariaDB database hook that uses native MariaDB commands instead of the deprecated
MySQL ones. Be aware though that any existing backups made with the "mysql_databases:" hook are
only restorable with a "mysql_databases:" configuration.
* #738: Fix for potential data loss (data not getting restored) in which the database "restore"
action didn't actually restore anything and indicated success anyway.
* Remove the deprecated use of the MongoDB hook's "--db" flag for database restoration.
* Add source code reference documentation for getting oriented with the borgmatic code as a
developer: https://torsion.org/borgmatic/docs/reference/source-code/
1.8.1
* #326: Add documentation for restoring a database to an alternate host:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#restore-to-an-alternate-host
* #697: Add documentation for "bootstrap" action:
https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#extract-the-configuration-files-used-to-create-an-archive
* #725: Add "store_config_files" option for disabling the automatic backup of configuration files
used by the "config bootstrap" action.
* #728: Fix for "prune" action error when using the "keep_exclude_tags" option.
* #730: Fix for Borg's interactive prompt on the "check --repair" action automatically getting
answered "NO" even when the "check_i_know_what_i_am_doing" option isn't set.
* #732: Include multiple configuration files with a single "!include". See the documentation for
more information:
https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#multiple-merge-includes
* #734: Omit "--glob-archives" or "--match-archives" Borg flag when its value would be "*" (meaning
all archives).
1.8.0
* #575: BREAKING: For the "borgmatic borg" action, instead of implicitly injecting
repository/archive into the resulting Borg command-line, pass repository to Borg via an
@@ -10,10 +70,10 @@
"check --repair".
* When merging two configuration files, error gracefully if the two files do not adhere to the same
format.
* #721: Remove configuration sections ("location:", "storage:", "hooks:" etc.), while still keeping
deprecated support for them. Now, all options are at the same level, and you don't need to worry
about commenting/uncommenting section headers when you change an option (if you remove your
sections first).
* #721: Remove configuration sections ("location:", "storage:", "hooks:", etc.), while still
keeping deprecated support for them. Now, all options are at the same level, and you don't need
to worry about commenting/uncommenting section headers when you change an option (if you remove
your sections first).
* #721: BREAKING: The retention prefix and the consistency prefix can no longer have different
values (unless one is not set).
* #721: BREAKING: The storage umask and the hooks umask can no longer have different values (unless
+15 -12
View File
@@ -48,24 +48,27 @@ postgresql_databases:
- name: users
# Third-party services to notify you if backups aren't happening.
healthchecks: https://hc-ping.com/be067061-cf96-4412-8eae-62b0c50d6a8c
healthchecks:
ping_url: https://hc-ping.com/be067061-cf96-4412-8eae-62b0c50d6a8c
```
borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
## Integrations
<a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://ntfy.sh/"><img src="docs/static/ntfy.png" alt="ntfy" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://ntfy.sh/"><img src="docs/static/ntfy.png" alt="ntfy" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://grafana.com/oss/loki/"><img src="docs/static/loki.png" alt="Loki" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://github.com/caronc/apprise/wiki"><img src="docs/static/apprise.png" alt="Apprise" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
## Getting started
+10 -7
View File
@@ -78,20 +78,23 @@ def run_create(
)
logger.info(f'{repository.get("label", repository["path"])}: Creating archive{dry_run_label}')
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_database_dumps',
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
global_arguments.dry_run,
)
active_dumps = borgmatic.hooks.dispatch.call_hooks(
'dump_databases',
'dump_data_sources',
config,
repository['path'],
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
global_arguments.dry_run,
)
create_borgmatic_manifest(config, global_arguments.used_config_paths, global_arguments.dry_run)
if config.get('store_config_files', True):
create_borgmatic_manifest(
config, global_arguments.used_config_paths, global_arguments.dry_run
)
stream_processes = [process for processes in active_dumps.values() for process in processes]
json_output = borgmatic.borg.create.create_archive(
@@ -112,10 +115,10 @@ def run_create(
yield json.loads(json_output)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_database_dumps',
'remove_data_source_dumps',
config,
config_filename,
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
global_arguments.dry_run,
)
borgmatic.hooks.command.execute_hook(
+33
View File
@@ -0,0 +1,33 @@
import logging
import borgmatic.borg.export_key
import borgmatic.config.validate
logger = logging.getLogger(__name__)
def run_export_key(
repository,
config,
local_borg_version,
export_arguments,
global_arguments,
local_path,
remote_path,
):
'''
Run the "key export" action for the given repository.
'''
if export_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, export_arguments.repository
):
logger.info(f'{repository.get("label", repository["path"])}: Exporting repository key')
borgmatic.borg.export_key.export_key(
repository['path'],
config,
local_borg_version,
export_arguments,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
+123 -115
View File
@@ -17,50 +17,51 @@ logger = logging.getLogger(__name__)
UNSPECIFIED_HOOK = object()
def get_configured_database(
config, archive_database_names, hook_name, database_name, configuration_database_name=None
def get_configured_data_source(
config,
archive_data_source_names,
hook_name,
data_source_name,
configuration_data_source_name=None,
):
'''
Find the first database with the given hook name and database name in the configuration dict and
the given archive database names dict (from hook name to database names contained in a
particular backup archive). If UNSPECIFIED_HOOK is given as the hook name, search all database
hooks for the named database. If a configuration database name is given, use that instead of the
database name to lookup the database in the given hooks configuration.
Find the first data source with the given hook name and data source name in the configuration
dict and the given archive data source names dict (from hook name to data source names contained
in a particular backup archive). If UNSPECIFIED_HOOK is given as the hook name, search all data
source hooks for the named data source. If a configuration data source name is given, use that
instead of the data source name to lookup the data source in the given hooks configuration.
Return the found database as a tuple of (found hook name, database configuration dict).
Return the found data source as a tuple of (found hook name, data source configuration dict) or
(None, None) if not found.
'''
if not configuration_database_name:
configuration_database_name = database_name
if not configuration_data_source_name:
configuration_data_source_name = data_source_name
if hook_name == UNSPECIFIED_HOOK:
hooks_to_search = {
hook_name: value
for (hook_name, value) in config.items()
if hook_name in borgmatic.hooks.dump.DATABASE_HOOK_NAMES
if hook_name in borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES
}
else:
hooks_to_search = {hook_name: config[hook_name]}
try:
hooks_to_search = {hook_name: config[hook_name]}
except KeyError:
return (None, None)
return next(
(
(name, hook_database)
(name, hook_data_source)
for (name, hook) in hooks_to_search.items()
for hook_database in hook
if hook_database['name'] == configuration_database_name
and database_name in archive_database_names.get(name, [])
for hook_data_source in hook
if hook_data_source['name'] == configuration_data_source_name
and data_source_name in archive_data_source_names.get(name, [])
),
(None, None),
)
def get_configured_hook_name_and_database(hooks, database_name):
'''
Find the hook name and first database dict with the given database name in the configured hooks
dict. This searches across all database hooks.
'''
def restore_single_database(
def restore_single_data_source(
repository,
config,
local_borg_version,
@@ -69,27 +70,27 @@ def restore_single_database(
remote_path,
archive_name,
hook_name,
database,
data_source,
connection_params,
): # pragma: no cover
'''
Given (among other things) an archive name, a database hook name, the hostname,
port, username and password as connection params, and a configured database
configuration dict, restore that database from the archive.
Given (among other things) an archive name, a data source hook name, the hostname, port,
username/password as connection params, and a configured data source configuration dict, restore
that data source from the archive.
'''
logger.info(
f'{repository.get("label", repository["path"])}: Restoring database {database["name"]}'
f'{repository.get("label", repository["path"])}: Restoring data source {data_source["name"]}'
)
dump_pattern = borgmatic.hooks.dispatch.call_hooks(
'make_database_dump_pattern',
'make_data_source_dump_pattern',
config,
repository['path'],
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
database['name'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
data_source['name'],
)[hook_name]
# Kick off a single database extract to stdout.
# Kick off a single data source extract to stdout.
extract_process = borgmatic.borg.extract.extract_archive(
dry_run=global_arguments.dry_run,
repository=repository['path'],
@@ -103,23 +104,23 @@ def restore_single_database(
destination_path='/',
# A directory format dump isn't a single file, and therefore can't extract
# to stdout. In this case, the extract_process return value is None.
extract_to_stdout=bool(database.get('format') != 'directory'),
extract_to_stdout=bool(data_source.get('format') != 'directory'),
)
# Run a single database restore, consuming the extract stdout (if any).
# Run a single data source restore, consuming the extract stdout (if any).
borgmatic.hooks.dispatch.call_hooks(
'restore_database_dump',
config,
repository['path'],
database['name'],
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
extract_process,
connection_params,
function_name='restore_data_source_dump',
config=config,
log_prefix=repository['path'],
hook_names=[hook_name],
data_source=data_source,
dry_run=global_arguments.dry_run,
extract_process=extract_process,
connection_params=connection_params,
)
def collect_archive_database_names(
def collect_archive_data_source_names(
repository,
archive,
config,
@@ -131,60 +132,62 @@ def collect_archive_database_names(
'''
Given a local or remote repository path, a resolved archive name, a configuration dict, the
local Borg version, global_arguments an argparse.Namespace, and local and remote Borg paths,
query the archive for the names of databases it contains and return them as a dict from hook
name to a sequence of database names.
query the archive for the names of data sources it contains as dumps and return them as a dict
from hook name to a sequence of data source names.
'''
borgmatic_source_directory = os.path.expanduser(
config.get(
'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
)
).lstrip('/')
parent_dump_path = os.path.expanduser(
borgmatic.hooks.dump.make_database_dump_path(borgmatic_source_directory, '*_databases/*/*')
)
dump_paths = borgmatic.borg.list.capture_archive_listing(
repository,
archive,
config,
local_borg_version,
global_arguments,
list_path=parent_dump_path,
list_paths=[
os.path.expanduser(
borgmatic.hooks.dump.make_data_source_dump_path(borgmatic_source_directory, pattern)
)
for pattern in ('*_databases/*/*',)
],
local_path=local_path,
remote_path=remote_path,
)
# Determine the database names corresponding to the dumps found in the archive and
# Determine the data source names corresponding to the dumps found in the archive and
# add them to restore_names.
archive_database_names = {}
archive_data_source_names = {}
for dump_path in dump_paths:
try:
(hook_name, _, database_name) = dump_path.split(
(hook_name, _, data_source_name) = dump_path.split(
borgmatic_source_directory + os.path.sep, 1
)[1].split(os.path.sep)[0:3]
except (ValueError, IndexError):
logger.warning(
f'{repository}: Ignoring invalid database dump path "{dump_path}" in archive {archive}'
f'{repository}: Ignoring invalid data source dump path "{dump_path}" in archive {archive}'
)
else:
if database_name not in archive_database_names.get(hook_name, []):
archive_database_names.setdefault(hook_name, []).extend([database_name])
if data_source_name not in archive_data_source_names.get(hook_name, []):
archive_data_source_names.setdefault(hook_name, []).extend([data_source_name])
return archive_database_names
return archive_data_source_names
def find_databases_to_restore(requested_database_names, archive_database_names):
def find_data_sources_to_restore(requested_data_source_names, archive_data_source_names):
'''
Given a sequence of requested database names to restore and a dict of hook name to the names of
databases found in an archive, return an expanded sequence of database names to restore,
replacing "all" with actual database names as appropriate.
Given a sequence of requested data source names to restore and a dict of hook name to the names
of data sources found in an archive, return an expanded sequence of data source names to
restore, replacing "all" with actual data source names as appropriate.
Raise ValueError if any of the requested database names cannot be found in the archive.
Raise ValueError if any of the requested data source names cannot be found in the archive.
'''
# A map from database hook name to the database names to restore for that hook.
# A map from data source hook name to the data source names to restore for that hook.
restore_names = (
{UNSPECIFIED_HOOK: requested_database_names}
if requested_database_names
{UNSPECIFIED_HOOK: requested_data_source_names}
if requested_data_source_names
else {UNSPECIFIED_HOOK: ['all']}
)
@@ -193,56 +196,59 @@ def find_databases_to_restore(requested_database_names, archive_database_names):
if 'all' in restore_names[UNSPECIFIED_HOOK]:
restore_names[UNSPECIFIED_HOOK].remove('all')
for hook_name, database_names in archive_database_names.items():
restore_names.setdefault(hook_name, []).extend(database_names)
for hook_name, data_source_names in archive_data_source_names.items():
restore_names.setdefault(hook_name, []).extend(data_source_names)
# If a database is to be restored as part of "all", then remove it from restore names so
# it doesn't get restored twice.
for database_name in database_names:
if database_name in restore_names[UNSPECIFIED_HOOK]:
restore_names[UNSPECIFIED_HOOK].remove(database_name)
# If a data source is to be restored as part of "all", then remove it from restore names
# so it doesn't get restored twice.
for data_source_name in data_source_names:
if data_source_name in restore_names[UNSPECIFIED_HOOK]:
restore_names[UNSPECIFIED_HOOK].remove(data_source_name)
if not restore_names[UNSPECIFIED_HOOK]:
restore_names.pop(UNSPECIFIED_HOOK)
combined_restore_names = set(
name for database_names in restore_names.values() for name in database_names
name for data_source_names in restore_names.values() for name in data_source_names
)
combined_archive_database_names = set(
name for database_names in archive_database_names.values() for name in database_names
combined_archive_data_source_names = set(
name
for data_source_names in archive_data_source_names.values()
for name in data_source_names
)
missing_names = sorted(set(combined_restore_names) - combined_archive_database_names)
missing_names = sorted(set(combined_restore_names) - combined_archive_data_source_names)
if missing_names:
joined_names = ', '.join(f'"{name}"' for name in missing_names)
raise ValueError(
f"Cannot restore database{'s' if len(missing_names) > 1 else ''} {joined_names} missing from archive"
f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from archive"
)
return restore_names
def ensure_databases_found(restore_names, remaining_restore_names, found_names):
def ensure_data_sources_found(restore_names, remaining_restore_names, found_names):
'''
Given a dict from hook name to database names to restore, a dict from hook name to remaining
database names to restore, and a sequence of found (actually restored) database names, raise
ValueError if requested databases to restore were missing from the archive and/or configuration.
Given a dict from hook name to data source names to restore, a dict from hook name to remaining
data source names to restore, and a sequence of found (actually restored) data source names,
raise ValueError if requested data source to restore were missing from the archive and/or
configuration.
'''
combined_restore_names = set(
name
for database_names in tuple(restore_names.values())
for data_source_names in tuple(restore_names.values())
+ tuple(remaining_restore_names.values())
for name in database_names
for name in data_source_names
)
if not combined_restore_names and not found_names:
raise ValueError('No databases were found to restore')
raise ValueError('No data sources were found to restore')
missing_names = sorted(set(combined_restore_names) - set(found_names))
if missing_names:
joined_names = ', '.join(f'"{name}"' for name in missing_names)
raise ValueError(
f"Cannot restore database{'s' if len(missing_names) > 1 else ''} {joined_names} missing from borgmatic's configuration"
f"Cannot restore data source{'s' if len(missing_names) > 1 else ''} {joined_names} missing from borgmatic's configuration"
)
@@ -259,7 +265,7 @@ def run_restore(
Run the "restore" action for the given repository, but only if the repository matches the
requested repository in restore arguments.
Raise ValueError if a configured database could not be found to restore.
Raise ValueError if a configured data source could not be found to restore.
'''
if restore_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, restore_arguments.repository
@@ -267,14 +273,14 @@ def run_restore(
return
logger.info(
f'{repository.get("label", repository["path"])}: Restoring databases from archive {restore_arguments.archive}'
f'{repository.get("label", repository["path"])}: Restoring data sources from archive {restore_arguments.archive}'
)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_database_dumps',
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
global_arguments.dry_run,
)
@@ -287,7 +293,7 @@ def run_restore(
local_path,
remote_path,
)
archive_database_names = collect_archive_database_names(
archive_data_source_names = collect_archive_data_source_names(
repository['path'],
archive_name,
config,
@@ -296,7 +302,9 @@ def run_restore(
local_path,
remote_path,
)
restore_names = find_databases_to_restore(restore_arguments.databases, archive_database_names)
restore_names = find_data_sources_to_restore(
restore_arguments.data_sources, archive_data_source_names
)
found_names = set()
remaining_restore_names = {}
connection_params = {
@@ -307,20 +315,20 @@ def run_restore(
'restore_path': restore_arguments.restore_path,
}
for hook_name, database_names in restore_names.items():
for database_name in database_names:
found_hook_name, found_database = get_configured_database(
config, archive_database_names, hook_name, database_name
for hook_name, data_source_names in restore_names.items():
for data_source_name in data_source_names:
found_hook_name, found_data_source = get_configured_data_source(
config, archive_data_source_names, hook_name, data_source_name
)
if not found_database:
if not found_data_source:
remaining_restore_names.setdefault(found_hook_name or hook_name, []).append(
database_name
data_source_name
)
continue
found_names.add(database_name)
restore_single_database(
found_names.add(data_source_name)
restore_single_data_source(
repository,
config,
local_borg_version,
@@ -329,26 +337,26 @@ def run_restore(
remote_path,
archive_name,
found_hook_name or hook_name,
dict(found_database, **{'schemas': restore_arguments.schemas}),
dict(found_data_source, **{'schemas': restore_arguments.schemas}),
connection_params,
)
# For any database that weren't found via exact matches in the configuration, try to fallback
# to "all" entries.
for hook_name, database_names in remaining_restore_names.items():
for database_name in database_names:
found_hook_name, found_database = get_configured_database(
config, archive_database_names, hook_name, database_name, 'all'
# For any data sources that weren't found via exact matches in the configuration, try to
# fallback to "all" entries.
for hook_name, data_source_names in remaining_restore_names.items():
for data_source_name in data_source_names:
found_hook_name, found_data_source = get_configured_data_source(
config, archive_data_source_names, hook_name, data_source_name, 'all'
)
if not found_database:
if not found_data_source:
continue
found_names.add(database_name)
database = copy.copy(found_database)
database['name'] = database_name
found_names.add(data_source_name)
data_source = copy.copy(found_data_source)
data_source['name'] = data_source_name
restore_single_database(
restore_single_data_source(
repository,
config,
local_borg_version,
@@ -357,16 +365,16 @@ def run_restore(
remote_path,
archive_name,
found_hook_name or hook_name,
dict(database, **{'schemas': restore_arguments.schemas}),
dict(data_source, **{'schemas': restore_arguments.schemas}),
connection_params,
)
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_database_dumps',
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
global_arguments.dry_run,
)
ensure_databases_found(restore_names, remaining_restore_names, found_names)
ensure_data_sources_found(restore_names, remaining_restore_names, found_names)
+1 -1
View File
@@ -142,7 +142,7 @@ def filter_checks_on_frequency(
if datetime.datetime.now() < check_time + frequency_delta:
remaining = check_time + frequency_delta - datetime.datetime.now()
logger.info(
f'Skipping {check} check due to configured frequency; {remaining} until next check'
f'Skipping {check} check due to configured frequency; {remaining} until next check (use --force to check anyway)'
)
filtered_checks.remove(check)
+17 -16
View File
@@ -215,9 +215,6 @@ def make_list_filter_flags(local_borg_version, dry_run):
return f'{base_flags}-'
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003
def collect_borgmatic_source_directories(borgmatic_source_directory):
'''
Return a list of borgmatic-specific source directories used for state like database backups.
@@ -354,7 +351,11 @@ def create_archive(
expand_directories(
tuple(config.get('source_directories', ()))
+ borgmatic_source_directories
+ tuple(global_arguments.used_config_paths)
+ tuple(
global_arguments.used_config_paths
if config.get('store_config_files', True)
else ()
)
)
),
additional_directory_devices=map_directories_to_devices(
@@ -384,7 +385,7 @@ def create_archive(
lock_wait = config.get('lock_wait', None)
list_filter_flags = make_list_filter_flags(local_borg_version, dry_run)
files_cache = config.get('files_cache')
archive_name_format = config.get('archive_name_format', DEFAULT_ARCHIVE_NAME_FORMAT)
archive_name_format = config.get('archive_name_format', flags.DEFAULT_ARCHIVE_NAME_FORMAT)
extra_borg_options = config.get('extra_borg_options', {}).get('create', '')
if feature.available(feature.Feature.ATIME, local_borg_version):
@@ -416,7 +417,7 @@ def create_archive(
f'{repository_path}: Ignoring configured "read_special" value of false, as true is needed for database hooks.'
)
create_command = (
create_flags = (
tuple(local_path.split(' '))
+ ('create',)
+ make_pattern_flags(config, pattern_file.name if pattern_file else None)
@@ -445,12 +446,12 @@ def create_archive(
)
+ (('--dry-run',) if dry_run else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_archive_flags(
repository_path, archive_name_format, local_borg_version
)
+ (sources if not pattern_file else ())
)
create_positional_arguments = flags.make_repository_archive_flags(
repository_path, archive_name_format, local_borg_version
) + (sources if not pattern_file else ())
if json:
output_log_level = None
elif list_files or (stats and not dry_run):
@@ -469,7 +470,7 @@ def create_archive(
if stream_processes and not config.get('read_special'):
logger.debug(f'{repository_path}: Collecting special file paths')
special_file_paths = collect_special_file_paths(
create_command,
create_flags + create_positional_arguments,
local_path,
working_directory,
borg_environment,
@@ -486,9 +487,9 @@ def create_archive(
),
pattern_file=exclude_file,
)
create_command += make_exclude_flags(config, exclude_file.name)
create_flags += make_exclude_flags(config, exclude_file.name)
create_command += (
create_flags += (
(('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ())
+ (('--stats',) if stats and not json and not dry_run else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ())
@@ -498,7 +499,7 @@ def create_archive(
if stream_processes:
return execute_command_with_processes(
create_command,
create_flags + create_positional_arguments,
stream_processes,
output_log_level,
output_file,
@@ -508,14 +509,14 @@ def create_archive(
)
elif output_log_level is None:
return execute_command_and_capture_output(
create_command,
create_flags + create_positional_arguments,
working_directory=working_directory,
extra_environment=borg_environment,
borg_local_path=local_path,
)
else:
execute_command(
create_command,
create_flags + create_positional_arguments,
output_log_level,
output_file,
borg_local_path=local_path,
+6 -4
View File
@@ -38,14 +38,16 @@ def make_environment(config):
option_name,
environment_variable_name,
) in DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE.items():
value = config.get(option_name, False)
environment[environment_variable_name] = 'yes' if value else 'no'
value = config.get(option_name)
if value is not None:
environment[environment_variable_name] = 'yes' if value else 'no'
for (
option_name,
environment_variable_name,
) in DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE.items():
value = config.get(option_name, False)
environment[environment_variable_name] = 'YES' if value else 'NO'
value = config.get(option_name)
if value is not None:
environment[environment_variable_name] = 'YES' if value else 'NO'
return environment
+70
View File
@@ -0,0 +1,70 @@
import logging
import os
import borgmatic.logger
from borgmatic.borg import environment, flags
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
def export_key(
repository_path,
config,
local_borg_version,
export_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, and
optional local and remote Borg paths, export the repository key to the destination path
indicated in the export arguments.
If the destination path is empty or "-", then print the key to stdout instead of to a file.
Raise FileExistsError if a path is given but it already exists on disk.
'''
borgmatic.logger.add_custom_log_levels()
umask = config.get('umask', None)
lock_wait = config.get('lock_wait', None)
if export_arguments.path and export_arguments.path != '-':
if os.path.exists(export_arguments.path):
raise FileExistsError(
f'Destination path {export_arguments.path} already exists. Aborting.'
)
output_file = None
else:
output_file = DO_NOT_CAPTURE
full_command = (
(local_path, 'key', 'export')
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ flags.make_flags('paper', export_arguments.paper)
+ flags.make_flags('qr-html', export_arguments.qr_html)
+ flags.make_repository_flags(
repository_path,
local_borg_version,
)
+ ((export_arguments.path,) if output_file is None else ())
)
if global_arguments.dry_run:
logging.info(f'{repository_path}: Skipping key export (dry run)')
return
execute_command(
full_command,
output_file=output_file,
output_log_level=logging.ANSWER,
borg_local_path=local_path,
extra_environment=environment.make_environment(config),
)
+38 -6
View File
@@ -1,8 +1,12 @@
import itertools
import json
import logging
import re
from borgmatic.borg import feature
logger = logging.getLogger(__name__)
def make_flags(name, value):
'''
@@ -59,12 +63,15 @@ def make_repository_archive_flags(repository_path, archive, local_borg_version):
)
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}' # noqa: FS003
def make_match_archives_flags(match_archives, archive_name_format, local_borg_version):
'''
Return match archives flags based on the given match archives value, if any. If it isn't set,
return match archives flags to match archives created with the given archive name format, if
any. This is done by replacing certain archive name format placeholders for ephemeral data (like
"{now}") with globs.
return match archives flags to match archives created with the given (or default) archive name
format. This is done by replacing certain archive name format placeholders for ephemeral data
(like "{now}") with globs.
'''
if match_archives:
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
@@ -72,12 +79,37 @@ def make_match_archives_flags(match_archives, archive_name_format, local_borg_ve
else:
return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
if not archive_name_format:
return ()
derived_match_archives = re.sub(
r'\{(now|utcnow|pid)([:%\w\.-]*)\}', '*', archive_name_format or DEFAULT_ARCHIVE_NAME_FORMAT
)
derived_match_archives = re.sub(r'\{(now|utcnow|pid)([:%\w\.-]*)\}', '*', archive_name_format)
if derived_match_archives == '*':
return ()
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
return ('--match-archives', f'sh:{derived_match_archives}')
else:
return ('--glob-archives', f'{derived_match_archives}')
def warn_for_aggressive_archive_flags(json_command, json_output):
'''
Given a JSON archives command and the resulting JSON string output from running it, parse the
JSON and warn if the command used an archive flag but the output indicates zero archives were
found.
'''
archive_flags_used = {'--glob-archives', '--match-archives'}.intersection(set(json_command))
if not archive_flags_used:
return
try:
if len(json.loads(json_output)['archives']) == 0:
logger.warning('An archive filter was applied, but no matching archives were found.')
logger.warning(
'Try adding --match-archives "*" or adjusting archive_name_format/match_archives in configuration.'
)
except json.JSONDecodeError as error:
logger.debug(f'Cannot parse JSON output from archive command: {error}')
except (TypeError, KeyError):
logger.debug('Cannot parse JSON output from archive command: No "archives" key found')
+61 -23
View File
@@ -1,3 +1,4 @@
import argparse
import logging
import borgmatic.logger
@@ -7,24 +8,21 @@ from borgmatic.execute import execute_command, execute_command_and_capture_outpu
logger = logging.getLogger(__name__)
def display_archives_info(
def make_info_command(
repository_path,
config,
local_borg_version,
info_arguments,
global_arguments,
local_path='borg',
remote_path=None,
local_path,
remote_path,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, global
arguments as an argparse.Namespace, and the arguments to the info action, display summary
information for Borg archives in the repository or return JSON summary information.
Given a local or remote repository path, a configuration dict, the local Borg version, the
arguments to the info action as an argparse.Namespace, and global arguments, return a command
as a tuple to display summary information for archives in the repository.
'''
borgmatic.logger.add_custom_log_levels()
lock_wait = config.get('lock_wait', None)
full_command = (
return (
(local_path, 'info')
+ (
('--info',)
@@ -38,7 +36,7 @@ def display_archives_info(
)
+ flags.make_flags('remote-path', remote_path)
+ flags.make_flags('log-json', global_arguments.log_json)
+ flags.make_flags('lock-wait', lock_wait)
+ flags.make_flags('lock-wait', config.get('lock_wait'))
+ (
(
flags.make_flags('match-archives', f'sh:{info_arguments.prefix}*')
@@ -62,16 +60,56 @@ def display_archives_info(
+ flags.make_repository_flags(repository_path, local_borg_version)
)
def display_archives_info(
repository_path,
config,
local_borg_version,
info_arguments,
global_arguments,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, the
arguments to the info action as an argparse.Namespace, and global arguments, display summary
information for Borg archives in the repository or return JSON summary information.
'''
borgmatic.logger.add_custom_log_levels()
main_command = make_info_command(
repository_path,
config,
local_borg_version,
info_arguments,
global_arguments,
local_path,
remote_path,
)
json_command = make_info_command(
repository_path,
config,
local_borg_version,
argparse.Namespace(**dict(info_arguments.__dict__, json=True)),
global_arguments,
local_path,
remote_path,
)
json_info = execute_command_and_capture_output(
json_command,
extra_environment=environment.make_environment(config),
borg_local_path=local_path,
)
if info_arguments.json:
return execute_command_and_capture_output(
full_command,
extra_environment=environment.make_environment(config),
borg_local_path=local_path,
)
else:
execute_command(
full_command,
output_log_level=logging.ANSWER,
borg_local_path=local_path,
extra_environment=environment.make_environment(config),
)
return json_info
flags.warn_for_aggressive_archive_flags(json_command, json_info)
execute_command(
main_command,
output_log_level=logging.ANSWER,
borg_local_path=local_path,
extra_environment=environment.make_environment(config),
)
+3 -3
View File
@@ -92,13 +92,13 @@ def capture_archive_listing(
config,
local_borg_version,
global_arguments,
list_path=None,
list_paths=None,
local_path='borg',
remote_path=None,
):
'''
Given a local or remote repository path, an archive name, a configuration dict, the local Borg
version, global arguments as an argparse.Namespace, the archive path in which to list files, and
version, global arguments as an argparse.Namespace, the archive paths in which to list files, and
local and remote Borg paths, capture the output of listing that archive and return it as a list
of file paths.
'''
@@ -113,7 +113,7 @@ def capture_archive_listing(
argparse.Namespace(
repository=repository_path,
archive=archive,
paths=[f'sh:{list_path}'],
paths=[f'sh:{path}' for path in list_paths] if list_paths else None,
find_paths=None,
json=None,
format='{path}{NL}', # noqa: FS003
+1 -1
View File
@@ -26,7 +26,7 @@ def make_prune_flags(config, local_borg_version):
flag_pairs = (
('--' + option_name.replace('_', '-'), str(value))
for option_name, value in config.items()
if option_name.startswith('keep_')
if option_name.startswith('keep_') and option_name != 'keep_exclude_tags'
)
prefix = config.get('prefix')
+24 -10
View File
@@ -1,3 +1,4 @@
import argparse
import logging
import borgmatic.logger
@@ -137,15 +138,28 @@ def list_repository(
local_path,
remote_path,
)
json_command = make_rlist_command(
repository_path,
config,
local_borg_version,
argparse.Namespace(**dict(rlist_arguments.__dict__, json=True)),
global_arguments,
local_path,
remote_path,
)
json_listing = execute_command_and_capture_output(
json_command, extra_environment=borg_environment, borg_local_path=local_path
)
if rlist_arguments.json:
return execute_command_and_capture_output(
main_command, extra_environment=borg_environment, borg_local_path=local_path
)
else:
execute_command(
main_command,
output_log_level=logging.ANSWER,
borg_local_path=local_path,
extra_environment=borg_environment,
)
return json_listing
flags.warn_for_aggressive_archive_flags(json_command, json_listing)
execute_command(
main_command,
output_log_level=logging.ANSWER,
borg_local_path=local_path,
extra_environment=borg_environment,
)
+65 -16
View File
@@ -23,6 +23,7 @@ ACTION_ALIASES = {
'info': ['-i'],
'transfer': [],
'break-lock': [],
'key': [],
'borg': [],
}
@@ -258,28 +259,28 @@ def make_parsers():
type=int,
choices=range(-2, 3),
default=0,
help='Display verbose progress to the console (disabled, errors only, default, some, or lots: -2, -1, 0, 1, or 2)',
help='Display verbose progress to the console: -2 (disabled), -1 (errors only), 0 (responses to actions, the default), 1 (info about steps borgmatic is taking), or 2 (debug)',
)
global_group.add_argument(
'--syslog-verbosity',
type=int,
choices=range(-2, 3),
default=0,
help='Log verbose progress to syslog (disabled, errors only, default, some, or lots: -2, -1, 0, 1, or 2). Ignored when console is interactive or --log-file is given',
default=-2,
help='Log verbose progress to syslog: -2 (disabled, the default), -1 (errors only), 0 (responses to actions), 1 (info about steps borgmatic is taking), or 2 (debug)',
)
global_group.add_argument(
'--log-file-verbosity',
type=int,
choices=range(-2, 3),
default=0,
help='Log verbose progress to log file (disabled, errors only, default, some, or lots: -2, -1, 0, 1, or 2). Only used when --log-file is given',
default=1,
help='When --log-file is given, log verbose progress to file: -2 (disabled), -1 (errors only), 0 (responses to actions), 1 (info about steps borgmatic is taking, the default), or 2 (debug)',
)
global_group.add_argument(
'--monitoring-verbosity',
type=int,
choices=range(-2, 3),
default=0,
help='Log verbose progress to monitoring integrations that support logging (from disabled, errors only, default, some, or lots: -2, -1, 0, 1, or 2)',
default=1,
help='When a monitoring integration supporting logging is configured, log verbose progress to it: -2 (disabled), -1 (errors only), responses to actions (0), 1 (info about steps borgmatic is taking, the default), or 2 (debug)',
)
global_group.add_argument(
'--log-file',
@@ -331,9 +332,8 @@ def make_parsers():
global_plus_action_parser = ArgumentParser(
description='''
Simple, configuration-driven backup software for servers and workstations. If none of
the action options are given, then borgmatic defaults to: create, prune, compact, and
check.
Simple, configuration-driven backup software for servers and workstations. If no actions
are given, then borgmatic defaults to: create, prune, compact, and check.
''',
parents=[global_parser],
)
@@ -906,8 +906,8 @@ def make_parsers():
restore_parser = action_parsers.add_parser(
'restore',
aliases=ACTION_ALIASES['restore'],
help='Restore database dumps from a named archive',
description='Restore database dumps from a named archive. (To extract files instead, use "borgmatic extract".)',
help='Restore data source (e.g. database) dumps from a named archive',
description='Restore data source (e.g. database) dumps from a named archive. (To extract files instead, use "borgmatic extract".)',
add_help=False,
)
restore_group = restore_parser.add_argument_group('restore arguments')
@@ -919,18 +919,19 @@ def make_parsers():
'--archive', help='Name of archive to restore from (or "latest")', required=True
)
restore_group.add_argument(
'--data-source',
'--database',
metavar='NAME',
dest='databases',
dest='data_sources',
action='append',
help="Name of database to restore from archive, must be defined in borgmatic's configuration, can specify flag multiple times, defaults to all databases",
help="Name of data source (e.g. database) to restore from archive, must be defined in borgmatic's configuration, can specify flag multiple times, defaults to all data sources in the archive",
)
restore_group.add_argument(
'--schema',
metavar='NAME',
dest='schemas',
action='append',
help='Name of schema to restore from the database, can specify flag multiple times, defaults to all schemas. Schemas are only supported for PostgreSQL and MongoDB databases',
help='Name of schema to restore from the data source, can specify flag multiple times, defaults to all schemas. Schemas are only supported for PostgreSQL and MongoDB databases',
)
restore_group.add_argument(
'--hostname',
@@ -938,7 +939,7 @@ def make_parsers():
)
restore_group.add_argument(
'--port',
help='Port to restore to. Defaults to the "restore_port" option in borgmatic\'s configuration',
help='Database port to restore to. Defaults to the "restore_port" option in borgmatic\'s configuration',
)
restore_group.add_argument(
'--username',
@@ -1177,6 +1178,51 @@ def make_parsers():
'-h', '--help', action='help', help='Show this help message and exit'
)
key_parser = action_parsers.add_parser(
'key',
aliases=ACTION_ALIASES['key'],
help='Perform repository key related operations',
description='Perform repository key related operations',
add_help=False,
)
key_group = key_parser.add_argument_group('key arguments')
key_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
key_parsers = key_parser.add_subparsers(
title='key sub-actions',
)
key_export_parser = key_parsers.add_parser(
'export',
help='Export a copy of the repository key for safekeeping in case the original goes missing or gets damaged',
description='Export a copy of the repository key for safekeeping in case the original goes missing or gets damaged',
add_help=False,
)
key_export_group = key_export_parser.add_argument_group('key export arguments')
key_export_group.add_argument(
'--paper',
action='store_true',
help='Export the key in a text format suitable for printing and later manual entry',
)
key_export_group.add_argument(
'--qr-html',
action='store_true',
help='Export the key in an HTML format suitable for printing and later manual entry or QR code scanning',
)
key_export_group.add_argument(
'--repository',
help='Path of repository to export the key for, defaults to the configured repository if there is only one',
)
key_export_group.add_argument(
'--path',
metavar='PATH',
help='Path to export the key to, defaults to stdout (but be careful about dirtying the output with --verbosity)',
)
key_export_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
)
borg_parser = action_parsers.add_parser(
'borg',
aliases=ACTION_ALIASES['borg'],
@@ -1282,4 +1328,7 @@ def parse_arguments(*unparsed_arguments):
'With the info action, only one of --archive, --prefix, or --match-archives flags can be used.'
)
if 'borg' in arguments and arguments['global'].dry_run:
raise ValueError('With the borg action, --dry-run is not supported.')
return arguments
+23 -11
View File
@@ -22,6 +22,7 @@ import borgmatic.actions.config.bootstrap
import borgmatic.actions.config.generate
import borgmatic.actions.config.validate
import borgmatic.actions.create
import borgmatic.actions.export_key
import borgmatic.actions.export_tar
import borgmatic.actions.extract
import borgmatic.actions.info
@@ -448,6 +449,16 @@ def run_actions(
local_path,
remote_path,
)
elif action_name == 'export':
borgmatic.actions.export_key.run_export_key(
repository,
config,
local_borg_version,
action_arguments,
global_arguments,
local_path,
remote_path,
)
elif action_name == 'borg':
borgmatic.actions.borg.run_borg(
repository,
@@ -544,9 +555,6 @@ def log_record(suppress_log=False, **kwargs):
return record
MAX_CAPTURED_OUTPUT_LENGTH = 1000
def log_error_records(
message, error=None, levelno=logging.CRITICAL, log_command_error_output=False
):
@@ -568,20 +576,24 @@ def log_error_records(
raise error
except CalledProcessError as error:
yield log_record(levelno=levelno, levelname=level_name, msg=message)
if error.output:
try:
output = error.output.decode('utf-8')
except (UnicodeDecodeError, AttributeError):
output = error.output
# Suppress these logs for now and save full error output for the log summary at the end.
yield log_record(
levelno=levelno,
levelname=level_name,
msg=output[:MAX_CAPTURED_OUTPUT_LENGTH]
+ ' ...' * (len(output) > MAX_CAPTURED_OUTPUT_LENGTH),
suppress_log=True,
)
# Suppress these logs for now and save the error output for the log summary at the end.
# Log a separate record per line, as some errors can be really verbose and overflow the
# per-record size limits imposed by some logging backends.
for output_line in output.splitlines():
yield log_record(
levelno=levelno,
levelname=level_name,
msg=output_line,
suppress_log=True,
)
yield log_record(levelno=levelno, levelname=level_name, msg=error)
except (ValueError, OSError) as error:
yield log_record(levelno=levelno, levelname=level_name, msg=message)
+204 -109
View File
@@ -1,6 +1,8 @@
import functools
import itertools
import json
import logging
import operator
import os
import ruamel.yaml
@@ -8,34 +10,67 @@ import ruamel.yaml
logger = logging.getLogger(__name__)
def probe_and_include_file(filename, include_directories):
'''
Given a filename to include and a list of include directories to search for matching files,
probe for the file, load it, and return the loaded configuration as a data structure of nested
dicts, lists, etc.
Raise FileNotFoundError if the included file was not found.
'''
expanded_filename = os.path.expanduser(filename)
if os.path.isabs(expanded_filename):
return load_configuration(expanded_filename)
candidate_filenames = {
os.path.join(directory, expanded_filename) for directory in include_directories
}
for candidate_filename in candidate_filenames:
if os.path.exists(candidate_filename):
return load_configuration(candidate_filename)
raise FileNotFoundError(
f'Could not find include {filename} at {" or ".join(candidate_filenames)}'
)
def include_configuration(loader, filename_node, include_directory):
'''
Given a ruamel.yaml.loader.Loader, a ruamel.yaml.serializer.ScalarNode containing the included
filename, and an include directory path to search for matching files, load the given YAML
filename (ignoring the given loader so we can use our own) and return its contents as a data
structure of nested dicts and lists. If the filename is relative, probe for it within 1. the
current working directory and 2. the given include directory.
Given a ruamel.yaml.loader.Loader, a ruamel.yaml.nodes.ScalarNode containing the included
filename (or a list containing multiple such filenames), and an include directory path to search
for matching files, load the given YAML filenames (ignoring the given loader so we can use our
own) and return their contents as data structure of nested dicts, lists, etc. If the given
filename node's value is a scalar string, then the return value will be a single value. But if
the given node value is a list, then the return value will be a list of values, one per loaded
configuration file.
If a filename is relative, probe for it within 1. the current working directory and 2. the given
include directory.
Raise FileNotFoundError if an included file was not found.
'''
include_directories = [os.getcwd(), os.path.abspath(include_directory)]
include_filename = os.path.expanduser(filename_node.value)
if not os.path.isabs(include_filename):
candidate_filenames = [
os.path.join(directory, include_filename) for directory in include_directories
if isinstance(filename_node.value, str):
return probe_and_include_file(filename_node.value, include_directories)
if (
isinstance(filename_node.value, list)
and len(filename_node.value)
and isinstance(filename_node.value[0], ruamel.yaml.nodes.ScalarNode)
):
# Reversing the values ensures the correct ordering if these includes are subsequently
# merged together.
return [
probe_and_include_file(node.value, include_directories)
for node in reversed(filename_node.value)
]
for candidate_filename in candidate_filenames:
if os.path.exists(candidate_filename):
include_filename = candidate_filename
break
else:
raise FileNotFoundError(
f'Could not find include {filename_node.value} at {" or ".join(candidate_filenames)}'
)
return load_configuration(include_filename)
raise ValueError(
'!include value is not supported; use a single filename or a list of filenames'
)
def raise_retain_node_error(loader, node):
@@ -53,7 +88,7 @@ def raise_retain_node_error(loader, node):
'The !retain tag may only be used within a configuration file containing a merged !include tag.'
)
raise ValueError('The !retain tag may only be used on a YAML mapping or sequence.')
raise ValueError('The !retain tag may only be used on a mapping or list.')
def raise_omit_node_error(loader, node):
@@ -65,14 +100,14 @@ def raise_omit_node_error(loader, node):
tags are handled by deep_merge_nodes() below.
'''
raise ValueError(
'The !omit tag may only be used on a scalar (e.g., string) list element within a configuration file containing a merged !include tag.'
'The !omit tag may only be used on a scalar (e.g., string) or list element within a configuration file containing a merged !include tag.'
)
class Include_constructor(ruamel.yaml.SafeConstructor):
'''
A YAML "constructor" (a ruamel.yaml concept) that supports a custom "!include" tag for including
separate YAML configuration files. Example syntax: `retention: !include common.yaml`
separate YAML configuration files. Example syntax: `option: !include common.yaml`
'''
def __init__(self, preserve_quotes=None, loader=None, include_directory=None):
@@ -81,6 +116,9 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
'!include',
functools.partial(include_configuration, include_directory=include_directory),
)
# These are catch-all error handlers for tags that don't get applied and removed by
# deep_merge_nodes() below.
self.add_constructor('!retain', raise_retain_node_error)
self.add_constructor('!omit', raise_omit_node_error)
@@ -90,8 +128,8 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
using the YAML '<<' merge key. Example syntax:
```
retention:
keep_daily: 1
option:
sub_option: 1
<<: !include common.yaml
```
@@ -104,9 +142,15 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
for index, (key_node, value_node) in enumerate(node.value):
if key_node.tag == u'tag:yaml.org,2002:merge' and value_node.tag == '!include':
included_value = representer.represent_data(self.construct_object(value_node))
node.value[index] = (key_node, included_value)
# Replace the merge include with a sequence of included configuration nodes ready
# for merging. The construct_object() call here triggers include_configuration()
# among other constructors.
node.value[index] = (
key_node,
representer.represent_data(self.construct_object(value_node)),
)
# This super().flatten_mapping() call actually performs "<<" merges.
super(Include_constructor, self).flatten_mapping(node)
node.value = deep_merge_nodes(node.value)
@@ -138,7 +182,12 @@ def load_configuration(filename):
file_contents = file.read()
config = yaml.load(file_contents)
if config and 'constants' in config:
try:
has_constants = bool(config and 'constants' in config)
except TypeError:
has_constants = False
if has_constants:
for key, value in config['constants'].items():
value = json.dumps(value)
file_contents = file_contents.replace(f'{{{key}}}', value.strip('"'))
@@ -149,53 +198,92 @@ def load_configuration(filename):
return config
def filter_omitted_nodes(nodes):
def filter_omitted_nodes(nodes, values):
'''
Given a list of nodes, return a filtered list omitting any nodes with an "!omit" tag or with a
value matching such nodes.
Given a nested borgmatic configuration data structure as a list of tuples in the form of:
[
(
ruamel.yaml.nodes.ScalarNode as a key,
ruamel.yaml.nodes.MappingNode or other Node as a value,
),
...
]
... and a combined list of all values for those nodes, return a filtered list of the values,
omitting any that have an "!omit" tag (or with a value matching such nodes).
But if only a single node is given, bail and return the given values unfiltered, as "!omit" only
applies when there are merge includes (and therefore multiple nodes).
'''
omitted_values = tuple(node.value for node in nodes if node.tag == '!omit')
if len(nodes) <= 1:
return values
return [node for node in nodes if node.value not in omitted_values]
omitted_values = tuple(node.value for node in values if node.tag == '!omit')
return [node for node in values if node.value not in omitted_values]
DELETED_NODE = object()
def merge_values(nodes):
'''
Given a nested borgmatic configuration data structure as a list of tuples in the form of:
[
(
ruamel.yaml.nodes.ScalarNode as a key,
ruamel.yaml.nodes.MappingNode or other Node as a value,
),
...
]
... merge its sequence or mapping node values and return the result. For sequence nodes, this
means appending together its contained lists. For mapping nodes, it means merging its contained
dicts.
'''
return functools.reduce(operator.add, (value.value for key, value in nodes))
def deep_merge_nodes(nodes):
'''
Given a nested borgmatic configuration data structure as a list of tuples in the form of:
[
(
ruamel.yaml.nodes.ScalarNode as a key,
ruamel.yaml.nodes.MappingNode or other Node as a value,
),
...
]
... deep merge any node values corresponding to duplicate keys and return the result. If
there are colliding keys with non-MappingNode values (e.g., integers or strings), the last
of the values wins.
... deep merge any node values corresponding to duplicate keys and return the result. The
purpose of merging like this is to support, for instance, merging one borgmatic configuration
file into another for reuse, such that a configuration option with sub-options does not
completely replace the corresponding option in a merged file.
If there are colliding keys with scalar values (e.g., integers or strings), the last of the
values wins.
For instance, given node values of:
[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
MappingNode(tag='tag:yaml.org,2002:map', value=[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_hourly'),
ScalarNode(tag='tag:yaml.org,2002:int', value='24')
ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option1'),
ScalarNode(tag='tag:yaml.org,2002:int', value='1')
),
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
ScalarNode(tag='tag:yaml.org,2002:int', value='7')
ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'),
ScalarNode(tag='tag:yaml.org,2002:int', value='2')
),
]),
),
(
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
MappingNode(tag='tag:yaml.org,2002:map', value=[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'),
ScalarNode(tag='tag:yaml.org,2002:int', value='5')
),
]),
@@ -206,88 +294,95 @@ def deep_merge_nodes(nodes):
[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='retention'),
ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
MappingNode(tag='tag:yaml.org,2002:map', value=[
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_hourly'),
ScalarNode(tag='tag:yaml.org,2002:int', value='24')
ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option1'),
ScalarNode(tag='tag:yaml.org,2002:int', value='1')
),
(
ScalarNode(tag='tag:yaml.org,2002:str', value='keep_daily'),
ScalarNode(tag='tag:yaml.org,2002:str', value='sub_option2'),
ScalarNode(tag='tag:yaml.org,2002:int', value='5')
),
]),
),
]
This function supports multi-way merging, meaning that if the same option name exists three or
more times (at the same scope level), all of those instances get merged together.
If a mapping or sequence node has a YAML "!retain" tag, then that node is not merged.
The purpose of deep merging like this is to support, for instance, merging one borgmatic
configuration file into another for reuse, such that a configuration option with sub-options
does not completely replace the corresponding option in a merged file.
Raise ValueError if a merge is implied using two incompatible types.
Raise ValueError if a merge is implied using multiple incompatible types.
'''
# Map from original node key/value to the replacement merged node. DELETED_NODE as a replacement
# node indications deletion.
replaced_nodes = {}
merged_nodes = []
# To find nodes that require merging, compare each node with each other node.
for a_key, a_value in nodes:
for b_key, b_value in nodes:
# If we've already considered one of the nodes for merging, skip it.
if (a_key, a_value) in replaced_nodes or (b_key, b_value) in replaced_nodes:
continue
def get_node_key_name(node):
return node[0].value
# If the keys match and the values are different, we need to merge these two A and B nodes.
if a_key.tag == b_key.tag and a_key.value == b_key.value and a_value != b_value:
if not type(a_value) is type(b_value):
raise ValueError(
f'Incompatible types found when trying to merge "{a_key.value}:" values across configuration files: {type(a_value).id} and {type(b_value).id}'
# Bucket the nodes by their keys. Then merge all of the values sharing the same key.
for key_name, grouped_nodes in itertools.groupby(
sorted(nodes, key=get_node_key_name), get_node_key_name
):
grouped_nodes = list(grouped_nodes)
# The merged node inherits its attributes from the final node in the group.
(last_node_key, last_node_value) = grouped_nodes[-1]
value_types = set(type(value) for (_, value) in grouped_nodes)
if len(value_types) > 1:
raise ValueError(
f'Incompatible types found when trying to merge "{key_name}:" values across configuration files: {", ".join(value_type.id for value_type in value_types)}'
)
# If we're dealing with MappingNodes, recurse and merge its values as well.
if ruamel.yaml.nodes.MappingNode in value_types:
# A "!retain" tag says to skip deep merging for this node. Replace the tag so
# downstream schema validation doesn't break on our application-specific tag.
if last_node_value.tag == '!retain' and len(grouped_nodes) > 1:
last_node_value.tag = 'tag:yaml.org,2002:map'
merged_nodes.append((last_node_key, last_node_value))
else:
merged_nodes.append(
(
last_node_key,
ruamel.yaml.nodes.MappingNode(
tag=last_node_value.tag,
value=deep_merge_nodes(merge_values(grouped_nodes)),
start_mark=last_node_value.start_mark,
end_mark=last_node_value.end_mark,
flow_style=last_node_value.flow_style,
comment=last_node_value.comment,
anchor=last_node_value.anchor,
),
)
)
# Since we're merging into the B node, consider the A node a duplicate and remove it.
replaced_nodes[(a_key, a_value)] = DELETED_NODE
continue
# If we're dealing with MappingNodes, recurse and merge its values as well.
if isinstance(b_value, ruamel.yaml.nodes.MappingNode):
# A "!retain" tag says to skip deep merging for this node. Replace the tag so
# downstream schema validation doesn't break on our application-specific tag.
if b_value.tag == '!retain':
b_value.tag = 'tag:yaml.org,2002:map'
else:
replaced_nodes[(b_key, b_value)] = (
b_key,
ruamel.yaml.nodes.MappingNode(
tag=b_value.tag,
value=deep_merge_nodes(a_value.value + b_value.value),
start_mark=b_value.start_mark,
end_mark=b_value.end_mark,
flow_style=b_value.flow_style,
comment=b_value.comment,
anchor=b_value.anchor,
),
)
# If we're dealing with SequenceNodes, merge by appending one sequence to the other.
elif isinstance(b_value, ruamel.yaml.nodes.SequenceNode):
# A "!retain" tag says to skip deep merging for this node. Replace the tag so
# downstream schema validation doesn't break on our application-specific tag.
if b_value.tag == '!retain':
b_value.tag = 'tag:yaml.org,2002:seq'
else:
replaced_nodes[(b_key, b_value)] = (
b_key,
ruamel.yaml.nodes.SequenceNode(
tag=b_value.tag,
value=filter_omitted_nodes(a_value.value + b_value.value),
start_mark=b_value.start_mark,
end_mark=b_value.end_mark,
flow_style=b_value.flow_style,
comment=b_value.comment,
anchor=b_value.anchor,
),
)
# If we're dealing with SequenceNodes, merge by appending sequences together.
if ruamel.yaml.nodes.SequenceNode in value_types:
if last_node_value.tag == '!retain' and len(grouped_nodes) > 1:
last_node_value.tag = 'tag:yaml.org,2002:seq'
merged_nodes.append((last_node_key, last_node_value))
else:
merged_nodes.append(
(
last_node_key,
ruamel.yaml.nodes.SequenceNode(
tag=last_node_value.tag,
value=filter_omitted_nodes(grouped_nodes, merge_values(grouped_nodes)),
start_mark=last_node_value.start_mark,
end_mark=last_node_value.end_mark,
flow_style=last_node_value.flow_style,
comment=last_node_value.comment,
anchor=last_node_value.anchor,
),
)
)
return [
replaced_nodes.get(node, node) for node in nodes if replaced_nodes.get(node) != DELETED_NODE
]
continue
merged_nodes.append((last_node_key, last_node_value))
return merged_nodes
+8 -4
View File
@@ -10,7 +10,11 @@ def normalize_sections(config_filename, config):
Raise ValueError if the "prefix" option is set in both "location" and "consistency" sections.
'''
location = config.get('location') or {}
try:
location = config.get('location') or {}
except AttributeError:
raise ValueError('Configuration does not contain any options')
storage = config.get('storage') or {}
consistency = config.get('consistency') or {}
hooks = config.get('hooks') or {}
@@ -35,7 +39,7 @@ def normalize_sections(config_filename, config):
for section_name in ('location', 'storage', 'retention', 'consistency', 'output', 'hooks'):
section_config = config.get(section_name)
if section_config:
if section_config is not None:
any_section_upgraded = True
del config[section_name]
config.update(section_config)
@@ -46,7 +50,7 @@ def normalize_sections(config_filename, config):
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: Configuration sections (like location: and storage:) are deprecated and support will be removed from a future release. To prepare for this, move your options out of sections to the global scope.',
msg=f'{config_filename}: Configuration sections (like location:, storage:, retention:, consistency:, and hooks:) are deprecated and support will be removed from a future release. To prepare for this, move your options out of sections to the global scope.',
)
)
]
@@ -86,7 +90,7 @@ def normalize(config_filename, config):
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The healthchecks hook now expects a mapping value. String values for this option are deprecated and support will be removed from a future release.',
msg=f'{config_filename}: The healthchecks hook now expects a key/value pair with "ping_url" as a key. String values for this option are deprecated and support will be removed from a future release.',
)
)
)
+273 -32
View File
@@ -7,7 +7,7 @@ properties:
type: object
description: |
Constants to use in the configuration file. All occurrences of the
constant name within culy braces will be replaced with the value.
constant name within curly braces will be replaced with the value.
For example, if you have a constant named "hostname" with the value
"myhostname", then the string "{hostname}" will be replaced with
"myhostname" in the configuration file.
@@ -19,8 +19,8 @@ properties:
items:
type: string
description: |
List of source directories and files to backup. Globs and tildes are
expanded. Do not backslash spaces in path names.
List of source directories and files to back up. Globs and tildes
are expanded. Do not backslash spaces in path names.
example:
- /home
- /etc
@@ -29,7 +29,7 @@ properties:
repositories:
type: array
items:
type: object
type: object
required:
- path
properties:
@@ -210,6 +210,13 @@ properties:
"borgmatic restore" from finding any database dumps created before
the change. Defaults to ~/.borgmatic
example: /tmp/borgmatic
store_config_files:
type: boolean
description: |
Store configuration files used to create a backup in the backup
itself. Defaults to true. Changing this to false prevents "borgmatic
bootstrap" from extracting configuration files from the backup.
example: true
source_directories_must_exist:
type: boolean
description: |
@@ -253,7 +260,7 @@ properties:
chunker_params:
type: string
description: |
Specify the parameters passed to then chunker (CHUNK_MIN_EXP,
Specify the parameters passed to the chunker (CHUNK_MIN_EXP,
CHUNK_MAX_EXP, HASH_MASK_BITS, HASH_WINDOW_SIZE). See
https://borgbackup.readthedocs.io/en/stable/internals.html for
details. Defaults to "19,23,21,4095".
@@ -293,7 +300,7 @@ properties:
type: string
description: |
Command to use instead of "ssh". This can be used to specify ssh
options. Defaults to not set.
options. Defaults to not set.
example: ssh -i /path/to/private/key
borg_base_directory:
type: string
@@ -365,19 +372,19 @@ properties:
type: boolean
description: |
Bypass Borg error about a repository that has been moved. Defaults
to false.
to not bypassing.
example: true
unknown_unencrypted_repo_access_is_ok:
type: boolean
description: |
Bypass Borg error about a previously unknown unencrypted repository.
Defaults to false.
Defaults to not bypassing.
example: true
check_i_know_what_i_am_doing:
type: boolean
description: |
Bypass Borg confirmation about check with repair option.
Defaults to false.
Bypass Borg confirmation about check with repair option. Defaults to
an interactive prompt from Borg.
example: true
extra_borg_options:
type: object
@@ -834,10 +841,121 @@ properties:
description: |
List of one or more PostgreSQL databases to dump before creating a
backup, run once per configuration file. The database dumps are
added to your source directories at runtime, backed up, and removed
afterwards. Requires pg_dump/pg_dumpall/pg_restore commands. See
added to your source directories at runtime and streamed directly
to Borg. Requires pg_dump/pg_dumpall/pg_restore commands. See
https://www.postgresql.org/docs/current/app-pgdump.html and
https://www.postgresql.org/docs/current/libpq-ssl.html for details.
https://www.postgresql.org/docs/current/libpq-ssl.html for
details.
mariadb_databases:
type: array
items:
type: object
required: ['name']
additionalProperties: false
properties:
name:
type: string
description: |
Database name (required if using this hook). Or "all" to
dump all databases on the host. Note that using this
database hook implicitly enables both read_special and
one_file_system (see above) to support dump and restore
streaming.
example: users
hostname:
type: string
description: |
Database hostname to connect to. Defaults to connecting
via local Unix socket.
example: database.example.org
restore_hostname:
type: string
description: |
Database hostname to restore to. Defaults to the
"hostname" option.
example: database.example.org
port:
type: integer
description: Port to connect to. Defaults to 3306.
example: 3307
restore_port:
type: integer
description: |
Port to restore to. Defaults to the "port" option.
example: 5433
username:
type: string
description: |
Username with which to connect to the database. Defaults
to the username of the current user.
example: dbuser
restore_username:
type: string
description: |
Username with which to restore the database. Defaults to
the "username" option.
example: dbuser
password:
type: string
description: |
Password with which to connect to the database. Omitting
a password will only work if MariaDB is configured to
trust the configured username without a password.
example: trustsome1
restore_password:
type: string
description: |
Password with which to connect to the restore database.
Defaults to the "password" option.
example: trustsome1
format:
type: string
enum: ['sql']
description: |
Database dump output format. Currently only "sql" is
supported. Defaults to "sql" for a single database. Or,
when database name is "all" and format is blank, dumps
all databases to a single file. But if a format is
specified with an "all" database name, dumps each
database to a separate file of that format, allowing
more convenient restores of individual databases.
example: directory
add_drop_database:
type: boolean
description: |
Use the "--add-drop-database" flag with mariadb-dump,
causing the database to be dropped right before restore.
Defaults to true.
example: false
options:
type: string
description: |
Additional mariadb-dump options to pass directly to the
dump command, without performing any validation on them.
See mariadb-dump documentation for details.
example: --skip-comments
list_options:
type: string
description: |
Additional options to pass directly to the mariadb
command that lists available databases, without
performing any validation on them. See mariadb command
documentation for details.
example: --defaults-extra-file=mariadb.cnf
restore_options:
type: string
description: |
Additional options to pass directly to the mariadb
command that restores database dumps, without
performing any validation on them. See mariadb command
documentation for details.
example: --defaults-extra-file=mariadb.cnf
description: |
List of one or more MariaDB databases to dump before creating a
backup, run once per configuration file. The database dumps are
added to your source directories at runtime and streamed directly
to Borg. Requires mariadb-dump/mariadb commands. See
https://mariadb.com/kb/en/library/mysqldump/ for details.
mysql_databases:
type: array
items:
@@ -886,7 +1004,7 @@ properties:
description: |
Username with which to restore the database. Defaults to
the "username" option.
example: dbuser
example: dbuser
password:
type: string
description: |
@@ -899,7 +1017,7 @@ properties:
description: |
Password with which to connect to the restore database.
Defaults to the "password" option.
example: trustsome1
example: trustsome1
format:
type: string
enum: ['sql']
@@ -929,26 +1047,26 @@ properties:
list_options:
type: string
description: |
Additional mysql options to pass directly to the mysql
Additional options to pass directly to the mysql
command that lists available databases, without
performing any validation on them. See mysql
performing any validation on them. See mysql command
documentation for details.
example: --defaults-extra-file=my.cnf
restore_options:
type: string
description: |
Additional mysql options to pass directly to the mysql
command that restores database dumps, without performing
any validation on them. See mysql documentation for
details.
Additional options to pass directly to the mysql
command that restores database dumps, without
performing any validation on them. See mysql command
documentation for details.
example: --defaults-extra-file=my.cnf
description: |
List of one or more MySQL/MariaDB databases to dump before creating
a backup, run once per configuration file. The database dumps are
added to your source directories at runtime, backed up, and removed
afterwards. Requires mysqldump/mysql commands (from either MySQL or
MariaDB). See https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html
or https://mariadb.com/kb/en/library/mysqldump/ for details.
List of one or more MySQL databases to dump before creating a
backup, run once per configuration file. The database dumps are
added to your source directories at runtime and streamed directly
to Borg. Requires mysqldump/mysql commands. See
https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html for
details.
sqlite_databases:
type: array
items:
@@ -1014,7 +1132,7 @@ properties:
type: integer
description: |
Port to restore to. Defaults to the "port" option.
example: 5433
example: 5433
username:
type: string
description: |
@@ -1026,7 +1144,7 @@ properties:
description: |
Username with which to restore the database. Defaults to
the "username" option.
example: dbuser
example: dbuser
password:
type: string
description: |
@@ -1073,8 +1191,8 @@ properties:
description: |
List of one or more MongoDB databases to dump before creating a
backup, run once per configuration file. The database dumps are
added to your source directories at runtime, backed up, and removed
afterwards. Requires mongodump/mongorestore commands. See
added to your source directories at runtime and streamed directly
to Borg. Requires mongodump/mongorestore commands. See
https://docs.mongodb.com/database-tools/mongodump/ and
https://docs.mongodb.com/database-tools/mongorestore/ for details.
ntfy:
@@ -1188,6 +1306,99 @@ properties:
example:
- start
- finish
apprise:
type: object
required: ['services']
additionalProperties: false
properties:
services:
type: array
items:
type: object
required:
- url
- label
properties:
url:
type: string
example: "gotify://hostname/token"
label:
type: string
example: gotify
description: |
A list of Apprise services to publish to with URLs and
labels. The labels are used for logging. A full list of
services and their configuration can be found at
https://github.com/caronc/apprise/wiki.
example:
- url: "kodi://user@hostname"
label: kodi
- url: "line://Token@User"
label: line
start:
type: object
required: ['body']
properties:
title:
type: string
description: |
Specify the message title. If left unspecified, no
title is sent.
example: Ping!
body:
type: string
description: |
Specify the message body.
example: Starting backup process.
finish:
type: object
required: ['body']
properties:
title:
type: string
description: |
Specify the message title. If left unspecified, no
title is sent.
example: Ping!
body:
type: string
description: |
Specify the message body.
example: Backups successfully made.
fail:
type: object
required: ['body']
properties:
title:
type: string
description: |
Specify the message title. If left unspecified, no
title is sent.
example: Ping!
body:
type: string
description: |
Specify the message body.
example: Your backups have failed.
states:
type: array
items:
type: string
enum:
- start
- finish
- fail
uniqueItems: true
description: |
List of one or more monitoring states to ping for: "start",
"finish", and/or "fail". Defaults to pinging for failure
only. For each selected state, corresponding configuration
for the message title and body should be given. If any is
left unspecified, a generic message is emitted instead.
example:
- start
- finish
healthchecks:
type: object
required: ['ping_url']
@@ -1268,7 +1479,7 @@ properties:
example: a177cad45bd374409f78906a810a3074
description: |
Configuration for a monitoring integration with PagerDuty. Create an
account at https://www.pagerduty.com/ if you'd like to use this
account at https://www.pagerduty.com if you'd like to use this
service. See borgmatic monitoring documentation for details.
cronhub:
type: object
@@ -1285,3 +1496,33 @@ properties:
Configuration for a monitoring integration with Crunhub. Create an
account at https://cronhub.io if you'd like to use this service. See
borgmatic monitoring documentation for details.
loki:
type: object
required: ['url', 'labels']
additionalProperties: false
properties:
url:
type: string
description: |
Grafana loki log URL to notify when a backup begins,
ends, or fails.
example: "http://localhost:3100/loki/api/v1/push"
labels:
type: object
additionalProperties:
type: string
description: |
Allows setting custom labels for the logging stream. At
least one label is required. "__hostname" gets replaced by
the machine hostname automatically. "__config" gets replaced
by just the name of the configuration file. "__config_path"
gets replaced by the full path of the configuration file.
example:
app: "borgmatic"
config: "__config"
hostname: "__hostname"
description: |
Configuration for a monitoring integration with Grafana loki. You
can send the logs to a self-hosted instance or create an account at
https://grafana.com/auth/sign-up/create-user. See borgmatic
monitoring documentation for details.
+1
View File
@@ -134,6 +134,7 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
still_running = True
command = process.args.split(' ') if isinstance(process.args, str) else process.args
# If any process errors, then raise accordingly.
if exit_code_indicates_error(command, exit_code, borg_local_path):
# If an error occurs, include its output in the raised exception so that we don't
+79
View File
@@ -0,0 +1,79 @@
import logging
import operator
logger = logging.getLogger(__name__)
def initialize_monitor(
ping_url, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No initialization is necessary for this monitor.
'''
pass
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
'''
Ping the configured Apprise service URLs. Use the given configuration filename in any log
entries. If this is a dry run, then don't actually ping anything.
'''
try:
import apprise
from apprise import NotifyFormat, NotifyType
except ImportError: # pragma: no cover
logger.warning('Unable to import Apprise in monitoring hook')
return
state_to_notify_type = {
'start': NotifyType.INFO,
'finish': NotifyType.SUCCESS,
'fail': NotifyType.FAILURE,
'log': NotifyType.INFO,
}
run_states = hook_config.get('states', ['fail'])
if state.name.lower() not in run_states:
return
state_config = hook_config.get(
state.name.lower(),
{
'title': f'A borgmatic {state.name} event happened',
'body': f'A borgmatic {state.name} event happened',
},
)
if not hook_config.get('services'):
logger.info(f'{config_filename}: No Apprise services to ping')
return
dry_run_string = ' (dry run; not actually pinging)' if dry_run else ''
labels_string = ', '.join(map(operator.itemgetter('label'), hook_config.get('services')))
logger.info(f'{config_filename}: Pinging Apprise services: {labels_string}{dry_run_string}')
apprise_object = apprise.Apprise()
apprise_object.add(list(map(operator.itemgetter('url'), hook_config.get('services'))))
if dry_run:
return
result = apprise_object.notify(
title=state_config.get('title', ''),
body=state_config.get('body'),
body_format=NotifyFormat.TEXT,
notify_type=state_to_notify_type[state.name.lower()],
)
if result is False:
logger.warning(f'{config_filename}: Error sending some Apprise notifications')
def destroy_monitor(
ping_url_or_uuid, config, config_filename, monitoring_log_level, dry_run
): # pragma: no cover
'''
No destruction is necessary for this monitor.
'''
pass
+6
View File
@@ -1,9 +1,12 @@
import logging
from borgmatic.hooks import (
apprise,
cronhub,
cronitor,
healthchecks,
loki,
mariadb,
mongodb,
mysql,
ntfy,
@@ -15,15 +18,18 @@ from borgmatic.hooks import (
logger = logging.getLogger(__name__)
HOOK_NAME_TO_MODULE = {
'apprise': apprise,
'cronhub': cronhub,
'cronitor': cronitor,
'healthchecks': healthchecks,
'mariadb_databases': mariadb,
'mongodb_databases': mongodb,
'mysql_databases': mysql,
'ntfy': ntfy,
'pagerduty': pagerduty,
'postgresql_databases': postgresql,
'sqlite_databases': sqlite,
'loki': loki,
}
+16 -15
View File
@@ -6,34 +6,35 @@ from borgmatic.borg.state import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
logger = logging.getLogger(__name__)
DATABASE_HOOK_NAMES = (
'postgresql_databases',
DATA_SOURCE_HOOK_NAMES = (
'mariadb_databases',
'mysql_databases',
'mongodb_databases',
'postgresql_databases',
'sqlite_databases',
)
def make_database_dump_path(borgmatic_source_directory, database_hook_name):
def make_data_source_dump_path(borgmatic_source_directory, data_source_hook_name):
'''
Given a borgmatic source directory (or None) and a database hook name, construct a database dump
path.
Given a borgmatic source directory (or None) and a data source hook name, construct a data
source dump path.
'''
if not borgmatic_source_directory:
borgmatic_source_directory = DEFAULT_BORGMATIC_SOURCE_DIRECTORY
return os.path.join(borgmatic_source_directory, database_hook_name)
return os.path.join(borgmatic_source_directory, data_source_hook_name)
def make_database_dump_filename(dump_path, name, hostname=None):
def make_data_source_dump_filename(dump_path, name, hostname=None):
'''
Based on the given dump directory path, database name, and hostname, return a filename to use
for the database dump. The hostname defaults to localhost.
Based on the given dump directory path, data source name, and hostname, return a filename to use
for the data source dump. The hostname defaults to localhost.
Raise ValueError if the database name is invalid.
Raise ValueError if the data source name is invalid.
'''
if os.path.sep in name:
raise ValueError(f'Invalid database name {name}')
raise ValueError(f'Invalid data source name {name}')
return os.path.join(os.path.expanduser(dump_path), hostname or 'localhost', name)
@@ -53,14 +54,14 @@ def create_named_pipe_for_dump(dump_path):
os.mkfifo(dump_path, mode=0o600)
def remove_database_dumps(dump_path, database_type_name, log_prefix, dry_run):
def remove_data_source_dumps(dump_path, data_source_type_name, log_prefix, dry_run):
'''
Remove all database dumps in the given dump directory path (including the directory itself). If
this is a dry run, then don't actually remove anything.
Remove all data source dumps in the given dump directory path (including the directory itself).
If this is a dry run, then don't actually remove anything.
'''
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
logger.debug(f'{log_prefix}: Removing {database_type_name} database dumps{dry_run_label}')
logger.debug(f'{log_prefix}: Removing {data_source_type_name} data source dumps{dry_run_label}')
expanded_path = os.path.expanduser(dump_path)
+154
View File
@@ -0,0 +1,154 @@
import json
import logging
import os
import platform
import time
import requests
from borgmatic.hooks import monitor
logger = logging.getLogger(__name__)
MONITOR_STATE_TO_LOKI = {
monitor.State.START: 'Started',
monitor.State.FINISH: 'Finished',
monitor.State.FAIL: 'Failed',
}
# Threshold at which logs get flushed to loki
MAX_BUFFER_LINES = 100
class Loki_log_buffer:
'''
A log buffer that allows to output the logs as loki requests in json. Allows
adding labels to the log stream and takes care of communication with loki.
'''
def __init__(self, url, dry_run):
self.url = url
self.dry_run = dry_run
self.root = {'streams': [{'stream': {}, 'values': []}]}
def add_value(self, value):
'''
Add a log entry to the stream.
'''
timestamp = str(time.time_ns())
self.root['streams'][0]['values'].append((timestamp, value))
def add_label(self, label, value):
'''
Add a label to the logging stream.
'''
self.root['streams'][0]['stream'][label] = value
def to_request(self):
return json.dumps(self.root)
def __len__(self):
'''
Gets the number of lines currently in the buffer.
'''
return len(self.root['streams'][0]['values'])
def flush(self):
if self.dry_run:
# Just empty the buffer and skip
self.root['streams'][0]['values'] = []
logger.info('Skipped uploading logs to loki due to dry run')
return
if len(self) == 0:
# Skip as there are not logs to send yet
return
request_body = self.to_request()
self.root['streams'][0]['values'] = []
request_header = {'Content-Type': 'application/json'}
try:
result = requests.post(self.url, headers=request_header, data=request_body, timeout=5)
result.raise_for_status()
except requests.RequestException:
logger.warning('Failed to upload logs to loki')
class Loki_log_handler(logging.Handler):
'''
A log handler that sends logs to loki.
'''
def __init__(self, url, dry_run):
super().__init__()
self.buffer = Loki_log_buffer(url, dry_run)
def emit(self, record):
'''
Add a log record from the logging module to the stream.
'''
self.raw(record.getMessage())
def add_label(self, key, value):
'''
Add a label to the logging stream.
'''
self.buffer.add_label(key, value)
def raw(self, msg):
'''
Add an arbitrary string as a log entry to the stream.
'''
self.buffer.add_value(msg)
if len(self.buffer) > MAX_BUFFER_LINES:
self.buffer.flush()
def flush(self):
'''
Send the logs to loki and empty the buffer.
'''
self.buffer.flush()
def initialize_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Add a handler to the root logger to regularly send the logs to loki.
'''
url = hook_config.get('url')
loki = Loki_log_handler(url, dry_run)
for key, value in hook_config.get('labels').items():
if value == '__hostname':
loki.add_label(key, platform.node())
elif value == '__config':
loki.add_label(key, os.path.basename(config_filename))
elif value == '__config_path':
loki.add_label(key, config_filename)
else:
loki.add_label(key, value)
logging.getLogger().addHandler(loki)
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
'''
Add an entry to the loki logger with the current state.
'''
for handler in tuple(logging.getLogger().handlers):
if isinstance(handler, Loki_log_handler):
if state in MONITOR_STATE_TO_LOKI.keys():
handler.raw(f'{config_filename}: {MONITOR_STATE_TO_LOKI[state]} backup')
def destroy_monitor(hook_config, config, config_filename, monitoring_log_level, dry_run):
'''
Remove the monitor handler that was added to the root logger.
'''
logger = logging.getLogger()
for handler in tuple(logger.handlers):
if isinstance(handler, Loki_log_handler):
handler.flush()
logger.removeHandler(handler)
+237
View File
@@ -0,0 +1,237 @@
import copy
import logging
import os
from borgmatic.execute import (
execute_command,
execute_command_and_capture_output,
execute_command_with_processes,
)
from borgmatic.hooks import dump
logger = logging.getLogger(__name__)
def make_dump_path(config): # pragma: no cover
'''
Make the dump path from the given configuration dict and the name of this hook.
'''
return dump.make_data_source_dump_path(
config.get('borgmatic_source_directory'), 'mariadb_databases'
)
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
'''
Given a requested database config, return the corresponding sequence of database names to dump.
In the case of "all", query for the names of databases on the configured host and return them,
excluding any system databases that will cause problems during restore.
'''
if database['name'] != 'all':
return (database['name'],)
if dry_run:
return ()
show_command = (
('mariadb',)
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
+ ('--skip-column-names', '--batch')
+ ('--execute', 'show schemas')
)
logger.debug(f'{log_prefix}: Querying for "all" MariaDB databases to dump')
show_output = execute_command_and_capture_output(
show_command, extra_environment=extra_environment
)
return tuple(
show_name
for show_name in show_output.strip().splitlines()
if show_name not in SYSTEM_DATABASE_NAMES
)
def execute_dump_command(
database, log_prefix, dump_path, database_names, extra_environment, dry_run, dry_run_label
):
'''
Kick off a dump for the given MariaDB database (provided as a configuration dict) to a named
pipe constructed from the given dump path and database name. Use the given log prefix in any
log entries.
Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if
this is a dry run, then don't actually dump anything and return None.
'''
database_name = database['name']
dump_filename = dump.make_data_source_dump_filename(
dump_path, database['name'], database.get('hostname')
)
if os.path.exists(dump_filename):
logger.warning(
f'{log_prefix}: Skipping duplicate dump of MariaDB database "{database_name}" to {dump_filename}'
)
return None
dump_command = (
('mariadb-dump',)
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
+ (('--add-drop-database',) if database.get('add_drop_database', True) else ())
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
+ ('--databases',)
+ database_names
+ ('--result-file', dump_filename)
)
logger.debug(
f'{log_prefix}: Dumping MariaDB database "{database_name}" to {dump_filename}{dry_run_label}'
)
if dry_run:
return None
dump.create_named_pipe_for_dump(dump_filename)
return execute_command(
dump_command,
extra_environment=extra_environment,
run_to_completion=False,
)
def dump_data_sources(databases, config, log_prefix, dry_run):
'''
Dump the given MariaDB databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given
configuration dict to construct the destination path and the given log prefix in any log
entries.
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
'''
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
processes = []
logger.info(f'{log_prefix}: Dumping MariaDB databases{dry_run_label}')
for database in databases:
dump_path = make_dump_path(config)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
dump_database_names = database_names_to_dump(
database, extra_environment, log_prefix, dry_run
)
if not dump_database_names:
if dry_run:
continue
raise ValueError('Cannot find any MariaDB databases to dump.')
if database['name'] == 'all' and database.get('format'):
for dump_name in dump_database_names:
renamed_database = copy.copy(database)
renamed_database['name'] = dump_name
processes.append(
execute_dump_command(
renamed_database,
log_prefix,
dump_path,
(dump_name,),
extra_environment,
dry_run,
dry_run_label,
)
)
else:
processes.append(
execute_dump_command(
database,
log_prefix,
dump_path,
dump_database_names,
extra_environment,
dry_run,
dry_run_label,
)
)
return [process for process in processes if process]
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the given
configuration dict to construct the destination path and the log prefix in any log entries. If
this is a dry run, then don't actually remove anything.
'''
dump.remove_data_source_dumps(make_dump_path(config), 'MariaDB', log_prefix, dry_run)
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
database name to match, return the corresponding glob patterns to match the database dump in an
archive.
'''
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
def restore_data_source_dump(
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
):
'''
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given configuration dict is
used to construct the destination path, and the given log prefix is used for any log entries. If
this is a dry run, then don't actually restore anything. Trigger the given active extract
process (an instance of subprocess.Popen) to produce output to consume.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
hostname = connection_params['hostname'] or data_source.get(
'restore_hostname', data_source.get('hostname')
)
port = str(
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
)
username = connection_params['username'] or data_source.get(
'restore_username', data_source.get('username')
)
password = connection_params['password'] or data_source.get(
'restore_password', data_source.get('password')
)
restore_command = (
('mariadb', '--batch')
+ (
tuple(data_source['restore_options'].split(' '))
if 'restore_options' in data_source
else ()
)
+ (('--host', hostname) if hostname else ())
+ (('--port', str(port)) if port else ())
+ (('--protocol', 'tcp') if hostname or port else ())
+ (('--user', username) if username else ())
)
extra_environment = {'MYSQL_PWD': password} if password else None
logger.debug(f"{log_prefix}: Restoring MariaDB database {data_source['name']}{dry_run_label}")
if dry_run:
return
# Don't give Borg local path so as to error on warnings, as "borg extract" only gives a warning
# if the restore paths don't exist in the archive.
execute_command_with_processes(
restore_command,
[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment=extra_environment,
)
+38 -53
View File
@@ -10,12 +10,12 @@ def make_dump_path(config): # pragma: no cover
'''
Make the dump path from the given configuration dict and the name of this hook.
'''
return dump.make_database_dump_path(
return dump.make_data_source_dump_path(
config.get('borgmatic_source_directory'), 'mongodb_databases'
)
def dump_databases(databases, config, log_prefix, dry_run):
def dump_data_sources(databases, config, log_prefix, dry_run):
'''
Dump the given MongoDB databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the configuration
@@ -31,7 +31,7 @@ def dump_databases(databases, config, log_prefix, dry_run):
processes = []
for database in databases:
name = database['name']
dump_filename = dump.make_database_dump_filename(
dump_filename = dump.make_data_source_dump_filename(
make_dump_path(config), name, database.get('hostname')
)
dump_format = database.get('format', 'archive')
@@ -59,81 +59,65 @@ def build_dump_command(database, dump_filename, dump_format):
Return the mongodump command from a single database configuration.
'''
all_databases = database['name'] == 'all'
command = ['mongodump']
if dump_format == 'directory':
command.extend(('--out', dump_filename))
if 'hostname' in database:
command.extend(('--host', database['hostname']))
if 'port' in database:
command.extend(('--port', str(database['port'])))
if 'username' in database:
command.extend(('--username', database['username']))
if 'password' in database:
command.extend(('--password', database['password']))
if 'authentication_database' in database:
command.extend(('--authenticationDatabase', database['authentication_database']))
if not all_databases:
command.extend(('--db', database['name']))
if 'options' in database:
command.extend(database['options'].split(' '))
if dump_format != 'directory':
command.extend(('--archive', '>', dump_filename))
return command
return (
('mongodump',)
+ (('--out', dump_filename) if dump_format == 'directory' else ())
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--username', database['username']) if 'username' in database else ())
+ (('--password', database['password']) if 'password' in database else ())
+ (
('--authenticationDatabase', database['authentication_database'])
if 'authentication_database' in database
else ()
)
+ (('--db', database['name']) if not all_databases else ())
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
+ (('--archive', '>', dump_filename) if dump_format != 'directory' else ())
)
def remove_database_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the log
prefix in any log entries. Use the given configuration dict to construct the destination path.
If this is a dry run, then don't actually remove anything.
'''
dump.remove_database_dumps(make_dump_path(config), 'MongoDB', log_prefix, dry_run)
dump.remove_data_source_dumps(make_dump_path(config), 'MongoDB', log_prefix, dry_run)
def make_database_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
'''
Given a sequence of database configurations dicts, a configuration dict, a prefix to log with,
and a database name to match, return the corresponding glob patterns to match the database dump
in an archive.
'''
return dump.make_database_dump_filename(make_dump_path(config), name, hostname='*')
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
def restore_database_dump(
databases_config, config, log_prefix, database_name, dry_run, extract_process, connection_params
def restore_data_source_dump(
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
):
'''
Restore the given MongoDB database from an extract stream. The databases are supplied as a
sequence containing one dict describing each database (as per the configuration schema), but
only the database corresponding to the given database name is restored. Use the configuration
dict to construct the destination path and the given log prefix in any log entries. If this is a
dry run, then don't actually restore anything. Trigger the given active extract process (an
instance of subprocess.Popen) to produce output to consume.
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given configuration dict is
used to construct the destination path, and the given log prefix is used for any log entries. If
this is a dry run, then don't actually restore anything. Trigger the given active extract
process (an instance of subprocess.Popen) to produce output to consume.
If the extract process is None, then restore the dump from the filesystem rather than from an
extract stream.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
try:
database = next(
database_config
for database_config in databases_config
if database_config.get('name') == database_name
)
except StopIteration:
raise ValueError(
f'A database named "{database_name}" could not be found in the configuration'
)
dump_filename = dump.make_database_dump_filename(
make_dump_path(config), database['name'], database.get('hostname')
dump_filename = dump.make_data_source_dump_filename(
make_dump_path(config), data_source['name'], data_source.get('hostname')
)
restore_command = build_restore_command(
extract_process, database, dump_filename, connection_params
extract_process, data_source, dump_filename, connection_params
)
logger.debug(f"{log_prefix}: Restoring MongoDB database {database['name']}{dry_run_label}")
logger.debug(f"{log_prefix}: Restoring MongoDB database {data_source['name']}{dry_run_label}")
if dry_run:
return
@@ -168,7 +152,7 @@ def build_restore_command(extract_process, database, dump_filename, connection_p
else:
command.extend(('--dir', dump_filename))
if database['name'] != 'all':
command.extend(('--drop', '--db', database['name']))
command.extend(('--drop',))
if hostname:
command.extend(('--host', hostname))
if port:
@@ -181,7 +165,8 @@ def build_restore_command(extract_process, database, dump_filename, connection_p
command.extend(('--authenticationDatabase', database['authentication_database']))
if 'restore_options' in database:
command.extend(database['restore_options'].split(' '))
if database['schemas']:
if database.get('schemas'):
for schema in database['schemas']:
command.extend(('--nsInclude', schema))
return command
+1 -1
View File
@@ -1,6 +1,6 @@
from enum import Enum
MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy')
MONITOR_HOOK_NAMES = ('apprise', 'healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki')
class State(Enum):
+33 -36
View File
@@ -16,7 +16,9 @@ def make_dump_path(config): # pragma: no cover
'''
Make the dump path from the given configuration dict and the name of this hook.
'''
return dump.make_database_dump_path(config.get('borgmatic_source_directory'), 'mysql_databases')
return dump.make_data_source_dump_path(
config.get('borgmatic_source_directory'), 'mysql_databases'
)
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
@@ -60,16 +62,17 @@ def execute_dump_command(
):
'''
Kick off a dump for the given MySQL/MariaDB database (provided as a configuration dict) to a
named pipe constructed from the given dump path and database names. Use the given log prefix in
named pipe constructed from the given dump path and database name. Use the given log prefix in
any log entries.
Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if
this is a dry run, then don't actually dump anything and return None.
'''
database_name = database['name']
dump_filename = dump.make_database_dump_filename(
dump_filename = dump.make_data_source_dump_filename(
dump_path, database['name'], database.get('hostname')
)
if os.path.exists(dump_filename):
logger.warning(
f'{log_prefix}: Skipping duplicate dump of MySQL database "{database_name}" to {dump_filename}'
@@ -104,7 +107,7 @@ def execute_dump_command(
)
def dump_databases(databases, config, log_prefix, dry_run):
def dump_data_sources(databases, config, log_prefix, dry_run):
'''
Dump the given MySQL/MariaDB databases to a named pipe. The databases are supplied as a sequence
of dicts, one dict describing each database as per the configuration schema. Use the given
@@ -162,61 +165,55 @@ def dump_databases(databases, config, log_prefix, dry_run):
return [process for process in processes if process]
def remove_database_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the given
configuration dict to construct the destination path and the log prefix in any log entries. If
this is a dry run, then don't actually remove anything.
'''
dump.remove_database_dumps(make_dump_path(config), 'MySQL', log_prefix, dry_run)
dump.remove_data_source_dumps(make_dump_path(config), 'MySQL', log_prefix, dry_run)
def make_database_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
database name to match, return the corresponding glob patterns to match the database dump in an
archive.
'''
return dump.make_database_dump_filename(make_dump_path(config), name, hostname='*')
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
def restore_database_dump(
databases_config, config, log_prefix, database_name, dry_run, extract_process, connection_params
def restore_data_source_dump(
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
):
'''
Restore the given MySQL/MariaDB database from an extract stream. The databases are supplied as a
sequence containing one dict describing each database (as per the configuration schema), but
only the database corresponding to the given database name is restored. Use the given log
prefix in any log entries. If this is a dry run, then don't actually restore anything. Trigger
the given active extract process (an instance of subprocess.Popen) to produce output to consume.
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given configuration dict is
used to construct the destination path, and the given log prefix is used for any log entries. If
this is a dry run, then don't actually restore anything. Trigger the given active extract
process (an instance of subprocess.Popen) to produce output to consume.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
try:
database = next(
database_config
for database_config in databases_config
if database_config.get('name') == database_name
)
except StopIteration:
raise ValueError(
f'A database named "{database_name}" could not be found in the configuration'
)
hostname = connection_params['hostname'] or database.get(
'restore_hostname', database.get('hostname')
hostname = connection_params['hostname'] or data_source.get(
'restore_hostname', data_source.get('hostname')
)
port = str(connection_params['port'] or database.get('restore_port', database.get('port', '')))
username = connection_params['username'] or database.get(
'restore_username', database.get('username')
port = str(
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
)
password = connection_params['password'] or database.get(
'restore_password', database.get('password')
username = connection_params['username'] or data_source.get(
'restore_username', data_source.get('username')
)
password = connection_params['password'] or data_source.get(
'restore_password', data_source.get('password')
)
restore_command = (
('mysql', '--batch')
+ (tuple(database['restore_options'].split(' ')) if 'restore_options' in database else ())
+ (
tuple(data_source['restore_options'].split(' '))
if 'restore_options' in data_source
else ()
)
+ (('--host', hostname) if hostname else ())
+ (('--port', str(port)) if port else ())
+ (('--protocol', 'tcp') if hostname or port else ())
@@ -224,7 +221,7 @@ def restore_database_dump(
)
extra_environment = {'MYSQL_PWD': password} if password else None
logger.debug(f"{log_prefix}: Restoring MySQL database {database['name']}{dry_run_label}")
logger.debug(f"{log_prefix}: Restoring MySQL database {data_source['name']}{dry_run_label}")
if dry_run:
return
+2 -2
View File
@@ -28,8 +28,8 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
state_config = hook_config.get(
state.name.lower(),
{
'title': f'A Borgmatic {state.name} event happened',
'message': f'A Borgmatic {state.name} event happened',
'title': f'A borgmatic {state.name} event happened',
'message': f'A borgmatic {state.name} event happened',
'priority': 'default',
'tags': 'borgmatic',
},
+46 -47
View File
@@ -18,7 +18,7 @@ def make_dump_path(config): # pragma: no cover
'''
Make the dump path from the given configuration dict and the name of this hook.
'''
return dump.make_database_dump_path(
return dump.make_data_source_dump_path(
config.get('borgmatic_source_directory'), 'postgresql_databases'
)
@@ -92,7 +92,7 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run):
)
def dump_databases(databases, config, log_prefix, dry_run):
def dump_data_sources(databases, config, log_prefix, dry_run):
'''
Dump the given PostgreSQL databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given
@@ -126,7 +126,7 @@ def dump_databases(databases, config, log_prefix, dry_run):
dump_format = database.get('format', None if database_name == 'all' else 'custom')
default_dump_command = 'pg_dumpall' if database_name == 'all' else 'pg_dump'
dump_command = database.get('pg_dump_command') or default_dump_command
dump_filename = dump.make_database_dump_filename(
dump_filename = dump.make_data_source_dump_filename(
dump_path, database_name, database.get('hostname')
)
if os.path.exists(dump_filename):
@@ -183,34 +183,33 @@ def dump_databases(databases, config, log_prefix, dry_run):
return processes
def remove_database_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
'''
Remove all database dump files for this hook regardless of the given databases. Use the given
configuration dict to construct the destination path and the log prefix in any log entries. If
this is a dry run, then don't actually remove anything.
'''
dump.remove_database_dumps(make_dump_path(config), 'PostgreSQL', log_prefix, dry_run)
dump.remove_data_source_dumps(make_dump_path(config), 'PostgreSQL', log_prefix, dry_run)
def make_database_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
'''
Given a sequence of configurations dicts, a configuration dict, a prefix to log with, and a
database name to match, return the corresponding glob patterns to match the database dump in an
archive.
'''
return dump.make_database_dump_filename(make_dump_path(config), name, hostname='*')
return dump.make_data_source_dump_filename(make_dump_path(config), name, hostname='*')
def restore_database_dump(
databases_config, config, log_prefix, database_name, dry_run, extract_process, connection_params
def restore_data_source_dump(
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
):
'''
Restore the given PostgreSQL database from an extract stream. The databases are supplied as a
sequence containing one dict describing each database (as per the configuration schema), but
only the database corresponding to the given database name is restored. Use the given
configuration dict to construct the destination path and the given log prefix in any log
entries. If this is a dry run, then don't actually restore anything. Trigger the given active
extract process (an instance of subprocess.Popen) to produce output to consume.
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given configuration dict is
used to construct the destination path, and the given log prefix is used for any log entries. If
this is a dry run, then don't actually restore anything. Trigger the given active extract
process (an instance of subprocess.Popen) to produce output to consume.
If the extract process is None, then restore the dump from the filesystem rather than from an
extract stream.
@@ -219,66 +218,66 @@ def restore_database_dump(
hostname, port, username, and password.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
try:
database = next(
database_config
for database_config in databases_config
if database_config.get('name') == database_name
)
except StopIteration:
raise ValueError(
f'A database named "{database_name}" could not be found in the configuration'
)
hostname = connection_params['hostname'] or database.get(
'restore_hostname', database.get('hostname')
hostname = connection_params['hostname'] or data_source.get(
'restore_hostname', data_source.get('hostname')
)
port = str(connection_params['port'] or database.get('restore_port', database.get('port', '')))
username = connection_params['username'] or database.get(
'restore_username', database.get('username')
port = str(
connection_params['port'] or data_source.get('restore_port', data_source.get('port', ''))
)
username = connection_params['username'] or data_source.get(
'restore_username', data_source.get('username')
)
all_databases = bool(database['name'] == 'all')
dump_filename = dump.make_database_dump_filename(
make_dump_path(config), database['name'], database.get('hostname')
all_databases = bool(data_source['name'] == 'all')
dump_filename = dump.make_data_source_dump_filename(
make_dump_path(config), data_source['name'], data_source.get('hostname')
)
psql_command = shlex.split(database.get('psql_command') or 'psql')
psql_command = shlex.split(data_source.get('psql_command') or 'psql')
analyze_command = (
tuple(psql_command)
+ ('--no-password', '--no-psqlrc', '--quiet')
+ (('--host', hostname) if hostname else ())
+ (('--port', port) if port else ())
+ (('--username', username) if username else ())
+ (('--dbname', database['name']) if not all_databases else ())
+ (tuple(database['analyze_options'].split(' ')) if 'analyze_options' in database else ())
+ (('--dbname', data_source['name']) if not all_databases else ())
+ (
tuple(data_source['analyze_options'].split(' '))
if 'analyze_options' in data_source
else ()
)
+ ('--command', 'ANALYZE')
)
use_psql_command = all_databases or database.get('format') == 'plain'
pg_restore_command = shlex.split(database.get('pg_restore_command') or 'pg_restore')
use_psql_command = all_databases or data_source.get('format') == 'plain'
pg_restore_command = shlex.split(data_source.get('pg_restore_command') or 'pg_restore')
restore_command = (
tuple(psql_command if use_psql_command else pg_restore_command)
+ ('--no-password',)
+ (('--no-psqlrc',) if use_psql_command else ('--if-exists', '--exit-on-error', '--clean'))
+ (('--dbname', database['name']) if not all_databases else ())
+ (('--dbname', data_source['name']) if not all_databases else ())
+ (('--host', hostname) if hostname else ())
+ (('--port', port) if port else ())
+ (('--username', username) if username else ())
+ (('--no-owner',) if database.get('no_owner', False) else ())
+ (tuple(database['restore_options'].split(' ')) if 'restore_options' in database else ())
+ (('--no-owner',) if data_source.get('no_owner', False) else ())
+ (
tuple(data_source['restore_options'].split(' '))
if 'restore_options' in data_source
else ()
)
+ (() if extract_process else (dump_filename,))
+ tuple(
itertools.chain.from_iterable(('--schema', schema) for schema in database['schemas'])
if database.get('schemas')
itertools.chain.from_iterable(('--schema', schema) for schema in data_source['schemas'])
if data_source.get('schemas')
else ()
)
)
extra_environment = make_extra_environment(
database, restore_connection_params=connection_params
data_source, restore_connection_params=connection_params
)
logger.debug(f"{log_prefix}: Restoring PostgreSQL database {database['name']}{dry_run_label}")
logger.debug(
f"{log_prefix}: Restoring PostgreSQL database {data_source['name']}{dry_run_label}"
)
if dry_run:
return
+18 -30
View File
@@ -11,12 +11,12 @@ def make_dump_path(config): # pragma: no cover
'''
Make the dump path from the given configuration dict and the name of this hook.
'''
return dump.make_database_dump_path(
return dump.make_data_source_dump_path(
config.get('borgmatic_source_directory'), 'sqlite_databases'
)
def dump_databases(databases, config, log_prefix, dry_run):
def dump_data_sources(databases, config, log_prefix, dry_run):
'''
Dump the given SQLite3 databases to a file. The databases are supplied as a sequence of
configuration dicts, as per the configuration schema. Use the given configuration dict to
@@ -32,14 +32,14 @@ def dump_databases(databases, config, log_prefix, dry_run):
database_path = database['path']
if database['name'] == 'all':
logger.warning('The "all" database name has no meaning for SQLite3 databases')
logger.warning('The "all" database name has no meaning for SQLite databases')
if not os.path.exists(database_path):
logger.warning(
f'{log_prefix}: No SQLite database at {database_path}; An empty database will be created and dumped'
f'{log_prefix}: No SQLite database at {database_path}; an empty database will be created and dumped'
)
dump_path = make_dump_path(config)
dump_filename = dump.make_database_dump_filename(dump_path, database['name'])
dump_filename = dump.make_data_source_dump_filename(dump_path, database['name'])
if os.path.exists(dump_filename):
logger.warning(
f'{log_prefix}: Skipping duplicate dump of SQLite database at {database_path} to {dump_filename}'
@@ -65,49 +65,37 @@ def dump_databases(databases, config, log_prefix, dry_run):
return processes
def remove_database_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
def remove_data_source_dumps(databases, config, log_prefix, dry_run): # pragma: no cover
'''
Remove the given SQLite3 database dumps from the filesystem. The databases are supplied as a
sequence of configuration dicts, as per the configuration schema. Use the given configuration
dict to construct the destination path and the given log prefix in any log entries. If this is a
dry run, then don't actually remove anything.
'''
dump.remove_database_dumps(make_dump_path(config), 'SQLite', log_prefix, dry_run)
dump.remove_data_source_dumps(make_dump_path(config), 'SQLite', log_prefix, dry_run)
def make_database_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
def make_data_source_dump_pattern(databases, config, log_prefix, name=None): # pragma: no cover
'''
Make a pattern that matches the given SQLite3 databases. The databases are supplied as a
sequence of configuration dicts, as per the configuration schema.
'''
return dump.make_database_dump_filename(make_dump_path(config), name)
return dump.make_data_source_dump_filename(make_dump_path(config), name)
def restore_database_dump(
databases_config, config, log_prefix, database_name, dry_run, extract_process, connection_params
def restore_data_source_dump(
hook_config, config, log_prefix, data_source, dry_run, extract_process, connection_params
):
'''
Restore the given SQLite3 database from an extract stream. The databases are supplied as a
sequence containing one dict describing each database (as per the configuration schema), but
only the database corresponding to the given database name is restored. Use the given log prefix
in any log entries. If this is a dry run, then don't actually restore anything. Trigger the
given active extract process (an instance of subprocess.Popen) to produce output to consume.
Restore a database from the given extract stream. The database is supplied as a data source
configuration dict, but the given hook configuration is ignored. The given configuration dict is
used to construct the destination path, and the given log prefix is used for any log entries. If
this is a dry run, then don't actually restore anything. Trigger the given active extract
process (an instance of subprocess.Popen) to produce output to consume.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
try:
database = next(
database_config
for database_config in databases_config
if database_config.get('name') == database_name
)
except StopIteration:
raise ValueError(
f'A database named "{database_name}" could not be found in the configuration'
)
database_path = connection_params['restore_path'] or database.get(
'restore_path', database.get('path')
database_path = connection_params['restore_path'] or data_source.get(
'restore_path', data_source.get('path')
)
logger.debug(f'{log_prefix}: Restoring SQLite database at {database_path}{dry_run_label}')
+19 -17
View File
@@ -166,15 +166,15 @@ def configure_logging(
Raise FileNotFoundError or PermissionError if the log file could not be opened for writing.
'''
add_custom_log_levels()
if syslog_log_level is None:
syslog_log_level = console_log_level
syslog_log_level = logging.DISABLED
if log_file_log_level is None:
log_file_log_level = console_log_level
if monitoring_log_level is None:
monitoring_log_level = console_log_level
add_custom_log_levels()
# Log certain log levels to console stderr and others to stdout. This supports use cases like
# grepping (non-error) output.
console_disabled = logging.NullHandler()
@@ -194,8 +194,11 @@ def configure_logging(
console_handler.setFormatter(Console_color_formatter())
console_handler.setLevel(console_log_level)
syslog_path = None
if log_file is None and syslog_log_level != logging.DISABLED:
handlers = [console_handler]
if syslog_log_level != logging.DISABLED:
syslog_path = None
if os.path.exists('/dev/log'):
syslog_path = '/dev/log'
elif os.path.exists('/var/run/syslog'):
@@ -203,14 +206,15 @@ def configure_logging(
elif os.path.exists('/var/run/log'):
syslog_path = '/var/run/log'
if syslog_path and not interactive_console():
syslog_handler = logging.handlers.SysLogHandler(address=syslog_path)
syslog_handler.setFormatter(
logging.Formatter('borgmatic: {levelname} {message}', style='{') # noqa: FS003
)
syslog_handler.setLevel(syslog_log_level)
handlers = (console_handler, syslog_handler)
elif log_file and log_file_log_level != logging.DISABLED:
if syslog_path:
syslog_handler = logging.handlers.SysLogHandler(address=syslog_path)
syslog_handler.setFormatter(
logging.Formatter('borgmatic: {levelname} {message}', style='{') # noqa: FS003
)
syslog_handler.setLevel(syslog_log_level)
handlers.append(syslog_handler)
if log_file and log_file_log_level != logging.DISABLED:
file_handler = logging.handlers.WatchedFileHandler(log_file)
file_handler.setFormatter(
logging.Formatter(
@@ -218,11 +222,9 @@ def configure_logging(
)
)
file_handler.setLevel(log_file_log_level)
handlers = (console_handler, file_handler)
else:
handlers = (console_handler,)
handlers.append(file_handler)
logging.basicConfig(
level=min(console_log_level, syslog_log_level, log_file_log_level, monitoring_log_level),
level=min(handler.level for handler in handlers),
handlers=handlers,
)
+10 -2
View File
@@ -23,12 +23,20 @@ def handle_signal(signal_number, frame):
if signal_number == signal.SIGTERM:
logger.critical('Exiting due to TERM signal')
sys.exit(EXIT_CODE_FROM_SIGNAL + signal.SIGTERM)
elif signal_number == signal.SIGINT:
raise KeyboardInterrupt()
def configure_signals():
'''
Configure borgmatic's signal handlers to pass relevant signals through to any child processes
like Borg. Note that SIGINT gets passed through even without these changes.
like Borg.
'''
for signal_number in (signal.SIGHUP, signal.SIGTERM, signal.SIGUSR1, signal.SIGUSR2):
for signal_number in (
signal.SIGHUP,
signal.SIGINT,
signal.SIGTERM,
signal.SIGUSR1,
signal.SIGUSR2,
):
signal.signal(signal_number, handle_signal)
+1 -1
View File
@@ -16,4 +16,4 @@ each.
If you find a security vulnerability, please [file a
ticket](https://torsion.org/borgmatic/#issues) or [send email
directly](mailto:witten@torsion.org) as appropriate. You should expect to hear
back within a few days at most, and generally sooner.
back within a few days at most and generally sooner.
@@ -17,7 +17,7 @@ feature](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/)
instead.
You can specify `before_backup` hooks to perform preparation steps before
running backups, and specify `after_backup` hooks to perform cleanup steps
running backups and specify `after_backup` hooks to perform cleanup steps
afterwards. Here's an example:
```yaml
+74 -22
View File
@@ -15,7 +15,7 @@ consistent snapshot that is more suited for backups.
Fortunately, borgmatic includes built-in support for creating database dumps
prior to running backups. For example, here is everything you need to dump and
backup a couple of local PostgreSQL databases and a MySQL/MariaDB database.
backup a couple of local PostgreSQL databases and a MySQL database.
```yaml
postgresql_databases:
@@ -46,6 +46,16 @@ sqlite_databases:
path: /var/lib/sqlite3/mydb.sqlite
```
<span class="minilink minilink-addedin">New in version 1.8.2</span> If you're
using MariaDB, use the MariaDB database hook instead of `mysql_databases:` as
the MariaDB hook calls native MariaDB commands instead of the deprecated MySQL
ones. For instance:
```yaml
mariadb_databases:
- name: comments
```
As part of each backup, borgmatic streams a database dump for each configured
database directly to Borg, so it's included in the backup without consuming
additional disk space. (The exceptions are the PostgreSQL/MongoDB "directory"
@@ -75,16 +85,23 @@ postgresql_databases:
password: trustsome1
format: tar
options: "--role=someone"
mariadb_databases:
- name: photos
hostname: database3.example.org
port: 3307
username: root
password: trustsome1
options: "--skip-comments"
mysql_databases:
- name: posts
hostname: database3.example.org
hostname: database4.example.org
port: 3307
username: root
password: trustsome1
options: "--skip-comments"
mongodb_databases:
- name: messages
hostname: database4.example.org
hostname: database5.example.org
port: 27018
username: dbuser
password: trustsome1
@@ -108,6 +125,8 @@ If you want to dump all databases on a host, use `all` for the database name:
```yaml
postgresql_databases:
- name: all
mariadb_databases:
- name: all
mysql_databases:
- name: all
mongodb_databases:
@@ -123,15 +142,18 @@ The SQLite hook in particular does not consider "all" a special database name.
these options in the `hooks:` section of your configuration.
<span class="minilink minilink-addedin">New in version 1.7.6</span> With
PostgreSQL and MySQL, you can optionally dump "all" databases to separate
files instead of one combined dump file, allowing more convenient restores of
individual databases. Enable this by specifying your desired database dump
`format`:
PostgreSQL, MariaDB, and MySQL, you can optionally dump "all" databases to
separate files instead of one combined dump file, allowing more convenient
restores of individual databases. Enable this by specifying your desired
database dump `format`:
```yaml
postgresql_databases:
- name: all
format: custom
mariadb_databases:
- name: all
format: sql
mysql_databases:
- name: all
format: sql
@@ -222,10 +244,16 @@ to prepare for this situation, it's a good idea to include borgmatic's own
configuration files as part of your regular backups. That way, you can always
bring back any missing configuration files in order to restore a database.
<span class="minilink minilink-addedin">New in version 1.7.15</span> borgmatic
automatically includes configuration files in your backup. See [the
documentation on the `config bootstrap`
action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#extract-the-configuration-files-used-to-create-an-archive)
for more information.
## Supported databases
As of now, borgmatic supports PostgreSQL, MySQL/MariaDB, MongoDB, and SQLite
As of now, borgmatic supports PostgreSQL, MariaDB, MySQL, MongoDB, and SQLite
databases directly. But see below about general-purpose preparation and
cleanup hooks as a work-around with other database systems. Also, please [file
a ticket](https://torsion.org/borgmatic/#issues) for additional database
@@ -282,7 +310,8 @@ problem: the `restore` action figures out which repository to use.
But if you have multiple repositories configured, then you'll need to specify
the repository to use via the `--repository` flag. This can be done either
with the repository's path or its label as configured in your borgmatic configuration file.
with the repository's path or its label as configured in your borgmatic
configuration file.
```bash
borgmatic restore --repository repo.borg --archive host-2023-...
@@ -338,6 +367,28 @@ within the database dump:
borgmatic restore --archive latest --database users --schema tentant1
```
### Restore to an alternate host
<span class="minilink minilink-addedin">New in version 1.7.15</span>
A database dump can be restored to a host other than the one from which it was
originally dumped. The connection parameters like the username, password, and
port can also be changed. This can be done from the command line:
```bash
borgmatic restore --archive latest --database users --hostname database2.example.org --port 5433 --username postgres --password trustsome1
```
Or from the configuration file:
```yaml
postgresql_databases:
- name: users
hostname: database1.example.org
restore_hostname: database1.example.org
restore_port: 5433
restore_username: postgres
restore_password: trustsome1
```
### Limitations
@@ -398,9 +449,9 @@ dumps with any database system.
## Troubleshooting
### PostgreSQL/MySQL authentication errors
### Authentication errors
With PostgreSQL and MySQL/MariaDB, if you're getting authentication errors
With PostgreSQL, MariaDB, and MySQL, if you're getting authentication errors
when borgmatic tries to connect to your database, a natural reaction is to
increase your borgmatic verbosity with `--verbosity 2` and go looking in the
logs. You'll notice though that your database password does not show up in the
@@ -414,23 +465,24 @@ authenticated. For instance, with PostgreSQL, check your
[pg_hba.conf](https://www.postgresql.org/docs/current/auth-pg-hba-conf.html)
file for that configuration.
Additionally, MySQL/MariaDB may be picking up some of your credentials from a
defaults file like `~/.my.cnf`. If that's the case, then it's possible
MySQL/MariaDB ends up using, say, a username from borgmatic's configuration
and a password from `~/.my.cnf`. This may result in authentication errors if
this combination of credentials is not what you intend.
Additionally, MariaDB or MySQL may be picking up some of your credentials from
a defaults file like `~/mariadb.cnf` or `~/.my.cnf`. If that's the case, then
it's possible MariaDB or MySQL end up using, say, a username from borgmatic's
configuration and a password from `~/mariadb.cnf` or `~/.my.cnf`. This may
result in authentication errors if this combination of credentials is not what
you intend.
### MySQL table lock errors
### MariaDB or MySQL table lock errors
If you encounter table lock errors during a database dump with MySQL/MariaDB,
you may need to [use a
transaction](https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html#option_mysqldump_single-transaction).
If you encounter table lock errors during a database dump with MariaDB or
MySQL, you may need to [use a
transaction](https://mariadb.com/docs/skysql-dbaas/ref/mdb/cli/mariadb-dump/single-transaction/).
You can add any additional flags to the `options:` in your database
configuration. Here's an example:
configuration. Here's an example with MariaDB:
```yaml
mysql_databases:
mariadb_databases:
- name: posts
options: "--single-transaction --quick"
```
+1 -1
View File
@@ -189,7 +189,7 @@ check_repositories:
- path/of/repository_to_check.borg
```
Finally, you can override your configuration file's consistency checks, and
Finally, you can override your configuration file's consistency checks and
run particular checks via the command-line. For instance:
```bash
+22 -19
View File
@@ -7,7 +7,12 @@ eleventyNavigation:
---
## Source code
To get set up to develop on borgmatic, first clone it via HTTPS or SSH:
To get set up to develop on borgmatic, first [`install
pipx`](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation)
to make managing your borgmatic environment easy without impacting other
Python applications on your system.
Then, clone borgmatic via HTTPS or SSH:
```bash
git clone https://projects.torsion.org/borgmatic-collective/borgmatic.git
@@ -19,36 +24,34 @@ Or:
git clone ssh://git@projects.torsion.org:3022/borgmatic-collective/borgmatic.git
```
Then, install borgmatic
"[editable](https://pip.pypa.io/en/stable/cli/pip_install/#editable-installs)"
Finally, install borgmatic
"[editable](https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs)"
so that you can run borgmatic actions during development to make sure your
changes work.
changes work:
```bash
cd borgmatic
pip3 install --user --editable .
pipx ensurepath
pipx install --editable .
```
Note that this will typically install the borgmatic commands into
`~/.local/bin`, which may or may not be on your PATH. There are other ways to
install borgmatic editable as well, for instance into the system Python
install (so without `--user`, as root), or even into a
[virtualenv](https://virtualenv.pypa.io/en/stable/). How or where you install
borgmatic is up to you, but generally an editable install makes development
and testing easier.
To get oriented with the borgmatic source code, have a look at the [source
code reference](https://torsion.org/borgmatic/docs/reference/source-code/).
## Automated tests
Assuming you've cloned the borgmatic source code as described above, and
you're in the `borgmatic/` working copy, install tox, which is used for
setting up testing environments:
Assuming you've cloned the borgmatic source code as described above and you're
in the `borgmatic/` working copy, install tox, which is used for setting up
testing environments. You can either install a system package of tox (likely
called `tox` or `python-tox`) or you can install tox with pipx:
```bash
pip3 install --user tox
pipx install tox
```
Finally, to actually run tests, run:
Finally, to actually run tests, run tox from inside the borgmatic
sourcedirectory:
```bash
tox
@@ -149,8 +152,8 @@ See the Black, Flake8, and isort documentation for more information.
Each pull request triggers a continuous integration build which runs the test
suite. You can view these builds on
[build.torsion.org](https://build.torsion.org/borgmatic-collective/borgmatic), and they're
also linked from the commits list on each pull request.
[build.torsion.org](https://build.torsion.org/borgmatic-collective/borgmatic),
and they're also linked from the commits list on each pull request.
## Documentation development
+43 -1
View File
@@ -44,7 +44,6 @@ entire contents of the archive to the current directory, so make sure you're
in the right place before running the command—or see below about the
`--destination` flag.
## Repository selection
If you have a single repository in your borgmatic configuration file(s), no
@@ -145,3 +144,46 @@ When you're all done exploring your files, unmount your mount point. No
```bash
borgmatic umount --mount-point /mnt
```
## Extract the configuration files used to create an archive
<span class="minilink minilink-addedin">New in version 1.7.15</span> borgmatic
automatically stores all the configuration files used to create an archive
inside the archive itself. This is useful in cases where you've lost a
configuration file or you want to see what configurations were used to create a
particular archive.
To extract the configuration files from an archive, use the `config bootstrap`
action. For example:
```bash
borgmatic config bootstrap --repository repo.borg --destination /tmp
```
This extracts the configuration file from the latest archive in the repository
`repo.borg` to `/tmp/etc/borgmatic/config.yaml`, assuming that the only
configuration file used to create this archive was located at
`/etc/borgmatic/config.yaml` when the archive was created.
Note that to run the `config bootstrap` action, you don't need to have a
borgmatic configuration file. You only need to specify the repository to use via
the `--repository` flag; borgmatic will figure out the rest.
If a destination directory is not specified, the configuration files will be
extracted to their original locations, silently *overwriting* any configuration
files that may already exist. For example, if a configuration file was located
at `/etc/borgmatic/config.yaml` when the archive was created, it will be
extracted to `/etc/borgmatic/config.yaml` too.
If you want to extract the configuration file from a specific archive, use the
`--archive` flag:
```bash
borgmatic config bootstrap --repository repo.borg --archive host-2023-01-02T04:06:07.080910 --destination /tmp
```
<span class="minilink minilink-addedin">New in version 1.8.1</span> Set the
`store_config_files` option to `false` to disable the automatic backup of
borgmatic configuration files, for instance if they contain sensitive
information you don't want to store even inside your encrypted backups. If you
do this though, the `config bootstrap` action will no longer work.
+53 -14
View File
@@ -153,7 +153,7 @@ in newer versions of borgmatic.
Once you have multiple different configuration files, you might want to share
common configuration options across these files with having to copy and paste
them. To achieve this, you can put fragments of common configuration options
into a file, and then include or inline that file into one or more borgmatic
into a file and then include or inline that file into one or more borgmatic
configuration files.
Let's say that you want to include common consistency check configuration across all
@@ -233,18 +233,57 @@ checks:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> These
options were organized into sections like `retention:` and `consistency:`.
Once this include gets merged in, the resulting configuration would have all
of the options from the original configuration file *and* the options from the
Once this include gets merged in, the resulting configuration has all of the
options from the original configuration file *and* the options from the
include.
Note that this `<<` include merging syntax is only for merging in mappings
(configuration options and their values). But if you'd like to include a
single value directly, please see the above about standard includes.
(configuration options and their values). If you'd like to include a single
value directly, please see above about standard includes.
Additionally, there is a limitation preventing multiple `<<` include merges
per file or option value. So for instance, that means you can do one `<<`
merge at the global level, another `<<` within each nested option value, etc.
(This is a YAML limitation.)
### Multiple merge includes
borgmatic has a limitation preventing multiple `<<` include merges per file or
option value. This means you can do a single `<<` merge at the global level,
another `<<` within each nested option value, etc. (This is a YAML
limitation.) For instance:
```yaml
repositories:
- path: repo.borg
# This won't work! You can't do multiple merges like this at the same level.
<<: !include common1.yaml
<<: !include common2.yaml
```
But read on for a way around this.
<span class="minilink minilink-addedin">New in version 1.8.1</span> You can
include and merge multiple configuration files all at once. For instance:
```yaml
repositories:
- path: repo.borg
<<: !include [common1.yaml, common2.yaml, common3.yaml]
```
This merges in each included configuration file in turn, such that later files
replace the options in earlier ones.
Here's another way to do the same thing:
```yaml
repositories:
- path: repo.borg
<<: !include
- common1.yaml
- common2.yaml
- common3.yaml
```
### Deep merge
@@ -309,8 +348,8 @@ source_directories:
- /etc
```
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `location:` section of your configuration.
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put the
`source_directories` option in the `location:` section of your configuration.
Once this include gets merged in, the resulting configuration will have a
`source_directories` value of `/etc` and `/var`—with `/home` omitted.
@@ -442,8 +481,8 @@ command-line via the `--override` flag. Here's an example:
borgmatic create --override remote_path=/usr/local/bin/borg1
```
What this does is load your configuration files, and for each one, disregard
the configured value for the `remote_path` option, and use the value of
What this does is load your configuration files and for each one, disregard
the configured value for the `remote_path` option and use the value of
`/usr/local/bin/borg1` instead.
You can even override nested values or multiple values at once. For instance:
@@ -525,7 +564,7 @@ forget to specify the section (like `location:` or `storage:`) that any option
is in.
In this example, when borgmatic runs, all instances of `{user}` get replaced
with `foo` and all instances of `{archive-prefix}` get replaced with `bar-`.
with `foo` and all instances of `{archive_prefix}` get replaced with `bar-`.
(And in this particular example, `{now}` doesn't get replaced with anything,
but gets passed directly to Borg.) After substitution, the logical result
looks something like this:
+171 -65
View File
@@ -36,25 +36,24 @@ below for how to configure this.
### Third-party monitoring services
borgmatic integrates with monitoring services like
[Healthchecks](https://healthchecks.io/), [Cronitor](https://cronitor.io),
[Cronhub](https://cronhub.io), [PagerDuty](https://www.pagerduty.com/), and
[ntfy](https://ntfy.sh/) and pings these services whenever borgmatic runs.
That way, you'll receive an alert when something goes wrong or (for certain
hooks) the service doesn't hear from borgmatic for a configured interval. See
[Healthchecks
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook),
[Cronitor
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook),
[Cronhub
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook),
[PagerDuty
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook),
and [ntfy hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook)
below for how to configure this.
borgmatic integrates with these monitoring services and libraries, pinging
them as backups happen:
* [Healthchecks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook)
* [Cronitor](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook)
* [Cronhub](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook)
* [PagerDuty](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook)
* [ntfy](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook)
* [Grafana Loki](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook)
* [Apprise](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook)
The idea is that you'll receive an alert when something goes wrong or when the
service doesn't hear from borgmatic for a configured interval (if supported).
See the documentation links above for configuration information.
While these services and libraries offer different features, you probably only
need to use one of them at most.
While these services offer different features, you probably only need to use
one of them at most.
### Third-party monitoring software
@@ -106,7 +105,7 @@ on_error:
```
In this example, when the error occurs, borgmatic interpolates runtime values
into the hook command: the borgmatic configuration filename, and the path of
into the hook command: the borgmatic configuration filename and the path of
the repository. Here's the full set of supported variables you can use here:
* `configuration_filename`: borgmatic configuration filename in which the
@@ -118,7 +117,7 @@ the repository. Here's the full set of supported variables you can use here:
occurred without running a command)
Note that borgmatic runs the `on_error` hooks only for `create`, `prune`,
`compact`, or `check` actions or hooks in which an error occurs, and not other
`compact`, or `check` actions/hooks in which an error occurs and not other
actions. borgmatic does not run `on_error` hooks if an error occurs within a
`before_everything` or `after_everything` hook. For more about hooks, see the
[borgmatic hooks
@@ -129,7 +128,7 @@ especially the security information.
## Healthchecks hook
[Healthchecks](https://healthchecks.io/) is a service that provides "instant
alerts when your cron jobs fail silently", and borgmatic has built-in
alerts when your cron jobs fail silently," and borgmatic has built-in
integration with it. Once you create a Healthchecks account and project on
their site, all you need to do is configure borgmatic with the unique "Ping
URL" for your project. Here's an example:
@@ -143,22 +142,20 @@ healthchecks:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `hooks:` section of your configuration.
With this hook in place, borgmatic pings your Healthchecks project when a
backup begins, ends, or errors. Specifically, after the <a
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
hooks</a> run, borgmatic lets Healthchecks know that it has started if any of
the `create`, `prune`, `compact`, or `check` actions are run.
With this configuration, borgmatic pings your Healthchecks project when a
backup begins, ends, or errors, but only when any of the `create`, `prune`,
`compact`, or `check` actions are run.
Then, if the actions complete successfully, borgmatic notifies Healthchecks of
the success after the `after_backup` hooks run, and includes borgmatic logs in
the payload data sent to Healthchecks. This means that borgmatic logs show up
in the Healthchecks UI, although be aware that Healthchecks currently has a
10-kilobyte limit for the logs in each ping.
the success and includes borgmatic logs in the payload data sent to
Healthchecks. This means that borgmatic logs show up in the Healthchecks UI,
although be aware that Healthchecks currently has a 10-kilobyte limit for the
logs in each ping.
If an error occurs during any action or hook, borgmatic notifies Healthchecks
after the `on_error` hooks run, also tacking on logs including the error
itself. But the logs are only included for errors that occur when a `create`,
`prune`, `compact`, or `check` action is run.
If an error occurs during any action or hook, borgmatic notifies Healthchecks,
also tacking on logs including the error itself. But the logs are only
included for errors that occur when a `create`, `prune`, `compact`, or `check`
action is run.
You can customize the verbosity of the logs that are sent to Healthchecks with
borgmatic's `--monitoring-verbosity` flag. The `--list` and `--stats` flags
@@ -175,7 +172,7 @@ or it doesn't hear from borgmatic for a certain period of time.
## Cronitor hook
[Cronitor](https://cronitor.io/) provides "Cron monitoring and uptime healthchecks
for websites, services and APIs", and borgmatic has built-in
for websites, services and APIs," and borgmatic has built-in
integration with it. Once you create a Cronitor account and cron job monitor on
their site, all you need to do is configure borgmatic with the unique "Ping
API URL" for your monitor. Here's an example:
@@ -189,14 +186,10 @@ cronitor:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `hooks:` section of your configuration.
With this hook in place, borgmatic pings your Cronitor monitor when a backup
begins, ends, or errors. Specifically, after the <a
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
hooks</a> run, borgmatic lets Cronitor know that it has started if any of the
`prune`, `compact`, `create`, or `check` actions are run. Then, if the actions
complete successfully, borgmatic notifies Cronitor of the success after the
`after_backup` hooks run. And if an error occurs during any action or hook,
borgmatic notifies Cronitor after the `on_error` hooks run.
With this configuration, borgmatic pings your Cronitor monitor when a backup
begins, ends, or errors, but only when any of the `prune`, `compact`,
`create`, or `check` actions are run. Then, if the actions complete
successfully or errors, borgmatic notifies Cronitor accordingly.
You can configure Cronitor to notify you by a [variety of
mechanisms](https://cronitor.io/docs/cron-job-notifications) when backups fail
@@ -206,7 +199,7 @@ or it doesn't hear from borgmatic for a certain period of time.
## Cronhub hook
[Cronhub](https://cronhub.io/) provides "instant alerts when any of your
background jobs fail silently or run longer than expected", and borgmatic has
background jobs fail silently or run longer than expected," and borgmatic has
built-in integration with it. Once you create a Cronhub account and monitor on
their site, all you need to do is configure borgmatic with the unique "Ping
URL" for your monitor. Here's an example:
@@ -220,14 +213,10 @@ cronhub:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `hooks:` section of your configuration.
With this hook in place, borgmatic pings your Cronhub monitor when a backup
begins, ends, or errors. Specifically, after the <a
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
hooks</a> run, borgmatic lets Cronhub know that it has started if any of the
`prune`, `compact`, `create`, or `check` actions are run. Then, if the actions
complete successfully, borgmatic notifies Cronhub of the success after the
`after_backup` hooks run. And if an error occurs during any action or hook,
borgmatic notifies Cronhub after the `on_error` hooks run.
With this configuration, borgmatic pings your Cronhub monitor when a backup
begins, ends, or errors, but only when any of the `prune`, `compact`,
`create`, or `check` actions are run. Then, if the actions complete
successfully or errors, borgmatic notifies Cronhub accordingly.
Note that even though you configure borgmatic with the "start" variant of the
ping URL, borgmatic substitutes the correct state into the URL when pinging
@@ -265,11 +254,10 @@ pagerduty:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `hooks:` section of your configuration.
With this hook in place, borgmatic creates a PagerDuty event for your service
whenever backups fail. Specifically, if an error occurs during a `create`,
`prune`, `compact`, or `check` action, borgmatic sends an event to PagerDuty
before the `on_error` hooks run. Note that borgmatic does not contact
PagerDuty when a backup starts or ends without error.
With this configuration, borgmatic creates a PagerDuty event for your service
whenever backups fail, but only when any of the `create`, `prune`, `compact`,
or `check` actions are run. Note that borgmatic does not contact PagerDuty
when a backup starts or when it ends without error.
You can configure PagerDuty to notify you by a [variety of
mechanisms](https://support.pagerduty.com/docs/notifications) when backups
@@ -281,10 +269,12 @@ us](https://torsion.org/borgmatic/#support-and-contributing).
## ntfy hook
[ntfy](https://ntfy.sh) is a free, simple, service (either hosted or self-hosted)
which offers simple pub/sub push notifications to multiple platforms including
[web](https://ntfy.sh/stats), [Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
and [iOS](https://apps.apple.com/us/app/ntfy/id1625396347).
<span class="minilink minilink-addedin">New in version 1.6.3</span>
[ntfy](https://ntfy.sh) is a free, simple, service (either hosted or
self-hosted) which offers simple pub/sub push notifications to multiple
platforms including [web](https://ntfy.sh/stats),
[Android](https://play.google.com/store/apps/details?id=io.heckel.ntfy) and
[iOS](https://apps.apple.com/us/app/ntfy/id1625396347).
Since push notifications for regular events might soon become quite annoying,
this hook only fires on any errors by default in order to instantly alert you to issues.
@@ -304,17 +294,17 @@ ntfy:
topic: my-unique-topic
server: https://ntfy.my-domain.com
start:
title: A Borgmatic backup started
title: A borgmatic backup started
message: Watch this space...
tags: borgmatic
priority: min
finish:
title: A Borgmatic backup completed successfully
title: A borgmatic backup completed successfully
message: Nice!
tags: borgmatic,+1
priority: min
fail:
title: A Borgmatic backup failed
title: A borgmatic backup failed
message: You should probably fix it
tags: borgmatic,-1,skull
priority: max
@@ -328,6 +318,122 @@ ntfy:
the `ntfy:` option in the `hooks:` section of your configuration.
## Loki hook
<span class="minilink minilink-addedin">New in version 1.8.3</span> [Grafana
Loki](https://grafana.com/oss/loki/) is a "horizontally scalable, highly
available, multi-tenant log aggregation system inspired by Prometheus."
borgmatic has built-in integration with Loki, sending both backup status and
borgmatic logs.
You can configure borgmatic to use either a [self-hosted Loki
instance](https://grafana.com/docs/loki/latest/installation/) or [a Grafana
Cloud account](https://grafana.com/auth/sign-up/create-user). Start by setting
your Loki API push URL. Here's an example:
```yaml
loki:
url: http://localhost:3100/loki/api/v1/push
```
With this configuration, borgmatic sends its logs to your Loki instance as any
of the `prune`, `compact`, `create`, or `check` actions are run. Then, after
the actions complete, borgmatic notifies Loki of success or failure.
This hook supports sending arbitrary labels to Loki. For instance:
```yaml
loki:
url: http://localhost:3100/loki/api/v1/push
labels:
app: borgmatic
hostname: example.org
```
There are also a few placeholders you can optionally use as label values:
* `__config`: name of the borgmatic configuration file
* `__config_path`: full path of the borgmatic configuration file
* `__hostname`: the local machine hostname
These placeholders are only substituted for the whole label value, not
interpolated into a larger string. For instance:
```yaml
loki:
url: http://localhost:3100/loki/api/v1/push
labels:
app: borgmatic
config: __config
hostname: __hostname
```
## Apprise hook
<span class="minilink minilink-addedin">New in version 1.8.4</span>
[Apprise](https://github.com/caronc/apprise/wiki) is a local notification library
that "allows you to send a notification to almost all of the most popular
[notification services](https://github.com/caronc/apprise/wiki) available to
us today such as: Telegram, Discord, Slack, Amazon SNS, Gotify, etc."
Depending on how you installed borgmatic, it may not have come with Apprise.
For instance, if you originally [installed borgmatic with
pipx](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation),
run the following to install Apprise so borgmatic can use it:
```bash
sudo pipx install --editable --force borgmatic[Apprise]
```
Omit `sudo` if borgmatic is installed as a non-root user.
Once Apprise is installed, configure borgmatic to notify one or more [Apprise
services](https://github.com/caronc/apprise/wiki). For example:
```yaml
apprise:
services:
- url: gotify://hostname/token
label: gotify
- url: mastodons://access_key@hostname/@user
label: mastodon
```
With this configuration, borgmatic pings each of the configured Apprise
services when a backup begins, ends, or errors, but only when any of the
`prune`, `compact`, `create`, or `check` actions are run.
You can optionally customize the contents of the default messages sent to
these services:
```yaml
apprise:
services:
- url: gotify://hostname/token
label: gotify
start:
title: Ping!
body: Starting backup process.
finish:
title: Ping!
body: Backups successfully made.
fail:
title: Ping!
body: Your backups have failed.
states:
- start
- finish
- fail
```
See the [configuration
reference](https://torsion.org/borgmatic/docs/reference/configuration/) for
details.
## Scripting borgmatic
To consume the output of borgmatic in other software, you can include an
@@ -336,7 +442,7 @@ output formatted as JSON.
Note that when you specify the `--json` flag, Borg's other non-JSON output is
suppressed so as not to interfere with the captured JSON. Also note that JSON
output only shows up at the console, and not in syslog.
output only shows up at the console and not in syslog.
### Latest backups
+24 -11
View File
@@ -9,9 +9,15 @@ eleventyNavigation:
If you want to use a Borg repository passphrase or database passwords with
borgmatic, you can set them directly in your borgmatic configuration file,
treating those secrets like any other option value. But if you'd rather store
them outside of borgmatic, whether for convenience or security reasons, read
on.
treating those secrets like any other option value. For instance, you can
specify your Borg passhprase with:
```yaml
encryption_passphrase: yourpassphrase
```
But if you'd rather store them outside of borgmatic, whether for convenience
or security reasons, read on.
<span class="minilink minilink-addedin">New in version 1.6.4</span> borgmatic
supports interpolating arbitrary environment variables directly into option
@@ -20,14 +26,14 @@ pull your repository passphrase, your database passwords, or any other option
values from environment variables. For instance:
```yaml
encryption_passphrase: ${MY_PASSPHRASE}
encryption_passphrase: ${YOUR_PASSPHRASE}
```
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `storage:` section of your configuration.
This uses the `MY_PASSPHRASE` environment variable as your encryption
passphrase. Note that the `{` `}` brackets are required. `$MY_PASSPHRASE` by
This uses the `YOUR_PASSPHRASE` environment variable as your encryption
passphrase. Note that the `{` `}` brackets are required. `$YOUR_PASSPHRASE` by
itself will not work.
In the case of `encryption_passphrase` in particular, an alternate approach
@@ -42,25 +48,26 @@ the same approach applies. For example:
```yaml
postgresql_databases:
- name: users
password: ${MY_DATABASE_PASSWORD}
password: ${YOUR_DATABASE_PASSWORD}
```
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `hooks:` section of your configuration.
This uses the `MY_DATABASE_PASSWORD` environment variable as your database
This uses the `YOUR_DATABASE_PASSWORD` environment variable as your database
password.
### Interpolation defaults
If you'd like to set a default for your environment variables, you can do so with the following syntax:
If you'd like to set a default for your environment variables, you can do so
with the following syntax:
```yaml
encryption_passphrase: ${MY_PASSPHRASE:-defaultpass}
encryption_passphrase: ${YOUR_PASSPHRASE:-defaultpass}
```
Here, "`defaultpass`" is the default passphrase if the `MY_PASSPHRASE`
Here, "`defaultpass`" is the default passphrase if the `YOUR_PASSPHRASE`
environment variable is not set. Without a default, if the environment
variable doesn't exist, borgmatic will error.
@@ -90,3 +97,9 @@ Additionally, borgmatic action hooks support their own [variable
interpolation](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/#variable-interpolation),
although in that case it's for particular borgmatic runtime values rather than
(only) environment variables.
Lastly, if you do want to specify your passhprase directly within borgmatic
configuration, but you'd like to keep it in a separate file from your main
configuration, you can [use a configuration include or a merge
include](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-includes)
to pull in an external password.
+39 -43
View File
@@ -7,74 +7,70 @@ eleventyNavigation:
---
## Installation
Many users need to backup system files that require privileged access, so
these instructions install and run borgmatic as root. If you don't need to
backup such files, then you are welcome to install and run borgmatic as a
non-root user.
### Prerequisites
First, manually [install
First, [install
Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at least
version 1.1. borgmatic does not install Borg automatically so as to avoid
conflicts with existing Borg installations.
Then, download and install borgmatic as a [user site
installation](https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site)
by running the following command:
Then, [install pipx](https://pypa.github.io/pipx/installation/) as the root
user (with `sudo`) to make installing borgmatic easy without impacting other
Python applications on your system. If you have trouble installing pipx with
pip, then you can install a system package instead. E.g. on Ubuntu or Debian,
run:
```bash
sudo pip3 install --user --upgrade borgmatic
sudo apt update
sudo apt install pipx
```
This installs borgmatic and its commands at the `/root/.local/bin` path.
### Root install
Your pip binary may have a different name than "pip3". Make sure you're using
Python 3.7+, as borgmatic does not support older versions of Python.
The next step is to ensure that borgmatic's commands available are on your
system `PATH`, so that you can run borgmatic:
If you want to run borgmatic on a schedule with privileged access to your
files, then you should install borgmatic as the root user by running the
following commands:
```bash
echo export 'PATH="$PATH:/root/.local/bin"' >> ~/.bashrc
source ~/.bashrc
sudo pipx ensurepath
sudo pipx install borgmatic
```
This adds `/root/.local/bin` to your non-root user's system `PATH`.
If you're using a command shell other than Bash, you may need to use different
commands here.
You can check whether all of this worked with:
Check whether this worked with:
```bash
sudo borgmatic --version
sudo su -
borgmatic --version
```
If borgmatic is properly installed, that should output your borgmatic version.
As an alternative to adding the path to `~/.bashrc` file, if you're using sudo
to run borgmatic, you can configure [sudo's
`secure_path` option](https://man.archlinux.org/man/sudoers.5) to include
borgmatic's path.
And if you'd also like `sudo borgmatic` to work, keep reading!
### Global install option
### Non-root install
If you try the user site installation above, and have problems making
borgmatic commands runnable on your system `PATH`, an alternate approach is to
install borgmatic globally.
The following uninstalls borgmatic, and then reinstalls it such that borgmatic
commands are on the default system `PATH`:
If you only want to run borgmatic as a non-root user (without privileged file
access) *or* you want to make `sudo borgmatic` work so borgmatic runs as root,
then install borgmatic as a non-root user by running the following commands as
that user:
```bash
sudo pip3 uninstall borgmatic
sudo pip3 install --upgrade borgmatic
pipx ensurepath
pipx install borgmatic
```
The main downside of a global install is that borgmatic is less cleanly
separated from the rest of your Python software, and there's the theoretical
possibility of library conflicts. But if you're okay with that, for instance
on a relatively dedicated system, then a global install can work out fine.
This should work even if you've also installed borgmatic as the root user.
Check whether this worked with:
```bash
borgmatic --version
```
If borgmatic is properly installed, that should output your borgmatic version.
You can also try `sudo borgmatic --version` if you intend to run borgmatic
with `sudo`. If that doesn't work, you may need to update your [sudoers
`secure_path` option](https://wiki.archlinux.org/title/Sudo).
### Other ways to install
@@ -229,7 +225,7 @@ sudo borgmatic rcreate --encryption repokey-aes-ocb
certain platforms like ARM64.)
This uses the borgmatic configuration file you created above to determine
which local or remote repository to create, and encrypts it with the
which local or remote repository to create and encrypts it with the
encryption passphrase specified there if one is provided. Read about [Borg
encryption
modes](https://borgbackup.readthedocs.io/en/stable/usage/init.html#encryption-mode-tldr)
+29 -52
View File
@@ -7,26 +7,38 @@ eleventyNavigation:
---
## Upgrading borgmatic
In general, all you should need to do to upgrade borgmatic is run the
following:
In general, all you should need to do to upgrade borgmatic if you've
[installed it with
pipx](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation)
is to run the following:
```bash
sudo pip3 install --user --upgrade borgmatic
sudo pipx upgrade borgmatic
```
See below about special cases with old versions of borgmatic. Additionally, if
you installed borgmatic [without using `pip3 install
--user`](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#other-ways-to-install),
then your upgrade process may be different.
Omit `sudo` if you installed borgmatic as a non-root user. And if you
installed borgmatic *both* as root and as a non-root user, you'll need to
upgrade each installation independently.
If you originally installed borgmatic with `sudo pip3 install --user`, you can
uninstall it first with `sudo pip3 uninstall borgmatic` and then [install it
again with
pipx](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation),
which should better isolate borgmatic from your other Python applications.
But if you [installed borgmatic without pipx or
pip3](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#other-ways-to-install),
then your upgrade method may be different.
### Upgrading your configuration
The borgmatic configuration file format is almost always backwards-compatible
from release to release without any changes, but you may still want to update
your configuration file when you upgrade to take advantage of new
configuration options. This is completely optional. If you prefer, you can add
new configuration options manually.
The borgmatic configuration file format is usually backwards-compatible from
release to release without any changes, but you may still want to update your
configuration file when you upgrade to take advantage of new configuration
options or avoid old configuration from eventually becoming unsupported. If
you prefer, you can add new configuration options manually.
If you do want to upgrade your configuration file to include new options, use
the `borgmatic config generate` action with its optional `--source` flag that
@@ -63,46 +75,11 @@ and, if desired, replace your original configuration file with it.
### Upgrading from borgmatic 1.0.x
borgmatic changed its configuration file format in version 1.1.0 from
INI-style to YAML. This better supports validation, and has a more natural way
to express lists of values. To upgrade your existing configuration, first
upgrade to the last version of borgmatic to support converting configuration:
borgmatic 1.7.14.
As of version 1.1.0, borgmatic no longer supports Python 2. If you were
already running borgmatic with Python 3, then you can upgrade borgmatic
in-place:
```bash
sudo pip3 install --user --upgrade borgmatic==1.7.14
```
But if you were running borgmatic with Python 2, uninstall and reinstall instead:
```bash
sudo pip uninstall borgmatic
sudo pip3 install --user borgmatic==1.7.14
```
The pip binary names for different versions of Python can differ, so the above
commands may need some tweaking to work on your machine.
Once borgmatic is upgraded, run:
```bash
sudo upgrade-borgmatic-config
```
That will generate a new YAML configuration file at /etc/borgmatic/config.yaml
(by default) using the values from both your existing configuration and
excludes files. The new version of borgmatic will consume the YAML
configuration file instead of the old one.
Now you can upgrade to a newer version of borgmatic:
```bash
sudo pip3 install --user borgmatic
```
INI-style to YAML. This better supports validation and has a more natural way
to express lists of values. Modern versions of borgmatic no longer include
support for upgrading configuration files this old, but feel free to [file a
ticket](https://torsion.org/borgmatic/#issues) for help with upgrading any old
INI-style configuration files you may have.
## Upgrading Borg
+29
View File
@@ -0,0 +1,29 @@
---
title: Source code reference
eleventyNavigation:
key: 🐍 Source code reference
parent: Reference guides
order: 3
---
## getting oriented
If case you're interested in [developing on
borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/),
here's an abridged primer on how its Python source code is organized to help
you get started. At the top level we have:
* [borgmatic](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic): The main borgmatic source module. Most of the code is here.
* [docs](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/docs): How-to and reference documentation, including the document you're reading now.
* [sample](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/sample): Example configurations for cron and systemd.
* [scripts](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/scripts): Dev-facing scripts for things like building documentation and running end-to-end tests.
* [tests](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/tests): Automated tests organized by: end-to-end, integration, and unit.
Within the `borgmatic` directory you'll find:
* [actions](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/actions): Mid-level code for running each borgmatic action (create, list, check, etc.).
* [borg](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/borg): Lower-level code that actually shells out to Borg for each action.
* [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion.
* [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration.
* [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database or monitoring integration? Start here.
So, broadly speaking, the control flow goes: `commands``config` followed by `commands``actions``borg` and `hooks`.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+6 -6
View File
@@ -32,16 +32,16 @@ RestrictSUIDSGID=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
# To restrict write access further, change "ProtectSystem" to "strict" and uncomment
# "ReadWritePaths", "ReadOnlyPaths", "ProtectHome", and "BindPaths". Then add any local repository
# paths to the list of "ReadWritePaths" and local backup source paths to "ReadOnlyPaths". This
# leaves most of the filesystem read-only to borgmatic.
# To restrict write access further, change "ProtectSystem" to "strict" and
# uncomment "ReadWritePaths", "TemporaryFileSystem", "BindPaths" and
# "BindReadOnlyPaths". Then add any local repository paths to the list of
# "ReadWritePaths". This leaves most of the filesystem read-only to borgmatic.
ProtectSystem=full
# ReadWritePaths=-/mnt/my_backup_drive
# ReadOnlyPaths=-/var/lib/my_backup_source
# This will mount a tmpfs on top of /root and pass through needed paths
# ProtectHome=tmpfs
# TemporaryFileSystem=/root:ro
# BindPaths=-/root/.cache/borg -/root/.config/borg -/root/.borgmatic
# BindReadOnlyPaths=-/root/.ssh
# May interfere with running external programs within borgmatic hooks.
CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_NET_RAW
+2 -2
View File
@@ -18,11 +18,11 @@ if [ -z "$TEST_CONTAINER" ]; then
fi
apk add --no-cache python3 py3-pip borgbackup postgresql-client mariadb-client mongodb-tools \
py3-ruamel.yaml py3-ruamel.yaml.clib bash sqlite fish
py3-ruamel.yaml py3-ruamel.yaml.clib py3-yaml bash sqlite fish
# If certain dependencies of black are available in this version of Alpine, install them.
apk add --no-cache py3-typed-ast py3-regex || true
python3 -m pip install --no-cache --upgrade pip==22.2.2 setuptools==64.0.1
pip3 install --ignore-installed tox==3.25.1
pip3 install --ignore-installed tox==4.11.3
export COVERAGE_FILE=/tmp/.coverage
if [ "$1" != "--end-to-end-only" ]; then
+2 -1
View File
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup
VERSION = '1.8.0'
VERSION = '1.8.4'
setup(
@@ -36,6 +36,7 @@ setup(
'ruamel.yaml>0.15.0,<0.18.0',
'setuptools',
),
extras_require={"Apprise": ["apprise"]},
include_package_data=True,
python_requires='>=3.7',
)
+6 -2
View File
@@ -1,6 +1,8 @@
appdirs==1.4.4; python_version >= '3.8'
apprise==1.3.0
attrs==22.2.0; python_version >= '3.8'
black==23.3.0; python_version >= '3.8'
certifi==2023.7.22
chardet==5.1.0
click==8.1.3; python_version >= '3.8'
codespell==2.2.4
@@ -14,16 +16,18 @@ flexmock==0.11.3
idna==3.4
importlib_metadata==6.3.0; python_version < '3.8'
isort==5.12.0
jsonschema==4.17.3
Markdown==3.4.1
mccabe==0.7.0
packaging==23.1
pluggy==1.0.0
pathspec==0.11.1; python_version >= '3.8'
pluggy==1.0.0
py==1.11.0
pycodestyle==2.10.0
pyflakes==3.0.1
jsonschema==4.17.3
pytest==7.3.0
pytest-cov==4.0.0
PyYAML>5.0.0
regex; python_version >= '3.8'
requests==2.31.0
ruamel.yaml>0.15.0,<0.18.0
+21 -11
View File
@@ -10,18 +10,28 @@ services:
environment:
POSTGRES_PASSWORD: test2
POSTGRES_DB: test
POSTGRES_USER: postgres2
command: docker-entrypoint.sh -p 5433
mysql:
image: docker.io/mariadb:10.5
mariadb:
image: docker.io/mariadb:10.11.4
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
mysql2:
image: docker.io/mariadb:10.5
MARIADB_ROOT_PASSWORD: test
MARIADB_DATABASE: test
mariadb2:
image: docker.io/mariadb:10.11.4
environment:
MYSQL_ROOT_PASSWORD: test2
MYSQL_DATABASE: test
MARIADB_ROOT_PASSWORD: test2
MARIADB_DATABASE: test
command: docker-entrypoint.sh --port=3307
not-actually-mysql:
image: docker.io/mariadb:10.11.4
environment:
MARIADB_ROOT_PASSWORD: test
MARIADB_DATABASE: test
not-actually-mysql2:
image: docker.io/mariadb:10.11.4
environment:
MARIADB_ROOT_PASSWORD: test2
MARIADB_DATABASE: test
command: docker-entrypoint.sh --port=3307
mongodb:
image: docker.io/mongo:5.0.5
@@ -50,7 +60,7 @@ services:
depends_on:
- postgresql
- postgresql2
- mysql
- mysql2
- mariadb
- mariadb2
- mongodb
- mongodb2
+225 -22
View File
@@ -5,7 +5,9 @@ import subprocess
import sys
import tempfile
import pymongo
import pytest
import ruamel.yaml
def write_configuration(
@@ -21,7 +23,7 @@ def write_configuration(
for testing. This includes injecting the given repository path, borgmatic source directory for
storing database dumps, dump format (for PostgreSQL), and encryption passphrase.
'''
config = f'''
config_yaml = f'''
source_directories:
- {source_directory}
repositories:
@@ -45,18 +47,32 @@ postgresql_databases:
hostname: postgresql
username: postgres
password: test
mysql_databases:
mariadb_databases:
- name: test
hostname: mysql
hostname: mariadb
username: root
password: test
- name: all
hostname: mysql
hostname: mariadb
username: root
password: test
- name: all
format: sql
hostname: mysql
hostname: mariadb
username: root
password: test
mysql_databases:
- name: test
hostname: not-actually-mysql
username: root
password: test
- name: all
hostname: not-actually-mysql
username: root
password: test
- name: all
format: sql
hostname: not-actually-mysql
username: root
password: test
mongodb_databases:
@@ -76,7 +92,9 @@ sqlite_databases:
'''
with open(config_path, 'w') as config_file:
config_file.write(config)
config_file.write(config_yaml)
return ruamel.yaml.YAML(typ='safe').load(config_yaml)
def write_custom_restore_configuration(
@@ -92,7 +110,7 @@ def write_custom_restore_configuration(
for testing with custom restore options. This includes a custom restore_hostname, restore_port,
restore_username, restore_password and restore_path.
'''
config = f'''
config_yaml = f'''
source_directories:
- {source_directory}
repositories:
@@ -109,14 +127,22 @@ postgresql_databases:
format: {postgresql_dump_format}
restore_hostname: postgresql2
restore_port: 5433
restore_username: postgres2
restore_password: test2
mariadb_databases:
- name: test
hostname: mariadb
username: root
password: test
restore_hostname: mariadb2
restore_port: 3307
restore_username: root
restore_password: test2
mysql_databases:
- name: test
hostname: mysql
hostname: not-actually-mysql
username: root
password: test
restore_hostname: mysql2
restore_hostname: not-actually-mysql2
restore_port: 3307
restore_username: root
restore_password: test2
@@ -138,7 +164,9 @@ sqlite_databases:
'''
with open(config_path, 'w') as config_file:
config_file.write(config)
config_file.write(config_yaml)
return ruamel.yaml.YAML(typ='safe').load(config_yaml)
def write_simple_custom_restore_configuration(
@@ -154,7 +182,7 @@ def write_simple_custom_restore_configuration(
custom restore_hostname, restore_port, restore_username and restore_password as we only test
these options for PostgreSQL.
'''
config = f'''
config_yaml = f'''
source_directories:
- {source_directory}
repositories:
@@ -172,7 +200,147 @@ postgresql_databases:
'''
with open(config_path, 'w') as config_file:
config_file.write(config)
config_file.write(config_yaml)
return ruamel.yaml.YAML(typ='safe').load(config_yaml)
def get_connection_params(database, use_restore_options=False):
hostname = (database.get('restore_hostname') if use_restore_options else None) or database.get(
'hostname'
)
port = (database.get('restore_port') if use_restore_options else None) or database.get('port')
username = (database.get('restore_username') if use_restore_options else None) or database.get(
'username'
)
password = (database.get('restore_password') if use_restore_options else None) or database.get(
'password'
)
return (hostname, port, username, password)
def run_postgresql_command(command, config, use_restore_options=False):
(hostname, port, username, password) = get_connection_params(
config['postgresql_databases'][0], use_restore_options
)
subprocess.check_call(
[
'/usr/bin/psql',
f'--host={hostname}',
f'--port={port or 5432}',
f"--username={username or 'root'}",
f'--command={command}',
'test',
],
env={'PGPASSWORD': password},
)
def run_mariadb_command(command, config, use_restore_options=False, binary_name='mariadb'):
(hostname, port, username, password) = get_connection_params(
config[f'{binary_name}_databases'][0], use_restore_options
)
subprocess.check_call(
[
f'/usr/bin/{binary_name}',
f'--host={hostname}',
f'--port={port or 3306}',
f'--user={username}',
f'--execute={command}',
'test',
],
env={'MYSQL_PWD': password},
)
def get_mongodb_database_client(config, use_restore_options=False):
(hostname, port, username, password) = get_connection_params(
config['mongodb_databases'][0], use_restore_options
)
return pymongo.MongoClient(f'mongodb://{username}:{password}@{hostname}:{port or 27017}').test
def run_sqlite_command(command, config, use_restore_options=False):
database = config['sqlite_databases'][0]
path = (database.get('restore_path') if use_restore_options else None) or database.get('path')
subprocess.check_call(
[
'/usr/bin/sqlite3',
path,
command,
'.exit',
],
)
DEFAULT_HOOK_NAMES = {'postgresql', 'mariadb', 'mysql', 'mongodb', 'sqlite'}
def create_test_tables(config, use_restore_options=False):
'''
Create test tables for borgmatic to dump and backup.
'''
command = 'create table test{id} (thing int); insert into test{id} values (1);'
if 'postgresql_databases' in config:
run_postgresql_command(command.format(id=1), config, use_restore_options)
if 'mariadb_databases' in config:
run_mariadb_command(command.format(id=2), config, use_restore_options)
if 'mysql_databases' in config:
run_mariadb_command(command.format(id=3), config, use_restore_options, binary_name='mysql')
if 'mongodb_databases' in config:
get_mongodb_database_client(config, use_restore_options)['test4'].insert_one({'thing': 1})
if 'sqlite_databases' in config:
run_sqlite_command(command.format(id=5), config, use_restore_options)
def drop_test_tables(config, use_restore_options=False):
'''
Drop the test tables in preparation for borgmatic restoring them.
'''
command = 'drop table if exists test{id};'
if 'postgresql_databases' in config:
run_postgresql_command(command.format(id=1), config, use_restore_options)
if 'mariadb_databases' in config:
run_mariadb_command(command.format(id=2), config, use_restore_options)
if 'mysql_databases' in config:
run_mariadb_command(command.format(id=3), config, use_restore_options, binary_name='mysql')
if 'mongodb_databases' in config:
get_mongodb_database_client(config, use_restore_options)['test4'].drop()
if 'sqlite_databases' in config:
run_sqlite_command(command.format(id=5), config, use_restore_options)
def select_test_tables(config, use_restore_options=False):
'''
Select the test tables to make sure they exist.
Raise if the expected tables cannot be selected, for instance if a restore hasn't worked as
expected.
'''
command = 'select count(*) from test{id};'
if 'postgresql_databases' in config:
run_postgresql_command(command.format(id=1), config, use_restore_options)
if 'mariadb_databases' in config:
run_mariadb_command(command.format(id=2), config, use_restore_options)
if 'mysql_databases' in config:
run_mariadb_command(command.format(id=3), config, use_restore_options, binary_name='mysql')
if 'mongodb_databases' in config:
assert (
get_mongodb_database_client(config, use_restore_options)['test4'].count_documents(
filter={}
)
> 0
)
if 'sqlite_databases' in config:
run_sqlite_command(command.format(id=5), config, use_restore_options)
def test_database_dump_and_restore():
@@ -188,15 +356,17 @@ def test_database_dump_and_restore():
try:
config_path = os.path.join(temporary_directory, 'test.yaml')
write_configuration(
config = write_configuration(
temporary_directory, config_path, repository_path, borgmatic_source_directory
)
create_test_tables(config)
select_test_tables(config)
subprocess.check_call(
['borgmatic', '-v', '2', '--config', config_path, 'rcreate', '--encryption', 'repokey']
)
# Run borgmatic to generate a backup archive including a database dump.
# Run borgmatic to generate a backup archive including database dumps.
subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2'])
# Get the created archive name.
@@ -209,16 +379,21 @@ def test_database_dump_and_restore():
assert len(parsed_output[0]['archives']) == 1
archive_name = parsed_output[0]['archives'][0]['archive']
# Restore the database from the archive.
# Restore the databases from the archive.
drop_test_tables(config)
subprocess.check_call(
['borgmatic', '-v', '2', '--config', config_path, 'restore', '--archive', archive_name]
)
# Ensure the test tables have actually been restored.
select_test_tables(config)
finally:
os.chdir(original_working_directory)
shutil.rmtree(temporary_directory)
drop_test_tables(config)
def test_database_dump_and_restore_with_restore_cli_arguments():
def test_database_dump_and_restore_with_restore_cli_flags():
# Create a Borg repository.
temporary_directory = tempfile.mkdtemp()
repository_path = os.path.join(temporary_directory, 'test.borg')
@@ -228,9 +403,11 @@ def test_database_dump_and_restore_with_restore_cli_arguments():
try:
config_path = os.path.join(temporary_directory, 'test.yaml')
write_simple_custom_restore_configuration(
config = write_simple_custom_restore_configuration(
temporary_directory, config_path, repository_path, borgmatic_source_directory
)
create_test_tables(config)
select_test_tables(config)
subprocess.check_call(
['borgmatic', '-v', '2', '--config', config_path, 'rcreate', '--encryption', 'repokey']
@@ -250,6 +427,7 @@ def test_database_dump_and_restore_with_restore_cli_arguments():
archive_name = parsed_output[0]['archives'][0]['archive']
# Restore the database from the archive.
drop_test_tables(config)
subprocess.check_call(
[
'borgmatic',
@@ -264,15 +442,25 @@ def test_database_dump_and_restore_with_restore_cli_arguments():
'postgresql2',
'--port',
'5433',
'--username',
'postgres2',
'--password',
'test2',
]
)
# Ensure the test tables have actually been restored. But first modify the config to contain
# the altered restore values from the borgmatic command above. This ensures that the test
# tables are selected from the correct database.
database = config['postgresql_databases'][0]
database['restore_hostname'] = 'postgresql2'
database['restore_port'] = '5433'
database['restore_password'] = 'test2'
select_test_tables(config, use_restore_options=True)
finally:
os.chdir(original_working_directory)
shutil.rmtree(temporary_directory)
drop_test_tables(config)
drop_test_tables(config, use_restore_options=True)
def test_database_dump_and_restore_with_restore_configuration_options():
@@ -285,9 +473,11 @@ def test_database_dump_and_restore_with_restore_configuration_options():
try:
config_path = os.path.join(temporary_directory, 'test.yaml')
write_custom_restore_configuration(
config = write_custom_restore_configuration(
temporary_directory, config_path, repository_path, borgmatic_source_directory
)
create_test_tables(config)
select_test_tables(config)
subprocess.check_call(
['borgmatic', '-v', '2', '--config', config_path, 'rcreate', '--encryption', 'repokey']
@@ -307,12 +497,18 @@ def test_database_dump_and_restore_with_restore_configuration_options():
archive_name = parsed_output[0]['archives'][0]['archive']
# Restore the database from the archive.
drop_test_tables(config)
subprocess.check_call(
['borgmatic', '-v', '2', '--config', config_path, 'restore', '--archive', archive_name]
)
# Ensure the test tables have actually been restored.
select_test_tables(config, use_restore_options=True)
finally:
os.chdir(original_working_directory)
shutil.rmtree(temporary_directory)
drop_test_tables(config)
drop_test_tables(config, use_restore_options=True)
def test_database_dump_and_restore_with_directory_format():
@@ -325,7 +521,7 @@ def test_database_dump_and_restore_with_directory_format():
try:
config_path = os.path.join(temporary_directory, 'test.yaml')
write_configuration(
config = write_configuration(
temporary_directory,
config_path,
repository_path,
@@ -333,6 +529,8 @@ def test_database_dump_and_restore_with_directory_format():
postgresql_dump_format='directory',
mongodb_dump_format='directory',
)
create_test_tables(config)
select_test_tables(config)
subprocess.check_call(
['borgmatic', '-v', '2', '--config', config_path, 'rcreate', '--encryption', 'repokey']
@@ -342,12 +540,17 @@ def test_database_dump_and_restore_with_directory_format():
subprocess.check_call(['borgmatic', 'create', '--config', config_path, '-v', '2'])
# Restore the database from the archive.
drop_test_tables(config)
subprocess.check_call(
['borgmatic', '--config', config_path, 'restore', '--archive', 'latest']
)
# Ensure the test tables have actually been restored.
select_test_tables(config)
finally:
os.chdir(original_working_directory)
shutil.rmtree(temporary_directory)
drop_test_tables(config)
def test_database_dump_with_error_causes_borgmatic_to_exit():
+11
View File
@@ -0,0 +1,11 @@
import subprocess
import sys
def test_borgmatic_command_with_invalid_flag_shows_error_but_not_traceback():
output = subprocess.run(
'borgmatic -v 2 --invalid'.split(' '), stdout=subprocess.PIPE, stderr=subprocess.STDOUT
).stdout.decode(sys.stdout.encoding)
assert 'Unrecognized argument' in output
assert 'Traceback' not in output
+3
View File
@@ -32,6 +32,9 @@ def assert_command_does_not_duplicate_flags(command, *args, **kwargs):
flag_name: 1 for flag_name in flag_counts
}, f"Duplicate flags found in: {' '.join(command)}"
if '--json' in command:
return '{}'
def fuzz_argument(arguments, argument_name):
'''
+26 -7
View File
@@ -13,8 +13,9 @@ def test_parse_arguments_with_no_arguments_uses_defaults():
global_arguments = arguments['global']
assert global_arguments.config_paths == config_paths
assert global_arguments.verbosity == 0
assert global_arguments.syslog_verbosity == 0
assert global_arguments.log_file_verbosity == 0
assert global_arguments.syslog_verbosity == -2
assert global_arguments.log_file_verbosity == 1
assert global_arguments.monitoring_verbosity == 1
def test_parse_arguments_with_multiple_config_flags_parses_as_list():
@@ -25,8 +26,9 @@ def test_parse_arguments_with_multiple_config_flags_parses_as_list():
global_arguments = arguments['global']
assert global_arguments.config_paths == ['myconfig', 'otherconfig']
assert global_arguments.verbosity == 0
assert global_arguments.syslog_verbosity == 0
assert global_arguments.log_file_verbosity == 0
assert global_arguments.syslog_verbosity == -2
assert global_arguments.log_file_verbosity == 1
assert global_arguments.monitoring_verbosity == 1
def test_parse_arguments_with_action_after_config_path_omits_action():
@@ -71,8 +73,9 @@ def test_parse_arguments_with_verbosity_overrides_default():
global_arguments = arguments['global']
assert global_arguments.config_paths == config_paths
assert global_arguments.verbosity == 1
assert global_arguments.syslog_verbosity == 0
assert global_arguments.log_file_verbosity == 0
assert global_arguments.syslog_verbosity == -2
assert global_arguments.log_file_verbosity == 1
assert global_arguments.monitoring_verbosity == 1
def test_parse_arguments_with_syslog_verbosity_overrides_default():
@@ -85,6 +88,8 @@ def test_parse_arguments_with_syslog_verbosity_overrides_default():
assert global_arguments.config_paths == config_paths
assert global_arguments.verbosity == 0
assert global_arguments.syslog_verbosity == 2
assert global_arguments.log_file_verbosity == 1
assert global_arguments.monitoring_verbosity == 1
def test_parse_arguments_with_log_file_verbosity_overrides_default():
@@ -96,8 +101,9 @@ def test_parse_arguments_with_log_file_verbosity_overrides_default():
global_arguments = arguments['global']
assert global_arguments.config_paths == config_paths
assert global_arguments.verbosity == 0
assert global_arguments.syslog_verbosity == 0
assert global_arguments.syslog_verbosity == -2
assert global_arguments.log_file_verbosity == -1
assert global_arguments.monitoring_verbosity == 1
def test_parse_arguments_with_single_override_parses():
@@ -616,3 +622,16 @@ def test_parse_arguments_config_with_subaction_and_explicit_config_file_does_not
module.parse_arguments(
'config', 'bootstrap', '--repository', 'repo.borg', '--config', 'test.yaml'
)
def test_parse_arguments_with_borg_action_and_dry_run_raises():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(ValueError):
module.parse_arguments('--dry-run', 'borg', 'list')
def test_parse_arguments_with_borg_action_and_no_dry_run_does_not_raise():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
module.parse_arguments('borg', 'list')
+220 -15
View File
@@ -44,6 +44,14 @@ def test_load_configuration_replaces_complex_constants():
assert module.load_configuration('config.yaml') == {'key': {'subkey': 'value'}}
def test_load_configuration_with_only_integer_value_does_not_raise():
builtins = flexmock(sys.modules['builtins'])
config_file = io.StringIO('33')
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
assert module.load_configuration('config.yaml') == 33
def test_load_configuration_inlines_include_relative_to_current_directory():
builtins = flexmock(sys.modules['builtins'])
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
@@ -124,6 +132,37 @@ def test_load_configuration_raises_if_absolute_include_does_not_exist():
assert module.load_configuration('config.yaml')
def test_load_configuration_inlines_multiple_file_include_as_list():
builtins = flexmock(sys.modules['builtins'])
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
flexmock(module.os.path).should_receive('isabs').and_return(True)
flexmock(module.os.path).should_receive('exists').never()
include1_file = io.StringIO('value1')
include1_file.name = '/root/include1.yaml'
builtins.should_receive('open').with_args('/root/include1.yaml').and_return(include1_file)
include2_file = io.StringIO('value2')
include2_file.name = '/root/include2.yaml'
builtins.should_receive('open').with_args('/root/include2.yaml').and_return(include2_file)
config_file = io.StringIO('key: !include [/root/include1.yaml, /root/include2.yaml]')
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
assert module.load_configuration('config.yaml') == {'key': ['value2', 'value1']}
def test_load_configuration_include_with_unsupported_filename_type_raises():
builtins = flexmock(sys.modules['builtins'])
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
flexmock(module.os.path).should_receive('isabs').and_return(True)
flexmock(module.os.path).should_receive('exists').never()
config_file = io.StringIO('key: !include {path: /root/include.yaml}')
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
with pytest.raises(ValueError):
module.load_configuration('config.yaml')
def test_load_configuration_merges_include():
builtins = flexmock(sys.modules['builtins'])
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
@@ -149,6 +188,43 @@ def test_load_configuration_merges_include():
assert module.load_configuration('config.yaml') == {'foo': 'override', 'baz': 'quux'}
def test_load_configuration_merges_multiple_file_include():
builtins = flexmock(sys.modules['builtins'])
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
flexmock(module.os.path).should_receive('isabs').and_return(False)
flexmock(module.os.path).should_receive('exists').and_return(True)
include1_file = io.StringIO(
'''
foo: bar
baz: quux
original: yes
'''
)
include1_file.name = 'include1.yaml'
builtins.should_receive('open').with_args('/tmp/include1.yaml').and_return(include1_file)
include2_file = io.StringIO(
'''
baz: second
'''
)
include2_file.name = 'include2.yaml'
builtins.should_receive('open').with_args('/tmp/include2.yaml').and_return(include2_file)
config_file = io.StringIO(
'''
foo: override
<<: !include [include1.yaml, include2.yaml]
'''
)
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
assert module.load_configuration('config.yaml') == {
'foo': 'override',
'baz': 'second',
'original': 'yes',
}
def test_load_configuration_with_retain_tag_merges_include_but_keeps_local_values():
builtins = flexmock(sys.modules['builtins'])
flexmock(module.os).should_receive('getcwd').and_return('/tmp')
@@ -438,8 +514,9 @@ def test_raise_omit_node_error_raises():
module.raise_omit_node_error(loader=flexmock(), node=flexmock())
def test_filter_omitted_nodes():
nodes = [
def test_filter_omitted_nodes_discards_values_with_omit_tag_and_also_equal_values():
nodes = [flexmock(), flexmock()]
values = [
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='a'),
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='b'),
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='c'),
@@ -448,11 +525,139 @@ def test_filter_omitted_nodes():
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='c'),
]
result = module.filter_omitted_nodes(nodes)
result = module.filter_omitted_nodes(nodes, values)
assert [item.value for item in result] == ['a', 'c', 'a', 'c']
def test_filter_omitted_nodes_keeps_all_values_when_given_only_one_node():
nodes = [flexmock()]
values = [
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='a'),
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='b'),
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='c'),
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='a'),
module.ruamel.yaml.nodes.ScalarNode(tag='!omit', value='b'),
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='c'),
]
result = module.filter_omitted_nodes(nodes, values)
assert [item.value for item in result] == ['a', 'b', 'c', 'a', 'b', 'c']
def test_merge_values_combines_mapping_values():
nodes = [
(
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
module.ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
module.ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='keep_hourly'
),
module.ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:int', value='24'
),
),
(
module.ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='keep_daily'
),
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='7'),
),
],
),
),
(
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
module.ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
module.ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='keep_daily'
),
module.ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:int', value='25'
),
),
],
),
),
(
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
module.ruamel.yaml.nodes.MappingNode(
tag='tag:yaml.org,2002:map',
value=[
(
module.ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:str', value='keep_nanosecondly'
),
module.ruamel.yaml.nodes.ScalarNode(
tag='tag:yaml.org,2002:int', value='1000'
),
),
],
),
),
]
values = module.merge_values(nodes)
assert len(values) == 4
assert values[0][0].value == 'keep_hourly'
assert values[0][1].value == '24'
assert values[1][0].value == 'keep_daily'
assert values[1][1].value == '7'
assert values[2][0].value == 'keep_daily'
assert values[2][1].value == '25'
assert values[3][0].value == 'keep_nanosecondly'
assert values[3][1].value == '1000'
def test_merge_values_combines_sequence_values():
nodes = [
(
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
module.ruamel.yaml.nodes.SequenceNode(
tag='tag:yaml.org,2002:seq',
value=[
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='1'),
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='2'),
],
),
),
(
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
module.ruamel.yaml.nodes.SequenceNode(
tag='tag:yaml.org,2002:seq',
value=[
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='3'),
],
),
),
(
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:str', value='option'),
module.ruamel.yaml.nodes.SequenceNode(
tag='tag:yaml.org,2002:seq',
value=[
module.ruamel.yaml.nodes.ScalarNode(tag='tag:yaml.org,2002:int', value='4'),
],
),
),
]
values = module.merge_values(nodes)
assert len(values) == 4
assert values[0].value == '1'
assert values[1].value == '2'
assert values[2].value == '3'
assert values[3].value == '4'
def test_deep_merge_nodes_replaces_colliding_scalar_values():
node_values = [
(
@@ -499,10 +704,10 @@ def test_deep_merge_nodes_replaces_colliding_scalar_values():
assert section_key.value == 'retention'
options = section_value.value
assert len(options) == 2
assert options[0][0].value == 'keep_hourly'
assert options[0][1].value == '24'
assert options[1][0].value == 'keep_daily'
assert options[1][1].value == '5'
assert options[0][0].value == 'keep_daily'
assert options[0][1].value == '5'
assert options[1][0].value == 'keep_hourly'
assert options[1][1].value == '24'
def test_deep_merge_nodes_keeps_non_colliding_scalar_values():
@@ -553,10 +758,10 @@ def test_deep_merge_nodes_keeps_non_colliding_scalar_values():
assert section_key.value == 'retention'
options = section_value.value
assert len(options) == 3
assert options[0][0].value == 'keep_hourly'
assert options[0][1].value == '24'
assert options[1][0].value == 'keep_daily'
assert options[1][1].value == '7'
assert options[0][0].value == 'keep_daily'
assert options[0][1].value == '7'
assert options[1][0].value == 'keep_hourly'
assert options[1][1].value == '24'
assert options[2][0].value == 'keep_minutely'
assert options[2][1].value == '10'
@@ -629,10 +834,10 @@ def test_deep_merge_nodes_keeps_deeply_nested_values():
assert section_key.value == 'storage'
options = section_value.value
assert len(options) == 2
assert options[0][0].value == 'lock_wait'
assert options[0][1].value == '5'
assert options[1][0].value == 'extra_borg_options'
nested_options = options[1][1].value
assert options[0][0].value == 'extra_borg_options'
assert options[1][0].value == 'lock_wait'
assert options[1][1].value == '5'
nested_options = options[0][1].value
assert len(nested_options) == 2
assert nested_options[0][0].value == 'init'
assert nested_options[0][1].value == '--init-option'
+1 -1
View File
@@ -117,7 +117,7 @@ def test_parse_configuration_with_schema_lacking_examples_does_not_raise():
module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
def test_parse_configuration_inlines_include():
def test_parse_configuration_inlines_include_inside_deprecated_section():
mock_config_and_schema(
'''
source_directories:
+89
View File
@@ -0,0 +1,89 @@
import logging
import platform
from flexmock import flexmock
from borgmatic.hooks import loki as module
def test_initialize_monitor_replaces_labels():
'''
Assert that label placeholders get replaced.
'''
hook_config = {
'url': 'http://localhost:3100/loki/api/v1/push',
'labels': {'hostname': '__hostname', 'config': '__config', 'config_full': '__config_path'},
}
config_filename = '/mock/path/test.yaml'
dry_run = True
module.initialize_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run)
for handler in tuple(logging.getLogger().handlers):
if isinstance(handler, module.Loki_log_handler):
assert handler.buffer.root['streams'][0]['stream']['hostname'] == platform.node()
assert handler.buffer.root['streams'][0]['stream']['config'] == 'test.yaml'
assert handler.buffer.root['streams'][0]['stream']['config_full'] == config_filename
return
assert False
def test_initialize_monitor_adds_log_handler():
'''
Assert that calling initialize_monitor adds our logger to the root logger.
'''
hook_config = {'url': 'http://localhost:3100/loki/api/v1/push', 'labels': {'app': 'borgmatic'}}
module.initialize_monitor(
hook_config,
flexmock(),
config_filename='test.yaml',
monitoring_log_level=flexmock(),
dry_run=True,
)
for handler in tuple(logging.getLogger().handlers):
if isinstance(handler, module.Loki_log_handler):
return
assert False
def test_ping_monitor_adds_log_message():
'''
Assert that calling ping_monitor adds a message to our logger.
'''
hook_config = {'url': 'http://localhost:3100/loki/api/v1/push', 'labels': {'app': 'borgmatic'}}
config_filename = 'test.yaml'
dry_run = True
module.initialize_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run)
module.ping_monitor(
hook_config, flexmock(), config_filename, module.monitor.State.FINISH, flexmock(), dry_run
)
for handler in tuple(logging.getLogger().handlers):
if isinstance(handler, module.Loki_log_handler):
assert any(
map(
lambda log: log
== f'{config_filename}: {module.MONITOR_STATE_TO_LOKI[module.monitor.State.FINISH]} backup',
map(lambda x: x[1], handler.buffer.root['streams'][0]['values']),
)
)
return
assert False
def test_destroy_monitor_removes_log_handler():
'''
Assert that destroy_monitor removes the logger from the root logger.
'''
hook_config = {'url': 'http://localhost:3100/loki/api/v1/push', 'labels': {'app': 'borgmatic'}}
config_filename = 'test.yaml'
dry_run = True
module.initialize_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run)
module.destroy_monitor(hook_config, flexmock(), config_filename, flexmock(), dry_run)
for handler in tuple(logging.getLogger().handlers):
if isinstance(handler, module.Loki_log_handler):
assert False
+35
View File
@@ -40,6 +40,41 @@ def test_run_create_executes_and_calls_hooks_for_configured_repository():
)
def test_run_create_with_store_config_files_false_does_not_create_borgmatic_manifest():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
flexmock(module).should_receive('create_borgmatic_manifest').never()
flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return({})
flexmock(module.borgmatic.hooks.dispatch).should_receive(
'call_hooks_even_if_unconfigured'
).and_return({})
create_arguments = flexmock(
repository=None,
progress=flexmock(),
stats=flexmock(),
json=flexmock(),
list_files=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[])
list(
module.run_create(
config_filename='test.yaml',
repository={'path': 'repo'},
config={'store_config_files': False},
hook_context={},
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_runs_with_selected_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive(
+20
View File
@@ -0,0 +1,20 @@
from flexmock import flexmock
from borgmatic.actions import export_key as module
def test_run_export_key_does_not_raise():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.borg.export_key).should_receive('export_key')
export_arguments = flexmock(repository=flexmock())
module.run_export_key(
repository={'path': 'repo'},
config={},
local_borg_version=None,
export_arguments=export_arguments,
global_arguments=flexmock(),
local_path=None,
remote_path=None,
)
+150 -135
View File
@@ -4,60 +4,71 @@ from flexmock import flexmock
import borgmatic.actions.restore as module
def test_get_configured_database_matches_database_by_name():
assert module.get_configured_database(
def test_get_configured_data_source_matches_data_source_by_name():
assert module.get_configured_data_source(
config={
'other_databases': [{'name': 'other'}],
'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}],
},
archive_database_names={'postgresql_databases': ['other', 'foo', 'bar']},
archive_data_source_names={'postgresql_databases': ['other', 'foo', 'bar']},
hook_name='postgresql_databases',
database_name='bar',
data_source_name='bar',
) == ('postgresql_databases', {'name': 'bar'})
def test_get_configured_database_matches_nothing_when_database_name_not_configured():
assert module.get_configured_database(
config={'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}]},
archive_database_names={'postgresql_databases': ['foo']},
def test_get_configured_data_source_matches_nothing_when_nothing_configured():
assert module.get_configured_data_source(
config={},
archive_data_source_names={'postgresql_databases': ['foo']},
hook_name='postgresql_databases',
database_name='quux',
data_source_name='quux',
) == (None, None)
def test_get_configured_database_matches_nothing_when_database_name_not_in_archive():
assert module.get_configured_database(
def test_get_configured_data_source_matches_nothing_when_data_source_name_not_configured():
assert module.get_configured_data_source(
config={'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}]},
archive_database_names={'postgresql_databases': ['bar']},
archive_data_source_names={'postgresql_databases': ['foo']},
hook_name='postgresql_databases',
database_name='foo',
data_source_name='quux',
) == (None, None)
def test_get_configured_database_matches_database_by_configuration_database_name():
assert module.get_configured_database(
def test_get_configured_data_source_matches_nothing_when_data_source_name_not_in_archive():
assert module.get_configured_data_source(
config={'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}]},
archive_data_source_names={'postgresql_databases': ['bar']},
hook_name='postgresql_databases',
data_source_name='foo',
) == (None, None)
def test_get_configured_data_source_matches_data_source_by_configuration_data_source_name():
assert module.get_configured_data_source(
config={'postgresql_databases': [{'name': 'all'}, {'name': 'bar'}]},
archive_database_names={'postgresql_databases': ['foo']},
archive_data_source_names={'postgresql_databases': ['foo']},
hook_name='postgresql_databases',
database_name='foo',
configuration_database_name='all',
data_source_name='foo',
configuration_data_source_name='all',
) == ('postgresql_databases', {'name': 'all'})
def test_get_configured_database_with_unspecified_hook_matches_database_by_name():
assert module.get_configured_database(
def test_get_configured_data_source_with_unspecified_hook_matches_data_source_by_name():
assert module.get_configured_data_source(
config={
'other_databases': [{'name': 'other'}],
'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}],
},
archive_database_names={'postgresql_databases': ['other', 'foo', 'bar']},
archive_data_source_names={'postgresql_databases': ['other', 'foo', 'bar']},
hook_name=module.UNSPECIFIED_HOOK,
database_name='bar',
data_source_name='bar',
) == ('postgresql_databases', {'name': 'bar'})
def test_collect_archive_database_names_parses_archive_paths():
flexmock(module.borgmatic.hooks.dump).should_receive('make_database_dump_path').and_return('')
def test_collect_archive_data_source_names_parses_archive_paths():
flexmock(module.borgmatic.hooks.dump).should_receive('make_data_source_dump_path').and_return(
''
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
[
'.borgmatic/postgresql_databases/localhost/foo',
@@ -66,7 +77,7 @@ def test_collect_archive_database_names_parses_archive_paths():
]
)
archive_database_names = module.collect_archive_database_names(
archive_data_source_names = module.collect_archive_data_source_names(
repository={'path': 'repo'},
archive='archive',
config={'borgmatic_source_directory': '.borgmatic'},
@@ -76,14 +87,16 @@ def test_collect_archive_database_names_parses_archive_paths():
remote_path=flexmock(),
)
assert archive_database_names == {
assert archive_data_source_names == {
'postgresql_databases': ['foo', 'bar'],
'mysql_databases': ['quux'],
}
def test_collect_archive_database_names_parses_directory_format_archive_paths():
flexmock(module.borgmatic.hooks.dump).should_receive('make_database_dump_path').and_return('')
def test_collect_archive_data_source_names_parses_directory_format_archive_paths():
flexmock(module.borgmatic.hooks.dump).should_receive('make_data_source_dump_path').and_return(
''
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
[
'.borgmatic/postgresql_databases/localhost/foo/table1',
@@ -91,7 +104,7 @@ def test_collect_archive_database_names_parses_directory_format_archive_paths():
]
)
archive_database_names = module.collect_archive_database_names(
archive_data_source_names = module.collect_archive_data_source_names(
repository={'path': 'repo'},
archive='archive',
config={'borgmatic_source_directory': '.borgmatic'},
@@ -101,18 +114,20 @@ def test_collect_archive_database_names_parses_directory_format_archive_paths():
remote_path=flexmock(),
)
assert archive_database_names == {
assert archive_data_source_names == {
'postgresql_databases': ['foo'],
}
def test_collect_archive_database_names_skips_bad_archive_paths():
flexmock(module.borgmatic.hooks.dump).should_receive('make_database_dump_path').and_return('')
def test_collect_archive_data_source_names_skips_bad_archive_paths():
flexmock(module.borgmatic.hooks.dump).should_receive('make_data_source_dump_path').and_return(
''
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
['.borgmatic/postgresql_databases/localhost/foo', '.borgmatic/invalid', 'invalid/as/well']
)
archive_database_names = module.collect_archive_database_names(
archive_data_source_names = module.collect_archive_data_source_names(
repository={'path': 'repo'},
archive='archive',
config={'borgmatic_source_directory': '.borgmatic'},
@@ -122,96 +137,96 @@ def test_collect_archive_database_names_skips_bad_archive_paths():
remote_path=flexmock(),
)
assert archive_database_names == {
assert archive_data_source_names == {
'postgresql_databases': ['foo'],
}
def test_find_databases_to_restore_passes_through_requested_names_found_in_archive():
restore_names = module.find_databases_to_restore(
requested_database_names=['foo', 'bar'],
archive_database_names={'postresql_databases': ['foo', 'bar', 'baz']},
def test_find_data_sources_to_restore_passes_through_requested_names_found_in_archive():
restore_names = module.find_data_sources_to_restore(
requested_data_source_names=['foo', 'bar'],
archive_data_source_names={'postresql_databases': ['foo', 'bar', 'baz']},
)
assert restore_names == {module.UNSPECIFIED_HOOK: ['foo', 'bar']}
def test_find_databases_to_restore_raises_for_requested_names_missing_from_archive():
def test_find_data_sources_to_restore_raises_for_requested_names_missing_from_archive():
with pytest.raises(ValueError):
module.find_databases_to_restore(
requested_database_names=['foo', 'bar'],
archive_database_names={'postresql_databases': ['foo']},
module.find_data_sources_to_restore(
requested_data_source_names=['foo', 'bar'],
archive_data_source_names={'postresql_databases': ['foo']},
)
def test_find_databases_to_restore_without_requested_names_finds_all_archive_databases():
archive_database_names = {'postresql_databases': ['foo', 'bar']}
def test_find_data_sources_to_restore_without_requested_names_finds_all_archive_data_sources():
archive_data_source_names = {'postresql_databases': ['foo', 'bar']}
restore_names = module.find_databases_to_restore(
requested_database_names=[],
archive_database_names=archive_database_names,
restore_names = module.find_data_sources_to_restore(
requested_data_source_names=[],
archive_data_source_names=archive_data_source_names,
)
assert restore_names == archive_database_names
assert restore_names == archive_data_source_names
def test_find_databases_to_restore_with_all_in_requested_names_finds_all_archive_databases():
archive_database_names = {'postresql_databases': ['foo', 'bar']}
def test_find_data_sources_to_restore_with_all_in_requested_names_finds_all_archive_data_sources():
archive_data_source_names = {'postresql_databases': ['foo', 'bar']}
restore_names = module.find_databases_to_restore(
requested_database_names=['all'],
archive_database_names=archive_database_names,
restore_names = module.find_data_sources_to_restore(
requested_data_source_names=['all'],
archive_data_source_names=archive_data_source_names,
)
assert restore_names == archive_database_names
assert restore_names == archive_data_source_names
def test_find_databases_to_restore_with_all_in_requested_names_plus_additional_requested_names_omits_duplicates():
archive_database_names = {'postresql_databases': ['foo', 'bar']}
def test_find_data_sources_to_restore_with_all_in_requested_names_plus_additional_requested_names_omits_duplicates():
archive_data_source_names = {'postresql_databases': ['foo', 'bar']}
restore_names = module.find_databases_to_restore(
requested_database_names=['all', 'foo', 'bar'],
archive_database_names=archive_database_names,
restore_names = module.find_data_sources_to_restore(
requested_data_source_names=['all', 'foo', 'bar'],
archive_data_source_names=archive_data_source_names,
)
assert restore_names == archive_database_names
assert restore_names == archive_data_source_names
def test_find_databases_to_restore_raises_for_all_in_requested_names_and_requested_named_missing_from_archives():
def test_find_data_sources_to_restore_raises_for_all_in_requested_names_and_requested_named_missing_from_archives():
with pytest.raises(ValueError):
module.find_databases_to_restore(
requested_database_names=['all', 'foo', 'bar'],
archive_database_names={'postresql_databases': ['foo']},
module.find_data_sources_to_restore(
requested_data_source_names=['all', 'foo', 'bar'],
archive_data_source_names={'postresql_databases': ['foo']},
)
def test_ensure_databases_found_with_all_databases_found_does_not_raise():
module.ensure_databases_found(
def test_ensure_data_sources_found_with_all_data_sources_found_does_not_raise():
module.ensure_data_sources_found(
restore_names={'postgresql_databases': ['foo']},
remaining_restore_names={'postgresql_databases': ['bar']},
found_names=['foo', 'bar'],
)
def test_ensure_databases_found_with_no_databases_raises():
def test_ensure_data_sources_found_with_no_data_sources_raises():
with pytest.raises(ValueError):
module.ensure_databases_found(
module.ensure_data_sources_found(
restore_names={'postgresql_databases': []},
remaining_restore_names={},
found_names=[],
)
def test_ensure_databases_found_with_missing_databases_raises():
def test_ensure_data_sources_found_with_missing_data_sources_raises():
with pytest.raises(ValueError):
module.ensure_databases_found(
module.ensure_data_sources_found(
restore_names={'postgresql_databases': ['foo']},
remaining_restore_names={'postgresql_databases': ['bar']},
found_names=['foo'],
)
def test_run_restore_restores_each_database():
def test_run_restore_restores_each_data_source():
restore_names = {
'postgresql_databases': ['foo', 'bar'],
}
@@ -221,12 +236,12 @@ def test_run_restore_restores_each_database():
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
flexmock()
)
flexmock(module).should_receive('collect_archive_database_names').and_return(flexmock())
flexmock(module).should_receive('find_databases_to_restore').and_return(restore_names)
flexmock(module).should_receive('get_configured_database').and_return(
flexmock(module).should_receive('collect_archive_data_source_names').and_return(flexmock())
flexmock(module).should_receive('find_data_sources_to_restore').and_return(restore_names)
flexmock(module).should_receive('get_configured_data_source').and_return(
('postgresql_databases', {'name': 'foo'})
).and_return(('postgresql_databases', {'name': 'bar'}))
flexmock(module).should_receive('restore_single_database').with_args(
flexmock(module).should_receive('restore_single_data_source').with_args(
repository=object,
config=object,
local_borg_version=object,
@@ -235,10 +250,10 @@ def test_run_restore_restores_each_database():
remote_path=object,
archive_name=object,
hook_name='postgresql_databases',
database={'name': 'foo', 'schemas': None},
data_source={'name': 'foo', 'schemas': None},
connection_params=object,
).once()
flexmock(module).should_receive('restore_single_database').with_args(
flexmock(module).should_receive('restore_single_data_source').with_args(
repository=object,
config=object,
local_borg_version=object,
@@ -247,10 +262,10 @@ def test_run_restore_restores_each_database():
remote_path=object,
archive_name=object,
hook_name='postgresql_databases',
database={'name': 'bar', 'schemas': None},
data_source={'name': 'bar', 'schemas': None},
connection_params=object,
).once()
flexmock(module).should_receive('ensure_databases_found')
flexmock(module).should_receive('ensure_data_sources_found')
module.run_restore(
repository={'path': 'repo'},
@@ -259,7 +274,7 @@ def test_run_restore_restores_each_database():
restore_arguments=flexmock(
repository='repo',
archive='archive',
databases=flexmock(),
data_sources=flexmock(),
schemas=None,
hostname=None,
port=None,
@@ -280,20 +295,20 @@ def test_run_restore_bails_for_non_matching_repository():
flexmock(module.borgmatic.hooks.dispatch).should_receive(
'call_hooks_even_if_unconfigured'
).never()
flexmock(module).should_receive('restore_single_database').never()
flexmock(module).should_receive('restore_single_data_source').never()
module.run_restore(
repository={'path': 'repo'},
config=flexmock(),
local_borg_version=flexmock(),
restore_arguments=flexmock(repository='repo', archive='archive', databases=flexmock()),
restore_arguments=flexmock(repository='repo', archive='archive', data_sources=flexmock()),
global_arguments=flexmock(dry_run=False),
local_path=flexmock(),
remote_path=flexmock(),
)
def test_run_restore_restores_database_configured_with_all_name():
def test_run_restore_restores_data_source_configured_with_all_name():
restore_names = {
'postgresql_databases': ['foo', 'bar'],
}
@@ -303,28 +318,28 @@ def test_run_restore_restores_database_configured_with_all_name():
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
flexmock()
)
flexmock(module).should_receive('collect_archive_database_names').and_return(flexmock())
flexmock(module).should_receive('find_databases_to_restore').and_return(restore_names)
flexmock(module).should_receive('get_configured_database').with_args(
flexmock(module).should_receive('collect_archive_data_source_names').and_return(flexmock())
flexmock(module).should_receive('find_data_sources_to_restore').and_return(restore_names)
flexmock(module).should_receive('get_configured_data_source').with_args(
config=object,
archive_database_names=object,
archive_data_source_names=object,
hook_name='postgresql_databases',
database_name='foo',
data_source_name='foo',
).and_return(('postgresql_databases', {'name': 'foo'}))
flexmock(module).should_receive('get_configured_database').with_args(
flexmock(module).should_receive('get_configured_data_source').with_args(
config=object,
archive_database_names=object,
archive_data_source_names=object,
hook_name='postgresql_databases',
database_name='bar',
data_source_name='bar',
).and_return((None, None))
flexmock(module).should_receive('get_configured_database').with_args(
flexmock(module).should_receive('get_configured_data_source').with_args(
config=object,
archive_database_names=object,
archive_data_source_names=object,
hook_name='postgresql_databases',
database_name='bar',
configuration_database_name='all',
data_source_name='bar',
configuration_data_source_name='all',
).and_return(('postgresql_databases', {'name': 'bar'}))
flexmock(module).should_receive('restore_single_database').with_args(
flexmock(module).should_receive('restore_single_data_source').with_args(
repository=object,
config=object,
local_borg_version=object,
@@ -333,10 +348,10 @@ def test_run_restore_restores_database_configured_with_all_name():
remote_path=object,
archive_name=object,
hook_name='postgresql_databases',
database={'name': 'foo', 'schemas': None},
data_source={'name': 'foo', 'schemas': None},
connection_params=object,
).once()
flexmock(module).should_receive('restore_single_database').with_args(
flexmock(module).should_receive('restore_single_data_source').with_args(
repository=object,
config=object,
local_borg_version=object,
@@ -345,10 +360,10 @@ def test_run_restore_restores_database_configured_with_all_name():
remote_path=object,
archive_name=object,
hook_name='postgresql_databases',
database={'name': 'bar', 'schemas': None},
data_source={'name': 'bar', 'schemas': None},
connection_params=object,
).once()
flexmock(module).should_receive('ensure_databases_found')
flexmock(module).should_receive('ensure_data_sources_found')
module.run_restore(
repository={'path': 'repo'},
@@ -357,7 +372,7 @@ def test_run_restore_restores_database_configured_with_all_name():
restore_arguments=flexmock(
repository='repo',
archive='archive',
databases=flexmock(),
data_sources=flexmock(),
schemas=None,
hostname=None,
port=None,
@@ -371,7 +386,7 @@ def test_run_restore_restores_database_configured_with_all_name():
)
def test_run_restore_skips_missing_database():
def test_run_restore_skips_missing_data_source():
restore_names = {
'postgresql_databases': ['foo', 'bar'],
}
@@ -381,28 +396,28 @@ def test_run_restore_skips_missing_database():
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
flexmock()
)
flexmock(module).should_receive('collect_archive_database_names').and_return(flexmock())
flexmock(module).should_receive('find_databases_to_restore').and_return(restore_names)
flexmock(module).should_receive('get_configured_database').with_args(
flexmock(module).should_receive('collect_archive_data_source_names').and_return(flexmock())
flexmock(module).should_receive('find_data_sources_to_restore').and_return(restore_names)
flexmock(module).should_receive('get_configured_data_source').with_args(
config=object,
archive_database_names=object,
archive_data_source_names=object,
hook_name='postgresql_databases',
database_name='foo',
data_source_name='foo',
).and_return(('postgresql_databases', {'name': 'foo'}))
flexmock(module).should_receive('get_configured_database').with_args(
flexmock(module).should_receive('get_configured_data_source').with_args(
config=object,
archive_database_names=object,
archive_data_source_names=object,
hook_name='postgresql_databases',
database_name='bar',
data_source_name='bar',
).and_return((None, None))
flexmock(module).should_receive('get_configured_database').with_args(
flexmock(module).should_receive('get_configured_data_source').with_args(
config=object,
archive_database_names=object,
archive_data_source_names=object,
hook_name='postgresql_databases',
database_name='bar',
configuration_database_name='all',
data_source_name='bar',
configuration_data_source_name='all',
).and_return((None, None))
flexmock(module).should_receive('restore_single_database').with_args(
flexmock(module).should_receive('restore_single_data_source').with_args(
repository=object,
config=object,
local_borg_version=object,
@@ -411,10 +426,10 @@ def test_run_restore_skips_missing_database():
remote_path=object,
archive_name=object,
hook_name='postgresql_databases',
database={'name': 'foo', 'schemas': None},
data_source={'name': 'foo', 'schemas': None},
connection_params=object,
).once()
flexmock(module).should_receive('restore_single_database').with_args(
flexmock(module).should_receive('restore_single_data_source').with_args(
repository=object,
config=object,
local_borg_version=object,
@@ -423,10 +438,10 @@ def test_run_restore_skips_missing_database():
remote_path=object,
archive_name=object,
hook_name='postgresql_databases',
database={'name': 'bar', 'schemas': None},
data_source={'name': 'bar', 'schemas': None},
connection_params=object,
).never()
flexmock(module).should_receive('ensure_databases_found')
flexmock(module).should_receive('ensure_data_sources_found')
module.run_restore(
repository={'path': 'repo'},
@@ -435,7 +450,7 @@ def test_run_restore_skips_missing_database():
restore_arguments=flexmock(
repository='repo',
archive='archive',
databases=flexmock(),
data_sources=flexmock(),
schemas=None,
hostname=None,
port=None,
@@ -449,7 +464,7 @@ def test_run_restore_skips_missing_database():
)
def test_run_restore_restores_databases_from_different_hooks():
def test_run_restore_restores_data_sources_from_different_hooks():
restore_names = {
'postgresql_databases': ['foo'],
'mysql_databases': ['bar'],
@@ -460,21 +475,21 @@ def test_run_restore_restores_databases_from_different_hooks():
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
flexmock()
)
flexmock(module).should_receive('collect_archive_database_names').and_return(flexmock())
flexmock(module).should_receive('find_databases_to_restore').and_return(restore_names)
flexmock(module).should_receive('get_configured_database').with_args(
flexmock(module).should_receive('collect_archive_data_source_names').and_return(flexmock())
flexmock(module).should_receive('find_data_sources_to_restore').and_return(restore_names)
flexmock(module).should_receive('get_configured_data_source').with_args(
config=object,
archive_database_names=object,
archive_data_source_names=object,
hook_name='postgresql_databases',
database_name='foo',
data_source_name='foo',
).and_return(('postgresql_databases', {'name': 'foo'}))
flexmock(module).should_receive('get_configured_database').with_args(
flexmock(module).should_receive('get_configured_data_source').with_args(
config=object,
archive_database_names=object,
archive_data_source_names=object,
hook_name='mysql_databases',
database_name='bar',
data_source_name='bar',
).and_return(('mysql_databases', {'name': 'bar'}))
flexmock(module).should_receive('restore_single_database').with_args(
flexmock(module).should_receive('restore_single_data_source').with_args(
repository=object,
config=object,
local_borg_version=object,
@@ -483,10 +498,10 @@ def test_run_restore_restores_databases_from_different_hooks():
remote_path=object,
archive_name=object,
hook_name='postgresql_databases',
database={'name': 'foo', 'schemas': None},
data_source={'name': 'foo', 'schemas': None},
connection_params=object,
).once()
flexmock(module).should_receive('restore_single_database').with_args(
flexmock(module).should_receive('restore_single_data_source').with_args(
repository=object,
config=object,
local_borg_version=object,
@@ -495,10 +510,10 @@ def test_run_restore_restores_databases_from_different_hooks():
remote_path=object,
archive_name=object,
hook_name='mysql_databases',
database={'name': 'bar', 'schemas': None},
data_source={'name': 'bar', 'schemas': None},
connection_params=object,
).once()
flexmock(module).should_receive('ensure_databases_found')
flexmock(module).should_receive('ensure_data_sources_found')
module.run_restore(
repository={'path': 'repo'},
@@ -507,7 +522,7 @@ def test_run_restore_restores_databases_from_different_hooks():
restore_arguments=flexmock(
repository='repo',
archive='archive',
databases=flexmock(),
data_sources=flexmock(),
schemas=None,
hostname=None,
port=None,
+67 -23
View File
@@ -647,6 +647,53 @@ def test_create_archive_with_sources_and_used_config_paths_calls_borg_with_sourc
)
def test_create_archive_with_sources_and_used_config_paths_with_store_config_files_false_calls_borg_with_sources_and_no_config_paths():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([])
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('expand_directories').with_args([]).and_return(())
flexmock(module).should_receive('expand_directories').with_args(('foo', 'bar')).and_return(
('foo', 'bar')
)
flexmock(module).should_receive('expand_directories').with_args([]).and_return(())
flexmock(module).should_receive('pattern_root_directories').and_return([])
flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError)
flexmock(module).should_receive('expand_home_directories').and_return(())
flexmock(module).should_receive('write_pattern_file').and_return(None)
flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module).should_receive('ensure_files_readable')
flexmock(module).should_receive('make_pattern_flags').and_return(())
flexmock(module).should_receive('make_exclude_flags').and_return(())
flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(
(f'repo::{DEFAULT_ARCHIVE_NAME}',)
)
environment = {'BORG_THINGY': 'YUP'}
flexmock(module.environment).should_receive('make_environment').and_return(environment)
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create') + REPO_ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
working_directory=None,
extra_environment=environment,
)
module.create_archive(
dry_run=False,
repository_path='repo',
config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'store_config_files': False,
},
local_borg_version='1.2.3',
global_arguments=flexmock(log_json=False, used_config_paths=['/etc/borgmatic/config.yaml']),
)
def test_create_archive_with_exclude_patterns_calls_borg_with_excludes():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
@@ -713,7 +760,7 @@ def test_create_archive_with_log_info_calls_borg_with_info_parameter():
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create') + REPO_ARCHIVE_WITH_PATHS + ('--info',),
('borg', 'create', '--info') + REPO_ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
@@ -756,7 +803,7 @@ def test_create_archive_with_log_info_and_json_suppresses_most_borg_output():
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'create') + REPO_ARCHIVE_WITH_PATHS + ('--json',),
('borg', 'create', '--json') + REPO_ARCHIVE_WITH_PATHS,
working_directory=None,
extra_environment=None,
borg_local_path='borg',
@@ -798,7 +845,7 @@ def test_create_archive_with_log_debug_calls_borg_with_debug_parameter():
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create') + REPO_ARCHIVE_WITH_PATHS + ('--debug', '--show-rc'),
('borg', 'create', '--debug', '--show-rc') + REPO_ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
@@ -841,7 +888,7 @@ def test_create_archive_with_log_debug_and_json_suppresses_most_borg_output():
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'create') + REPO_ARCHIVE_WITH_PATHS + ('--json',),
('borg', 'create', '--json') + REPO_ARCHIVE_WITH_PATHS,
working_directory=None,
extra_environment=None,
borg_local_path='borg',
@@ -927,7 +974,7 @@ def test_create_archive_with_stats_and_dry_run_calls_borg_without_stats_paramete
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--dry-run') + REPO_ARCHIVE_WITH_PATHS + ('--info',),
('borg', 'create', '--dry-run', '--info') + REPO_ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=None,
borg_local_path='borg',
@@ -1802,7 +1849,7 @@ def test_create_archive_with_stats_calls_borg_with_stats_parameter_and_answer_ou
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create') + REPO_ARCHIVE_WITH_PATHS + ('--stats',),
('borg', 'create', '--stats') + REPO_ARCHIVE_WITH_PATHS,
output_log_level=module.borgmatic.logger.ANSWER,
output_file=None,
borg_local_path='borg',
@@ -1888,12 +1935,7 @@ def test_create_archive_with_progress_and_log_info_calls_borg_with_progress_para
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create')
+ REPO_ARCHIVE_WITH_PATHS
+ (
'--info',
'--progress',
),
('borg', 'create', '--info', '--progress') + REPO_ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=module.DO_NOT_CAPTURE,
borg_local_path='borg',
@@ -1937,7 +1979,7 @@ def test_create_archive_with_progress_calls_borg_with_progress_parameter():
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create') + REPO_ARCHIVE_WITH_PATHS + ('--progress',),
('borg', 'create', '--progress') + REPO_ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
output_file=module.DO_NOT_CAPTURE,
borg_local_path='borg',
@@ -1982,10 +2024,12 @@ def test_create_archive_with_progress_and_stream_processes_calls_borg_with_progr
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('collect_special_file_paths').and_return(())
create_command = (
('borg', 'create', '--one-file-system', '--read-special')
+ REPO_ARCHIVE_WITH_PATHS
+ ('--progress',)
)
'borg',
'create',
'--one-file-system',
'--read-special',
'--progress',
) + REPO_ARCHIVE_WITH_PATHS
flexmock(module).should_receive('execute_command_with_processes').with_args(
create_command + ('--dry-run', '--list'),
processes=processes,
@@ -2111,14 +2155,14 @@ def test_create_archive_with_stream_processes_adds_special_files_to_excludes():
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('collect_special_file_paths').and_return(('special',))
create_command = (
create_flags = (
'borg',
'create',
'--one-file-system',
'--read-special',
) + REPO_ARCHIVE_WITH_PATHS
)
flexmock(module).should_receive('execute_command_with_processes').with_args(
create_command + ('--dry-run', '--list'),
create_flags + ('--dry-run', '--list') + REPO_ARCHIVE_WITH_PATHS,
processes=processes,
output_log_level=logging.INFO,
output_file=None,
@@ -2127,7 +2171,7 @@ def test_create_archive_with_stream_processes_adds_special_files_to_excludes():
extra_environment=None,
)
flexmock(module).should_receive('execute_command_with_processes').with_args(
create_command + ('--exclude-from', '/excludes'),
create_flags + ('--exclude-from', '/excludes') + REPO_ARCHIVE_WITH_PATHS,
processes=processes,
output_log_level=logging.INFO,
output_file=None,
@@ -2235,7 +2279,7 @@ def test_create_archive_with_json_calls_borg_with_json_parameter():
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'create') + REPO_ARCHIVE_WITH_PATHS + ('--json',),
('borg', 'create', '--json') + REPO_ARCHIVE_WITH_PATHS,
working_directory=None,
extra_environment=None,
borg_local_path='borg',
@@ -2278,7 +2322,7 @@ def test_create_archive_with_stats_and_json_calls_borg_without_stats_parameter()
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'create') + REPO_ARCHIVE_WITH_PATHS + ('--json',),
('borg', 'create', '--json') + REPO_ARCHIVE_WITH_PATHS,
working_directory=None,
extra_environment=None,
borg_local_path='borg',
+16 -8
View File
@@ -19,28 +19,36 @@ def test_make_environment_with_ssh_command_should_set_environment():
assert environment.get('BORG_RSH') == 'ssh -C'
def test_make_environment_without_configuration_should_only_set_default_environment():
def test_make_environment_without_configuration_should_not_set_environment():
environment = module.make_environment({})
assert environment == {
'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'no',
'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'no',
'BORG_CHECK_I_KNOW_WHAT_I_AM_DOING': 'NO',
}
assert environment == {}
def test_make_environment_with_relocated_repo_access_should_override_default():
def test_make_environment_with_relocated_repo_access_true_should_set_environment_yes():
environment = module.make_environment({'relocated_repo_access_is_ok': True})
assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'yes'
def test_make_environment_check_i_know_what_i_am_doing_should_override_default():
def test_make_environment_with_relocated_repo_access_false_should_set_environment_no():
environment = module.make_environment({'relocated_repo_access_is_ok': False})
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():
environment = module.make_environment({'check_i_know_what_i_am_doing': True})
assert environment.get('BORG_CHECK_I_KNOW_WHAT_I_AM_DOING') == 'YES'
def test_make_environment_check_i_know_what_i_am_doing_false_should_set_environment_NO():
environment = module.make_environment({'check_i_know_what_i_am_doing': False})
assert environment.get('BORG_CHECK_I_KNOW_WHAT_I_AM_DOING') == 'NO'
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'
+222
View File
@@ -0,0 +1,222 @@
import logging
import pytest
from flexmock import flexmock
import borgmatic.logger
from borgmatic.borg import export_key as module
from ..test_verbosity import insert_logging_mock
def insert_execute_command_mock(command, output_file=module.DO_NOT_CAPTURE):
borgmatic.logger.add_custom_log_levels()
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
command,
output_file=output_file,
output_log_level=module.logging.ANSWER,
borg_local_path='borg',
extra_environment=None,
).once()
def test_export_key_calls_borg_with_required_flags():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_remote_path_flags():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', '--remote-path', 'borg1', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
remote_path='borg1',
)
def test_export_key_calls_borg_with_umask_flags():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', '--umask', '0770', 'repo'))
module.export_key(
repository_path='repo',
config={'umask': '0770'},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_log_json_flags():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', '--log-json', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=True),
)
def test_export_key_calls_borg_with_lock_wait_flags():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', '--lock-wait', '5', 'repo'))
module.export_key(
repository_path='repo',
config={'lock_wait': '5'},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_with_log_info_calls_borg_with_info_parameter():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', '--info', 'repo'))
insert_logging_mock(logging.INFO)
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_with_log_debug_calls_borg_with_debug_flags():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', '--debug', '--show-rc', 'repo'))
insert_logging_mock(logging.DEBUG)
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_paper_flags():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', '--paper', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=True, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_paper_flag():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', '--paper', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=True, qr_html=False, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_qr_html_flag():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', '--qr-html', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=True, path=None),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_calls_borg_with_path_argument():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').and_return(False)
insert_execute_command_mock(('borg', 'key', 'export', 'repo', 'dest'), output_file=None)
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path='dest'),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_with_already_existent_path_raises():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module).should_receive('execute_command').never()
with pytest.raises(FileExistsError):
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path='dest'),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_with_stdout_path_calls_borg_without_path_argument():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
insert_execute_command_mock(('borg', 'key', 'export', 'repo'))
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path='-'),
global_arguments=flexmock(dry_run=False, log_json=False),
)
def test_export_key_with_dry_run_skip_borg_call():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.os.path).should_receive('exists').never()
flexmock(module).should_receive('execute_command').never()
module.export_key(
repository_path='repo',
config={},
local_borg_version='1.2.3',
export_arguments=flexmock(paper=False, qr_html=False, path=None),
global_arguments=flexmock(dry_run=True, log_json=False),
)
+77 -10
View File
@@ -86,28 +86,28 @@ def test_make_repository_archive_flags_with_borg_features_joins_repository_and_a
@pytest.mark.parametrize(
'match_archives, archive_name_format,feature_available,expected_result',
'match_archives,archive_name_format,feature_available,expected_result',
(
(None, None, True, ()),
(None, '', True, ()),
(None, None, True, ('--match-archives', 'sh:{hostname}-*')), # noqa: FS003
(None, '', True, ('--match-archives', 'sh:{hostname}-*')), # noqa: FS003
(
're:foo-.*',
'{hostname}-{now}',
'{hostname}-{now}', # noqa: FS003
True,
('--match-archives', 're:foo-.*'),
), # noqa: FS003
),
(
'sh:foo-*',
'{hostname}-{now}',
'{hostname}-{now}', # noqa: FS003
False,
('--glob-archives', 'foo-*'),
), # noqa: FS003
),
(
'foo-*',
'{hostname}-{now}',
'{hostname}-{now}', # noqa: FS003
False,
('--glob-archives', 'foo-*'),
), # noqa: FS003
),
(
None,
'{hostname}-docs-{now}', # noqa: FS003
@@ -133,7 +133,24 @@ def test_make_repository_archive_flags_with_borg_features_joins_repository_and_a
False,
('--glob-archives', '{hostname}-docs-*'), # noqa: FS003
),
(None, '{utcnow}-docs-{user}', False, ('--glob-archives', '*-docs-{user}')), # noqa: FS003
(
None,
'{now}', # noqa: FS003
False,
(),
),
(
None,
'{now}', # noqa: FS003
True,
(),
),
(
None,
'{utcnow}-docs-{user}', # noqa: FS003
False,
('--glob-archives', '*-docs-{user}'), # noqa: FS003
),
),
)
def test_make_match_archives_flags_makes_flags_with_globs(
@@ -147,3 +164,53 @@ def test_make_match_archives_flags_makes_flags_with_globs(
)
== expected_result
)
def test_warn_for_aggressive_archive_flags_without_archive_flags_bails():
flexmock(module.logger).should_receive('warning').never()
module.warn_for_aggressive_archive_flags(('borg', '--do-stuff'), '{}')
def test_warn_for_aggressive_archive_flags_with_glob_archives_and_zero_archives_warns():
flexmock(module.logger).should_receive('warning').twice()
module.warn_for_aggressive_archive_flags(
('borg', '--glob-archives', 'foo*'), '{"archives": []}'
)
def test_warn_for_aggressive_archive_flags_with_match_archives_and_zero_archives_warns():
flexmock(module.logger).should_receive('warning').twice()
module.warn_for_aggressive_archive_flags(
('borg', '--match-archives', 'foo*'), '{"archives": []}'
)
def test_warn_for_aggressive_archive_flags_with_glob_archives_and_one_archive_does_not_warn():
flexmock(module.logger).should_receive('warning').never()
module.warn_for_aggressive_archive_flags(
('borg', '--glob-archives', 'foo*'), '{"archives": [{"name": "foo"]}'
)
def test_warn_for_aggressive_archive_flags_with_match_archives_and_one_archive_does_not_warn():
flexmock(module.logger).should_receive('warning').never()
module.warn_for_aggressive_archive_flags(
('borg', '--match-archives', 'foo*'), '{"archives": [{"name": "foo"]}'
)
def test_warn_for_aggressive_archive_flags_with_glob_archives_and_invalid_json_does_not_warn():
flexmock(module.logger).should_receive('warning').never()
module.warn_for_aggressive_archive_flags(('borg', '--glob-archives', 'foo*'), '{"archives": [}')
def test_warn_for_aggressive_archive_flags_with_glob_archives_and_json_missing_archives_does_not_warn():
flexmock(module.logger).should_receive('warning').never()
module.warn_for_aggressive_archive_flags(('borg', '--glob-archives', 'foo*'), '{}')
+157 -210
View File
@@ -8,224 +8,178 @@ from borgmatic.borg import info as module
from ..test_verbosity import insert_logging_mock
def test_display_archives_info_calls_borg_with_parameters():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_constructs_borg_info_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4'
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--repo', 'repo')
def test_display_archives_info_with_log_info_calls_borg_with_info_parameter():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_log_info_passes_through_to_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4'
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--info', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
insert_logging_mock(logging.INFO)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--info', '--repo', 'repo')
def test_display_archives_info_with_log_info_and_json_suppresses_most_borg_output():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_log_info_and_json_omits_borg_logging_flags():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4'
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',))
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'info', '--json', '--repo', 'repo'),
extra_environment=None,
borg_local_path='borg',
).and_return('[]')
insert_logging_mock(logging.INFO)
json_output = module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert json_output == '[]'
assert command == ('borg', 'info', '--json', '--repo', 'repo')
def test_display_archives_info_with_log_debug_calls_borg_with_debug_parameter():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_log_debug_passes_through_to_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4'
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--debug', '--show-rc', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
insert_logging_mock(logging.DEBUG)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--debug', '--show-rc', '--repo', 'repo')
def test_display_archives_info_with_log_debug_and_json_suppresses_most_borg_output():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_log_debug_and_json_omits_borg_logging_flags():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4'
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',))
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'info', '--json', '--repo', 'repo'),
extra_environment=None,
borg_local_path='borg',
).and_return('[]')
insert_logging_mock(logging.DEBUG)
json_output = module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert json_output == '[]'
assert command == ('borg', 'info', '--json', '--repo', 'repo')
def test_display_archives_info_with_json_calls_borg_with_json_parameter():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_json_passes_through_to_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4'
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',))
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'info', '--json', '--repo', 'repo'),
extra_environment=None,
borg_local_path='borg',
).and_return('[]')
json_output = module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert json_output == '[]'
assert command == ('borg', 'info', '--json', '--repo', 'repo')
def test_display_archives_info_with_archive_calls_borg_with_match_archives_parameter():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_archive_uses_match_archives_flags():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
'archive', None, '2.3.4'
).and_return(('--match-archives', 'archive'))
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--match-archives', 'archive', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive='archive', json=False, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--match-archives', 'archive', '--repo', 'repo')
def test_display_archives_info_with_local_path_calls_borg_via_local_path():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_local_path_passes_through_to_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4'
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg1', 'info', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg1',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg1',
remote_path=None,
)
command == ('borg1', 'info', '--repo', 'repo')
def test_display_archives_info_with_remote_path_calls_borg_with_remote_path_parameters():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_remote_path_passes_through_to_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args(
'remote-path', 'borg1'
@@ -235,27 +189,21 @@ def test_display_archives_info_with_remote_path_calls_borg_with_remote_path_para
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--remote-path', 'borg1', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg',
remote_path='borg1',
)
assert command == ('borg', 'info', '--remote-path', 'borg1', '--repo', 'repo')
def test_display_archives_info_with_log_json_calls_borg_with_log_json_parameters():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_log_json_passes_through_to_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args('log-json', True).and_return(
('--log-json',)
@@ -265,26 +213,21 @@ def test_display_archives_info_with_log_json_calls_borg_with_log_json_parameters
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--log-json', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=True),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--log-json', '--repo', 'repo')
def test_display_archives_info_with_lock_wait_calls_borg_with_lock_wait_parameters():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_lock_wait_passes_through_to_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args('lock-wait', 5).and_return(
('--lock-wait', '5')
@@ -295,26 +238,21 @@ def test_display_archives_info_with_lock_wait_calls_borg_with_lock_wait_paramete
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
config = {'lock_wait': 5}
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--lock-wait', '5', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config=config,
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--lock-wait', '5', '--repo', 'repo')
def test_display_archives_info_transforms_prefix_into_match_archives_parameters():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_transforms_prefix_into_match_archives_flags():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args(
'match-archives', 'sh:foo*'
@@ -324,26 +262,21 @@ def test_display_archives_info_transforms_prefix_into_match_archives_parameters(
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--match-archives', 'sh:foo*', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix='foo'),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--match-archives', 'sh:foo*', '--repo', 'repo')
def test_display_archives_info_prefers_prefix_over_archive_name_format():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_prefers_prefix_over_archive_name_format():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args(
'match-archives', 'sh:foo*'
@@ -353,52 +286,42 @@ def test_display_archives_info_prefers_prefix_over_archive_name_format():
).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--match-archives', 'sh:foo*', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={'archive_name_format': 'bar-{now}'}, # noqa: FS003
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix='foo'),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--match-archives', 'sh:foo*', '--repo', 'repo')
def test_display_archives_info_transforms_archive_name_format_into_match_archives_parameters():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_transforms_archive_name_format_into_match_archives_flags():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, 'bar-{now}', '2.3.4' # noqa: FS003
).and_return(('--match-archives', 'sh:bar-*'))
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--match-archives', 'sh:bar-*', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={'archive_name_format': 'bar-{now}'}, # noqa: FS003
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--match-archives', 'sh:bar-*', '--repo', 'repo')
def test_display_archives_with_match_archives_option_calls_borg_with_match_archives_parameter():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_match_archives_option_passes_through_to_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
'sh:foo-*', 'bar-{now}', '2.3.4' # noqa: FS003
@@ -406,14 +329,8 @@ def test_display_archives_with_match_archives_option_calls_borg_with_match_archi
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--match-archives', 'sh:foo-*', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={
'archive_name_format': 'bar-{now}', # noqa: FS003
@@ -422,12 +339,14 @@ def test_display_archives_with_match_archives_option_calls_borg_with_match_archi
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--match-archives', 'sh:foo-*', '--repo', 'repo')
def test_display_archives_with_match_archives_flag_calls_borg_with_match_archives_parameter():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_match_archives_flag_passes_through_to_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
'sh:foo-*', 'bar-{now}', '2.3.4' # noqa: FS003
@@ -435,26 +354,22 @@ def test_display_archives_with_match_archives_flag_calls_borg_with_match_archive
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', '--match-archives', 'sh:foo-*', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={'archive_name_format': 'bar-{now}'}, # noqa: FS003
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives='sh:foo-*'),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', '--match-archives', 'sh:foo-*', '--repo', 'repo')
@pytest.mark.parametrize('argument_name', ('sort_by', 'first', 'last'))
def test_display_archives_info_passes_through_arguments_to_borg(argument_name):
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_passes_arguments_through_to_command(argument_name):
flag_name = f"--{argument_name.replace('_', ' ')}"
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
@@ -465,14 +380,8 @@ def test_display_archives_info_passes_through_arguments_to_borg(argument_name):
)
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', flag_name, 'value', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
@@ -480,12 +389,14 @@ def test_display_archives_info_passes_through_arguments_to_borg(argument_name):
info_arguments=flexmock(
archive=None, json=False, prefix=None, match_archives=None, **{argument_name: 'value'}
),
local_path='borg',
remote_path=None,
)
assert command == ('borg', 'info', flag_name, 'value', '--repo', 'repo')
def test_display_archives_info_with_date_based_matching_calls_borg_with_date_based_flags():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
def test_make_info_command_with_date_based_matching_passes_through_to_command():
flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4'
@@ -494,26 +405,6 @@ def test_display_archives_info_with_date_based_matching_calls_borg_with_date_bas
('--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w')
)
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
(
'borg',
'info',
'--newer',
'1d',
'--newest',
'1y',
'--older',
'1m',
'--oldest',
'1w',
'--repo',
'repo',
),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
)
info_arguments = flexmock(
archive=None,
json=False,
@@ -524,10 +415,66 @@ def test_display_archives_info_with_date_based_matching_calls_borg_with_date_bas
older='1m',
oldest='1w',
)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=info_arguments,
local_path='borg',
remote_path=None,
)
assert command == (
'borg',
'info',
'--newer',
'1d',
'--newest',
'1y',
'--older',
'1m',
'--oldest',
'1w',
'--repo',
'repo',
)
def test_display_archives_info_calls_two_commands():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module).should_receive('make_info_command')
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').once()
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').once()
module.display_archives_info(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
)
def test_display_archives_info_with_json_calls_json_command_only():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module).should_receive('make_info_command')
flexmock(module.environment).should_receive('make_environment')
json_output = flexmock()
flexmock(module).should_receive('execute_command_and_capture_output').and_return(json_output)
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never()
flexmock(module).should_receive('execute_command').never()
assert (
module.display_archives_info(
repository_path='repo',
config={},
local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None),
)
== json_output
)
+13
View File
@@ -118,6 +118,19 @@ def test_make_prune_flags_without_prefix_uses_archive_name_format_instead():
assert result == expected
def test_make_prune_flags_ignores_keep_exclude_tags_in_config():
config = {
'keep_daily': 1,
'keep_exclude_tags': True,
}
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, local_borg_version='1.2.3')
assert result == ('--keep-daily', '1')
PRUNE_COMMAND = ('borg', 'prune', '--keep-daily', '1', '--keep-weekly', '2', '--keep-monthly', '3')
+51
View File
@@ -18,6 +18,12 @@ def test_display_repository_info_calls_borg_with_flags():
)
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'rinfo', '--json', '--repo', 'repo'),
borg_local_path='borg',
extra_environment=None,
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
@@ -40,6 +46,12 @@ def test_display_repository_info_without_borg_features_calls_borg_with_info_sub_
flexmock(module.feature).should_receive('available').and_return(False)
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'rinfo', '--json', 'repo'),
borg_local_path='borg',
extra_environment=None,
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
@@ -67,6 +79,12 @@ def test_display_repository_info_with_log_info_calls_borg_with_info_flag():
)
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'rinfo', '--info', '--json', '--repo', 'repo'),
borg_local_path='borg',
extra_environment=None,
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--info', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
@@ -99,6 +117,7 @@ def test_display_repository_info_with_log_info_and_json_suppresses_most_borg_out
extra_environment=None,
borg_local_path='borg',
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never()
insert_logging_mock(logging.INFO)
json_output = module.display_repository_info(
@@ -123,6 +142,12 @@ def test_display_repository_info_with_log_debug_calls_borg_with_debug_flag():
)
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'rinfo', '--debug', '--show-rc', '--json', '--repo', 'repo'),
borg_local_path='borg',
extra_environment=None,
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--debug', '--show-rc', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
@@ -156,6 +181,7 @@ def test_display_repository_info_with_log_debug_and_json_suppresses_most_borg_ou
extra_environment=None,
borg_local_path='borg',
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never()
insert_logging_mock(logging.DEBUG)
json_output = module.display_repository_info(
@@ -185,6 +211,7 @@ def test_display_repository_info_with_json_calls_borg_with_json_flag():
extra_environment=None,
borg_local_path='borg',
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never()
json_output = module.display_repository_info(
repository_path='repo',
@@ -208,6 +235,12 @@ def test_display_repository_info_with_local_path_calls_borg_via_local_path():
)
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg1', 'rinfo', '--json', '--repo', 'repo'),
extra_environment=None,
borg_local_path='borg',
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').with_args(
('borg1', 'rinfo', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
@@ -236,6 +269,12 @@ def test_display_repository_info_with_remote_path_calls_borg_with_remote_path_fl
)
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'rinfo', '--remote-path', 'borg1', '--json', '--repo', 'repo'),
extra_environment=None,
borg_local_path='borg',
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--remote-path', 'borg1', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
@@ -264,6 +303,12 @@ def test_display_repository_info_with_log_json_calls_borg_with_log_json_flags():
)
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'rinfo', '--log-json', '--json', '--repo', 'repo'),
extra_environment=None,
borg_local_path='borg',
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--log-json', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
@@ -292,6 +337,12 @@ def test_display_repository_info_with_lock_wait_calls_borg_with_lock_wait_flags(
)
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('borg', 'rinfo', '--lock-wait', '5', '--json', '--repo', 'repo'),
extra_environment=None,
borg_local_path='borg',
).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--lock-wait', '5', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
+14 -41
View File
@@ -559,66 +559,39 @@ def test_make_rlist_command_with_match_archives_calls_borg_with_match_archives_f
assert command == ('borg', 'list', '--match-archives', 'foo-*', 'repo')
def test_list_repository_calls_borg_with_flags():
def test_list_repository_calls_two_commands():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
rlist_arguments = argparse.Namespace(json=False)
global_arguments = flexmock()
flexmock(module.feature).should_receive('available').and_return(False)
flexmock(module).should_receive('make_rlist_command').with_args(
repository_path='repo',
config={},
local_borg_version='1.2.3',
rlist_arguments=rlist_arguments,
global_arguments=global_arguments,
local_path='borg',
remote_path=None,
).and_return(('borg', 'rlist', 'repo'))
flexmock(module).should_receive('make_rlist_command')
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'rlist', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER,
borg_local_path='borg',
extra_environment=None,
).once()
flexmock(module).should_receive('execute_command_and_capture_output').once()
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
flexmock(module).should_receive('execute_command').once()
module.list_repository(
repository_path='repo',
config={},
local_borg_version='1.2.3',
rlist_arguments=rlist_arguments,
global_arguments=global_arguments,
rlist_arguments=argparse.Namespace(json=False),
global_arguments=flexmock(),
)
def test_list_repository_with_json_returns_borg_output():
def test_list_repository_with_json_calls_json_command_only():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
rlist_arguments = argparse.Namespace(json=True)
global_arguments = flexmock()
json_output = flexmock()
flexmock(module.feature).should_receive('available').and_return(False)
flexmock(module).should_receive('make_rlist_command').with_args(
repository_path='repo',
config={},
local_borg_version='1.2.3',
rlist_arguments=rlist_arguments,
global_arguments=global_arguments,
local_path='borg',
remote_path=None,
).and_return(('borg', 'rlist', 'repo'))
flexmock(module).should_receive('make_rlist_command')
flexmock(module.environment).should_receive('make_environment')
json_output = flexmock()
flexmock(module).should_receive('execute_command_and_capture_output').and_return(json_output)
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never()
flexmock(module).should_receive('execute_command').never()
assert (
module.list_repository(
repository_path='repo',
config={},
local_borg_version='1.2.3',
rlist_arguments=rlist_arguments,
global_arguments=global_arguments,
rlist_arguments=argparse.Namespace(json=True),
global_arguments=flexmock(),
)
== json_output
)
+38 -6
View File
@@ -748,6 +748,24 @@ def test_run_actions_runs_break_lock():
)
def test_run_actions_runs_export_key():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.export_key).should_receive('run_export_key').once()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'export': flexmock()},
config_filename=flexmock(),
config={'repositories': []},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_borg():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
@@ -831,7 +849,7 @@ def test_log_record_with_suppress_does_not_raise():
def test_log_error_records_generates_output_logs_for_message_only():
flexmock(module).should_receive('log_record').replace_with(dict)
flexmock(module).should_receive('log_record').replace_with(dict).once()
logs = tuple(module.log_error_records('Error'))
@@ -839,7 +857,7 @@ def test_log_error_records_generates_output_logs_for_message_only():
def test_log_error_records_generates_output_logs_for_called_process_error_with_bytes_ouput():
flexmock(module).should_receive('log_record').replace_with(dict)
flexmock(module).should_receive('log_record').replace_with(dict).times(3)
flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING)
logs = tuple(
@@ -851,7 +869,7 @@ def test_log_error_records_generates_output_logs_for_called_process_error_with_b
def test_log_error_records_generates_output_logs_for_called_process_error_with_string_ouput():
flexmock(module).should_receive('log_record').replace_with(dict)
flexmock(module).should_receive('log_record').replace_with(dict).times(3)
flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING)
logs = tuple(
@@ -862,8 +880,22 @@ def test_log_error_records_generates_output_logs_for_called_process_error_with_s
assert any(log for log in logs if 'error output' in str(log))
def test_log_error_records_splits_called_process_error_with_multiline_ouput_into_multiple_logs():
flexmock(module).should_receive('log_record').replace_with(dict).times(4)
flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING)
logs = tuple(
module.log_error_records(
'Error', subprocess.CalledProcessError(1, 'ls', 'error output\nanother line')
)
)
assert {log['levelno'] for log in logs} == {logging.CRITICAL}
assert any(log for log in logs if 'error output' in str(log))
def test_log_error_records_generates_logs_for_value_error():
flexmock(module).should_receive('log_record').replace_with(dict)
flexmock(module).should_receive('log_record').replace_with(dict).twice()
logs = tuple(module.log_error_records('Error', ValueError()))
@@ -871,7 +903,7 @@ def test_log_error_records_generates_logs_for_value_error():
def test_log_error_records_generates_logs_for_os_error():
flexmock(module).should_receive('log_record').replace_with(dict)
flexmock(module).should_receive('log_record').replace_with(dict).twice()
logs = tuple(module.log_error_records('Error', OSError()))
@@ -879,7 +911,7 @@ def test_log_error_records_generates_logs_for_os_error():
def test_log_error_records_generates_nothing_for_other_error():
flexmock(module).should_receive('log_record').replace_with(dict)
flexmock(module).should_receive('log_record').never()
logs = tuple(module.log_error_records('Error', KeyError()))
+37
View File
@@ -0,0 +1,37 @@
import pytest
from flexmock import flexmock
from borgmatic.config import load as module
def test_probe_and_include_file_with_absolute_path_skips_probing():
config = flexmock()
flexmock(module).should_receive('load_configuration').with_args('/etc/include.yaml').and_return(
config
).once()
assert module.probe_and_include_file('/etc/include.yaml', ['/etc', '/var']) == config
def test_probe_and_include_file_with_relative_path_probes_include_directories():
config = flexmock()
flexmock(module.os.path).should_receive('exists').with_args('/etc/include.yaml').and_return(
False
)
flexmock(module.os.path).should_receive('exists').with_args('/var/include.yaml').and_return(
True
)
flexmock(module).should_receive('load_configuration').with_args('/etc/include.yaml').never()
flexmock(module).should_receive('load_configuration').with_args('/var/include.yaml').and_return(
config
).once()
assert module.probe_and_include_file('include.yaml', ['/etc', '/var']) == config
def test_probe_and_include_file_with_relative_path_and_missing_files_raises():
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module).should_receive('load_configuration').never()
with pytest.raises(FileNotFoundError):
module.probe_and_include_file('include.yaml', ['/etc', '/var'])
+12
View File
@@ -77,6 +77,11 @@ from borgmatic.config import normalize as module
{'bar': 'baz', 'prefix': 'foo'},
True,
),
(
{'location': {}, 'consistency': {'prefix': 'foo'}},
{'prefix': 'foo'},
True,
),
(
{},
{},
@@ -111,6 +116,13 @@ def test_normalize_sections_with_different_umask_values_raises():
module.normalize_sections('test.yaml', config)
def test_normalize_sections_with_only_scalar_raises():
config = 33
with pytest.raises(ValueError):
module.normalize_sections('test.yaml', config)
@pytest.mark.parametrize(
'config,expected_config,produces_logs',
(
+223
View File
@@ -0,0 +1,223 @@
import apprise
from apprise import NotifyFormat, NotifyType
from flexmock import flexmock
import borgmatic.hooks.monitor
from borgmatic.hooks import apprise as module
TOPIC = 'borgmatic-unit-testing'
def mock_apprise():
apprise_mock = flexmock(
add=lambda servers: None, notify=lambda title, body, body_format, notify_type: None
)
flexmock(apprise.Apprise).new_instances(apprise_mock)
return apprise_mock
def test_ping_monitor_adheres_dry_run():
mock_apprise().should_receive('notify').never()
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_does_not_hit_with_no_states():
mock_apprise().should_receive('notify').never()
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': []},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_hits_fail_by_default():
mock_apprise().should_receive('notify').with_args(
title='A borgmatic FAIL event happened',
body='A borgmatic FAIL event happened',
body_format=NotifyFormat.TEXT,
notify_type=NotifyType.FAILURE,
).once()
for state in borgmatic.hooks.monitor.State:
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
'config.yaml',
state,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_hits_with_finish_default_config():
mock_apprise().should_receive('notify').with_args(
title='A borgmatic FINISH event happened',
body='A borgmatic FINISH event happened',
body_format=NotifyFormat.TEXT,
notify_type=NotifyType.SUCCESS,
).once()
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['finish']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_hits_with_start_default_config():
mock_apprise().should_receive('notify').with_args(
title='A borgmatic START event happened',
body='A borgmatic START event happened',
body_format=NotifyFormat.TEXT,
notify_type=NotifyType.INFO,
).once()
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['start']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_hits_with_fail_default_config():
mock_apprise().should_receive('notify').with_args(
title='A borgmatic FAIL event happened',
body='A borgmatic FAIL event happened',
body_format=NotifyFormat.TEXT,
notify_type=NotifyType.FAILURE,
).once()
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_hits_with_log_default_config():
mock_apprise().should_receive('notify').with_args(
title='A borgmatic LOG event happened',
body='A borgmatic LOG event happened',
body_format=NotifyFormat.TEXT,
notify_type=NotifyType.INFO,
).once()
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['log']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.LOG,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_passes_through_custom_message_title():
mock_apprise().should_receive('notify').with_args(
title='foo',
body='bar',
body_format=NotifyFormat.TEXT,
notify_type=NotifyType.FAILURE,
).once()
module.ping_monitor(
{
'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}],
'states': ['fail'],
'fail': {'title': 'foo', 'body': 'bar'},
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_passes_through_custom_message_body():
mock_apprise().should_receive('notify').with_args(
title='',
body='baz',
body_format=NotifyFormat.TEXT,
notify_type=NotifyType.FAILURE,
).once()
module.ping_monitor(
{
'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}],
'states': ['fail'],
'fail': {'body': 'baz'},
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_pings_multiple_services():
mock_apprise().should_receive('add').with_args([f'ntfys://{TOPIC}', f'ntfy://{TOPIC}']).once()
module.ping_monitor(
{
'services': [
{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'},
{'url': f'ntfy://{TOPIC}', 'label': 'ntfy'},
]
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_logs_info_for_no_services():
flexmock(module.logger).should_receive('info').once()
module.ping_monitor(
{'services': []},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_logs_warning_when_notify_fails():
mock_apprise().should_receive('notify').and_return(False)
flexmock(module.logger).should_receive('warning').once()
for state in borgmatic.hooks.monitor.State:
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
'config.yaml',
state,
monitoring_log_level=1,
dry_run=False,
)
+18 -16
View File
@@ -4,34 +4,36 @@ from flexmock import flexmock
from borgmatic.hooks import dump as module
def test_make_database_dump_path_joins_arguments():
assert module.make_database_dump_path('/tmp', 'super_databases') == '/tmp/super_databases'
def test_make_data_source_dump_path_joins_arguments():
assert module.make_data_source_dump_path('/tmp', 'super_databases') == '/tmp/super_databases'
def test_make_database_dump_path_defaults_without_source_directory():
assert module.make_database_dump_path(None, 'super_databases') == '~/.borgmatic/super_databases'
def test_make_data_source_dump_path_defaults_without_source_directory():
assert (
module.make_data_source_dump_path(None, 'super_databases') == '~/.borgmatic/super_databases'
)
def test_make_database_dump_filename_uses_name_and_hostname():
def test_make_data_source_dump_filename_uses_name_and_hostname():
flexmock(module.os.path).should_receive('expanduser').and_return('databases')
assert (
module.make_database_dump_filename('databases', 'test', 'hostname')
module.make_data_source_dump_filename('databases', 'test', 'hostname')
== 'databases/hostname/test'
)
def test_make_database_dump_filename_without_hostname_defaults_to_localhost():
def test_make_data_source_dump_filename_without_hostname_defaults_to_localhost():
flexmock(module.os.path).should_receive('expanduser').and_return('databases')
assert module.make_database_dump_filename('databases', 'test') == 'databases/localhost/test'
assert module.make_data_source_dump_filename('databases', 'test') == 'databases/localhost/test'
def test_make_database_dump_filename_with_invalid_name_raises():
def test_make_data_source_dump_filename_with_invalid_name_raises():
flexmock(module.os.path).should_receive('expanduser').and_return('databases')
with pytest.raises(ValueError):
module.make_database_dump_filename('databases', 'invalid/name')
module.make_data_source_dump_filename('databases', 'invalid/name')
def test_create_parent_directory_for_dump_does_not_raise():
@@ -47,28 +49,28 @@ def test_create_named_pipe_for_dump_does_not_raise():
module.create_named_pipe_for_dump('/path/to/pipe')
def test_remove_database_dumps_removes_dump_path():
def test_remove_data_source_dumps_removes_dump_path():
flexmock(module.os.path).should_receive('expanduser').and_return('databases/localhost')
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.shutil).should_receive('rmtree').with_args('databases/localhost').once()
module.remove_database_dumps('databases', 'SuperDB', 'test.yaml', dry_run=False)
module.remove_data_source_dumps('databases', 'SuperDB', 'test.yaml', dry_run=False)
def test_remove_database_dumps_with_dry_run_skips_removal():
def test_remove_data_source_dumps_with_dry_run_skips_removal():
flexmock(module.os.path).should_receive('expanduser').and_return('databases/localhost')
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_database_dumps('databases', 'SuperDB', 'test.yaml', dry_run=True)
module.remove_data_source_dumps('databases', 'SuperDB', 'test.yaml', dry_run=True)
def test_remove_database_dumps_without_dump_path_present_skips_removal():
def test_remove_data_source_dumps_without_dump_path_present_skips_removal():
flexmock(module.os.path).should_receive('expanduser').and_return('databases/localhost')
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_database_dumps('databases', 'SuperDB', 'test.yaml', dry_run=False)
module.remove_data_source_dumps('databases', 'SuperDB', 'test.yaml', dry_run=False)
def test_convert_glob_patterns_to_borg_patterns_removes_leading_slash():
+108
View File
@@ -0,0 +1,108 @@
import json
import requests
from flexmock import flexmock
from borgmatic.hooks import loki as module
def test_loki_log_buffer_add_value_gets_raw():
'''
Assert that adding values to the log buffer increases it's length.
'''
buffer = module.Loki_log_buffer(flexmock(), False)
assert len(buffer) == 0
buffer.add_value('Some test log line')
assert len(buffer) == 1
buffer.add_value('Another test log line')
assert len(buffer) == 2
def test_loki_log_buffer_json_serializes_empty_buffer():
'''
Assert that the buffer correctly serializes when empty.
'''
buffer = module.Loki_log_buffer(flexmock(), False)
assert json.loads(buffer.to_request()) == json.loads('{"streams":[{"stream":{},"values":[]}]}')
def test_loki_log_buffer_json_serializes_labels():
'''
Assert that the buffer correctly serializes with labels.
'''
buffer = module.Loki_log_buffer(flexmock(), False)
buffer.add_label('test', 'label')
assert json.loads(buffer.to_request()) == json.loads(
'{"streams":[{"stream":{"test": "label"},"values":[]}]}'
)
def test_loki_log_buffer_json_serializes_log_lines():
'''
Assert that log lines end up in the correct place in the log buffer.
'''
buffer = module.Loki_log_buffer(flexmock(), False)
buffer.add_value('Some test log line')
assert json.loads(buffer.to_request())['streams'][0]['values'][0][1] == 'Some test log line'
def test_loki_log_handler_add_label_gets_labels():
'''
Assert that adding labels works.
'''
buffer = module.Loki_log_buffer(flexmock(), False)
buffer.add_label('test', 'label')
assert buffer.root['streams'][0]['stream']['test'] == 'label'
buffer.add_label('test2', 'label2')
assert buffer.root['streams'][0]['stream']['test2'] == 'label2'
def test_loki_log_handler_emit_gets_log_messages():
'''
Assert that adding log records works.
'''
handler = module.Loki_log_handler(flexmock(), False)
handler.emit(flexmock(getMessage=lambda: 'Some test log line'))
assert len(handler.buffer) == 1
def test_loki_log_handler_raw_posts_to_server():
'''
Assert that the flush function sends a post request after a certain limit.
'''
handler = module.Loki_log_handler(flexmock(), False)
flexmock(module.requests).should_receive('post').and_return(
flexmock(raise_for_status=lambda: '')
).once()
for num in range(int(module.MAX_BUFFER_LINES * 1.5)):
handler.raw(num)
def test_loki_log_handler_raw_post_failure_does_not_raise():
'''
Assert that the flush function catches request exceptions.
'''
handler = module.Loki_log_handler(flexmock(), False)
flexmock(module.requests).should_receive('post').and_return(
flexmock(raise_for_status=lambda: (_ for _ in ()).throw(requests.RequestException()))
).once()
for num in range(int(module.MAX_BUFFER_LINES * 1.5)):
handler.raw(num)
def test_loki_log_handler_flush_with_empty_buffer_does_not_raise():
'''
Test that flushing an empty buffer does indeed nothing.
'''
handler = module.Loki_log_handler(flexmock(), False)
handler.flush()
+621
View File
@@ -0,0 +1,621 @@
import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import mariadb as module
def test_database_names_to_dump_passes_through_name():
extra_environment = flexmock()
log_prefix = ''
names = module.database_names_to_dump(
{'name': 'foo'}, extra_environment, log_prefix, dry_run=False
)
assert names == ('foo',)
def test_database_names_to_dump_bails_for_dry_run():
extra_environment = flexmock()
log_prefix = ''
flexmock(module).should_receive('execute_command_and_capture_output').never()
names = module.database_names_to_dump(
{'name': 'all'}, extra_environment, log_prefix, dry_run=True
)
assert names == ()
def test_database_names_to_dump_queries_mariadb_for_database_names():
extra_environment = flexmock()
log_prefix = ''
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('mariadb', '--skip-column-names', '--batch', '--execute', 'show schemas'),
extra_environment=extra_environment,
).and_return('foo\nbar\nmysql\n').once()
names = module.database_names_to_dump(
{'name': 'all'}, extra_environment, log_prefix, dry_run=False
)
assert names == ('foo', 'bar')
def test_dump_data_sources_dumps_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
processes = [flexmock(), flexmock()]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
for name, process in zip(('foo', 'bar'), processes):
flexmock(module).should_receive('execute_dump_command').with_args(
database={'name': name},
log_prefix=object,
dump_path=object,
database_names=(name,),
extra_environment=object,
dry_run=object,
dry_run_label=object,
).and_return(process).once()
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes
def test_dump_data_sources_dumps_with_password():
database = {'name': 'foo', 'username': 'root', 'password': 'trustsome1'}
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module).should_receive('execute_dump_command').with_args(
database=database,
log_prefix=object,
dump_path=object,
database_names=('foo',),
extra_environment={'MYSQL_PWD': 'trustsome1'},
dry_run=object,
dry_run_label=object,
).and_return(process).once()
assert module.dump_data_sources([database], {}, 'test.yaml', dry_run=False) == [process]
def test_dump_data_sources_dumps_all_databases_at_once():
databases = [{'name': 'all'}]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
flexmock(module).should_receive('execute_dump_command').with_args(
database={'name': 'all'},
log_prefix=object,
dump_path=object,
database_names=('foo', 'bar'),
extra_environment=object,
dry_run=object,
dry_run_label=object,
).and_return(process).once()
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_dump_data_sources_dumps_all_databases_separately_when_format_configured():
databases = [{'name': 'all', 'format': 'sql'}]
processes = [flexmock(), flexmock()]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
for name, process in zip(('foo', 'bar'), processes):
flexmock(module).should_receive('execute_dump_command').with_args(
database={'name': name, 'format': 'sql'},
log_prefix=object,
dump_path=object,
database_names=(name,),
extra_environment=object,
dry_run=object,
dry_run_label=object,
).and_return(process).once()
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes
def test_database_names_to_dump_runs_mariadb_with_list_options():
database = {'name': 'all', 'list_options': '--defaults-extra-file=mariadb.cnf'}
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
(
'mariadb',
'--defaults-extra-file=mariadb.cnf',
'--skip-column-names',
'--batch',
'--execute',
'show schemas',
),
extra_environment=None,
).and_return(('foo\nbar')).once()
assert module.database_names_to_dump(database, None, 'test.yaml', '') == ('foo', 'bar')
def test_execute_dump_command_runs_mariadb_dump():
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.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
(
'mariadb-dump',
'--add-drop-database',
'--databases',
'foo',
'--result-file',
'dump',
),
extra_environment=None,
run_to_completion=False,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'foo'},
log_prefix='log',
dump_path=flexmock(),
database_names=('foo',),
extra_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')
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
(
'mariadb-dump',
'--databases',
'foo',
'--result-file',
'dump',
),
extra_environment=None,
run_to_completion=False,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'foo', 'add_drop_database': False},
log_prefix='log',
dump_path=flexmock(),
database_names=('foo',),
extra_environment=None,
dry_run=False,
dry_run_label='',
)
== process
)
def test_execute_dump_command_runs_mariadb_dump_with_hostname_and_port():
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.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
(
'mariadb-dump',
'--add-drop-database',
'--host',
'database.example.org',
'--port',
'5433',
'--protocol',
'tcp',
'--databases',
'foo',
'--result-file',
'dump',
),
extra_environment=None,
run_to_completion=False,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'foo', 'hostname': 'database.example.org', 'port': 5433},
log_prefix='log',
dump_path=flexmock(),
database_names=('foo',),
extra_environment=None,
dry_run=False,
dry_run_label='',
)
== process
)
def test_execute_dump_command_runs_mariadb_dump_with_username_and_password():
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.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',
),
extra_environment={'MYSQL_PWD': 'trustsome1'},
run_to_completion=False,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'foo', 'username': 'root', 'password': 'trustsome1'},
log_prefix='log',
dump_path=flexmock(),
database_names=('foo',),
extra_environment={'MYSQL_PWD': 'trustsome1'},
dry_run=False,
dry_run_label='',
)
== process
)
def test_execute_dump_command_runs_mariadb_dump_with_options():
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.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
(
'mariadb-dump',
'--stuff=such',
'--add-drop-database',
'--databases',
'foo',
'--result-file',
'dump',
),
extra_environment=None,
run_to_completion=False,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'foo', 'options': '--stuff=such'},
log_prefix='log',
dump_path=flexmock(),
database_names=('foo',),
extra_environment=None,
dry_run=False,
dry_run_label='',
)
== process
)
def test_execute_dump_command_with_duplicate_dump_skips_mariadb_dump():
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
flexmock(module).should_receive('execute_command').never()
assert (
module.execute_dump_command(
database={'name': 'foo'},
log_prefix='log',
dump_path=flexmock(),
database_names=('foo',),
extra_environment=None,
dry_run=True,
dry_run_label='SO DRY',
)
is None
)
def test_execute_dump_command_with_dry_run_skips_mariadb_dump():
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.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').never()
assert (
module.execute_dump_command(
database={'name': 'foo'},
log_prefix='log',
dump_path=flexmock(),
database_names=('foo',),
extra_environment=None,
dry_run=True,
dry_run_label='SO DRY',
)
is None
)
def test_dump_data_sources_errors_for_missing_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
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, {}, 'test.yaml', 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.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, {}, 'test.yaml', dry_run=True) == []
def test_restore_data_source_dump_runs_mariadb_to_restore():
hook_config = [{'name': 'foo'}, {'name': 'bar'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
('mariadb', '--batch'),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment=None,
).once()
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
data_source={'name': 'foo'},
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
)
def test_restore_data_source_dump_runs_mariadb_with_options():
hook_config = [{'name': 'foo', 'restore_options': '--harder'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
('mariadb', '--batch', '--harder'),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment=None,
).once()
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
)
def test_restore_data_source_dump_runs_mariadb_with_hostname_and_port():
hook_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'mariadb',
'--batch',
'--host',
'database.example.org',
'--port',
'5433',
'--protocol',
'tcp',
),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment=None,
).once()
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
)
def test_restore_data_source_dump_runs_mariadb_with_username_and_password():
hook_config = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
extract_process = flexmock(stdout=flexmock())
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,
extra_environment={'MYSQL_PWD': 'trustsome1'},
).once()
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
)
def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore():
hook_config = [
{
'name': 'foo',
'username': 'root',
'password': 'trustsome1',
'restore_hostname': 'restorehost',
'restore_port': 'restoreport',
'restore_username': 'restoreusername',
'restore_password': 'restorepassword',
}
]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'mariadb',
'--batch',
'--host',
'clihost',
'--port',
'cliport',
'--protocol',
'tcp',
'--user',
'cliusername',
),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment={'MYSQL_PWD': 'clipassword'},
).once()
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': 'clihost',
'port': 'cliport',
'username': 'cliusername',
'password': 'clipassword',
},
)
def test_restore_data_source_dump_without_connection_params_uses_restore_params_in_config_for_restore():
hook_config = [
{
'name': 'foo',
'username': 'root',
'password': 'trustsome1',
'hostname': 'dbhost',
'port': 'dbport',
'restore_username': 'restoreuser',
'restore_password': 'restorepass',
'restore_hostname': 'restorehost',
'restore_port': 'restoreport',
}
]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'mariadb',
'--batch',
'--host',
'restorehost',
'--port',
'restoreport',
'--protocol',
'tcp',
'--user',
'restoreuser',
),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
extra_environment={'MYSQL_PWD': 'restorepass'},
).once()
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
)
def test_restore_data_source_dump_with_dry_run_skips_restore():
hook_config = [{'name': 'foo'}]
flexmock(module).should_receive('execute_command_with_processes').never()
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
data_source={'name': 'foo'},
dry_run=True,
extract_process=flexmock(),
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
)
+99 -127
View File
@@ -1,53 +1,52 @@
import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import mongodb as module
def test_dump_databases_runs_mongodump_for_each_database():
def test_dump_data_sources_runs_mongodump_for_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
processes = [flexmock(), flexmock()]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
for name, process in zip(('foo', 'bar'), processes):
flexmock(module).should_receive('execute_command').with_args(
['mongodump', '--db', name, '--archive', '>', f'databases/localhost/{name}'],
('mongodump', '--db', name, '--archive', '>', f'databases/localhost/{name}'),
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == processes
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes
def test_dump_databases_with_dry_run_skips_mongodump():
def test_dump_data_sources_with_dry_run_skips_mongodump():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
flexmock(module).should_receive('execute_command').never()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=True) == []
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=True) == []
def test_dump_databases_runs_mongodump_with_hostname_and_port():
def test_dump_data_sources_runs_mongodump_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/database.example.org/foo'
)
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
[
(
'mongodump',
'--host',
'database.example.org',
@@ -58,15 +57,15 @@ def test_dump_databases_runs_mongodump_with_hostname_and_port():
'--archive',
'>',
'databases/database.example.org/foo',
],
),
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_dump_databases_runs_mongodump_with_username_and_password():
def test_dump_data_sources_runs_mongodump_with_username_and_password():
databases = [
{
'name': 'foo',
@@ -77,13 +76,13 @@ def test_dump_databases_runs_mongodump_with_username_and_password():
]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
[
(
'mongodump',
'--username',
'mongo',
@@ -96,85 +95,93 @@ def test_dump_databases_runs_mongodump_with_username_and_password():
'--archive',
'>',
'databases/localhost/foo',
],
),
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_dump_databases_runs_mongodump_with_directory_format():
def test_dump_data_sources_runs_mongodump_with_directory_format():
databases = [{'name': 'foo', 'format': 'directory'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.dump).should_receive('create_parent_directory_for_dump')
flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
flexmock(module).should_receive('execute_command').with_args(
['mongodump', '--out', 'databases/localhost/foo', '--db', 'foo'],
('mongodump', '--out', 'databases/localhost/foo', '--db', 'foo'),
shell=True,
).and_return(flexmock()).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == []
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == []
def test_dump_databases_runs_mongodump_with_options():
def test_dump_data_sources_runs_mongodump_with_options():
databases = [{'name': 'foo', 'options': '--stuff=such'}]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
['mongodump', '--db', 'foo', '--stuff=such', '--archive', '>', 'databases/localhost/foo'],
(
'mongodump',
'--db',
'foo',
'--stuff=such',
'--archive',
'>',
'databases/localhost/foo',
),
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_dump_databases_runs_mongodumpall_for_all_databases():
def test_dump_data_sources_runs_mongodumpall_for_all_databases():
databases = [{'name': 'all'}]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
['mongodump', '--archive', '>', 'databases/localhost/all'],
('mongodump', '--archive', '>', 'databases/localhost/all'),
shell=True,
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_restore_database_dump_runs_mongorestore():
databases_config = [{'name': 'foo', 'schemas': None}, {'name': 'bar'}]
def test_restore_data_source_dump_runs_mongorestore():
hook_config = [{'name': 'foo', 'schemas': None}, {'name': 'bar'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
['mongorestore', '--archive', '--drop', '--db', 'foo'],
['mongorestore', '--archive', '--drop'],
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source={'name': 'foo'},
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -186,46 +193,19 @@ def test_restore_database_dump_runs_mongorestore():
)
def test_restore_database_dump_errors_on_empty_databases_config():
databases_config = []
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').never()
flexmock(module).should_receive('execute_command').never()
with pytest.raises(ValueError):
module.restore_database_dump(
databases_config,
{},
'test.yaml',
database_name='foo',
dry_run=False,
extract_process=flexmock(),
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
)
def test_restore_database_dump_runs_mongorestore_with_hostname_and_port():
databases_config = [
def test_restore_data_source_dump_runs_mongorestore_with_hostname_and_port():
hook_config = [
{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433, 'schemas': None}
]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
[
'mongorestore',
'--archive',
'--drop',
'--db',
'foo',
'--host',
'database.example.org',
'--port',
@@ -236,11 +216,11 @@ def test_restore_database_dump_runs_mongorestore_with_hostname_and_port():
input_file=extract_process.stdout,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -252,8 +232,8 @@ def test_restore_database_dump_runs_mongorestore_with_hostname_and_port():
)
def test_restore_database_dump_runs_mongorestore_with_username_and_password():
databases_config = [
def test_restore_data_source_dump_runs_mongorestore_with_username_and_password():
hook_config = [
{
'name': 'foo',
'username': 'mongo',
@@ -265,14 +245,12 @@ def test_restore_database_dump_runs_mongorestore_with_username_and_password():
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
[
'mongorestore',
'--archive',
'--drop',
'--db',
'foo',
'--username',
'mongo',
'--password',
@@ -285,11 +263,11 @@ def test_restore_database_dump_runs_mongorestore_with_username_and_password():
input_file=extract_process.stdout,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -301,8 +279,8 @@ def test_restore_database_dump_runs_mongorestore_with_username_and_password():
)
def test_restore_database_dump_with_connection_params_uses_connection_params_for_restore():
databases_config = [
def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore():
hook_config = [
{
'name': 'foo',
'username': 'mongo',
@@ -318,14 +296,12 @@ def test_restore_database_dump_with_connection_params_uses_connection_params_for
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
[
'mongorestore',
'--archive',
'--drop',
'--db',
'foo',
'--host',
'clihost',
'--port',
@@ -342,11 +318,11 @@ def test_restore_database_dump_with_connection_params_uses_connection_params_for
input_file=extract_process.stdout,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -358,8 +334,8 @@ def test_restore_database_dump_with_connection_params_uses_connection_params_for
)
def test_restore_database_dump_without_connection_params_uses_restore_params_in_config_for_restore():
databases_config = [
def test_restore_data_source_dump_without_connection_params_uses_restore_params_in_config_for_restore():
hook_config = [
{
'name': 'foo',
'username': 'mongo',
@@ -375,14 +351,12 @@ def test_restore_database_dump_without_connection_params_uses_restore_params_in_
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
[
'mongorestore',
'--archive',
'--drop',
'--db',
'foo',
'--host',
'restorehost',
'--port',
@@ -399,11 +373,11 @@ def test_restore_database_dump_without_connection_params_uses_restore_params_in_
input_file=extract_process.stdout,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -415,24 +389,24 @@ def test_restore_database_dump_without_connection_params_uses_restore_params_in_
)
def test_restore_database_dump_runs_mongorestore_with_options():
databases_config = [{'name': 'foo', 'restore_options': '--harder', 'schemas': None}]
def test_restore_data_source_dump_runs_mongorestore_with_options():
hook_config = [{'name': 'foo', 'restore_options': '--harder', 'schemas': None}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
['mongorestore', '--archive', '--drop', '--db', 'foo', '--harder'],
['mongorestore', '--archive', '--drop', '--harder'],
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -445,18 +419,16 @@ def test_restore_database_dump_runs_mongorestore_with_options():
def test_restore_databases_dump_runs_mongorestore_with_schemas():
databases_config = [{'name': 'foo', 'schemas': ['bar', 'baz']}]
hook_config = [{'name': 'foo', 'schemas': ['bar', 'baz']}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
[
'mongorestore',
'--archive',
'--drop',
'--db',
'foo',
'--nsInclude',
'bar',
'--nsInclude',
@@ -467,11 +439,11 @@ def test_restore_databases_dump_runs_mongorestore_with_schemas():
input_file=extract_process.stdout,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -483,12 +455,12 @@ def test_restore_databases_dump_runs_mongorestore_with_schemas():
)
def test_restore_database_dump_runs_psql_for_all_database_dump():
databases_config = [{'name': 'all', 'schemas': None}]
def test_restore_data_source_dump_runs_psql_for_all_database_dump():
hook_config = [{'name': 'all', 'schemas': None}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
['mongorestore', '--archive'],
processes=[extract_process],
@@ -496,11 +468,11 @@ def test_restore_database_dump_runs_psql_for_all_database_dump():
input_file=extract_process.stdout,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='all',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -512,18 +484,18 @@ def test_restore_database_dump_runs_psql_for_all_database_dump():
)
def test_restore_database_dump_with_dry_run_skips_restore():
databases_config = [{'name': 'foo', 'schemas': None}]
def test_restore_data_source_dump_with_dry_run_skips_restore():
hook_config = [{'name': 'foo', 'schemas': None}]
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').never()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source={'name': 'foo'},
dry_run=True,
extract_process=flexmock(),
connection_params={
@@ -535,23 +507,23 @@ def test_restore_database_dump_with_dry_run_skips_restore():
)
def test_restore_database_dump_without_extract_process_restores_from_disk():
databases_config = [{'name': 'foo', 'format': 'directory', 'schemas': None}]
def test_restore_data_source_dump_without_extract_process_restores_from_disk():
hook_config = [{'name': 'foo', 'format': 'directory', 'schemas': None}]
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('/dump/path')
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path')
flexmock(module).should_receive('execute_command_with_processes').with_args(
['mongorestore', '--dir', '/dump/path', '--drop', '--db', 'foo'],
['mongorestore', '--dir', '/dump/path', '--drop'],
processes=[],
output_log_level=logging.DEBUG,
input_file=None,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source={'name': 'foo'},
dry_run=False,
extract_process=None,
connection_params={
+56 -79
View File
@@ -44,7 +44,7 @@ def test_database_names_to_dump_queries_mysql_for_database_names():
assert names == ('foo', 'bar')
def test_dump_databases_dumps_each_database():
def test_dump_data_sources_dumps_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
processes = [flexmock(), flexmock()]
flexmock(module).should_receive('make_dump_path').and_return('')
@@ -63,10 +63,10 @@ def test_dump_databases_dumps_each_database():
dry_run_label=object,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == processes
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes
def test_dump_databases_dumps_with_password():
def test_dump_data_sources_dumps_with_password():
database = {'name': 'foo', 'username': 'root', 'password': 'trustsome1'}
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
@@ -84,10 +84,10 @@ def test_dump_databases_dumps_with_password():
dry_run_label=object,
).and_return(process).once()
assert module.dump_databases([database], {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources([database], {}, 'test.yaml', dry_run=False) == [process]
def test_dump_databases_dumps_all_databases_at_once():
def test_dump_data_sources_dumps_all_databases_at_once():
databases = [{'name': 'all'}]
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
@@ -102,10 +102,10 @@ def test_dump_databases_dumps_all_databases_at_once():
dry_run_label=object,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_dump_databases_dumps_all_databases_separately_when_format_configured():
def test_dump_data_sources_dumps_all_databases_separately_when_format_configured():
databases = [{'name': 'all', 'format': 'sql'}]
processes = [flexmock(), flexmock()]
flexmock(module).should_receive('make_dump_path').and_return('')
@@ -122,7 +122,7 @@ def test_dump_databases_dumps_all_databases_separately_when_format_configured():
dry_run_label=object,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == processes
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes
def test_database_names_to_dump_runs_mysql_with_list_options():
@@ -144,7 +144,7 @@ def test_database_names_to_dump_runs_mysql_with_list_options():
def test_execute_dump_command_runs_mysqldump():
process = flexmock()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
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.dump).should_receive('create_named_pipe_for_dump')
@@ -177,7 +177,7 @@ def test_execute_dump_command_runs_mysqldump():
def test_execute_dump_command_runs_mysqldump_without_add_drop_database():
process = flexmock()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
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.dump).should_receive('create_named_pipe_for_dump')
@@ -209,7 +209,7 @@ def test_execute_dump_command_runs_mysqldump_without_add_drop_database():
def test_execute_dump_command_runs_mysqldump_with_hostname_and_port():
process = flexmock()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
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.dump).should_receive('create_named_pipe_for_dump')
@@ -248,7 +248,7 @@ def test_execute_dump_command_runs_mysqldump_with_hostname_and_port():
def test_execute_dump_command_runs_mysqldump_with_username_and_password():
process = flexmock()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
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.dump).should_receive('create_named_pipe_for_dump')
@@ -283,7 +283,7 @@ def test_execute_dump_command_runs_mysqldump_with_username_and_password():
def test_execute_dump_command_runs_mysqldump_with_options():
process = flexmock()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
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.dump).should_receive('create_named_pipe_for_dump')
@@ -316,7 +316,7 @@ def test_execute_dump_command_runs_mysqldump_with_options():
def test_execute_dump_command_with_duplicate_dump_skips_mysqldump():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
flexmock(module).should_receive('execute_command').never()
@@ -336,7 +336,7 @@ def test_execute_dump_command_with_duplicate_dump_skips_mysqldump():
def test_execute_dump_command_with_dry_run_skips_mysqldump():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
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.dump).should_receive('create_named_pipe_for_dump')
@@ -356,31 +356,31 @@ def test_execute_dump_command_with_dry_run_skips_mysqldump():
)
def test_dump_databases_errors_for_missing_all_databases():
def test_dump_data_sources_errors_for_missing_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
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_databases(databases, {}, 'test.yaml', dry_run=False)
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False)
def test_dump_databases_does_not_error_for_missing_all_databases_with_dry_run():
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.dump).should_receive('make_database_dump_filename').and_return(
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_databases(databases, {}, 'test.yaml', dry_run=True) == []
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=True) == []
def test_restore_database_dump_runs_mysql_to_restore():
databases_config = [{'name': 'foo'}, {'name': 'bar'}]
def test_restore_data_source_dump_runs_mysql_to_restore():
hook_config = [{'name': 'foo'}, {'name': 'bar'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
@@ -391,11 +391,11 @@ def test_restore_database_dump_runs_mysql_to_restore():
extra_environment=None,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source={'name': 'foo'},
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -407,31 +407,8 @@ def test_restore_database_dump_runs_mysql_to_restore():
)
def test_restore_database_dump_errors_when_database_missing_from_configuration():
databases_config = [{'name': 'foo'}, {'name': 'bar'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').never()
with pytest.raises(ValueError):
module.restore_database_dump(
databases_config,
{},
'test.yaml',
database_name='other',
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
)
def test_restore_database_dump_runs_mysql_with_options():
databases_config = [{'name': 'foo', 'restore_options': '--harder'}]
def test_restore_data_source_dump_runs_mysql_with_options():
hook_config = [{'name': 'foo', 'restore_options': '--harder'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
@@ -442,11 +419,11 @@ def test_restore_database_dump_runs_mysql_with_options():
extra_environment=None,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -458,8 +435,8 @@ def test_restore_database_dump_runs_mysql_with_options():
)
def test_restore_database_dump_runs_mysql_with_hostname_and_port():
databases_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
def test_restore_data_source_dump_runs_mysql_with_hostname_and_port():
hook_config = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
@@ -479,11 +456,11 @@ def test_restore_database_dump_runs_mysql_with_hostname_and_port():
extra_environment=None,
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -495,8 +472,8 @@ def test_restore_database_dump_runs_mysql_with_hostname_and_port():
)
def test_restore_database_dump_runs_mysql_with_username_and_password():
databases_config = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
def test_restore_data_source_dump_runs_mysql_with_username_and_password():
hook_config = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
@@ -507,11 +484,11 @@ def test_restore_database_dump_runs_mysql_with_username_and_password():
extra_environment={'MYSQL_PWD': 'trustsome1'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -523,8 +500,8 @@ def test_restore_database_dump_runs_mysql_with_username_and_password():
)
def test_restore_database_dump_with_connection_params_uses_connection_params_for_restore():
databases_config = [
def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore():
hook_config = [
{
'name': 'foo',
'username': 'root',
@@ -556,11 +533,11 @@ def test_restore_database_dump_with_connection_params_uses_connection_params_for
extra_environment={'MYSQL_PWD': 'clipassword'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source={'name': 'foo'},
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -572,8 +549,8 @@ def test_restore_database_dump_with_connection_params_uses_connection_params_for
)
def test_restore_database_dump_without_connection_params_uses_restore_params_in_config_for_restore():
databases_config = [
def test_restore_data_source_dump_without_connection_params_uses_restore_params_in_config_for_restore():
hook_config = [
{
'name': 'foo',
'username': 'root',
@@ -607,11 +584,11 @@ def test_restore_database_dump_without_connection_params_uses_restore_params_in_
extra_environment={'MYSQL_PWD': 'restorepass'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -623,16 +600,16 @@ def test_restore_database_dump_without_connection_params_uses_restore_params_in_
)
def test_restore_database_dump_with_dry_run_skips_restore():
databases_config = [{'name': 'foo'}]
def test_restore_data_source_dump_with_dry_run_skips_restore():
hook_config = [{'name': 'foo'}]
flexmock(module).should_receive('execute_command_with_processes').never()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source={'name': 'foo'},
dry_run=True,
extract_process=flexmock(),
connection_params={
+4 -4
View File
@@ -10,8 +10,8 @@ custom_base_url = 'https://ntfy.example.com'
topic = 'borgmatic-unit-testing'
custom_message_config = {
'title': 'Borgmatic unit testing',
'message': 'Borgmatic unit testing',
'title': 'borgmatic unit testing',
'message': 'borgmatic unit testing',
'priority': 'min',
'tags': '+1',
}
@@ -26,8 +26,8 @@ custom_message_headers = {
def return_default_message_headers(state=Enum):
headers = {
'X-Title': f'A Borgmatic {state.name} event happened',
'X-Message': f'A Borgmatic {state.name} event happened',
'X-Title': f'A borgmatic {state.name} event happened',
'X-Message': f'A borgmatic {state.name} event happened',
'X-Priority': 'default',
'X-Tags': 'borgmatic',
}
+103 -127
View File
@@ -185,7 +185,7 @@ def test_database_names_to_dump_with_all_and_psql_command_uses_custom_command():
)
def test_dump_databases_runs_pg_dump_for_each_database():
def test_dump_data_sources_runs_pg_dump_for_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
processes = [flexmock(), flexmock()]
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
@@ -193,7 +193,7 @@ def test_dump_databases_runs_pg_dump_for_each_database():
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.os.path).should_receive('exists').and_return(False)
@@ -217,69 +217,69 @@ def test_dump_databases_runs_pg_dump_for_each_database():
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == processes
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes
def test_dump_databases_raises_when_no_database_names_to_dump():
def test_dump_data_sources_raises_when_no_database_names_to_dump():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(())
with pytest.raises(ValueError):
module.dump_databases(databases, {}, 'test.yaml', dry_run=False)
module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False)
def test_dump_databases_does_not_raise_when_no_database_names_to_dump():
def test_dump_data_sources_does_not_raise_when_no_database_names_to_dump():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(())
module.dump_databases(databases, {}, 'test.yaml', dry_run=True) == []
module.dump_data_sources(databases, {}, 'test.yaml', dry_run=True) == []
def test_dump_databases_with_duplicate_dump_skips_pg_dump():
def test_dump_data_sources_with_duplicate_dump_skips_pg_dump():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
flexmock(module).should_receive('execute_command').never()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == []
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == []
def test_dump_databases_with_dry_run_skips_pg_dump():
def test_dump_data_sources_with_dry_run_skips_pg_dump():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
flexmock(module).should_receive('execute_command').never()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=True) == []
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=True) == []
def test_dump_databases_runs_pg_dump_with_hostname_and_port():
def test_dump_data_sources_runs_pg_dump_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
process = flexmock()
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/database.example.org/foo'
)
flexmock(module.os.path).should_receive('exists').and_return(False)
@@ -306,10 +306,10 @@ def test_dump_databases_runs_pg_dump_with_hostname_and_port():
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_dump_databases_runs_pg_dump_with_username_and_password():
def test_dump_data_sources_runs_pg_dump_with_username_and_password():
databases = [{'name': 'foo', 'username': 'postgres', 'password': 'trustsome1'}]
process = flexmock()
flexmock(module).should_receive('make_extra_environment').and_return(
@@ -317,7 +317,7 @@ def test_dump_databases_runs_pg_dump_with_username_and_password():
)
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.os.path).should_receive('exists').and_return(False)
@@ -342,15 +342,15 @@ def test_dump_databases_runs_pg_dump_with_username_and_password():
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_dump_databases_runs_pg_dump_with_directory_format():
def test_dump_data_sources_runs_pg_dump_with_directory_format():
databases = [{'name': 'foo', 'format': 'directory'}]
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.os.path).should_receive('exists').and_return(False)
@@ -373,16 +373,16 @@ def test_dump_databases_runs_pg_dump_with_directory_format():
extra_environment={'PGSSLMODE': 'disable'},
).and_return(flexmock()).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == []
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == []
def test_dump_databases_runs_pg_dump_with_options():
def test_dump_data_sources_runs_pg_dump_with_options():
databases = [{'name': 'foo', 'options': '--stuff=such'}]
process = flexmock()
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.os.path).should_receive('exists').and_return(False)
@@ -406,16 +406,16 @@ def test_dump_databases_runs_pg_dump_with_options():
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_dump_databases_runs_pg_dumpall_for_all_databases():
def test_dump_data_sources_runs_pg_dumpall_for_all_databases():
databases = [{'name': 'all'}]
process = flexmock()
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('all',))
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module.os.path).should_receive('exists').and_return(False)
@@ -428,16 +428,16 @@ def test_dump_databases_runs_pg_dumpall_for_all_databases():
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_dump_databases_runs_non_default_pg_dump():
def test_dump_data_sources_runs_non_default_pg_dump():
databases = [{'name': 'foo', 'pg_dump_command': 'special_pg_dump'}]
process = flexmock()
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.os.path).should_receive('exists').and_return(False)
@@ -460,16 +460,16 @@ def test_dump_databases_runs_non_default_pg_dump():
run_to_completion=False,
).and_return(process).once()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == [process]
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == [process]
def test_restore_database_dump_runs_pg_restore():
databases_config = [{'name': 'foo', 'schemas': None}, {'name': 'bar'}]
def test_restore_data_source_dump_runs_pg_restore():
hook_config = [{'name': 'foo', 'schemas': None}, {'name': 'bar'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
@@ -499,11 +499,11 @@ def test_restore_database_dump_runs_pg_restore():
extra_environment={'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source={'name': 'foo'},
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -515,39 +515,15 @@ def test_restore_database_dump_runs_pg_restore():
)
def test_restore_database_dump_errors_when_database_missing_from_configuration():
databases_config = [{'name': 'foo', 'schemas': None}, {'name': 'bar'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').never()
flexmock(module).should_receive('execute_command').never()
with pytest.raises(ValueError):
module.restore_database_dump(
databases_config,
{},
'test.yaml',
database_name='other',
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
)
def test_restore_database_dump_runs_pg_restore_with_hostname_and_port():
databases_config = [
def test_restore_data_source_dump_runs_pg_restore_with_hostname_and_port():
hook_config = [
{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433, 'schemas': None}
]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
@@ -585,11 +561,11 @@ def test_restore_database_dump_runs_pg_restore_with_hostname_and_port():
extra_environment={'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -601,8 +577,8 @@ def test_restore_database_dump_runs_pg_restore_with_hostname_and_port():
)
def test_restore_database_dump_runs_pg_restore_with_username_and_password():
databases_config = [
def test_restore_data_source_dump_runs_pg_restore_with_username_and_password():
hook_config = [
{'name': 'foo', 'username': 'postgres', 'password': 'trustsome1', 'schemas': None}
]
extract_process = flexmock(stdout=flexmock())
@@ -611,7 +587,7 @@ def test_restore_database_dump_runs_pg_restore_with_username_and_password():
{'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'}
)
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
@@ -645,11 +621,11 @@ def test_restore_database_dump_runs_pg_restore_with_username_and_password():
extra_environment={'PGPASSWORD': 'trustsome1', 'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -661,8 +637,8 @@ def test_restore_database_dump_runs_pg_restore_with_username_and_password():
)
def test_restore_database_dump_with_connection_params_uses_connection_params_for_restore():
databases_config = [
def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore():
hook_config = [
{
'name': 'foo',
'hostname': 'database.example.org',
@@ -682,7 +658,7 @@ def test_restore_database_dump_with_connection_params_uses_connection_params_for
{'PGPASSWORD': 'clipassword', 'PGSSLMODE': 'disable'}
)
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
@@ -724,11 +700,11 @@ def test_restore_database_dump_with_connection_params_uses_connection_params_for
extra_environment={'PGPASSWORD': 'clipassword', 'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source={'name': 'foo'},
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -740,8 +716,8 @@ def test_restore_database_dump_with_connection_params_uses_connection_params_for
)
def test_restore_database_dump_without_connection_params_uses_restore_params_in_config_for_restore():
databases_config = [
def test_restore_data_source_dump_without_connection_params_uses_restore_params_in_config_for_restore():
hook_config = [
{
'name': 'foo',
'hostname': 'database.example.org',
@@ -761,7 +737,7 @@ def test_restore_database_dump_without_connection_params_uses_restore_params_in_
{'PGPASSWORD': 'restorepassword', 'PGSSLMODE': 'disable'}
)
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
@@ -803,11 +779,11 @@ def test_restore_database_dump_without_connection_params_uses_restore_params_in_
extra_environment={'PGPASSWORD': 'restorepassword', 'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -819,8 +795,8 @@ def test_restore_database_dump_without_connection_params_uses_restore_params_in_
)
def test_restore_database_dump_runs_pg_restore_with_options():
databases_config = [
def test_restore_data_source_dump_runs_pg_restore_with_options():
hook_config = [
{
'name': 'foo',
'restore_options': '--harder',
@@ -832,7 +808,7 @@ def test_restore_database_dump_runs_pg_restore_with_options():
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
@@ -864,11 +840,11 @@ def test_restore_database_dump_runs_pg_restore_with_options():
extra_environment={'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -880,13 +856,13 @@ def test_restore_database_dump_runs_pg_restore_with_options():
)
def test_restore_database_dump_runs_psql_for_all_database_dump():
databases_config = [{'name': 'all', 'schemas': None}]
def test_restore_data_source_dump_runs_psql_for_all_database_dump():
hook_config = [{'name': 'all', 'schemas': None}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'psql',
@@ -903,11 +879,11 @@ def test_restore_database_dump_runs_psql_for_all_database_dump():
extra_environment={'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='all',
data_source={'name': 'all'},
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -919,13 +895,13 @@ def test_restore_database_dump_runs_psql_for_all_database_dump():
)
def test_restore_database_dump_runs_psql_for_plain_database_dump():
databases_config = [{'name': 'foo', 'format': 'plain', 'schemas': None}]
def test_restore_data_source_dump_runs_psql_for_plain_database_dump():
hook_config = [{'name': 'foo', 'format': 'plain', 'schemas': None}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
('psql', '--no-password', '--no-psqlrc', '--dbname', 'foo'),
processes=[extract_process],
@@ -947,11 +923,11 @@ def test_restore_database_dump_runs_psql_for_plain_database_dump():
extra_environment={'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -963,8 +939,8 @@ def test_restore_database_dump_runs_psql_for_plain_database_dump():
)
def test_restore_database_dump_runs_non_default_pg_restore_and_psql():
databases_config = [
def test_restore_data_source_dump_runs_non_default_pg_restore_and_psql():
hook_config = [
{
'name': 'foo',
'pg_restore_command': 'docker exec mycontainer pg_restore',
@@ -976,7 +952,7 @@ def test_restore_database_dump_runs_non_default_pg_restore_and_psql():
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'docker',
@@ -1012,11 +988,11 @@ def test_restore_database_dump_runs_non_default_pg_restore_and_psql():
extra_environment={'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
@@ -1028,19 +1004,19 @@ def test_restore_database_dump_runs_non_default_pg_restore_and_psql():
)
def test_restore_database_dump_with_dry_run_skips_restore():
databases_config = [{'name': 'foo', 'schemas': None}]
def test_restore_data_source_dump_with_dry_run_skips_restore():
hook_config = [{'name': 'foo', 'schemas': None}]
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename')
flexmock(module.dump).should_receive('make_data_source_dump_filename')
flexmock(module).should_receive('execute_command_with_processes').never()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source={'name': 'foo'},
dry_run=True,
extract_process=flexmock(),
connection_params={
@@ -1052,12 +1028,12 @@ def test_restore_database_dump_with_dry_run_skips_restore():
)
def test_restore_database_dump_without_extract_process_restores_from_disk():
databases_config = [{'name': 'foo', 'schemas': None}]
def test_restore_data_source_dump_without_extract_process_restores_from_disk():
hook_config = [{'name': 'foo', 'schemas': None}]
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('/dump/path')
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path')
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
@@ -1088,11 +1064,11 @@ def test_restore_database_dump_without_extract_process_restores_from_disk():
extra_environment={'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source={'name': 'foo'},
dry_run=False,
extract_process=None,
connection_params={
@@ -1104,12 +1080,12 @@ def test_restore_database_dump_without_extract_process_restores_from_disk():
)
def test_restore_database_dump_with_schemas_restores_schemas():
databases_config = [{'name': 'foo', 'schemas': ['bar', 'baz']}]
def test_restore_data_source_dump_with_schemas_restores_schemas():
hook_config = [{'name': 'foo', 'schemas': ['bar', 'baz']}]
flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return('/dump/path')
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('/dump/path')
flexmock(module).should_receive('execute_command_with_processes').with_args(
(
'pg_restore',
@@ -1144,11 +1120,11 @@ def test_restore_database_dump_with_schemas_restores_schemas():
extra_environment={'PGSSLMODE': 'disable'},
).once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='foo',
data_source=hook_config[0],
dry_run=False,
extract_process=None,
connection_params={
+33 -50
View File
@@ -1,26 +1,25 @@
import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import sqlite as module
def test_dump_databases_logs_and_skips_if_dump_already_exists():
def test_dump_data_sources_logs_and_skips_if_dump_already_exists():
databases = [{'path': '/path/to/database', 'name': 'database'}]
flexmock(module).should_receive('make_dump_path').and_return('/path/to/dump')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'/path/to/dump/database'
)
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.dump).should_receive('create_parent_directory_for_dump').never()
flexmock(module).should_receive('execute_command').never()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == []
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == []
def test_dump_databases_dumps_each_database():
def test_dump_data_sources_dumps_each_database():
databases = [
{'path': '/path/to/database1', 'name': 'database1'},
{'path': '/path/to/database2', 'name': 'database2'},
@@ -28,7 +27,7 @@ def test_dump_databases_dumps_each_database():
processes = [flexmock(), flexmock()]
flexmock(module).should_receive('make_dump_path').and_return('/path/to/dump')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'/path/to/dump/database'
)
flexmock(module.os.path).should_receive('exists').and_return(False)
@@ -37,7 +36,7 @@ def test_dump_databases_dumps_each_database():
processes[1]
)
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == processes
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes
def test_dumping_database_with_non_existent_path_warns_and_dumps_database():
@@ -48,14 +47,14 @@ def test_dumping_database_with_non_existent_path_warns_and_dumps_database():
flexmock(module).should_receive('make_dump_path').and_return('/path/to/dump')
flexmock(module.logger).should_receive('warning').once()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'/path/to/dump/database'
)
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.dump).should_receive('create_parent_directory_for_dump')
flexmock(module).should_receive('execute_command').and_return(processes[0])
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == processes
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes
def test_dumping_database_with_name_all_warns_and_dumps_all_databases():
@@ -68,32 +67,32 @@ def test_dumping_database_with_name_all_warns_and_dumps_all_databases():
flexmock(module.logger).should_receive(
'warning'
).twice() # once for the name=all, once for the non-existent path
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'/path/to/dump/database'
)
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.dump).should_receive('create_parent_directory_for_dump')
flexmock(module).should_receive('execute_command').and_return(processes[0])
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=False) == processes
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=False) == processes
def test_dump_databases_does_not_dump_if_dry_run():
def test_dump_data_sources_does_not_dump_if_dry_run():
databases = [{'path': '/path/to/database', 'name': 'database'}]
flexmock(module).should_receive('make_dump_path').and_return('/path/to/dump')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'/path/to/dump/database'
)
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.dump).should_receive('create_parent_directory_for_dump').never()
flexmock(module).should_receive('execute_command').never()
assert module.dump_databases(databases, {}, 'test.yaml', dry_run=True) == []
assert module.dump_data_sources(databases, {}, 'test.yaml', dry_run=True) == []
def test_restore_database_dump_restores_database():
databases_config = [{'path': '/path/to/database', 'name': 'database'}, {'name': 'other'}]
def test_restore_data_source_dump_restores_database():
hook_config = [{'path': '/path/to/database', 'name': 'database'}, {'name': 'other'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').with_args(
@@ -108,19 +107,19 @@ def test_restore_database_dump_restores_database():
flexmock(module.os).should_receive('remove').once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='database',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={'restore_path': None},
)
def test_restore_database_dump_with_connection_params_uses_connection_params_for_restore():
databases_config = [
def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore():
hook_config = [
{'path': '/path/to/database', 'name': 'database', 'restore_path': 'config/path/to/database'}
]
extract_process = flexmock(stdout=flexmock())
@@ -137,19 +136,19 @@ def test_restore_database_dump_with_connection_params_uses_connection_params_for
flexmock(module.os).should_receive('remove').once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='database',
data_source={'name': 'database'},
dry_run=False,
extract_process=extract_process,
connection_params={'restore_path': 'cli/path/to/database'},
)
def test_restore_database_dump_without_connection_params_uses_restore_params_in_config_for_restore():
databases_config = [
def test_restore_data_source_dump_without_connection_params_uses_restore_params_in_config_for_restore():
hook_config = [
{'path': '/path/to/database', 'name': 'database', 'restore_path': 'config/path/to/database'}
]
extract_process = flexmock(stdout=flexmock())
@@ -166,46 +165,30 @@ def test_restore_database_dump_without_connection_params_uses_restore_params_in_
flexmock(module.os).should_receive('remove').once()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='database',
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={'restore_path': None},
)
def test_restore_database_dump_does_not_restore_database_if_dry_run():
databases_config = [{'path': '/path/to/database', 'name': 'database'}]
def test_restore_data_source_dump_does_not_restore_database_if_dry_run():
hook_config = [{'path': '/path/to/database', 'name': 'database'}]
extract_process = flexmock(stdout=flexmock())
flexmock(module).should_receive('execute_command_with_processes').never()
flexmock(module.os).should_receive('remove').never()
module.restore_database_dump(
databases_config,
module.restore_data_source_dump(
hook_config,
{},
'test.yaml',
database_name='database',
data_source={'name': 'database'},
dry_run=True,
extract_process=extract_process,
connection_params={'restore_path': None},
)
def test_restore_database_dump_raises_error_if_database_config_is_empty():
databases_config = []
extract_process = flexmock(stdout=flexmock())
with pytest.raises(ValueError):
module.restore_database_dump(
databases_config,
{},
'test.yaml',
database_name='database',
dry_run=False,
extract_process=extract_process,
connection_params={'restore_path': None},
)
+82 -70
View File
@@ -174,16 +174,18 @@ def test_add_logging_level_skips_global_setting_if_already_set():
module.add_logging_level('PLAID', 99)
def test_configure_logging_probes_for_log_socket_on_linux():
def test_configure_logging_with_syslog_log_level_probes_for_log_socket_on_linux():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
flexmock(
setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO
)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
level=logging.DEBUG, handlers=list
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True)
syslog_handler = logging.handlers.SysLogHandler()
@@ -191,19 +193,21 @@ def test_configure_logging_probes_for_log_socket_on_linux():
address='/dev/log'
).and_return(syslog_handler).once()
module.configure_logging(logging.INFO)
module.configure_logging(logging.INFO, syslog_log_level=logging.DEBUG)
def test_configure_logging_probes_for_log_socket_on_macos():
def test_configure_logging_with_syslog_log_level_probes_for_log_socket_on_macos():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
flexmock(
setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO
)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
level=logging.DEBUG, handlers=list
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/var/run/syslog').and_return(True)
@@ -212,19 +216,21 @@ def test_configure_logging_probes_for_log_socket_on_macos():
address='/var/run/syslog'
).and_return(syslog_handler).once()
module.configure_logging(logging.INFO)
module.configure_logging(logging.INFO, syslog_log_level=logging.DEBUG)
def test_configure_logging_probes_for_log_socket_on_freebsd():
def test_configure_logging_with_syslog_log_level_probes_for_log_socket_on_freebsd():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
flexmock(
setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO
)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
level=logging.DEBUG, handlers=list
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/var/run/syslog').and_return(False)
@@ -234,85 +240,56 @@ def test_configure_logging_probes_for_log_socket_on_freebsd():
address='/var/run/log'
).and_return(syslog_handler).once()
module.configure_logging(logging.INFO)
module.configure_logging(logging.INFO, syslog_log_level=logging.DEBUG)
def test_configure_logging_sets_global_logger_to_most_verbose_log_level():
def test_configure_logging_without_syslog_log_level_skips_syslog():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
flexmock(
setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO
)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.DEBUG, handlers=tuple
).once()
flexmock(module.os.path).should_receive('exists').and_return(False)
level=logging.INFO, handlers=list
)
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
module.configure_logging(console_log_level=logging.INFO, syslog_log_level=logging.DEBUG)
module.configure_logging(console_log_level=logging.INFO)
def test_configure_logging_skips_syslog_if_not_found():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
flexmock(
setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO
)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
level=logging.INFO, handlers=list
)
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
module.configure_logging(console_log_level=logging.INFO)
def test_configure_logging_skips_syslog_if_interactive_console():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(True)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True)
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
module.configure_logging(console_log_level=logging.INFO)
def test_configure_logging_skips_syslog_if_syslog_logging_is_disabled():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).DISABLED = module.DISABLED
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
)
flexmock(module).should_receive('Console_color_formatter')
flexmock(module).should_receive('interactive_console').never()
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True)
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
module.configure_logging(console_log_level=logging.INFO, syslog_log_level=logging.DISABLED)
module.configure_logging(console_log_level=logging.INFO, syslog_log_level=logging.DEBUG)
def test_configure_logging_skips_log_file_if_log_file_logging_is_disabled():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).DISABLED = module.DISABLED
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
flexmock(
setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO
)
)
# syslog skipped in non-interactive console if --log-file argument provided
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
level=logging.INFO, handlers=list
)
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
@@ -327,13 +304,13 @@ def test_configure_logging_to_log_file_instead_of_syslog():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
flexmock(
setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO
)
)
# syslog skipped in non-interactive console if --log-file argument provided
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.DEBUG, handlers=tuple
level=logging.DEBUG, handlers=list
)
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
@@ -343,7 +320,40 @@ def test_configure_logging_to_log_file_instead_of_syslog():
).and_return(file_handler).once()
module.configure_logging(
console_log_level=logging.INFO, log_file_log_level=logging.DEBUG, log_file='/tmp/logfile'
console_log_level=logging.INFO,
syslog_log_level=logging.DISABLED,
log_file_log_level=logging.DEBUG,
log_file='/tmp/logfile',
)
def test_configure_logging_to_both_log_file_and_syslog():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(
setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO
)
)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.DEBUG, handlers=list
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True)
syslog_handler = logging.handlers.SysLogHandler()
flexmock(module.logging.handlers).should_receive('SysLogHandler').with_args(
address='/dev/log'
).and_return(syslog_handler).once()
file_handler = logging.handlers.WatchedFileHandler('/tmp/logfile')
flexmock(module.logging.handlers).should_receive('WatchedFileHandler').with_args(
'/tmp/logfile'
).and_return(file_handler).once()
module.configure_logging(
console_log_level=logging.INFO,
syslog_log_level=logging.DEBUG,
log_file_log_level=logging.DEBUG,
log_file='/tmp/logfile',
)
@@ -354,12 +364,14 @@ def test_configure_logging_to_log_file_formats_with_custom_log_format():
'{message}', style='{' # noqa: FS003
).once()
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
flexmock(
setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO
)
)
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.DEBUG, handlers=tuple
level=logging.DEBUG, handlers=list
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True)
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
@@ -380,13 +392,13 @@ def test_configure_logging_skips_log_file_if_argument_is_none():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return(
flexmock(setFormatter=lambda formatter: None, setLevel=lambda level: None)
flexmock(
setFormatter=lambda formatter: None, setLevel=lambda level: None, level=logging.INFO
)
)
# No WatchedFileHandler added if argument --log-file is None
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
level=logging.INFO, handlers=list
)
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.logging.handlers).should_receive('WatchedFileHandler').never()
+12
View File
@@ -1,3 +1,4 @@
import pytest
from flexmock import flexmock
from borgmatic import signals as module
@@ -34,6 +35,17 @@ def test_handle_signal_exits_on_sigterm():
module.handle_signal(signal_number, frame)
def test_handle_signal_raises_on_sigint():
signal_number = module.signal.SIGINT
frame = flexmock(f_back=flexmock(f_code=flexmock(co_name='something')))
flexmock(module.os).should_receive('getpgrp').and_return(flexmock)
flexmock(module.os).should_receive('killpg')
flexmock(module.sys).should_receive('exit').never()
with pytest.raises(KeyboardInterrupt):
module.handle_signal(signal_number, frame)
def test_configure_signals_installs_signal_handlers():
flexmock(module.signal).should_receive('signal').at_least().once()
+10 -8
View File
@@ -1,12 +1,12 @@
[tox]
envlist = py37,py38,py39,py310,py311
env_list = py37,py38,py39,py310,py311
skip_missing_interpreters = True
skipsdist = True
minversion = 3.14.1
package = editable
min_version = 4.0
[testenv]
usedevelop = True
deps = -rtest_requirements.txt
deps =
-r test_requirements.txt
whitelist_externals =
find
sh
@@ -27,10 +27,12 @@ commands =
pytest {posargs}
[testenv:end-to-end]
usedevelop = False
deps = -rtest_requirements.txt
package = editable
deps =
-r test_requirements.txt
pymongo==4.4.1
.
passenv = COVERAGE_FILE
pass_env = COVERAGE_FILE
commands =
pytest {posargs} --no-cov tests/end-to-end