Compare commits

...

73 Commits

Author SHA1 Message Date
Dan Helfman dc2dbf6dbb Validate the configured action names in the "skip_actions" option (#804). 2023-12-30 22:55:46 +00:00
Dan Helfman e16dcf175a The "check --force" flag now runs checks even if "check" is in "skip_actions" (#802). 2023-12-30 22:55:46 +00:00
Dan Helfman e9a973b12b Clarify constants/placeholders interaction and improve examples (#763). 2023-12-30 22:55:46 +00:00
Dan Helfman 755ec48d57 Add an "--ssh-command" flag to the "config bootstrap" action (#767). 2023-12-30 22:55:46 +00:00
Dan Helfman 402b03887e Document limitation with constant interpolation at the start of a value (#741). 2023-12-30 22:55:46 +00:00
Dan Helfman e62ff6d28b Add configured repository labels to the JSON output for all actions (#800). 2023-12-30 22:55:46 +00:00
Tobias Hodapp 8862a7123f Fixed borg -> Borg 2023-12-30 22:55:46 +00:00
Tobias Hodapp a7821b20e7 Added debug message that logs borg version for every config 2023-12-30 22:55:46 +00:00
Dan Helfman 0cac27c0f9 Fix a traceback when the "repositories" option contains both strings and key/value pairs (#794). 2023-12-30 22:55:46 +00:00
Dan Helfman 774805c647 Update documentation about configuration includes and constants (#745). 2023-12-30 22:55:46 +00:00
Dan Helfman aa376a6103 Bump version for release. 2023-12-30 22:55:46 +00:00
Dan Helfman 384033708a Constants support includes and command-line overrides (#745, #782) 2023-12-30 22:55:46 +00:00
Dan Helfman a54b9bb5eb Documentation clarifications (#791). 2023-12-30 22:55:46 +00:00
Dan Helfman 62a56507f3 Add another mention of "skip_actions" to the docs (#701). 2023-12-30 22:55:46 +00:00
Dan Helfman 4c9c275965 Documentation formatting. 2023-12-30 22:55:46 +00:00
Dan Helfman 0d43ba1f78 Document the possible units of times for a configured check frequency. 2023-12-30 22:55:45 +00:00
Dan Helfman f5e6bf14f3 Remove broken link in documentation (#786). 2023-12-30 22:55:45 +00:00
Dan Helfman 959374ebd8 Add test support for Python 3.12. 2023-12-30 22:55:45 +00:00
Dan Helfman b7c714e1bd Remove additional Python 3.7-isms (#784). 2023-12-30 22:55:45 +00:00
Dan Helfman f231a3f1c0 Drop support for Python 3.7, which has been end-of-lifed (#784). 2023-12-30 22:55:45 +00:00
Dan Helfman e1eeef99ac Fix tests (#783). 2023-12-30 22:55:45 +00:00
Dan Helfman a16a14c905 Upgrade ruamel.yaml dependency to support version 0.18.x (#783). 2023-12-30 22:55:45 +00:00
Dan Helfman 38cec60efe Update documentation about logging changes from version 1.8.3 (#665). 2023-12-30 22:55:45 +00:00
tdltdc ca7262ef30 Typo 2023-12-30 22:55:45 +00:00
Dan Helfman 7ef372cc4c Fix environment variable interpolation within configured repository paths (#782). 2023-12-30 22:55:45 +00:00
debuglevel 45481a671a Update docs/how-to/inspect-your-backups.md 2023-12-30 22:55:45 +00:00
debuglevel 0e420ef8ab Typo 2023-12-30 22:55:45 +00:00
debuglevel 35c5196fd7 Typo 2023-12-30 22:55:45 +00:00
Dan Helfman 9fb69694d3 Add a "skip_actions" option to skip running particular actions (#701). 2023-12-30 22:55:45 +00:00
Dan Helfman dc0c623f49 Only parse "--override" values as complex data types when they're for options of those types (#779). 2023-12-30 22:55:45 +00:00
Dan Helfman 82a1e6d23e Correct changelog addition (#779). 2023-12-30 22:55:45 +00:00
Dan Helfman 8d3e36b0c8 Add a "--match-archives" flag to the "check" action (#779). 2023-12-30 22:55:45 +00:00
Dan Helfman 7a095b71e4 Fix home page CSS layout to prevent overflow at certain window widths (#777). 2023-12-30 22:55:45 +00:00
Dan Helfman cb71b0ce74 Bump version for release. 2023-12-30 22:55:45 +00:00
Dan Helfman 35e65c2296 When an archive filter causes no matching archives for the "rlist" or "info" actions, warn (#748). 2023-12-30 22:55:45 +00:00
Dan Helfman 64ac449258 Upgrade to tox 4. (Now a minimum requirement.) 2023-12-30 22:55:45 +00:00
Dan Helfman 1991c1ce6b Disallow the "--dry-run" flag with the "borg" action (#774). 2023-12-30 22:55:45 +00:00
David Härdeman 421ba89902 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-12-30 22:55:45 +00:00
Dan Helfman 9b80716c0d Fix normalization of deprecated sections to support empty sections without erroring (#771). 2023-12-30 22:55:45 +00:00
Dan Helfman b31bd52a19 Update home page example of Healthchecks configuration not to use deprecated config. 2023-12-30 22:55:45 +00:00
Dan Helfman d0b2155af7 Update Healthchecks deprecation warning message for clarity. 2023-12-30 22:55:45 +00:00
Dan Helfman aa0214fd49 Be more explicit in documentation that you don't have to use an environment variable for passphrases. 2023-12-30 22:55:45 +00:00
Dan Helfman 6754afb1d0 Add documentation note about using includes for specifying passphrases (#769). 2023-12-30 22:55:45 +00:00
Dan Helfman 8cd2d26dbe Add documentation note about sudo and sudoers "secure_path" option (#757). 2023-12-30 22:55:45 +00:00
Dan Helfman 84cd7af3f8 Fix a traceback when an invalid command-line flag or action is used (#768). 2023-12-30 22:55:45 +00:00
Dan Helfman 6dee7f59ac Add Grafana Loki badge to integrations documentation. 2023-12-30 22:55:45 +00:00
Pim Kunis 4d463ba689 add apprise logo to integrations in readme 2023-12-30 22:55:45 +00:00
Dan Helfman 5ef532599a Upgrade certifi test dependency to fix security alert. 2023-12-30 22:55:45 +00:00
Dan Helfman a925228cd1 Update Apprise documentation to use sudo for pipx install (#715). 2023-12-30 22:55:45 +00:00
Dan Helfman b228f98052 Fix Apprise/PyYAML end-to-end test breakage (#715). 2023-12-30 22:55:45 +00:00
Dan Helfman 50a94e2a6b Apprise hook documentation (#715). 2023-12-30 22:55:45 +00:00
Pim Kunis 01f8af25e4 fix PR comments 2023-12-30 22:55:45 +00:00
Pim Kunis e29fafba83 add unit tests for apprise hook 2023-12-30 22:55:45 +00:00
Pim Kunis 814c4fd102 fix PR comments 2023-12-30 22:55:45 +00:00
Pim Kunis 2b14e09c86 pin Apprise dependencies for test requirements 2023-12-30 22:55:45 +00:00
Pim Kunis 39f4a0eb0f fix typo in setup.py
handle if apprise cannot be imported
2023-12-30 22:55:45 +00:00
Pim Kunis aa609d8c05 convert map to list for apprise function call
fix apprise config schema
remove apprise from required dependencies
2023-12-30 22:55:45 +00:00
Pim Kunis f4f5a12609 incorporate PR review comments 2023-12-30 22:55:45 +00:00
Pim Kunis 5e2af48584 remove comments about tags 2023-12-30 22:55:45 +00:00
Pim Kunis 4a3891ac30 default apprise notify type per borgmatic state 2023-12-30 22:55:45 +00:00
Pim Kunis c54287c6fd add support for apprise 2023-12-30 22:55:45 +00:00
Dan Helfman 36f8f30158 Bump version for release. 2023-12-30 22:55:45 +00:00
Dan Helfman e5f5e38dc4 Build docs regardless of Drone "event" (push, etc.). 2023-12-30 22:55:45 +00:00
Dan Helfman bbb4bf29e1 Simplify logging logic (#665). 2023-12-30 22:55:45 +00:00
Dan Helfman c53a15a460 Fix for borgmatic not stopping Borg immediately when the user presses ctrl-C (#761). 2023-12-30 22:55:45 +00:00
Dan Helfman 81a2debf62 Add documentation note about upgrading multiple pipx installations of borgmatic. 2023-12-30 22:55:45 +00:00
Dan Helfman f23ea6b520 Fix tense typo. 2023-12-30 22:55:45 +00:00
Dan Helfman df1fbf1ab0 Updated documentation so "sudo borgmatic" works for pipx borgmatic installations (#757). 2023-12-30 22:55:45 +00:00
Dan Helfman ba6ffcea9d Fix documentation typo. 2023-12-30 22:55:45 +00:00
Dan Helfman a5514a8f25 Fix error handling to log command output as one record per line (#754). 2023-12-30 22:55:45 +00:00
Dan Helfman 6e05a0c952 Attempt to unbreak ticket filing. 2023-12-30 22:55:45 +00:00
Dan Helfman 9b6327677a When "archive_name_format" is not set, filter archives using the default archive name format (#753). 2023-12-30 22:55:45 +00:00
Dan Helfman fe10ccba59 Update documentation to recommend installing/upgrading borgmatic with pipx instead of pip. 2023-12-30 22:55:45 +00:00
84 changed files with 2571 additions and 950 deletions

View File

@ -93,5 +93,3 @@ trigger:
- borgmatic-collective/borgmatic - borgmatic-collective/borgmatic
branch: branch:
- main - main
event:
- push

View File

@ -1 +1 @@
blank_issues_enabled: false blank_issues_enabled: true

61
NEWS
View File

@ -1,7 +1,64 @@
1.8.3.dev0 1.8.6.dev0
* #743: Add a monitoring hook for sending backup status and logs to to Grafana Loki. See the * #767: Add an "--ssh-command" flag to the "config bootstrap" action for setting a custom SSH
command, as no configuration is available (including the "ssh_command" option) until
bootstrapping completes.
* #794: Fix a traceback when the "repositories" option contains both strings and key/value pairs.
* #800: Add configured repository labels to the JSON output for all actions.
* #802: The "check --force" flag now runs checks even if "check" is in "skip_actions".
* #804: Validate the configured action names in the "skip_actions" option.
* When logging commands that borgmatic executes, log the environment variables that
borgmatic sets for those commands. (But don't log their values, since they often contain
passwords.)
1.8.5
* #701: Add a "skip_actions" option to skip running particular actions, handy for append-only or
checkless configurations. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#skipping-actions
* #701: Deprecate the "disabled" value for the "checks" option in favor of the new "skip_actions"
option.
* #745: Constants now apply to included configuration, not just the file doing the includes. As a
side effect of this change, constants no longer apply to option names and only substitute into
configuration values.
* #779: Add a "--match-archives" flag to the "check" action for selecting the archives to check,
overriding the existing "archive_name_format" and "match_archives" options in configuration.
* #779: Only parse "--override" values as complex data types when they're for options of those
types.
* #782: Fix environment variable interpolation within configured repository paths.
* #782: Add configuration constant overriding via the existing "--override" flag.
* #783: Upgrade ruamel.yaml dependency to support version 0.18.x.
* #784: Drop support for Python 3.7, which has been end-of-lifed.
1.8.4
* #715: Add a monitoring hook for sending backup status to a variety of monitoring services via the
Apprise library. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook
* #748: When an archive filter causes no matching archives for the "rlist" or "info"
actions, warn the user and suggest how to remove the filter.
* #768: Fix a traceback when an invalid command-line flag or action is used.
* #771: Fix normalization of deprecated sections ("location:", "storage:", "hooks:", etc.) to
support empty sections without erroring.
* #774: Disallow the "--dry-run" flag with the "borg" action, as borgmatic can't guarantee the Borg
command won't have side effects.
1.8.3
* #665: BREAKING: Simplify logging logic as follows: Syslog verbosity is now disabled by
default, but setting the "--syslog-verbosity" flag enables it regardless of whether you're at an
interactive console. Additionally, "--log-file-verbosity" and "--monitoring-verbosity" now
default to 1 (info about steps borgmatic is taking) instead of 0. And both syslog logging and
file logging can be enabled simultaneously.
* #743: Add a monitoring hook for sending backup status and logs to Grafana Loki. See the
documentation for more information: documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook 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 1.8.2
* #345: Add "key export" action to export a copy of the repository key for safekeeping in case * #345: Add "key export" action to export a copy of the repository key for safekeeping in case

View File

@ -48,24 +48,27 @@ postgresql_databases:
- name: users - name: users
# Third-party services to notify you if backups aren't happening. # 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/). borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
## Integrations ## 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.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;"></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; margin-right:20px;"></a>
<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://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;"></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; margin-right:20px;"></a>
<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://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;"></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; margin-right:20px;"></a>
<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://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;"></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; margin-right:20px;"></a>
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <a href="https://www.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;"></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; 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;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; <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 ## Getting started

View File

@ -39,13 +39,10 @@ def run_check(
repository['path'], repository['path'],
config, config,
local_borg_version, local_borg_version,
check_arguments,
global_arguments, global_arguments,
local_path=local_path, local_path=local_path,
remote_path=remote_path, remote_path=remote_path,
progress=check_arguments.progress,
repair=check_arguments.repair,
only_checks=check_arguments.only,
force=check_arguments.force,
) )
borgmatic.hooks.command.execute_hook( borgmatic.hooks.command.execute_hook(
config.get('after_check'), config.get('after_check'),

View File

@ -31,18 +31,19 @@ def get_config_paths(bootstrap_arguments, global_arguments, local_borg_version):
borgmatic_manifest_path = os.path.expanduser( borgmatic_manifest_path = os.path.expanduser(
os.path.join(borgmatic_source_directory, 'bootstrap', 'manifest.json') os.path.join(borgmatic_source_directory, 'bootstrap', 'manifest.json')
) )
config = {'ssh_command': bootstrap_arguments.ssh_command}
extract_process = borgmatic.borg.extract.extract_archive( extract_process = borgmatic.borg.extract.extract_archive(
global_arguments.dry_run, global_arguments.dry_run,
bootstrap_arguments.repository, bootstrap_arguments.repository,
borgmatic.borg.rlist.resolve_archive_name( borgmatic.borg.rlist.resolve_archive_name(
bootstrap_arguments.repository, bootstrap_arguments.repository,
bootstrap_arguments.archive, bootstrap_arguments.archive,
{}, config,
local_borg_version, local_borg_version,
global_arguments, global_arguments,
), ),
[borgmatic_manifest_path], [borgmatic_manifest_path],
{}, config,
local_borg_version, local_borg_version,
global_arguments, global_arguments,
extract_to_stdout=True, extract_to_stdout=True,
@ -79,6 +80,7 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
manifest_config_paths = get_config_paths( manifest_config_paths = get_config_paths(
bootstrap_arguments, global_arguments, local_borg_version bootstrap_arguments, global_arguments, local_borg_version
) )
config = {'ssh_command': bootstrap_arguments.ssh_command}
logger.info(f"Bootstrapping config paths: {', '.join(manifest_config_paths)}") logger.info(f"Bootstrapping config paths: {', '.join(manifest_config_paths)}")
@ -88,12 +90,12 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
borgmatic.borg.rlist.resolve_archive_name( borgmatic.borg.rlist.resolve_archive_name(
bootstrap_arguments.repository, bootstrap_arguments.repository,
bootstrap_arguments.archive, bootstrap_arguments.archive,
{}, config,
local_borg_version, local_borg_version,
global_arguments, global_arguments,
), ),
[config_path.lstrip(os.path.sep) for config_path in manifest_config_paths], [config_path.lstrip(os.path.sep) for config_path in manifest_config_paths],
{}, config,
local_borg_version, local_borg_version,
global_arguments, global_arguments,
extract_to_stdout=False, extract_to_stdout=False,

View File

@ -1,12 +1,9 @@
import importlib.metadata
import json import json
import logging import logging
import os import os
try: import borgmatic.actions.json
import importlib_metadata
except ModuleNotFoundError: # pragma: nocover
import importlib.metadata as importlib_metadata
import borgmatic.borg.create import borgmatic.borg.create
import borgmatic.borg.state import borgmatic.borg.state
import borgmatic.config.validate import borgmatic.config.validate
@ -39,7 +36,7 @@ def create_borgmatic_manifest(config, config_paths, dry_run):
with open(borgmatic_manifest_path, 'w') as config_list_file: with open(borgmatic_manifest_path, 'w') as config_list_file:
json.dump( json.dump(
{ {
'borgmatic_version': importlib_metadata.version('borgmatic'), 'borgmatic_version': importlib.metadata.version('borgmatic'),
'config_paths': config_paths, 'config_paths': config_paths,
}, },
config_list_file, config_list_file,
@ -111,8 +108,8 @@ def run_create(
list_files=create_arguments.list_files, list_files=create_arguments.list_files,
stream_processes=stream_processes, stream_processes=stream_processes,
) )
if json_output: # pragma: nocover if json_output:
yield json.loads(json_output) yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))
borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured( borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
'remove_data_source_dumps', 'remove_data_source_dumps',

View File

@ -1,7 +1,7 @@
import json
import logging import logging
import borgmatic.actions.arguments import borgmatic.actions.arguments
import borgmatic.actions.json
import borgmatic.borg.info import borgmatic.borg.info
import borgmatic.borg.rlist import borgmatic.borg.rlist
import borgmatic.config.validate import borgmatic.config.validate
@ -26,7 +26,7 @@ def run_info(
if info_arguments.repository is None or borgmatic.config.validate.repositories_match( if info_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, info_arguments.repository repository, info_arguments.repository
): ):
if not info_arguments.json: # pragma: nocover if not info_arguments.json:
logger.answer( logger.answer(
f'{repository.get("label", repository["path"])}: Displaying archive summary information' f'{repository.get("label", repository["path"])}: Displaying archive summary information'
) )
@ -48,5 +48,5 @@ def run_info(
local_path, local_path,
remote_path, remote_path,
) )
if json_output: # pragma: nocover if json_output:
yield json.loads(json_output) yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))

16
borgmatic/actions/json.py Normal file
View File

@ -0,0 +1,16 @@
import json
def parse_json(borg_json_output, label):
'''
Given a Borg JSON output string, parse it as JSON into a dict. Inject the given borgmatic
repository label into it and return the dict.
'''
json_data = json.loads(borg_json_output)
if 'repository' not in json_data:
return json_data
json_data['repository']['label'] = label or ''
return json_data

View File

@ -1,7 +1,7 @@
import json
import logging import logging
import borgmatic.actions.arguments import borgmatic.actions.arguments
import borgmatic.actions.json
import borgmatic.borg.list import borgmatic.borg.list
import borgmatic.config.validate import borgmatic.config.validate
@ -25,10 +25,10 @@ def run_list(
if list_arguments.repository is None or borgmatic.config.validate.repositories_match( if list_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, list_arguments.repository repository, list_arguments.repository
): ):
if not list_arguments.json: # pragma: nocover if not list_arguments.json:
if list_arguments.find_paths: if list_arguments.find_paths: # pragma: no cover
logger.answer(f'{repository.get("label", repository["path"])}: Searching archives') logger.answer(f'{repository.get("label", repository["path"])}: Searching archives')
elif not list_arguments.archive: elif not list_arguments.archive: # pragma: no cover
logger.answer(f'{repository.get("label", repository["path"])}: Listing archives') logger.answer(f'{repository.get("label", repository["path"])}: Listing archives')
archive_name = borgmatic.borg.rlist.resolve_archive_name( archive_name = borgmatic.borg.rlist.resolve_archive_name(
@ -49,5 +49,5 @@ def run_list(
local_path, local_path,
remote_path, remote_path,
) )
if json_output: # pragma: nocover if json_output:
yield json.loads(json_output) yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))

View File

@ -1,6 +1,6 @@
import json
import logging import logging
import borgmatic.actions.json
import borgmatic.borg.rinfo import borgmatic.borg.rinfo
import borgmatic.config.validate import borgmatic.config.validate
@ -24,7 +24,7 @@ def run_rinfo(
if rinfo_arguments.repository is None or borgmatic.config.validate.repositories_match( if rinfo_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, rinfo_arguments.repository repository, rinfo_arguments.repository
): ):
if not rinfo_arguments.json: # pragma: nocover if not rinfo_arguments.json:
logger.answer( logger.answer(
f'{repository.get("label", repository["path"])}: Displaying repository summary information' f'{repository.get("label", repository["path"])}: Displaying repository summary information'
) )
@ -38,5 +38,5 @@ def run_rinfo(
local_path=local_path, local_path=local_path,
remote_path=remote_path, remote_path=remote_path,
) )
if json_output: # pragma: nocover if json_output:
yield json.loads(json_output) yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))

View File

@ -1,6 +1,6 @@
import json
import logging import logging
import borgmatic.actions.json
import borgmatic.borg.rlist import borgmatic.borg.rlist
import borgmatic.config.validate import borgmatic.config.validate
@ -24,7 +24,7 @@ def run_rlist(
if rlist_arguments.repository is None or borgmatic.config.validate.repositories_match( if rlist_arguments.repository is None or borgmatic.config.validate.repositories_match(
repository, rlist_arguments.repository repository, rlist_arguments.repository
): ):
if not rlist_arguments.json: # pragma: nocover if not rlist_arguments.json:
logger.answer(f'{repository.get("label", repository["path"])}: Listing repository') logger.answer(f'{repository.get("label", repository["path"])}: Listing repository')
json_output = borgmatic.borg.rlist.list_repository( json_output = borgmatic.borg.rlist.list_repository(
@ -36,5 +36,5 @@ def run_rlist(
local_path=local_path, local_path=local_path,
remote_path=remote_path, remote_path=remote_path,
) )
if json_output: # pragma: nocover if json_output:
yield json.loads(json_output) yield borgmatic.actions.json.parse_json(json_output, repository.get('label'))

View File

@ -39,7 +39,11 @@ def parse_checks(config, only_checks=None):
check_config['name'] for check_config in (config.get('checks', None) or DEFAULT_CHECKS) check_config['name'] for check_config in (config.get('checks', None) or DEFAULT_CHECKS)
) )
checks = tuple(check.lower() for check in checks) checks = tuple(check.lower() for check in checks)
if 'disabled' in checks: if 'disabled' in checks:
logger.warning(
'The "disabled" value for the "checks" option is deprecated and will be removed from a future release; use "skip_actions" instead'
)
if len(checks) > 1: if len(checks) > 1:
logger.warning( logger.warning(
'Multiple checks are configured, but one of them is "disabled"; not running any checks' 'Multiple checks are configured, but one of them is "disabled"; not running any checks'
@ -119,6 +123,9 @@ def filter_checks_on_frequency(
Raise ValueError if a frequency cannot be parsed. Raise ValueError if a frequency cannot be parsed.
''' '''
if not checks:
return checks
filtered_checks = list(checks) filtered_checks = list(checks)
if force: if force:
@ -149,11 +156,13 @@ def filter_checks_on_frequency(
return tuple(filtered_checks) return tuple(filtered_checks)
def make_archive_filter_flags(local_borg_version, config, checks, check_last=None, prefix=None): def make_archive_filter_flags(
local_borg_version, config, checks, check_arguments, check_last=None, prefix=None
):
''' '''
Given the local Borg version, a configuration dict, a parsed sequence of checks, the check last Given the local Borg version, a configuration dict, a parsed sequence of checks, check arguments
value, and a consistency check prefix, transform the checks into tuple of command-line flags for as an argparse.Namespace instance, the check last value, and a consistency check prefix,
filtering archives in a check command. transform the checks into tuple of command-line flags for filtering archives in a check command.
If a check_last value is given and "archives" is in checks, then include a "--last" flag. And if If a check_last value is given and "archives" is in checks, then include a "--last" flag. And if
a prefix value is given and "archives" is in checks, then include a "--match-archives" flag. a prefix value is given and "archives" is in checks, then include a "--match-archives" flag.
@ -168,7 +177,7 @@ def make_archive_filter_flags(local_borg_version, config, checks, check_last=Non
if prefix if prefix
else ( else (
flags.make_match_archives_flags( flags.make_match_archives_flags(
config.get('match_archives'), check_arguments.match_archives or config.get('match_archives'),
config.get('archive_name_format'), config.get('archive_name_format'),
local_borg_version, local_borg_version,
) )
@ -353,18 +362,15 @@ def check_archives(
repository_path, repository_path,
config, config,
local_borg_version, local_borg_version,
check_arguments,
global_arguments, global_arguments,
local_path='borg', local_path='borg',
remote_path=None, remote_path=None,
progress=None,
repair=None,
only_checks=None,
force=None,
): ):
''' '''
Given a local or remote repository path, a configuration dict, local/remote commands to run, Given a local or remote repository path, a configuration dict, the local Borg version, check
whether to include progress information, whether to attempt a repair, and an optional list of arguments as an argparse.Namespace instance, global arguments, and local/remote commands to run,
checks to use instead of configured checks, check the contained Borg archives for consistency. check the contained Borg archives for consistency.
If there are no consistency checks to run, skip running them. If there are no consistency checks to run, skip running them.
@ -389,11 +395,11 @@ def check_archives(
check_last = config.get('check_last', None) check_last = config.get('check_last', None)
prefix = config.get('prefix') prefix = config.get('prefix')
configured_checks = parse_checks(config, only_checks) configured_checks = parse_checks(config, check_arguments.only_checks)
lock_wait = None lock_wait = None
extra_borg_options = config.get('extra_borg_options', {}).get('check', '') extra_borg_options = config.get('extra_borg_options', {}).get('check', '')
archive_filter_flags = make_archive_filter_flags( archive_filter_flags = make_archive_filter_flags(
local_borg_version, config, configured_checks, check_last, prefix local_borg_version, config, configured_checks, check_arguments, check_last, prefix
) )
archives_check_id = make_archives_check_id(archive_filter_flags) archives_check_id = make_archives_check_id(archive_filter_flags)
@ -401,7 +407,7 @@ def check_archives(
config, config,
borg_repository_id, borg_repository_id,
configured_checks, configured_checks,
force, check_arguments.force,
archives_check_id, archives_check_id,
) )
@ -416,13 +422,13 @@ def check_archives(
full_command = ( full_command = (
(local_path, 'check') (local_path, 'check')
+ (('--repair',) if repair else ()) + (('--repair',) if check_arguments.repair else ())
+ make_check_flags(checks, archive_filter_flags) + make_check_flags(checks, archive_filter_flags)
+ (('--remote-path', remote_path) if remote_path else ()) + (('--remote-path', remote_path) if remote_path else ())
+ (('--log-json',) if global_arguments.log_json else ()) + (('--log-json',) if global_arguments.log_json else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ()) + (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags + verbosity_flags
+ (('--progress',) if progress else ()) + (('--progress',) if check_arguments.progress else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ()) + (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version) + flags.make_repository_flags(repository_path, local_borg_version)
) )
@ -431,7 +437,7 @@ def check_archives(
# The Borg repair option triggers an interactive prompt, which won't work when output is # The Borg repair option triggers an interactive prompt, which won't work when output is
# captured. And progress messes with the terminal directly. # captured. And progress messes with the terminal directly.
if repair or progress: if check_arguments.repair or check_arguments.progress:
execute_command( execute_command(
full_command, output_file=DO_NOT_CAPTURE, extra_environment=borg_environment full_command, output_file=DO_NOT_CAPTURE, extra_environment=borg_environment
) )

View File

@ -215,9 +215,6 @@ def make_list_filter_flags(local_borg_version, dry_run):
return f'{base_flags}-' 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): def collect_borgmatic_source_directories(borgmatic_source_directory):
''' '''
Return a list of borgmatic-specific source directories used for state like database backups. Return a list of borgmatic-specific source directories used for state like database backups.
@ -388,7 +385,7 @@ def create_archive(
lock_wait = config.get('lock_wait', None) lock_wait = config.get('lock_wait', None)
list_filter_flags = make_list_filter_flags(local_borg_version, dry_run) list_filter_flags = make_list_filter_flags(local_borg_version, dry_run)
files_cache = config.get('files_cache') 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', '') extra_borg_options = config.get('extra_borg_options', {}).get('create', '')
if feature.available(feature.Feature.ATIME, local_borg_version): if feature.available(feature.Feature.ATIME, local_borg_version):

View File

@ -1,8 +1,12 @@
import itertools import itertools
import json
import logging
import re import re
from borgmatic.borg import feature from borgmatic.borg import feature
logger = logging.getLogger(__name__)
def make_flags(name, value): 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): 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 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 return match archives flags to match archives created with the given (or default) archive name
any. This is done by replacing certain archive name format placeholders for ephemeral data (like format. This is done by replacing certain archive name format placeholders for ephemeral data
"{now}") with globs. (like "{now}") with globs.
''' '''
if match_archives: if match_archives:
if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version): if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
@ -72,10 +79,9 @@ def make_match_archives_flags(match_archives, archive_name_format, local_borg_ve
else: else:
return ('--glob-archives', re.sub(r'^sh:', '', match_archives)) return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
if not archive_name_format: derived_match_archives = re.sub(
return () 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 == '*': if derived_match_archives == '*':
return () return ()
@ -84,3 +90,26 @@ def make_match_archives_flags(match_archives, archive_name_format, local_borg_ve
return ('--match-archives', f'sh:{derived_match_archives}') return ('--match-archives', f'sh:{derived_match_archives}')
else: else:
return ('--glob-archives', f'{derived_match_archives}') return ('--glob-archives', f'{derived_match_archives}')
def warn_for_aggressive_archive_flags(json_command, json_output):
'''
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')

View File

@ -1,3 +1,4 @@
import argparse
import logging import logging
import borgmatic.logger import borgmatic.logger
@ -7,24 +8,21 @@ from borgmatic.execute import execute_command, execute_command_and_capture_outpu
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def display_archives_info( def make_info_command(
repository_path, repository_path,
config, config,
local_borg_version, local_borg_version,
info_arguments, info_arguments,
global_arguments, global_arguments,
local_path='borg', local_path,
remote_path=None, remote_path,
): ):
''' '''
Given a local or remote repository path, a configuration dict, the local Borg version, global Given a local or remote repository path, a configuration dict, the local Borg version, the
arguments as an argparse.Namespace, and the arguments to the info action, display summary arguments to the info action as an argparse.Namespace, and global arguments, return a command
information for Borg archives in the repository or return JSON summary information. as a tuple to display summary information for archives in the repository.
''' '''
borgmatic.logger.add_custom_log_levels() return (
lock_wait = config.get('lock_wait', None)
full_command = (
(local_path, 'info') (local_path, 'info')
+ ( + (
('--info',) ('--info',)
@ -38,7 +36,7 @@ def display_archives_info(
) )
+ flags.make_flags('remote-path', remote_path) + flags.make_flags('remote-path', remote_path)
+ flags.make_flags('log-json', global_arguments.log_json) + 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}*') 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) + 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: if info_arguments.json:
return execute_command_and_capture_output( return json_info
full_command,
extra_environment=environment.make_environment(config), flags.warn_for_aggressive_archive_flags(json_command, json_info)
borg_local_path=local_path,
) execute_command(
else: main_command,
execute_command( output_log_level=logging.ANSWER,
full_command, borg_local_path=local_path,
output_log_level=logging.ANSWER, extra_environment=environment.make_environment(config),
borg_local_path=local_path, )
extra_environment=environment.make_environment(config),
)

View File

@ -1,3 +1,4 @@
import argparse
import logging import logging
import borgmatic.logger import borgmatic.logger
@ -137,15 +138,28 @@ def list_repository(
local_path, local_path,
remote_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: if rlist_arguments.json:
return execute_command_and_capture_output( return json_listing
main_command, extra_environment=borg_environment, borg_local_path=local_path
) flags.warn_for_aggressive_archive_flags(json_command, json_listing)
else:
execute_command( execute_command(
main_command, main_command,
output_log_level=logging.ANSWER, output_log_level=logging.ANSWER,
borg_local_path=local_path, borg_local_path=local_path,
extra_environment=borg_environment, extra_environment=borg_environment,
) )

View File

@ -259,28 +259,28 @@ def make_parsers():
type=int, type=int,
choices=range(-2, 3), choices=range(-2, 3),
default=0, 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( global_group.add_argument(
'--syslog-verbosity', '--syslog-verbosity',
type=int, type=int,
choices=range(-2, 3), choices=range(-2, 3),
default=0, default=-2,
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', 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( global_group.add_argument(
'--log-file-verbosity', '--log-file-verbosity',
type=int, type=int,
choices=range(-2, 3), choices=range(-2, 3),
default=0, default=1,
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', 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( global_group.add_argument(
'--monitoring-verbosity', '--monitoring-verbosity',
type=int, type=int,
choices=range(-2, 3), choices=range(-2, 3),
default=0, default=1,
help='Log verbose progress to monitoring integrations that support logging (from disabled, errors only, default, some, or lots: -2, -1, 0, 1, or 2)', 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( global_group.add_argument(
'--log-file', '--log-file',
@ -604,11 +604,18 @@ def make_parsers():
action='store_true', action='store_true',
help='Attempt to repair any inconsistencies found (for interactive use)', help='Attempt to repair any inconsistencies found (for interactive use)',
) )
check_group.add_argument(
'-a',
'--match-archives',
'--glob-archives',
metavar='PATTERN',
help='Only check archives with names matching this pattern',
)
check_group.add_argument( check_group.add_argument(
'--only', '--only',
metavar='CHECK', metavar='CHECK',
choices=('repository', 'archives', 'data', 'extract'), choices=('repository', 'archives', 'data', 'extract'),
dest='only', dest='only_checks',
action='append', action='append',
help='Run a particular consistency check (repository, archives, data, or extract) instead of configured checks (subject to configured frequency, can specify flag multiple times)', help='Run a particular consistency check (repository, archives, data, or extract) instead of configured checks (subject to configured frequency, can specify flag multiple times)',
) )
@ -724,6 +731,11 @@ def make_parsers():
action='store_true', action='store_true',
help='Display progress for each file as it is extracted', help='Display progress for each file as it is extracted',
) )
config_bootstrap_group.add_argument(
'--ssh-command',
metavar='COMMAND',
help='Command to use instead of "ssh"',
)
config_bootstrap_group.add_argument( config_bootstrap_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit' '-h', '--help', action='help', help='Show this help message and exit'
) )
@ -1328,4 +1340,7 @@ def parse_arguments(*unparsed_arguments):
'With the info action, only one of --archive, --prefix, or --match-archives flags can be used.' 'With the info action, only one of --archive, --prefix, or --match-archives flags can be used.'
) )
if 'borg' in arguments and arguments['global'].dry_run:
raise ValueError('With the borg action, --dry-run is not supported.')
return arguments return arguments

View File

@ -1,4 +1,5 @@
import collections import collections
import importlib.metadata
import json import json
import logging import logging
import os import os
@ -9,11 +10,6 @@ from subprocess import CalledProcessError
import colorama import colorama
try:
import importlib_metadata
except ModuleNotFoundError: # pragma: nocover
import importlib.metadata as importlib_metadata
import borgmatic.actions.borg import borgmatic.actions.borg
import borgmatic.actions.break_lock import borgmatic.actions.break_lock
import borgmatic.actions.check import borgmatic.actions.check
@ -48,6 +44,20 @@ from borgmatic.verbosity import verbosity_to_log_level
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_skip_actions(config, arguments):
'''
Given a configuration dict and command-line arguments as an argparse.Namespace, return a list of
the configured action names to skip. Omit "check" from this list though if "check --force" is
part of the command-like arguments.
'''
skip_actions = config.get('skip_actions', [])
if 'check' in arguments and arguments['check'].force:
return [action for action in skip_actions if action != 'check']
return skip_actions
def run_configuration(config_filename, config, arguments): def run_configuration(config_filename, config, arguments):
''' '''
Given a config filename, the corresponding parsed config dict, and command-line arguments as a Given a config filename, the corresponding parsed config dict, and command-line arguments as a
@ -70,9 +80,16 @@ def run_configuration(config_filename, config, arguments):
using_primary_action = {'create', 'prune', 'compact', 'check'}.intersection(arguments) using_primary_action = {'create', 'prune', 'compact', 'check'}.intersection(arguments)
monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity) monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
monitoring_hooks_are_activated = using_primary_action and monitoring_log_level != DISABLED monitoring_hooks_are_activated = using_primary_action and monitoring_log_level != DISABLED
skip_actions = get_skip_actions(config, arguments)
if skip_actions:
logger.debug(
f"{config_filename}: Skipping {'/'.join(skip_actions)} action{'s' if len(skip_actions) > 1 else ''} due to configured skip_actions"
)
try: try:
local_borg_version = borg_version.local_borg_version(config, local_path) local_borg_version = borg_version.local_borg_version(config, local_path)
logger.debug(f'{config_filename}: Borg {local_borg_version}')
except (OSError, CalledProcessError, ValueError) as error: except (OSError, CalledProcessError, ValueError) as error:
yield from log_error_records(f'{config_filename}: Error getting local Borg version', error) yield from log_error_records(f'{config_filename}: Error getting local Borg version', error)
return return
@ -274,6 +291,7 @@ def run_actions(
'repositories': ','.join([repo['path'] for repo in config['repositories']]), 'repositories': ','.join([repo['path'] for repo in config['repositories']]),
'log_file': global_arguments.log_file if global_arguments.log_file else '', 'log_file': global_arguments.log_file if global_arguments.log_file else '',
} }
skip_actions = set(get_skip_actions(config, arguments))
command.execute_hook( command.execute_hook(
config.get('before_actions'), config.get('before_actions'),
@ -285,7 +303,7 @@ def run_actions(
) )
for action_name, action_arguments in arguments.items(): for action_name, action_arguments in arguments.items():
if action_name == 'rcreate': if action_name == 'rcreate' and action_name not in skip_actions:
borgmatic.actions.rcreate.run_rcreate( borgmatic.actions.rcreate.run_rcreate(
repository, repository,
config, config,
@ -295,7 +313,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'transfer': elif action_name == 'transfer' and action_name not in skip_actions:
borgmatic.actions.transfer.run_transfer( borgmatic.actions.transfer.run_transfer(
repository, repository,
config, config,
@ -305,7 +323,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'create': elif action_name == 'create' and action_name not in skip_actions:
yield from borgmatic.actions.create.run_create( yield from borgmatic.actions.create.run_create(
config_filename, config_filename,
repository, repository,
@ -318,7 +336,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'prune': elif action_name == 'prune' and action_name not in skip_actions:
borgmatic.actions.prune.run_prune( borgmatic.actions.prune.run_prune(
config_filename, config_filename,
repository, repository,
@ -331,7 +349,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'compact': elif action_name == 'compact' and action_name not in skip_actions:
borgmatic.actions.compact.run_compact( borgmatic.actions.compact.run_compact(
config_filename, config_filename,
repository, repository,
@ -344,7 +362,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'check': elif action_name == 'check' and action_name not in skip_actions:
if checks.repository_enabled_for_checks(repository, config): if checks.repository_enabled_for_checks(repository, config):
borgmatic.actions.check.run_check( borgmatic.actions.check.run_check(
config_filename, config_filename,
@ -357,7 +375,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'extract': elif action_name == 'extract' and action_name not in skip_actions:
borgmatic.actions.extract.run_extract( borgmatic.actions.extract.run_extract(
config_filename, config_filename,
repository, repository,
@ -369,7 +387,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'export-tar': elif action_name == 'export-tar' and action_name not in skip_actions:
borgmatic.actions.export_tar.run_export_tar( borgmatic.actions.export_tar.run_export_tar(
repository, repository,
config, config,
@ -379,7 +397,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'mount': elif action_name == 'mount' and action_name not in skip_actions:
borgmatic.actions.mount.run_mount( borgmatic.actions.mount.run_mount(
repository, repository,
config, config,
@ -389,7 +407,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'restore': elif action_name == 'restore' and action_name not in skip_actions:
borgmatic.actions.restore.run_restore( borgmatic.actions.restore.run_restore(
repository, repository,
config, config,
@ -399,7 +417,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'rlist': elif action_name == 'rlist' and action_name not in skip_actions:
yield from borgmatic.actions.rlist.run_rlist( yield from borgmatic.actions.rlist.run_rlist(
repository, repository,
config, config,
@ -409,7 +427,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'list': elif action_name == 'list' and action_name not in skip_actions:
yield from borgmatic.actions.list.run_list( yield from borgmatic.actions.list.run_list(
repository, repository,
config, config,
@ -419,7 +437,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'rinfo': elif action_name == 'rinfo' and action_name not in skip_actions:
yield from borgmatic.actions.rinfo.run_rinfo( yield from borgmatic.actions.rinfo.run_rinfo(
repository, repository,
config, config,
@ -429,7 +447,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'info': elif action_name == 'info' and action_name not in skip_actions:
yield from borgmatic.actions.info.run_info( yield from borgmatic.actions.info.run_info(
repository, repository,
config, config,
@ -439,7 +457,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'break-lock': elif action_name == 'break-lock' and action_name not in skip_actions:
borgmatic.actions.break_lock.run_break_lock( borgmatic.actions.break_lock.run_break_lock(
repository, repository,
config, config,
@ -449,7 +467,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'export': elif action_name == 'export' and action_name not in skip_actions:
borgmatic.actions.export_key.run_export_key( borgmatic.actions.export_key.run_export_key(
repository, repository,
config, config,
@ -459,7 +477,7 @@ def run_actions(
local_path, local_path,
remote_path, remote_path,
) )
elif action_name == 'borg': elif action_name == 'borg' and action_name not in skip_actions:
borgmatic.actions.borg.run_borg( borgmatic.actions.borg.run_borg(
repository, repository,
config, config,
@ -555,9 +573,6 @@ def log_record(suppress_log=False, **kwargs):
return record return record
MAX_CAPTURED_OUTPUT_LENGTH = 1000
def log_error_records( def log_error_records(
message, error=None, levelno=logging.CRITICAL, log_command_error_output=False message, error=None, levelno=logging.CRITICAL, log_command_error_output=False
): ):
@ -579,20 +594,24 @@ def log_error_records(
raise error raise error
except CalledProcessError as error: except CalledProcessError as error:
yield log_record(levelno=levelno, levelname=level_name, msg=message) yield log_record(levelno=levelno, levelname=level_name, msg=message)
if error.output: if error.output:
try: try:
output = error.output.decode('utf-8') output = error.output.decode('utf-8')
except (UnicodeDecodeError, AttributeError): except (UnicodeDecodeError, AttributeError):
output = error.output output = error.output
# Suppress these logs for now and save full error output for the log summary at the end. # Suppress these logs for now and save the error output for the log summary at the end.
yield log_record( # Log a separate record per line, as some errors can be really verbose and overflow the
levelno=levelno, # per-record size limits imposed by some logging backends.
levelname=level_name, for output_line in output.splitlines():
msg=output[:MAX_CAPTURED_OUTPUT_LENGTH] yield log_record(
+ ' ...' * (len(output) > MAX_CAPTURED_OUTPUT_LENGTH), levelno=levelno,
suppress_log=True, levelname=level_name,
) msg=output_line,
suppress_log=True,
)
yield log_record(levelno=levelno, levelname=level_name, msg=error) yield log_record(levelno=levelno, levelname=level_name, msg=error)
except (ValueError, OSError) as error: except (ValueError, OSError) as error:
yield log_record(levelno=levelno, levelname=level_name, msg=message) yield log_record(levelno=levelno, levelname=level_name, msg=message)
@ -826,7 +845,7 @@ def main(extra_summary_logs=[]): # pragma: no cover
global_arguments = arguments['global'] global_arguments = arguments['global']
if global_arguments.version: if global_arguments.version:
print(importlib_metadata.version('borgmatic')) print(importlib.metadata.version('borgmatic'))
sys.exit(0) sys.exit(0)
if global_arguments.bash_completion: if global_arguments.bash_completion:
print(borgmatic.commands.completion.bash.bash_completion()) print(borgmatic.commands.completion.bash.bash_completion())

View File

@ -1,9 +1,9 @@
def repository_enabled_for_checks(repository, consistency): def repository_enabled_for_checks(repository, config):
''' '''
Given a repository name and a consistency configuration dict, return whether the repository Given a repository name and a configuration dict, return whether the
is enabled to have consistency checks run. repository is enabled to have consistency checks run.
''' '''
if not consistency.get('check_repositories'): if not config.get('check_repositories'):
return True return True
return repository in consistency['check_repositories'] return repository in config['check_repositories']

View File

@ -0,0 +1,47 @@
def coerce_scalar(value):
'''
Given a configuration value, coerce it to an integer or a boolean as appropriate and return the
result.
'''
try:
return int(value)
except (TypeError, ValueError):
pass
if value == 'true' or value == 'True':
return True
if value == 'false' or value == 'False':
return False
return value
def apply_constants(value, constants):
'''
Given a configuration value (bool, dict, int, list, or string) and a dict of named constants,
replace any configuration string values of the form "{constant}" (or containing it) with the
value of the correspondingly named key from the constants. Recurse as necessary into nested
configuration to find values to replace.
For instance, if a configuration value contains "{foo}", replace it with the value of the "foo"
key found within the configuration's "constants".
Return the configuration value and modify the original.
'''
if not value or not constants:
return value
if isinstance(value, str):
for constant_name, constant_value in constants.items():
value = value.replace('{' + constant_name + '}', str(constant_value))
# Support constants within non-string scalars by coercing the value to its appropriate type.
value = coerce_scalar(value)
elif isinstance(value, list):
for index, list_value in enumerate(value):
value[index] = apply_constants(list_value, constants)
elif isinstance(value, dict):
for option_name, option_value in value.items():
value[option_name] = apply_constants(option_value, constants)
return value

View File

@ -1,21 +1,22 @@
import os import os
import re import re
_VARIABLE_PATTERN = re.compile( VARIABLE_PATTERN = re.compile(
r'(?P<escape>\\)?(?P<variable>\$\{(?P<name>[A-Za-z0-9_]+)((:?-)(?P<default>[^}]+))?\})' r'(?P<escape>\\)?(?P<variable>\$\{(?P<name>[A-Za-z0-9_]+)((:?-)(?P<default>[^}]+))?\})'
) )
def _resolve_string(matcher): def resolve_string(matcher):
''' '''
Get the value from environment given a matcher containing a name and an optional default value. Given a matcher containing a name and an optional default value, get the value from environment.
If the variable is not defined in environment and no default value is provided, an Error is raised.
Raise ValueError if the variable is not defined in environment and no default value is provided.
''' '''
if matcher.group('escape') is not None: if matcher.group('escape') is not None:
# in case of escaped envvar, unescape it # In the case of an escaped environment variable, unescape it.
return matcher.group('variable') return matcher.group('variable')
# resolve the env var # Resolve the environment variable.
name, default = matcher.group('name'), matcher.group('default') name, default = matcher.group('name'), matcher.group('default')
out = os.getenv(name, default=default) out = os.getenv(name, default=default)
@ -27,19 +28,24 @@ def _resolve_string(matcher):
def resolve_env_variables(item): def resolve_env_variables(item):
''' '''
Resolves variables like or ${FOO} from given configuration with values from process environment Resolves variables like or ${FOO} from given configuration with values from process environment.
Supported formats:
- ${FOO} will return FOO env variable
- ${FOO-bar} or ${FOO:-bar} will return FOO env variable if it exists, else "bar"
If any variable is missing in environment and no default value is provided, an Error is raised. Supported formats:
* ${FOO} will return FOO env variable
* ${FOO-bar} or ${FOO:-bar} will return FOO env variable if it exists, else "bar"
Raise if any variable is missing in environment and no default value is provided.
''' '''
if isinstance(item, str): if isinstance(item, str):
return _VARIABLE_PATTERN.sub(_resolve_string, item) return VARIABLE_PATTERN.sub(resolve_string, item)
if isinstance(item, list): if isinstance(item, list):
for i, subitem in enumerate(item): for index, subitem in enumerate(item):
item[i] = resolve_env_variables(subitem) item[index] = resolve_env_variables(subitem)
if isinstance(item, dict): if isinstance(item, dict):
for key, value in item.items(): for key, value in item.items():
item[key] = resolve_env_variables(value) item[key] = resolve_env_variables(value)
return item return item

View File

@ -3,7 +3,7 @@ import io
import os import os
import re import re
from ruamel import yaml import ruamel.yaml
from borgmatic.config import load, normalize from borgmatic.config import load, normalize
@ -17,7 +17,7 @@ def insert_newline_before_comment(config, field_name):
field and its comments. field and its comments.
''' '''
config.ca.items[field_name][1].insert( config.ca.items[field_name][1].insert(
0, yaml.tokens.CommentToken('\n', yaml.error.CommentMark(0), None) 0, ruamel.yaml.tokens.CommentToken('\n', ruamel.yaml.error.CommentMark(0), None)
) )
@ -32,12 +32,12 @@ def schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
return example return example
if schema_type == 'array': if schema_type == 'array':
config = yaml.comments.CommentedSeq( config = ruamel.yaml.comments.CommentedSeq(
[schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)] [schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
) )
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT)) add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
elif schema_type == 'object': elif schema_type == 'object':
config = yaml.comments.CommentedMap( config = ruamel.yaml.comments.CommentedMap(
[ [
(field_name, schema_to_sample_configuration(sub_schema, level + 1)) (field_name, schema_to_sample_configuration(sub_schema, level + 1))
for field_name, sub_schema in schema['properties'].items() for field_name, sub_schema in schema['properties'].items()
@ -101,7 +101,7 @@ def render_configuration(config):
''' '''
Given a config data structure of nested OrderedDicts, render the config as YAML and return it. Given a config data structure of nested OrderedDicts, render the config as YAML and return it.
''' '''
dumper = yaml.YAML() dumper = ruamel.yaml.YAML(typ='rt')
dumper.indent(mapping=INDENT, sequence=INDENT + SEQUENCE_INDENT, offset=INDENT) dumper.indent(mapping=INDENT, sequence=INDENT + SEQUENCE_INDENT, offset=INDENT)
rendered = io.StringIO() rendered = io.StringIO()
dumper.dump(config, rendered) dumper.dump(config, rendered)
@ -236,7 +236,9 @@ def merge_source_configuration_into_destination(destination_config, source_confi
for field_name, source_value in source_config.items(): for field_name, source_value in source_config.items():
# Since this key/value is from the source configuration, leave it uncommented and remove any # Since this key/value is from the source configuration, leave it uncommented and remove any
# sentinel that would cause it to get commented out. # sentinel that would cause it to get commented out.
remove_commented_out_sentinel(destination_config, field_name) remove_commented_out_sentinel(
ruamel.yaml.comments.CommentedMap(destination_config), field_name
)
# This is a mapping. Recurse for this key/value. # This is a mapping. Recurse for this key/value.
if isinstance(source_value, collections.abc.Mapping): if isinstance(source_value, collections.abc.Mapping):
@ -248,7 +250,7 @@ def merge_source_configuration_into_destination(destination_config, source_confi
# This is a sequence. Recurse for each item in it. # This is a sequence. Recurse for each item in it.
if isinstance(source_value, collections.abc.Sequence) and not isinstance(source_value, str): if isinstance(source_value, collections.abc.Sequence) and not isinstance(source_value, str):
destination_value = destination_config[field_name] destination_value = destination_config[field_name]
destination_config[field_name] = yaml.comments.CommentedSeq( destination_config[field_name] = ruamel.yaml.comments.CommentedSeq(
[ [
merge_source_configuration_into_destination( merge_source_configuration_into_destination(
destination_value[index] if index < len(destination_value) else None, destination_value[index] if index < len(destination_value) else None,
@ -275,7 +277,7 @@ def generate_sample_configuration(
schema. If a source filename is provided, merge the parsed contents of that configuration into schema. If a source filename is provided, merge the parsed contents of that configuration into
the generated configuration. the generated configuration.
''' '''
schema = yaml.round_trip_load(open(schema_filename)) schema = ruamel.yaml.YAML(typ='safe').load(open(schema_filename))
source_config = None source_config = None
if source_filename: if source_filename:

View File

@ -1,6 +1,5 @@
import functools import functools
import itertools import itertools
import json
import logging import logging
import operator import operator
import os import os
@ -159,8 +158,7 @@ class Include_constructor(ruamel.yaml.SafeConstructor):
def load_configuration(filename): def load_configuration(filename):
''' '''
Load the given configuration file and return its contents as a data structure of nested dicts Load the given configuration file and return its contents as a data structure of nested dicts
and lists. Also, replace any "{constant}" strings with the value of the "constant" key in the and lists.
"constants" option of the configuration file.
Raise ruamel.yaml.error.YAMLError if something goes wrong parsing the YAML, or RecursionError Raise ruamel.yaml.error.YAMLError if something goes wrong parsing the YAML, or RecursionError
if there are too many recursive includes. if there are too many recursive includes.
@ -179,23 +177,7 @@ def load_configuration(filename):
yaml.Constructor = Include_constructor_with_include_directory yaml.Constructor = Include_constructor_with_include_directory
with open(filename) as file: with open(filename) as file:
file_contents = file.read() return yaml.load(file.read())
config = yaml.load(file_contents)
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('"'))
config = yaml.load(file_contents)
del config['constants']
return config
def filter_omitted_nodes(nodes, values): def filter_omitted_nodes(nodes, values):

View File

@ -39,7 +39,7 @@ def normalize_sections(config_filename, config):
for section_name in ('location', 'storage', 'retention', 'consistency', 'output', 'hooks'): for section_name in ('location', 'storage', 'retention', 'consistency', 'output', 'hooks'):
section_config = config.get(section_name) section_config = config.get(section_name)
if section_config: if section_config is not None:
any_section_upgraded = True any_section_upgraded = True
del config[section_name] del config[section_name]
config.update(section_config) config.update(section_config)
@ -90,7 +90,7 @@ def normalize(config_filename, config):
dict( dict(
levelno=logging.WARNING, levelno=logging.WARNING,
levelname='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.',
) )
) )
) )
@ -192,7 +192,7 @@ def normalize(config_filename, config):
# Upgrade remote repositories to ssh:// syntax, required in Borg 2. # Upgrade remote repositories to ssh:// syntax, required in Borg 2.
repositories = config.get('repositories') repositories = config.get('repositories')
if repositories: if repositories:
if isinstance(repositories[0], str): if any(isinstance(repository, str) for repository in repositories):
logs.append( logs.append(
logging.makeLogRecord( logging.makeLogRecord(
dict( dict(
@ -202,7 +202,10 @@ def normalize(config_filename, config):
) )
) )
) )
config['repositories'] = [{'path': repository} for repository in repositories] config['repositories'] = [
{'path': repository} if isinstance(repository, str) else repository
for repository in repositories
]
repositories = config['repositories'] repositories = config['repositories']
config['repositories'] = [] config['repositories'] = []

View File

@ -22,13 +22,19 @@ def set_values(config, keys, value):
set_values(config[first_key], keys[1:], value) set_values(config[first_key], keys[1:], value)
def convert_value_type(value): def convert_value_type(value, option_type):
''' '''
Given a string value, determine its logical type (string, boolean, integer, etc.), and return it Given a string value and its schema type as a string, determine its logical type (string,
converted to that type. boolean, integer, etc.), and return it converted to that type.
If the option type is a string, leave the value as a string so that special characters in it
don't get interpreted as YAML during conversion.
Raise ruamel.yaml.error.YAMLError if there's a parse issue with the YAML. Raise ruamel.yaml.error.YAMLError if there's a parse issue with the YAML.
''' '''
if option_type == 'string':
return value
return ruamel.yaml.YAML(typ='safe').load(io.StringIO(value)) return ruamel.yaml.YAML(typ='safe').load(io.StringIO(value))
@ -46,11 +52,32 @@ def strip_section_names(parsed_override_key):
return parsed_override_key return parsed_override_key
def parse_overrides(raw_overrides): def type_for_option(schema, option_keys):
''' '''
Given a sequence of configuration file override strings in the form of "option.suboption=value", Given a configuration schema and a sequence of keys identifying an option, e.g.
parse and return a sequence of tuples (keys, values), where keys is a sequence of strings. For ('extra_borg_options', 'init'), return the schema type of that option as a string.
instance, given the following raw overrides:
Return None if the option or its type cannot be found in the schema.
'''
option_schema = schema
for key in option_keys:
try:
option_schema = option_schema['properties'][key]
except KeyError:
return None
try:
return option_schema['type']
except KeyError:
return None
def parse_overrides(raw_overrides, schema):
'''
Given a sequence of configuration file override strings in the form of "option.suboption=value"
and a configuration schema dict, parse and return a sequence of tuples (keys, values), where
keys is a sequence of strings. For instance, given the following raw overrides:
['my_option.suboption=value1', 'other_option=value2'] ['my_option.suboption=value1', 'other_option=value2']
@ -71,10 +98,13 @@ def parse_overrides(raw_overrides):
for raw_override in raw_overrides: for raw_override in raw_overrides:
try: try:
raw_keys, value = raw_override.split('=', 1) raw_keys, value = raw_override.split('=', 1)
keys = strip_section_names(tuple(raw_keys.split('.')))
option_type = type_for_option(schema, keys)
parsed_overrides.append( parsed_overrides.append(
( (
strip_section_names(tuple(raw_keys.split('.'))), keys,
convert_value_type(value), convert_value_type(value, option_type),
) )
) )
except ValueError: except ValueError:
@ -87,12 +117,13 @@ def parse_overrides(raw_overrides):
return tuple(parsed_overrides) return tuple(parsed_overrides)
def apply_overrides(config, raw_overrides): def apply_overrides(config, schema, raw_overrides):
''' '''
Given a configuration dict and a sequence of configuration file override strings in the form of Given a configuration dict, a corresponding configuration schema dict, and a sequence of
"option.suboption=value", parse each override and set it the configuration dict. configuration file override strings in the form of "option.suboption=value", parse each override
and set it into the configuration dict.
''' '''
overrides = parse_overrides(raw_overrides) overrides = parse_overrides(raw_overrides, schema)
for keys, value in overrides: for keys, value in overrides:
set_values(config, keys, value) set_values(config, keys, value)

View File

@ -6,14 +6,15 @@ properties:
constants: constants:
type: object type: object
description: | description: |
Constants to use in the configuration file. All occurrences of the Constants to use in the configuration file. Within option values,
constant name within curly braces will be replaced with the value. all occurrences of the constant name in curly braces will be
For example, if you have a constant named "hostname" with the value replaced with the constant value. For example, if you have a
"myhostname", then the string "{hostname}" will be replaced with constant named "app_name" with the value "myapp", then the string
"myhostname" in the configuration file. "{app_name}" will be replaced with "myapp" in the configuration
file.
example: example:
hostname: myhostname app_name: myapp
prefix: myprefix user: myuser
source_directories: source_directories:
type: array type: array
items: items:
@ -216,7 +217,7 @@ properties:
Store configuration files used to create a backup in the backup Store configuration files used to create a backup in the backup
itself. Defaults to true. Changing this to false prevents "borgmatic itself. Defaults to true. Changing this to false prevents "borgmatic
bootstrap" from extracting configuration files from the backup. bootstrap" from extracting configuration files from the backup.
example: true example: false
source_directories_must_exist: source_directories_must_exist:
type: boolean type: boolean
description: | description: |
@ -287,14 +288,17 @@ properties:
retry_wait: retry_wait:
type: integer type: integer
description: | description: |
Wait time between retries (in seconds) to allow transient issues to Wait time between retries (in seconds) to allow transient issues
pass. Increases after each retry as a form of backoff. Defaults to 0 to pass. Increases after each retry by that same wait time as a
(no wait). form of backoff. Defaults to 0 (no wait).
example: 10 example: 10
temporary_directory: temporary_directory:
type: string type: string
description: | description: |
Directory where temporary files are stored. Defaults to $TMPDIR. Directory where temporary Borg files are stored. Defaults to
$TMPDIR. See "Resource Usage" at
https://borgbackup.readthedocs.io/en/stable/usage/general.html for
details.
example: /path/to/tmpdir example: /path/to/tmpdir
ssh_command: ssh_command:
type: string type: string
@ -423,7 +427,9 @@ properties:
command-line invocation. command-line invocation.
keep_within: keep_within:
type: string type: string
description: Keep all archives within this time interval. description: |
Keep all archives within this time interval. See "skip_actions" for
disabling pruning altogether.
example: 3H example: 3H
keep_secondly: keep_secondly:
type: integer type: integer
@ -479,13 +485,13 @@ properties:
- disabled - disabled
description: | description: |
Name of consistency check to run: "repository", Name of consistency check to run: "repository",
"archives", "data", and/or "extract". Set to "disabled" "archives", "data", and/or "extract". "repository"
to disable all consistency checks. "repository" checks checks the consistency of the repository, "archives"
the consistency of the repository, "archives" checks all checks all of the archives, "data" verifies the
of the archives, "data" verifies the integrity of the integrity of the data within the archives, and "extract"
data within the archives, and "extract" does an does an extraction dry-run of the most recent archive.
extraction dry-run of the most recent archive. Note that Note that "data" implies "archives". See "skip_actions"
"data" implies "archives". for disabling checks altogether.
example: repository example: repository
frequency: frequency:
type: string type: string
@ -525,6 +531,38 @@ properties:
Apply color to console output. Can be overridden with --no-color Apply color to console output. Can be overridden with --no-color
command-line flag. Defaults to true. command-line flag. Defaults to true.
example: false example: false
skip_actions:
type: array
items:
type: string
enum:
- rcreate
- transfer
- prune
- compact
- create
- check
- extract
- config
- export-tar
- mount
- umount
- restore
- rlist
- list
- rinfo
- info
- break-lock
- key
- borg
description: |
List of one or more actions to skip running for this configuration
file, even if specified on the command-line (explicitly or
implicitly). This is handy for append-only configurations where you
never want to run "compact" or checkless configuration where you
want to skip "check". Defaults to not skipping any actions.
example:
- compact
before_actions: before_actions:
type: array type: array
items: items:
@ -1306,6 +1344,99 @@ properties:
example: example:
- start - start
- finish - 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: healthchecks:
type: object type: object
required: ['ping_url'] required: ['ping_url']
@ -1400,7 +1531,7 @@ properties:
ends, or errors. ends, or errors.
example: https://cronhub.io/ping/1f5e3410-254c-5587 example: https://cronhub.io/ping/1f5e3410-254c-5587
description: | description: |
Configuration for a monitoring integration with Crunhub. Create an Configuration for a monitoring integration with Cronhub. Create an
account at https://cronhub.io if you'd like to use this service. See account at https://cronhub.io if you'd like to use this service. See
borgmatic monitoring documentation for details. borgmatic monitoring documentation for details.
loki: loki:

View File

@ -4,7 +4,7 @@ import jsonschema
import ruamel.yaml import ruamel.yaml
import borgmatic.config import borgmatic.config
from borgmatic.config import environment, load, normalize, override from borgmatic.config import constants, environment, load, normalize, override
def schema_filename(): def schema_filename():
@ -109,11 +109,14 @@ def parse_configuration(config_filename, schema_filename, overrides=None, resolv
except (ruamel.yaml.error.YAMLError, RecursionError) as error: except (ruamel.yaml.error.YAMLError, RecursionError) as error:
raise Validation_error(config_filename, (str(error),)) raise Validation_error(config_filename, (str(error),))
override.apply_overrides(config, overrides) override.apply_overrides(config, schema, overrides)
logs = normalize.normalize(config_filename, config) constants.apply_constants(config, config.get('constants') if config else {})
if resolve_env: if resolve_env:
environment.resolve_env_variables(config) environment.resolve_env_variables(config)
logs = normalize.normalize(config_filename, config)
try: try:
validator = jsonschema.Draft7Validator(schema) validator = jsonschema.Draft7Validator(schema)
except AttributeError: # pragma: no cover except AttributeError: # pragma: no cover

View File

@ -134,6 +134,7 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
still_running = True still_running = True
command = process.args.split(' ') if isinstance(process.args, str) else process.args command = process.args.split(' ') if isinstance(process.args, str) else process.args
# If any process errors, then raise accordingly. # If any process errors, then raise accordingly.
if exit_code_indicates_error(command, exit_code, borg_local_path): 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 # If an error occurs, include its output in the raised exception so that we don't
@ -171,19 +172,19 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
} }
def log_command(full_command, input_file=None, output_file=None): def log_command(full_command, input_file=None, output_file=None, environment=None):
''' '''
Log the given command (a sequence of command/argument strings), along with its input/output file Log the given command (a sequence of command/argument strings), along with its input/output file
paths. paths and extra environment variables (with omitted values in case they contain passwords).
''' '''
logger.debug( logger.debug(
' '.join(full_command) ' '.join(tuple(f'{key}=***' for key in (environment or {}).keys()) + tuple(full_command))
+ (f" < {getattr(input_file, 'name', '')}" if input_file else '') + (f" < {getattr(input_file, 'name', '')}" if input_file else '')
+ (f" > {getattr(output_file, 'name', '')}" if output_file else '') + (f" > {getattr(output_file, 'name', '')}" if output_file else '')
) )
# An sentinel passed as an output file to execute_command() to indicate that the command's output # A sentinel passed as an output file to execute_command() to indicate that the command's output
# should be allowed to flow through to stdout without being captured for logging. Useful for # should be allowed to flow through to stdout without being captured for logging. Useful for
# commands with interactive prompts or those that mess directly with the console. # commands with interactive prompts or those that mess directly with the console.
DO_NOT_CAPTURE = object() DO_NOT_CAPTURE = object()
@ -213,7 +214,7 @@ def execute_command(
Raise subprocesses.CalledProcessError if an error occurs while running the command. Raise subprocesses.CalledProcessError if an error occurs while running the command.
''' '''
log_command(full_command, input_file, output_file) log_command(full_command, input_file, output_file, extra_environment)
environment = {**os.environ, **extra_environment} if extra_environment else None environment = {**os.environ, **extra_environment} if extra_environment else None
do_not_capture = bool(output_file is DO_NOT_CAPTURE) do_not_capture = bool(output_file is DO_NOT_CAPTURE)
command = ' '.join(full_command) if shell else full_command command = ' '.join(full_command) if shell else full_command
@ -254,7 +255,7 @@ def execute_command_and_capture_output(
Raise subprocesses.CalledProcessError if an error occurs while running the command. Raise subprocesses.CalledProcessError if an error occurs while running the command.
''' '''
log_command(full_command) log_command(full_command, environment=extra_environment)
environment = {**os.environ, **extra_environment} if extra_environment else None environment = {**os.environ, **extra_environment} if extra_environment else None
command = ' '.join(full_command) if shell else full_command command = ' '.join(full_command) if shell else full_command
@ -303,7 +304,7 @@ def execute_command_with_processes(
Raise subprocesses.CalledProcessError if an error occurs while running the command or in the Raise subprocesses.CalledProcessError if an error occurs while running the command or in the
upstream process. upstream process.
''' '''
log_command(full_command, input_file, output_file) log_command(full_command, input_file, output_file, extra_environment)
environment = {**os.environ, **extra_environment} if extra_environment else None environment = {**os.environ, **extra_environment} if extra_environment else None
do_not_capture = bool(output_file is DO_NOT_CAPTURE) do_not_capture = bool(output_file is DO_NOT_CAPTURE)
command = ' '.join(full_command) if shell else full_command command = ' '.join(full_command) if shell else full_command

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

View File

@ -1,6 +1,7 @@
import logging import logging
from borgmatic.hooks import ( from borgmatic.hooks import (
apprise,
cronhub, cronhub,
cronitor, cronitor,
healthchecks, healthchecks,
@ -17,6 +18,7 @@ from borgmatic.hooks import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
HOOK_NAME_TO_MODULE = { HOOK_NAME_TO_MODULE = {
'apprise': apprise,
'cronhub': cronhub, 'cronhub': cronhub,
'cronitor': cronitor, 'cronitor': cronitor,
'healthchecks': healthchecks, 'healthchecks': healthchecks,

View File

@ -1,6 +1,6 @@
from enum import Enum from enum import Enum
MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki') MONITOR_HOOK_NAMES = ('apprise', 'healthchecks', 'cronitor', 'cronhub', 'pagerduty', 'ntfy', 'loki')
class State(Enum): class State(Enum):

View File

@ -166,15 +166,15 @@ def configure_logging(
Raise FileNotFoundError or PermissionError if the log file could not be opened for writing. Raise FileNotFoundError or PermissionError if the log file could not be opened for writing.
''' '''
add_custom_log_levels()
if syslog_log_level is None: if syslog_log_level is None:
syslog_log_level = console_log_level syslog_log_level = logging.DISABLED
if log_file_log_level is None: if log_file_log_level is None:
log_file_log_level = console_log_level log_file_log_level = console_log_level
if monitoring_log_level is None: if monitoring_log_level is None:
monitoring_log_level = console_log_level 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 # Log certain log levels to console stderr and others to stdout. This supports use cases like
# grepping (non-error) output. # grepping (non-error) output.
console_disabled = logging.NullHandler() console_disabled = logging.NullHandler()
@ -194,8 +194,11 @@ def configure_logging(
console_handler.setFormatter(Console_color_formatter()) console_handler.setFormatter(Console_color_formatter())
console_handler.setLevel(console_log_level) console_handler.setLevel(console_log_level)
syslog_path = None handlers = [console_handler]
if log_file is None and syslog_log_level != logging.DISABLED:
if syslog_log_level != logging.DISABLED:
syslog_path = None
if os.path.exists('/dev/log'): if os.path.exists('/dev/log'):
syslog_path = '/dev/log' syslog_path = '/dev/log'
elif os.path.exists('/var/run/syslog'): elif os.path.exists('/var/run/syslog'):
@ -203,14 +206,15 @@ def configure_logging(
elif os.path.exists('/var/run/log'): elif os.path.exists('/var/run/log'):
syslog_path = '/var/run/log' syslog_path = '/var/run/log'
if syslog_path and not interactive_console(): if syslog_path:
syslog_handler = logging.handlers.SysLogHandler(address=syslog_path) syslog_handler = logging.handlers.SysLogHandler(address=syslog_path)
syslog_handler.setFormatter( syslog_handler.setFormatter(
logging.Formatter('borgmatic: {levelname} {message}', style='{') # noqa: FS003 logging.Formatter('borgmatic: {levelname} {message}', style='{') # noqa: FS003
) )
syslog_handler.setLevel(syslog_log_level) syslog_handler.setLevel(syslog_log_level)
handlers = (console_handler, syslog_handler) handlers.append(syslog_handler)
elif log_file and log_file_log_level != logging.DISABLED:
if log_file and log_file_log_level != logging.DISABLED:
file_handler = logging.handlers.WatchedFileHandler(log_file) file_handler = logging.handlers.WatchedFileHandler(log_file)
file_handler.setFormatter( file_handler.setFormatter(
logging.Formatter( logging.Formatter(
@ -218,11 +222,9 @@ def configure_logging(
) )
) )
file_handler.setLevel(log_file_log_level) file_handler.setLevel(log_file_log_level)
handlers = (console_handler, file_handler) handlers.append(file_handler)
else:
handlers = (console_handler,)
logging.basicConfig( 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, handlers=handlers,
) )

View File

@ -23,12 +23,20 @@ def handle_signal(signal_number, frame):
if signal_number == signal.SIGTERM: if signal_number == signal.SIGTERM:
logger.critical('Exiting due to TERM signal') logger.critical('Exiting due to TERM signal')
sys.exit(EXIT_CODE_FROM_SIGNAL + signal.SIGTERM) sys.exit(EXIT_CODE_FROM_SIGNAL + signal.SIGTERM)
elif signal_number == signal.SIGINT:
raise KeyboardInterrupt()
def configure_signals(): def configure_signals():
''' '''
Configure borgmatic's signal handlers to pass relevant signals through to any child processes 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) signal.signal(signal_number, handle_signal)

View File

@ -2,13 +2,13 @@
font-size: 1rem; /* Reset */ font-size: 1rem; /* Reset */
} }
.elv-toc details { .elv-toc details {
--details-force-closed: (max-width: 63.9375em); /* 1023px */ --details-force-closed: (max-width: 79.9375em); /* 1023px */
} }
.elv-toc details > summary { .elv-toc details > summary {
font-size: 1.375rem; /* 22px /16 */ font-size: 1.375rem; /* 22px /16 */
margin-bottom: .5em; margin-bottom: .5em;
} }
@media (min-width: 64em) { /* 1024px */ @media (min-width: 80em) {
.elv-toc { .elv-toc {
position: absolute; position: absolute;
left: 3rem; left: 3rem;

View File

@ -121,7 +121,7 @@ main h1:first-child,
main .elv-toc + h1 { main .elv-toc + h1 {
border-bottom: 2px dotted #666; border-bottom: 2px dotted #666;
} }
@media (min-width: 64em) { /* 1024px */ @media (min-width: 80em) {
main .elv-toc + h1, main .elv-toc + h1,
main .elv-toc + h2 { main .elv-toc + h2 {
margin-top: 0; margin-top: 0;
@ -243,10 +243,10 @@ footer.elv-layout {
.elv-layout-full { .elv-layout-full {
max-width: none; max-width: none;
} }
@media (min-width: 64em) { /* 1024px */ @media (min-width: 80em) {
.elv-layout-toc { .elv-layout-toc {
padding-left: 15rem; padding-left: 15rem;
max-width: 60rem; max-width: 76rem;
margin-right: 1rem; margin-right: 1rem;
position: relative; position: relative;
} }

View File

@ -126,7 +126,7 @@ for more information.
## Hook output ## Hook output
Any output produced by your hooks shows up both at the console and in syslog Any output produced by your hooks shows up both at the console and in syslog
(when run in a non-interactive console). For more information, read about <a (when enabled). For more information, read about <a
href="https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/">inspecting href="https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/">inspecting
your backups</a>. your backups</a>.

View File

@ -51,6 +51,11 @@ cron job), while only running expensive consistency checks with `check` on a
much less frequent basis (e.g. with `borgmatic check` called from a separate much less frequent basis (e.g. with `borgmatic check` called from a separate
cron job). cron job).
<span class="minilink minilink-addedin">New in version 1.8.5</span> Instead of
(or in addition to) specifying actions on the command-line, you can configure
borgmatic to [skip particular
actions](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#skipping-actions).
### Consistency check configuration ### Consistency check configuration
@ -116,8 +121,17 @@ this option in the `consistency:` section of your configuration.
This tells borgmatic to run the `repository` consistency check at most once This tells borgmatic to run the `repository` consistency check at most once
every two weeks for a given repository and the `archives` check at most once a every two weeks for a given repository and the `archives` check at most once a
month. The `frequency` value is a number followed by a unit of time, e.g. "3 month. The `frequency` value is a number followed by a unit of time, e.g. `3
days", "1 week", "2 months", etc. days`, `1 week`, `2 months`, etc. The set of possible time units is as
follows (singular or plural):
* `second`
* `minute`
* `hour`
* `day`
* `week` (7 days)
* `month` (30 days)
* `year` (365 days)
The `frequency` defaults to `always` for a check configured without a The `frequency` defaults to `always` for a check configured without a
`frequency`, which means run this check every time checks run. But if you omit `frequency`, which means run this check every time checks run. But if you omit
@ -139,6 +153,10 @@ though—or the most frequently configured check will apply.
If you want to temporarily ignore your configured frequencies, you can invoke If you want to temporarily ignore your configured frequencies, you can invoke
`borgmatic check --force` to run checks unconditionally. `borgmatic check --force` to run checks unconditionally.
<span class="minilink minilink-addedin">New in version 1.8.6</span> `borgmatic
check --force` runs `check` even if it's specified in the `skip_actions`
option.
### Running only checks ### Running only checks
@ -162,7 +180,16 @@ location:
If that's still too slow, you can disable consistency checks entirely, If that's still too slow, you can disable consistency checks entirely,
either for a single repository or for all repositories. either for a single repository or for all repositories.
Disabling all consistency checks looks like this: <span class="minilink minilink-addedin">New in version 1.8.5</span> Disabling
all consistency checks looks like this:
```yaml
skip_actions:
- check
```
<span class="minilink minilink-addedin">Prior to version 1.8.5</span> Use this
configuration instead:
```yaml ```yaml
checks: checks:
@ -170,10 +197,10 @@ checks:
``` ```
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put <span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `consistency:` section of your configuration. `checks:` in the `consistency:` section of your configuration.
<span class="minilink minilink-addedin">Prior to version 1.6.2</span> `checks` <span class="minilink minilink-addedin">Prior to version 1.6.2</span>
was a plain list of strings without the `name:` part. For instance: `checks:` was a plain list of strings without the `name:` part. For instance:
```yaml ```yaml
checks: checks:

View File

@ -7,7 +7,12 @@ eleventyNavigation:
--- ---
## Source code ## 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 ```bash
git clone https://projects.torsion.org/borgmatic-collective/borgmatic.git git clone https://projects.torsion.org/borgmatic-collective/borgmatic.git
@ -19,39 +24,34 @@ Or:
git clone ssh://git@projects.torsion.org:3022/borgmatic-collective/borgmatic.git git clone ssh://git@projects.torsion.org:3022/borgmatic-collective/borgmatic.git
``` ```
Then, install borgmatic Finally, install borgmatic
"[editable](https://pip.pypa.io/en/stable/cli/pip_install/#editable-installs)" "[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 so that you can run borgmatic actions during development to make sure your
changes work. changes work:
```bash ```bash
cd borgmatic 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 To get oriented with the borgmatic source code, have a look at the [source
code reference](https://torsion.org/borgmatic/docs/reference/source-code/). code reference](https://torsion.org/borgmatic/docs/reference/source-code/).
## Automated tests ## Automated tests
Assuming you've cloned the borgmatic source code as described above, and Assuming you've cloned the borgmatic source code as described above and you're
you're in the `borgmatic/` working copy, install tox, which is used for in the `borgmatic/` working copy, install tox, which is used for setting up
setting up testing environments: 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 ```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 ```bash
tox tox

View File

@ -149,9 +149,10 @@ borgmatic umount --mount-point /mnt
<span class="minilink minilink-addedin">New in version 1.7.15</span> borgmatic <span class="minilink minilink-addedin">New in version 1.7.15</span> borgmatic
automatically stores all the configuration files used to create an archive 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 inside the archive itself. They are stored in the archive using their full
configuration file or you want to see what configurations were used to create a paths from the machine being backed up. This is useful in cases where you've
particular archive. 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` To extract the configuration files from an archive, use the `config bootstrap`
action. For example: action. For example:
@ -166,8 +167,8 @@ configuration file used to create this archive was located at
`/etc/borgmatic/config.yaml` when the archive was created. `/etc/borgmatic/config.yaml` when the archive was created.
Note that to run the `config bootstrap` action, you don't need to have a 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 borgmatic configuration file. You only need to specify the repository to use
the `--repository` flag; borgmatic will figure out the rest. via the `--repository` flag; borgmatic will figure out the rest.
If a destination directory is not specified, the configuration files will be If a destination directory is not specified, the configuration files will be
extracted to their original locations, silently *overwriting* any configuration extracted to their original locations, silently *overwriting* any configuration
@ -182,6 +183,9 @@ If you want to extract the configuration file from a specific archive, use the
borgmatic config bootstrap --repository repo.borg --archive host-2023-01-02T04:06:07.080910 --destination /tmp borgmatic config bootstrap --repository repo.borg --archive host-2023-01-02T04:06:07.080910 --destination /tmp
``` ```
See the output of `config bootstrap --help` for additional flags you may need
for bootstrapping.
<span class="minilink minilink-addedin">New in version 1.8.1</span> Set the <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 `store_config_files` option to `false` to disable the automatic backup of
borgmatic configuration files, for instance if they contain sensitive borgmatic configuration files, for instance if they contain sensitive

View File

@ -116,27 +116,30 @@ archive, complete with file sizes.
## Logging ## Logging
By default, borgmatic logs to a local syslog-compatible daemon if one is By default, borgmatic logs to the console. You can enable simultaneous syslog
present and borgmatic is running in a non-interactive console. Where those logging and customize its log level with the `--syslog-verbosity` flag, which
logs show up depends on your particular system. If you're using systemd, try is independent from the console logging `--verbosity` flag described above.
running `journalctl -xe`. Otherwise, try viewing `/var/log/syslog` or For instance, to enable syslog logging, run:
similar.
You can customize the log level used for syslog logging with the
`--syslog-verbosity` flag, and this is independent from the console logging
`--verbosity` flag described above. For instance, to get additional
information about the progress of the backup as it proceeds:
```bash ```bash
borgmatic --syslog-verbosity 1 borgmatic --syslog-verbosity 1
``` ```
Or to increase syslog logging to include debug spew: To increase syslog logging further to include debugging information, run:
```bash ```bash
borgmatic --syslog-verbosity 2 borgmatic --syslog-verbosity 2
``` ```
See above for further details about the verbosity levels.
Where these logs show up depends on your particular system. If you're using
systemd, try running `journalctl -xe`. Otherwise, try viewing
`/var/log/syslog` or similar.
<span class="minilink minilink-addedin">Prior to version 1.8.3</span>borgmatic
logged to syslog by default whenever run at a non-interactive console.
### Rate limiting ### Rate limiting
If you are using rsyslog or systemd's journal, be aware that by default they If you are using rsyslog or systemd's journal, be aware that by default they
@ -165,7 +168,7 @@ Note that if you use the `--log-file` flag, you are responsible for rotating
the log file so it doesn't grow too large, for example with the log file so it doesn't grow too large, for example with
[logrotate](https://wiki.archlinux.org/index.php/Logrotate). [logrotate](https://wiki.archlinux.org/index.php/Logrotate).
You can the `--log-file-verbosity` flag to customize the log file's log level: You can use the `--log-file-verbosity` flag to customize the log file's log level:
```bash ```bash
borgmatic --log-file /path/to/file.log --log-file-verbosity 2 borgmatic --log-file /path/to/file.log --log-file-verbosity 2
@ -197,5 +200,5 @@ See the [Python logging
documentation](https://docs.python.org/3/library/logging.html#logrecord-attributes) documentation](https://docs.python.org/3/library/logging.html#logrecord-attributes)
for additional placeholders. for additional placeholders.
Note that this `--log-file-format` flg only applies to the specified Note that this `--log-file-format` flag only applies to the specified
`--log-file` and not to syslog or other logging. `--log-file` and not to syslog or other logging.

View File

@ -151,7 +151,7 @@ in newer versions of borgmatic.
## Configuration includes ## Configuration includes
Once you have multiple different configuration files, you might want to share Once you have multiple different configuration files, you might want to share
common configuration options across these files with having to copy and paste common configuration options across these files without having to copy and paste
them. To achieve this, you can put fragments of common configuration options 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. configuration files.
@ -301,7 +301,7 @@ options via an include and then overrides one of them locally:
<<: !include /etc/borgmatic/common.yaml <<: !include /etc/borgmatic/common.yaml
constants: constants:
hostname: myhostname base_directory: /opt
repositories: repositories:
- path: repo.borg - path: repo.borg
@ -311,13 +311,13 @@ This is what `common.yaml` might look like:
```yaml ```yaml
constants: constants:
prefix: myprefix app_name: myapp
hostname: otherhost base_directory: /var/lib
``` ```
Once this include gets merged in, the resulting configuration would have a Once this include gets merged in, the resulting configuration would have an
`prefix` value of `myprefix` and an overridden `hostname` value of `app_name` value of `myapp` and an overridden `base_directory` value of
`myhostname`. `/opt`.
When there's an option collision between the local file and the merged When there's an option collision between the local file and the merged
include, the local file's option takes precedence. include, the local file's option takes precedence.
@ -540,8 +540,7 @@ tool is borgmatic's support for defining custom constants. This is similar to
the [variable interpolation the [variable interpolation
feature](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/#variable-interpolation) feature](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/#variable-interpolation)
for command hooks, but the constants feature lets you substitute your own for command hooks, but the constants feature lets you substitute your own
custom values into anywhere in the entire configuration file. (Constants don't custom values into any option values in the entire configuration file.
work across includes or separate configuration files though.)
Here's an example usage: Here's an example usage:
@ -564,10 +563,15 @@ forget to specify the section (like `location:` or `storage:`) that any option
is in. is in.
In this example, when borgmatic runs, all instances of `{user}` get replaced 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, And `{now}` doesn't get replaced with anything, but gets passed directly to
but gets passed directly to Borg.) After substitution, the logical result Borg, which has its own
looks something like this: [placeholders](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-placeholders)
using the same syntax as borgmatic constants. So borgmatic options like
`archive_name_format` that get passed directly to Borg can use either Borg
placeholders or borgmatic constants or both!
After substitution, the logical result looks something like this:
```yaml ```yaml
source_directories: source_directories:
@ -579,5 +583,24 @@ source_directories:
archive_name_format: 'bar-{now}' archive_name_format: 'bar-{now}'
``` ```
Note that if you'd like to interpolate a constant into the beginning of a
value, you'll need to quote it. For instance, this won't work:
```yaml
source_directories:
- {my_home_directory}/.config # This will error!
```
Instead, do this:
```yaml
source_directories:
- "{my_home_directory}/.config"
```
<span class="minilink minilink-addedin">New in version 1.8.5</span> Constants
work across includes, meaning you can define a constant and then include a
separate configuration file that uses that constant.
An alternate to constants is passing in your values via [environment An alternate to constants is passing in your values via [environment
variables](https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/). variables](https://torsion.org/borgmatic/docs/how-to/provide-your-passwords/).

View File

@ -36,28 +36,24 @@ below for how to configure this.
### Third-party monitoring services ### Third-party monitoring services
borgmatic integrates with monitoring services like borgmatic integrates with these monitoring services and libraries, pinging
[Healthchecks](https://healthchecks.io/), [Cronitor](https://cronitor.io), them as backups happen:
[Cronhub](https://cronhub.io), [PagerDuty](https://www.pagerduty.com/),
[ntfy](https://ntfy.sh/), and [Grafana Loki](https://grafana.com/oss/loki/) * [Healthchecks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook)
and pings these services whenever borgmatic runs. That way, you'll receive an * [Cronitor](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook)
alert when something goes wrong or (for certain hooks) the service doesn't * [Cronhub](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook)
hear from borgmatic for a configured interval. See [Healthchecks * [PagerDuty](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook)
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook), * [ntfy](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook)
[Cronitor * [Grafana Loki](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook)
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook), * [Apprise](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#apprise-hook)
[Cronhub
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook), The idea is that you'll receive an alert when something goes wrong or when the
[PagerDuty service doesn't hear from borgmatic for a configured interval (if supported).
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook), See the documentation links above for configuration information.
[ntfy
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#ntfy-hook), While these services and libraries offer different features, you probably only
and [Loki need to use one of them at most.
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#loki-hook),
below for how to configure this.
While these services offer different features, you probably only need to use
one of them at most.
### Third-party monitoring software ### Third-party monitoring software
@ -146,7 +142,7 @@ healthchecks:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put <span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `hooks:` section of your configuration. this option in the `hooks:` section of your configuration.
With this hook in place, borgmatic pings your Healthchecks project when a With this configuration, borgmatic pings your Healthchecks project when a
backup begins, ends, or errors, but only when any of the `create`, `prune`, backup begins, ends, or errors, but only when any of the `create`, `prune`,
`compact`, or `check` actions are run. `compact`, or `check` actions are run.
@ -190,7 +186,7 @@ cronitor:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put <span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `hooks:` section of your configuration. this option in the `hooks:` section of your configuration.
With this hook in place, borgmatic pings your Cronitor monitor when a backup With this configuration, borgmatic pings your Cronitor monitor when a backup
begins, ends, or errors, but only when any of the `prune`, `compact`, begins, ends, or errors, but only when any of the `prune`, `compact`,
`create`, or `check` actions are run. Then, if the actions complete `create`, or `check` actions are run. Then, if the actions complete
successfully or errors, borgmatic notifies Cronitor accordingly. successfully or errors, borgmatic notifies Cronitor accordingly.
@ -217,7 +213,7 @@ cronhub:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put <span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `hooks:` section of your configuration. this option in the `hooks:` section of your configuration.
With this hook in place, borgmatic pings your Cronhub monitor when a backup With this configuration, borgmatic pings your Cronhub monitor when a backup
begins, ends, or errors, but only when any of the `prune`, `compact`, begins, ends, or errors, but only when any of the `prune`, `compact`,
`create`, or `check` actions are run. Then, if the actions complete `create`, or `check` actions are run. Then, if the actions complete
successfully or errors, borgmatic notifies Cronhub accordingly. successfully or errors, borgmatic notifies Cronhub accordingly.
@ -258,7 +254,7 @@ pagerduty:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put <span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `hooks:` section of your configuration. this option in the `hooks:` section of your configuration.
With this hook in place, borgmatic creates a PagerDuty event for your service With this configuration, borgmatic creates a PagerDuty event for your service
whenever backups fail, but only when any of the `create`, `prune`, `compact`, whenever backups fail, but only when any of the `create`, `prune`, `compact`,
or `check` actions are run. Note that borgmatic does not contact PagerDuty or `check` actions are run. Note that borgmatic does not contact PagerDuty
when a backup starts or when it ends without error. when a backup starts or when it ends without error.
@ -340,7 +336,7 @@ loki:
url: http://localhost:3100/loki/api/v1/push url: http://localhost:3100/loki/api/v1/push
``` ```
With this hook in place, borgmatic sends its logs to your Loki instance as any 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 of the `prune`, `compact`, `create`, or `check` actions are run. Then, after
the actions complete, borgmatic notifies Loki of success or failure. the actions complete, borgmatic notifies Loki of success or failure.
@ -375,6 +371,69 @@ loki:
``` ```
## 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 ## Scripting borgmatic
To consume the output of borgmatic in other software, you can include an To consume the output of borgmatic in other software, you can include an

View File

@ -9,9 +9,15 @@ eleventyNavigation:
If you want to use a Borg repository passphrase or database passwords with If you want to use a Borg repository passphrase or database passwords with
borgmatic, you can set them directly in your borgmatic configuration file, 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 treating those secrets like any other option value. For instance, you can
them outside of borgmatic, whether for convenience or security reasons, read specify your Borg passhprase with:
on.
```yaml
encryption_passphrase: yourpassphrase
```
But if you'd rather store them outside of borgmatic, whether for convenience
or security reasons, read on.
### Delegating to a another application ### Delegating to a another application
@ -32,14 +38,14 @@ pull your repository passphrase, your database passwords, or any other option
values from environment variables. For instance: values from environment variables. For instance:
```yaml ```yaml
encryption_passphrase: ${MY_PASSPHRASE} encryption_passphrase: ${YOUR_PASSPHRASE}
``` ```
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put <span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `storage:` section of your configuration. this option in the `storage:` section of your configuration.
This uses the `MY_PASSPHRASE` environment variable as your encryption This uses the `YOUR_PASSPHRASE` environment variable as your encryption
passphrase. Note that the `{` `}` brackets are required. `$MY_PASSPHRASE` by passphrase. Note that the `{` `}` brackets are required. `$YOUR_PASSPHRASE` by
itself will not work. itself will not work.
In the case of `encryption_passphrase` in particular, an alternate approach In the case of `encryption_passphrase` in particular, an alternate approach
@ -54,25 +60,26 @@ the same approach applies. For example:
```yaml ```yaml
postgresql_databases: postgresql_databases:
- name: users - name: users
password: ${MY_DATABASE_PASSWORD} password: ${YOUR_DATABASE_PASSWORD}
``` ```
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put <span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
this option in the `hooks:` section of your configuration. 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. password.
#### Interpolation defaults #### 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 ```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 environment variable is not set. Without a default, if the environment
variable doesn't exist, borgmatic will error. variable doesn't exist, borgmatic will error.
@ -102,3 +109,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), 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 although in that case it's for particular borgmatic runtime values rather than
(only) environment variables. (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.

View File

@ -7,74 +7,70 @@ eleventyNavigation:
--- ---
## Installation ## Installation
Many users need to backup system files that require privileged access, so ### Prerequisites
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.
First, manually [install First, [install
Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at least Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at least
version 1.1. borgmatic does not install Borg automatically so as to avoid version 1.1. borgmatic does not install Borg automatically so as to avoid
conflicts with existing Borg installations. conflicts with existing Borg installations.
Then, download and install borgmatic as a [user site Then, [install pipx](https://pypa.github.io/pipx/installation/) as the root
installation](https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site) user (with `sudo`) to make installing borgmatic easy without impacting other
by running the following command: 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 ```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 If you want to run borgmatic on a schedule with privileged access to your
Python 3.7+, as borgmatic does not support older versions of Python. files, then you should install borgmatic as the root user by running the
following commands:
The next step is to ensure that borgmatic's commands available are on your
system `PATH`, so that you can run borgmatic:
```bash ```bash
echo export 'PATH="$PATH:/root/.local/bin"' >> ~/.bashrc sudo pipx ensurepath
source ~/.bashrc sudo pipx install borgmatic
``` ```
This adds `/root/.local/bin` to your non-root user's system `PATH`. Check whether this worked with:
If you're using a command shell other than Bash, you may need to use different
commands here.
You can check whether all of this worked with:
```bash ```bash
sudo borgmatic --version sudo su -
borgmatic --version
``` ```
If borgmatic is properly installed, that should output your borgmatic version. If borgmatic is properly installed, that should output your borgmatic version.
And if you'd also like `sudo borgmatic` to work, keep reading!
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.
### Global install option ### Non-root install
If you try the user site installation above and have problems making borgmatic If you only want to run borgmatic as a non-root user (without privileged file
commands runnable on your system `PATH`, an alternate approach is to install access) *or* you want to make `sudo borgmatic` work so borgmatic runs as root,
borgmatic globally. then install borgmatic as a non-root user by running the following commands as
that user:
The following uninstalls borgmatic and then reinstalls it such that borgmatic
commands are on the default system `PATH`:
```bash ```bash
sudo pip3 uninstall borgmatic pipx ensurepath
sudo pip3 install --upgrade borgmatic pipx install borgmatic
``` ```
The main downside of a global install is that borgmatic is less cleanly This should work even if you've also installed borgmatic as the root user.
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 Check whether this worked with:
on a relatively dedicated system, then a global install can work out fine.
```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 ### Other ways to install
@ -286,6 +282,21 @@ due to things like file damage. For instance:
sudo borgmatic --verbosity 1 --list --stats sudo borgmatic --verbosity 1 --list --stats
``` ```
### Skipping actions
<span class="minilink minilink-addedin">New in version 1.8.5</span> You can
configure borgmatic to skip running certain actions (default or otherwise).
For instance, to always skip the `compact` action when using [Borg's
append-only
mode](https://borgbackup.readthedocs.io/en/stable/usage/notes.html#append-only-mode-forbid-compaction),
set the `skip_actions` option:
```
skip_actions:
- compact
```
## Autopilot ## Autopilot
Running backups manually is good for validating your configuration, but I'm Running backups manually is good for validating your configuration, but I'm

View File

@ -7,26 +7,38 @@ eleventyNavigation:
--- ---
## Upgrading borgmatic ## Upgrading borgmatic
In general, all you should need to do to upgrade borgmatic is run the In general, all you should need to do to upgrade borgmatic if you've
following: [installed it with
pipx](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#installation)
is to run the following:
```bash ```bash
sudo pip3 install --user --upgrade borgmatic sudo pipx upgrade borgmatic
``` ```
See below about special cases with old versions of borgmatic. Additionally, if Omit `sudo` if you installed borgmatic as a non-root user. And if you
you installed borgmatic [without using `pip3 install installed borgmatic *both* as root and as a non-root user, you'll need to
--user`](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#other-ways-to-install), upgrade each installation independently.
then your upgrade process may be different.
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 ### Upgrading your configuration
The borgmatic configuration file format is almost always backwards-compatible The borgmatic configuration file format is usually backwards-compatible from
from release to release without any changes, but you may still want to update release to release without any changes, but you may still want to update your
your configuration file when you upgrade to take advantage of new configuration file when you upgrade to take advantage of new configuration
configuration options. This is completely optional. If you prefer, you can add options or avoid old configuration from eventually becoming unsupported. If
new configuration options manually. you prefer, you can add new configuration options manually.
If you do want to upgrade your configuration file to include new options, use 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 the `borgmatic config generate` action with its optional `--source` flag that
@ -64,45 +76,10 @@ and, if desired, replace your original configuration file with it.
borgmatic changed its configuration file format in version 1.1.0 from 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 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 to express lists of values. Modern versions of borgmatic no longer include
upgrade to the last version of borgmatic to support converting configuration: support for upgrading configuration files this old, but feel free to [file a
borgmatic 1.7.14. ticket](https://torsion.org/borgmatic/#issues) for help with upgrading any old
INI-style configuration files you may have.
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
```
## Upgrading Borg ## Upgrading Borg

View File

@ -21,5 +21,3 @@ version](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#configuration
```yaml ```yaml
{% include borgmatic/config.yaml %} {% include borgmatic/config.yaml %}
``` ```
Note that you can also [download this configuration

BIN
docs/static/apprise.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

BIN
docs/static/loki.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -32,16 +32,16 @@ RestrictSUIDSGID=yes
SystemCallArchitectures=native SystemCallArchitectures=native
SystemCallFilter=@system-service SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM SystemCallErrorNumber=EPERM
# To restrict write access further, change "ProtectSystem" to "strict" and uncomment # To restrict write access further, change "ProtectSystem" to "strict" and
# "ReadWritePaths", "ReadOnlyPaths", "ProtectHome", and "BindPaths". Then add any local repository # uncomment "ReadWritePaths", "TemporaryFileSystem", "BindPaths" and
# paths to the list of "ReadWritePaths" and local backup source paths to "ReadOnlyPaths". This # "BindReadOnlyPaths". Then add any local repository paths to the list of
# leaves most of the filesystem read-only to borgmatic. # "ReadWritePaths". This leaves most of the filesystem read-only to borgmatic.
ProtectSystem=full ProtectSystem=full
# ReadWritePaths=-/mnt/my_backup_drive # 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 # 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 # BindPaths=-/root/.cache/borg -/root/.config/borg -/root/.borgmatic
# BindReadOnlyPaths=-/root/.ssh
# May interfere with running external programs within borgmatic hooks. # May interfere with running external programs within borgmatic hooks.
CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_NET_RAW CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_NET_RAW

View File

@ -18,11 +18,11 @@ if [ -z "$TEST_CONTAINER" ]; then
fi fi
apk add --no-cache python3 py3-pip borgbackup postgresql-client mariadb-client mongodb-tools \ 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. # If certain dependencies of black are available in this version of Alpine, install them.
apk add --no-cache py3-typed-ast py3-regex || true 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 pymongo==4.4.1 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 export COVERAGE_FILE=/tmp/.coverage
if [ "$1" != "--end-to-end-only" ]; then if [ "$1" != "--end-to-end-only" ]; then

View File

@ -1,6 +1,6 @@
from setuptools import find_packages, setup from setuptools import find_packages, setup
VERSION = '1.8.3.dev0' VERSION = '1.8.6.dev0'
setup( setup(
@ -33,9 +33,10 @@ setup(
'jsonschema', 'jsonschema',
'packaging', 'packaging',
'requests', 'requests',
'ruamel.yaml>0.15.0,<0.18.0', 'ruamel.yaml>0.15.0',
'setuptools', 'setuptools',
), ),
extras_require={"Apprise": ["apprise"]},
include_package_data=True, include_package_data=True,
python_requires='>=3.7', python_requires='>=3.8',
) )

View File

@ -1,6 +1,8 @@
appdirs==1.4.4; python_version >= '3.8' appdirs==1.4.4; python_version >= '3.8'
apprise==1.3.0
attrs==22.2.0; python_version >= '3.8' attrs==22.2.0; python_version >= '3.8'
black==23.3.0; python_version >= '3.8' black==23.3.0; python_version >= '3.8'
certifi==2023.7.22
chardet==5.1.0 chardet==5.1.0
click==8.1.3; python_version >= '3.8' click==8.1.3; python_version >= '3.8'
codespell==2.2.4 codespell==2.2.4
@ -12,22 +14,21 @@ flake8-use-fstring==1.4
flake8-variables-names==0.0.5 flake8-variables-names==0.0.5
flexmock==0.11.3 flexmock==0.11.3
idna==3.4 idna==3.4
importlib_metadata==6.3.0; python_version < '3.8'
isort==5.12.0 isort==5.12.0
jsonschema==4.17.3
Markdown==3.4.1
mccabe==0.7.0 mccabe==0.7.0
packaging==23.1 packaging==23.1
pathspec==0.11.1
pluggy==1.0.0 pluggy==1.0.0
pathspec==0.11.1; python_version >= '3.8'
py==1.11.0 py==1.11.0
pycodestyle==2.10.0 pycodestyle==2.10.0
pyflakes==3.0.1 pyflakes==3.0.1
jsonschema==4.17.3
pytest==7.3.0 pytest==7.3.0
pytest-cov==4.0.0 pytest-cov==4.0.0
regex; python_version >= '3.8' PyYAML>5.0.0
regex
requests==2.31.0 requests==2.31.0
ruamel.yaml>0.15.0,<0.18.0 ruamel.yaml>0.15.0
toml==0.10.2; python_version >= '3.8' toml==0.10.2
typed-ast; python_version >= '3.8' typed-ast
typing-extensions==4.5.0; python_version < '3.8'
zipp==3.15.0; python_version < '3.8'

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

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 flag_name: 1 for flag_name in flag_counts
}, f"Duplicate flags found in: {' '.join(command)}" }, f"Duplicate flags found in: {' '.join(command)}"
if '--json' in command:
return '{}'
def fuzz_argument(arguments, argument_name): def fuzz_argument(arguments, argument_name):
''' '''

View File

@ -13,8 +13,9 @@ def test_parse_arguments_with_no_arguments_uses_defaults():
global_arguments = arguments['global'] global_arguments = arguments['global']
assert global_arguments.config_paths == config_paths assert global_arguments.config_paths == config_paths
assert global_arguments.verbosity == 0 assert global_arguments.verbosity == 0
assert global_arguments.syslog_verbosity == 0 assert global_arguments.syslog_verbosity == -2
assert global_arguments.log_file_verbosity == 0 assert global_arguments.log_file_verbosity == 1
assert global_arguments.monitoring_verbosity == 1
def test_parse_arguments_with_multiple_config_flags_parses_as_list(): 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'] global_arguments = arguments['global']
assert global_arguments.config_paths == ['myconfig', 'otherconfig'] assert global_arguments.config_paths == ['myconfig', 'otherconfig']
assert global_arguments.verbosity == 0 assert global_arguments.verbosity == 0
assert global_arguments.syslog_verbosity == 0 assert global_arguments.syslog_verbosity == -2
assert global_arguments.log_file_verbosity == 0 assert global_arguments.log_file_verbosity == 1
assert global_arguments.monitoring_verbosity == 1
def test_parse_arguments_with_action_after_config_path_omits_action(): 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'] global_arguments = arguments['global']
assert global_arguments.config_paths == config_paths assert global_arguments.config_paths == config_paths
assert global_arguments.verbosity == 1 assert global_arguments.verbosity == 1
assert global_arguments.syslog_verbosity == 0 assert global_arguments.syslog_verbosity == -2
assert global_arguments.log_file_verbosity == 0 assert global_arguments.log_file_verbosity == 1
assert global_arguments.monitoring_verbosity == 1
def test_parse_arguments_with_syslog_verbosity_overrides_default(): 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.config_paths == config_paths
assert global_arguments.verbosity == 0 assert global_arguments.verbosity == 0
assert global_arguments.syslog_verbosity == 2 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(): 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'] global_arguments = arguments['global']
assert global_arguments.config_paths == config_paths assert global_arguments.config_paths == config_paths
assert global_arguments.verbosity == 0 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.log_file_verbosity == -1
assert global_arguments.monitoring_verbosity == 1
def test_parse_arguments_with_single_override_parses(): 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( module.parse_arguments(
'config', 'bootstrap', '--repository', 'repo.borg', '--config', 'test.yaml' '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')

View File

@ -10,7 +10,7 @@ from borgmatic.config import generate as module
def test_insert_newline_before_comment_does_not_raise(): def test_insert_newline_before_comment_does_not_raise():
field_name = 'foo' field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)]) config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Comment') config.yaml_set_comment_before_after_key(key=field_name, before='Comment')
module.insert_newline_before_comment(config, field_name) module.insert_newline_before_comment(config, field_name)
@ -125,14 +125,16 @@ def test_write_configuration_with_already_existing_directory_does_not_raise():
def test_add_comments_to_configuration_sequence_of_strings_does_not_raise(): def test_add_comments_to_configuration_sequence_of_strings_does_not_raise():
config = module.yaml.comments.CommentedSeq(['foo', 'bar']) config = module.ruamel.yaml.comments.CommentedSeq(['foo', 'bar'])
schema = {'type': 'array', 'items': {'type': 'string'}} schema = {'type': 'array', 'items': {'type': 'string'}}
module.add_comments_to_configuration_sequence(config, schema) module.add_comments_to_configuration_sequence(config, schema)
def test_add_comments_to_configuration_sequence_of_maps_does_not_raise(): def test_add_comments_to_configuration_sequence_of_maps_does_not_raise():
config = module.yaml.comments.CommentedSeq([module.yaml.comments.CommentedMap([('foo', 'yo')])]) config = module.ruamel.yaml.comments.CommentedSeq(
[module.ruamel.yaml.comments.CommentedMap([('foo', 'yo')])]
)
schema = { schema = {
'type': 'array', 'type': 'array',
'items': {'type': 'object', 'properties': {'foo': {'description': 'yo'}}}, 'items': {'type': 'object', 'properties': {'foo': {'description': 'yo'}}},
@ -142,7 +144,9 @@ def test_add_comments_to_configuration_sequence_of_maps_does_not_raise():
def test_add_comments_to_configuration_sequence_of_maps_without_description_does_not_raise(): def test_add_comments_to_configuration_sequence_of_maps_without_description_does_not_raise():
config = module.yaml.comments.CommentedSeq([module.yaml.comments.CommentedMap([('foo', 'yo')])]) config = module.ruamel.yaml.comments.CommentedSeq(
[module.ruamel.yaml.comments.CommentedMap([('foo', 'yo')])]
)
schema = {'type': 'array', 'items': {'type': 'object', 'properties': {'foo': {}}}} schema = {'type': 'array', 'items': {'type': 'object', 'properties': {'foo': {}}}}
module.add_comments_to_configuration_sequence(config, schema) module.add_comments_to_configuration_sequence(config, schema)
@ -150,7 +154,7 @@ def test_add_comments_to_configuration_sequence_of_maps_without_description_does
def test_add_comments_to_configuration_object_does_not_raise(): def test_add_comments_to_configuration_object_does_not_raise():
# Ensure that it can deal with fields both in the schema and missing from the schema. # Ensure that it can deal with fields both in the schema and missing from the schema.
config = module.yaml.comments.CommentedMap([('foo', 33), ('bar', 44), ('baz', 55)]) config = module.ruamel.yaml.comments.CommentedMap([('foo', 33), ('bar', 44), ('baz', 55)])
schema = { schema = {
'type': 'object', 'type': 'object',
'properties': {'foo': {'description': 'Foo'}, 'bar': {'description': 'Bar'}}, 'properties': {'foo': {'description': 'Foo'}, 'bar': {'description': 'Bar'}},
@ -160,7 +164,7 @@ def test_add_comments_to_configuration_object_does_not_raise():
def test_add_comments_to_configuration_object_with_skip_first_does_not_raise(): def test_add_comments_to_configuration_object_with_skip_first_does_not_raise():
config = module.yaml.comments.CommentedMap([('foo', 33)]) config = module.ruamel.yaml.comments.CommentedMap([('foo', 33)])
schema = {'type': 'object', 'properties': {'foo': {'description': 'Foo'}}} schema = {'type': 'object', 'properties': {'foo': {'description': 'Foo'}}}
module.add_comments_to_configuration_object(config, schema, skip_first=True) module.add_comments_to_configuration_object(config, schema, skip_first=True)
@ -168,7 +172,7 @@ def test_add_comments_to_configuration_object_with_skip_first_does_not_raise():
def test_remove_commented_out_sentinel_keeps_other_comments(): def test_remove_commented_out_sentinel_keeps_other_comments():
field_name = 'foo' field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)]) config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.\nCOMMENT_OUT') config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.\nCOMMENT_OUT')
module.remove_commented_out_sentinel(config, field_name) module.remove_commented_out_sentinel(config, field_name)
@ -180,7 +184,7 @@ def test_remove_commented_out_sentinel_keeps_other_comments():
def test_remove_commented_out_sentinel_without_sentinel_keeps_other_comments(): def test_remove_commented_out_sentinel_without_sentinel_keeps_other_comments():
field_name = 'foo' field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)]) config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.') config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.')
module.remove_commented_out_sentinel(config, field_name) module.remove_commented_out_sentinel(config, field_name)
@ -192,7 +196,7 @@ def test_remove_commented_out_sentinel_without_sentinel_keeps_other_comments():
def test_remove_commented_out_sentinel_on_unknown_field_does_not_raise(): def test_remove_commented_out_sentinel_on_unknown_field_does_not_raise():
field_name = 'foo' field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)]) config = module.ruamel.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.') config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.')
module.remove_commented_out_sentinel(config, 'unknown') module.remove_commented_out_sentinel(config, 'unknown')
@ -201,7 +205,9 @@ def test_remove_commented_out_sentinel_on_unknown_field_does_not_raise():
def test_generate_sample_configuration_does_not_raise(): def test_generate_sample_configuration_does_not_raise():
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('') builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.yaml).should_receive('round_trip_load') flexmock(module.ruamel.yaml).should_receive('YAML').and_return(
flexmock(load=lambda filename: {})
)
flexmock(module).should_receive('schema_to_sample_configuration') flexmock(module).should_receive('schema_to_sample_configuration')
flexmock(module).should_receive('merge_source_configuration_into_destination') flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('render_configuration') flexmock(module).should_receive('render_configuration')
@ -214,7 +220,9 @@ def test_generate_sample_configuration_does_not_raise():
def test_generate_sample_configuration_with_source_filename_does_not_raise(): def test_generate_sample_configuration_with_source_filename_does_not_raise():
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('') builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.yaml).should_receive('round_trip_load') flexmock(module.ruamel.yaml).should_receive('YAML').and_return(
flexmock(load=lambda filename: {})
)
flexmock(module.load).should_receive('load_configuration') flexmock(module.load).should_receive('load_configuration')
flexmock(module.normalize).should_receive('normalize') flexmock(module.normalize).should_receive('normalize')
flexmock(module).should_receive('schema_to_sample_configuration') flexmock(module).should_receive('schema_to_sample_configuration')
@ -229,7 +237,9 @@ def test_generate_sample_configuration_with_source_filename_does_not_raise():
def test_generate_sample_configuration_with_dry_run_does_not_write_file(): def test_generate_sample_configuration_with_dry_run_does_not_write_file():
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('') builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.yaml).should_receive('round_trip_load') flexmock(module.ruamel.yaml).should_receive('YAML').and_return(
flexmock(load=lambda filename: {})
)
flexmock(module).should_receive('schema_to_sample_configuration') flexmock(module).should_receive('schema_to_sample_configuration')
flexmock(module).should_receive('merge_source_configuration_into_destination') flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('render_configuration') flexmock(module).should_receive('render_configuration')

View File

@ -15,35 +15,6 @@ def test_load_configuration_parses_contents():
assert module.load_configuration('config.yaml') == {'key': 'value'} assert module.load_configuration('config.yaml') == {'key': 'value'}
def test_load_configuration_replaces_constants():
builtins = flexmock(sys.modules['builtins'])
config_file = io.StringIO(
'''
constants:
key: value
key: {key}
'''
)
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
assert module.load_configuration('config.yaml') == {'key': 'value'}
def test_load_configuration_replaces_complex_constants():
builtins = flexmock(sys.modules['builtins'])
config_file = io.StringIO(
'''
constants:
key:
subkey: value
key: {key}
'''
)
config_file.name = 'config.yaml'
builtins.should_receive('open').with_args('config.yaml').and_return(config_file)
assert module.load_configuration('config.yaml') == {'key': {'subkey': 'value'}}
def test_load_configuration_with_only_integer_value_does_not_raise(): def test_load_configuration_with_only_integer_value_does_not_raise():
builtins = flexmock(sys.modules['builtins']) builtins = flexmock(sys.modules['builtins'])
config_file = io.StringIO('33') config_file = io.StringIO('33')

View File

@ -4,19 +4,24 @@ from borgmatic.config import override as module
@pytest.mark.parametrize( @pytest.mark.parametrize(
'value,expected_result', 'value,expected_result,option_type',
( (
('thing', 'thing'), ('thing', 'thing', 'string'),
('33', 33), ('33', 33, 'integer'),
('33b', '33b'), ('33', '33', 'string'),
('true', True), ('33b', '33b', 'integer'),
('false', False), ('33b', '33b', 'string'),
('[foo]', ['foo']), ('true', True, 'boolean'),
('[foo, bar]', ['foo', 'bar']), ('false', False, 'boolean'),
('true', 'true', 'string'),
('[foo]', ['foo'], 'array'),
('[foo]', '[foo]', 'string'),
('[foo, bar]', ['foo', 'bar'], 'array'),
('[foo, bar]', '[foo, bar]', 'string'),
), ),
) )
def test_convert_value_type_coerces_values(value, expected_result): def test_convert_value_type_coerces_values(value, expected_result, option_type):
assert module.convert_value_type(value) == expected_result assert module.convert_value_type(value, option_type) == expected_result
def test_apply_overrides_updates_config(): def test_apply_overrides_updates_config():
@ -25,16 +30,23 @@ def test_apply_overrides_updates_config():
'other_section.thing=value2', 'other_section.thing=value2',
'section.nested.key=value3', 'section.nested.key=value3',
'new.foo=bar', 'new.foo=bar',
'new.mylist=[baz]',
'new.nonlist=[quux]',
] ]
config = { config = {
'section': {'key': 'value', 'other': 'other_value'}, 'section': {'key': 'value', 'other': 'other_value'},
'other_section': {'thing': 'thing_value'}, 'other_section': {'thing': 'thing_value'},
} }
schema = {
'properties': {
'new': {'properties': {'mylist': {'type': 'array'}, 'nonlist': {'type': 'string'}}}
}
}
module.apply_overrides(config, raw_overrides) module.apply_overrides(config, schema, raw_overrides)
assert config == { assert config == {
'section': {'key': 'value1', 'other': 'other_value', 'nested': {'key': 'value3'}}, 'section': {'key': 'value1', 'other': 'other_value', 'nested': {'key': 'value3'}},
'other_section': {'thing': 'value2'}, 'other_section': {'thing': 'value2'},
'new': {'foo': 'bar'}, 'new': {'foo': 'bar', 'mylist': ['baz'], 'nonlist': '[quux]'},
} }

View File

@ -1,3 +1,9 @@
import pkgutil
import borgmatic.actions
import borgmatic.config.load
import borgmatic.config.validate
MAXIMUM_LINE_LENGTH = 80 MAXIMUM_LINE_LENGTH = 80
@ -6,3 +12,23 @@ def test_schema_line_length_stays_under_limit():
for line in schema_file.readlines(): for line in schema_file.readlines():
assert len(line.rstrip('\n')) <= MAXIMUM_LINE_LENGTH assert len(line.rstrip('\n')) <= MAXIMUM_LINE_LENGTH
ACTIONS_MODULE_NAMES_TO_OMIT = {'arguments', 'export_key', 'json'}
ACTIONS_MODULE_NAMES_TO_ADD = {'key', 'umount'}
def test_schema_skip_actions_correspond_to_supported_actions():
'''
Ensure that the allowed actions in the schema's "skip_actions" option don't drift from
borgmatic's actual supported actions.
'''
schema = borgmatic.config.load.load_configuration(borgmatic.config.validate.schema_filename())
schema_skip_actions = set(schema['properties']['skip_actions']['items']['enum'])
supported_actions = {
module.name.replace('_', '-')
for module in pkgutil.iter_modules(borgmatic.actions.__path__)
if module.name not in ACTIONS_MODULE_NAMES_TO_OMIT
}.union(ACTIONS_MODULE_NAMES_TO_ADD)
assert schema_skip_actions == supported_actions

View File

@ -1,4 +1,5 @@
import io import io
import os
import string import string
import sys import sys
@ -244,7 +245,7 @@ def test_parse_configuration_applies_overrides():
assert logs == [] assert logs == []
def test_parse_configuration_applies_normalization(): def test_parse_configuration_applies_normalization_after_environment_variable_interpolation():
mock_config_and_schema( mock_config_and_schema(
''' '''
location: location:
@ -252,17 +253,18 @@ def test_parse_configuration_applies_normalization():
- /home - /home
repositories: repositories:
- path: hostname.borg - ${NO_EXIST:-user@hostname:repo}
exclude_if_present: .nobackup exclude_if_present: .nobackup
''' '''
) )
flexmock(os).should_receive('getenv').replace_with(lambda variable_name, default: default)
config, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml') config, logs = module.parse_configuration('/tmp/config.yaml', '/tmp/schema.yaml')
assert config == { assert config == {
'source_directories': ['/home'], 'source_directories': ['/home'],
'repositories': [{'path': 'hostname.borg'}], 'repositories': [{'path': 'ssh://user@hostname/./repo'}],
'exclude_if_present': ['.nobackup'], 'exclude_if_present': ['.nobackup'],
} }
assert logs assert logs

View File

@ -9,6 +9,7 @@ def test_get_config_paths_returns_list_of_config_paths():
borgmatic_source_directory=None, borgmatic_source_directory=None,
repository='repo', repository='repo',
archive='archive', archive='archive',
ssh_command=None,
) )
global_arguments = flexmock( global_arguments = flexmock(
dry_run=False, dry_run=False,
@ -30,11 +31,46 @@ def test_get_config_paths_returns_list_of_config_paths():
] ]
def test_get_config_paths_translates_ssh_command_argument_to_config():
bootstrap_arguments = flexmock(
borgmatic_source_directory=None,
repository='repo',
archive='archive',
ssh_command='ssh -i key',
)
global_arguments = flexmock(
dry_run=False,
)
local_borg_version = flexmock()
extract_process = flexmock(
stdout=flexmock(
read=lambda: '{"config_paths": ["/borgmatic/config.yaml"]}',
),
)
flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').with_args(
False,
'repo',
'archive',
object,
{'ssh_command': 'ssh -i key'},
object,
object,
extract_to_stdout=True,
).and_return(extract_process)
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').with_args(
'repo', 'archive', {'ssh_command': 'ssh -i key'}, object, object
).and_return('archive')
assert module.get_config_paths(bootstrap_arguments, global_arguments, local_borg_version) == [
'/borgmatic/config.yaml'
]
def test_get_config_paths_with_missing_manifest_raises_value_error(): def test_get_config_paths_with_missing_manifest_raises_value_error():
bootstrap_arguments = flexmock( bootstrap_arguments = flexmock(
borgmatic_source_directory=None, borgmatic_source_directory=None,
repository='repo', repository='repo',
archive='archive', archive='archive',
ssh_command=None,
) )
global_arguments = flexmock( global_arguments = flexmock(
dry_run=False, dry_run=False,
@ -57,6 +93,7 @@ def test_get_config_paths_with_broken_json_raises_value_error():
borgmatic_source_directory=None, borgmatic_source_directory=None,
repository='repo', repository='repo',
archive='archive', archive='archive',
ssh_command=None,
) )
global_arguments = flexmock( global_arguments = flexmock(
dry_run=False, dry_run=False,
@ -81,6 +118,7 @@ def test_get_config_paths_with_json_missing_key_raises_value_error():
borgmatic_source_directory=None, borgmatic_source_directory=None,
repository='repo', repository='repo',
archive='archive', archive='archive',
ssh_command=None,
) )
global_arguments = flexmock( global_arguments = flexmock(
dry_run=False, dry_run=False,
@ -101,6 +139,7 @@ def test_get_config_paths_with_json_missing_key_raises_value_error():
def test_run_bootstrap_does_not_raise(): def test_run_bootstrap_does_not_raise():
flexmock(module).should_receive('get_config_paths').and_return(['/borgmatic/config.yaml'])
bootstrap_arguments = flexmock( bootstrap_arguments = flexmock(
repository='repo', repository='repo',
archive='archive', archive='archive',
@ -108,6 +147,7 @@ def test_run_bootstrap_does_not_raise():
strip_components=1, strip_components=1,
progress=False, progress=False,
borgmatic_source_directory='/borgmatic', borgmatic_source_directory='/borgmatic',
ssh_command=None,
) )
global_arguments = flexmock( global_arguments = flexmock(
dry_run=False, dry_run=False,
@ -115,14 +155,54 @@ def test_run_bootstrap_does_not_raise():
local_borg_version = flexmock() local_borg_version = flexmock()
extract_process = flexmock( extract_process = flexmock(
stdout=flexmock( stdout=flexmock(
read=lambda: '{"config_paths": ["/borgmatic/config.yaml"]}', read=lambda: '{"config_paths": ["borgmatic/config.yaml"]}',
), ),
) )
flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return( flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return(
extract_process extract_process
).twice() ).once()
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return( flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
'archive' 'archive'
) )
module.run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version) module.run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version)
def test_run_bootstrap_translates_ssh_command_argument_to_config():
flexmock(module).should_receive('get_config_paths').and_return(['/borgmatic/config.yaml'])
bootstrap_arguments = flexmock(
repository='repo',
archive='archive',
destination='dest',
strip_components=1,
progress=False,
borgmatic_source_directory='/borgmatic',
ssh_command='ssh -i key',
)
global_arguments = flexmock(
dry_run=False,
)
local_borg_version = flexmock()
extract_process = flexmock(
stdout=flexmock(
read=lambda: '{"config_paths": ["borgmatic/config.yaml"]}',
),
)
flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').with_args(
False,
'repo',
'archive',
object,
{'ssh_command': 'ssh -i key'},
object,
object,
extract_to_stdout=False,
destination_path='dest',
strip_components=1,
progress=False,
).and_return(extract_process).once()
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').with_args(
'repo', 'archive', {'ssh_command': 'ssh -i key'}, object, object
).and_return('archive')
module.run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version)

View File

@ -19,7 +19,7 @@ def test_run_create_executes_and_calls_hooks_for_configured_repository():
repository=None, repository=None,
progress=flexmock(), progress=flexmock(),
stats=flexmock(), stats=flexmock(),
json=flexmock(), json=False,
list_files=flexmock(), list_files=flexmock(),
) )
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[]) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[])
@ -54,7 +54,7 @@ def test_run_create_with_store_config_files_false_does_not_create_borgmatic_mani
repository=None, repository=None,
progress=flexmock(), progress=flexmock(),
stats=flexmock(), stats=flexmock(),
json=flexmock(), json=False,
list_files=flexmock(), list_files=flexmock(),
) )
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[]) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[])
@ -91,7 +91,7 @@ def test_run_create_runs_with_selected_repository():
repository=flexmock(), repository=flexmock(),
progress=flexmock(), progress=flexmock(),
stats=flexmock(), stats=flexmock(),
json=flexmock(), json=False,
list_files=flexmock(), list_files=flexmock(),
) )
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[]) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[])
@ -123,7 +123,7 @@ def test_run_create_bails_if_repository_does_not_match():
repository=flexmock(), repository=flexmock(),
progress=flexmock(), progress=flexmock(),
stats=flexmock(), stats=flexmock(),
json=flexmock(), json=False,
list_files=flexmock(), list_files=flexmock(),
) )
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[]) global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[])
@ -144,6 +144,47 @@ def test_run_create_bails_if_repository_does_not_match():
) )
def test_run_create_produces_json():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive(
'repositories_match'
).once().and_return(True)
flexmock(module.borgmatic.borg.create).should_receive('create_archive').once().and_return(
flexmock()
)
parsed_json = flexmock()
flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json)
flexmock(module).should_receive('create_borgmatic_manifest').once()
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=flexmock(),
progress=flexmock(),
stats=flexmock(),
json=True,
list_files=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False, used_config_paths=[])
assert list(
module.run_create(
config_filename='test.yaml',
repository={'path': 'repo'},
config={},
hook_context={},
local_borg_version=None,
create_arguments=create_arguments,
global_arguments=global_arguments,
dry_run_label='',
local_path=None,
remote_path=None,
)
) == [parsed_json]
def test_create_borgmatic_manifest_creates_manifest_file(): def test_create_borgmatic_manifest_creates_manifest_file():
flexmock(module.os.path).should_receive('join').with_args( flexmock(module.os.path).should_receive('join').with_args(
module.borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY, 'bootstrap', 'manifest.json' module.borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY, 'bootstrap', 'manifest.json'
@ -151,7 +192,7 @@ def test_create_borgmatic_manifest_creates_manifest_file():
flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.os).should_receive('makedirs').and_return(True) flexmock(module.os).should_receive('makedirs').and_return(True)
flexmock(module.importlib_metadata).should_receive('version').and_return('1.0.0') flexmock(module.importlib.metadata).should_receive('version').and_return('1.0.0')
flexmock(sys.modules['builtins']).should_receive('open').with_args( flexmock(sys.modules['builtins']).should_receive('open').with_args(
'/home/user/.borgmatic/bootstrap/manifest.json', 'w' '/home/user/.borgmatic/bootstrap/manifest.json', 'w'
).and_return( ).and_return(
@ -172,7 +213,7 @@ def test_create_borgmatic_manifest_creates_manifest_file_with_custom_borgmatic_s
flexmock(module.os.path).should_receive('exists').and_return(False) flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.os).should_receive('makedirs').and_return(True) flexmock(module.os).should_receive('makedirs').and_return(True)
flexmock(module.importlib_metadata).should_receive('version').and_return('1.0.0') flexmock(module.importlib.metadata).should_receive('version').and_return('1.0.0')
flexmock(sys.modules['builtins']).should_receive('open').with_args( flexmock(sys.modules['builtins']).should_receive('open').with_args(
'/borgmatic/bootstrap/manifest.json', 'w' '/borgmatic/bootstrap/manifest.json', 'w'
).and_return( ).and_return(

View File

@ -13,7 +13,7 @@ def test_run_info_does_not_raise():
flexmock() flexmock()
) )
flexmock(module.borgmatic.borg.info).should_receive('display_archives_info') flexmock(module.borgmatic.borg.info).should_receive('display_archives_info')
info_arguments = flexmock(repository=flexmock(), archive=flexmock(), json=flexmock()) info_arguments = flexmock(repository=flexmock(), archive=flexmock(), json=False)
list( list(
module.run_info( module.run_info(
@ -26,3 +26,32 @@ def test_run_info_does_not_raise():
remote_path=None, remote_path=None,
) )
) )
def test_run_info_produces_json():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
flexmock()
)
flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return(
flexmock()
)
flexmock(module.borgmatic.borg.info).should_receive('display_archives_info').and_return(
flexmock()
)
parsed_json = flexmock()
flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json)
info_arguments = flexmock(repository=flexmock(), archive=flexmock(), json=True)
assert list(
module.run_info(
repository={'path': 'repo'},
config={},
local_borg_version=None,
info_arguments=info_arguments,
global_arguments=flexmock(log_json=False),
local_path=None,
remote_path=None,
)
) == [parsed_json]

View File

@ -0,0 +1,25 @@
from flexmock import flexmock
from borgmatic.actions import json as module
def test_parse_json_loads_json_from_string():
flexmock(module.json).should_receive('loads').and_return({'repository': {'id': 'foo'}})
assert module.parse_json('{"repository": {"id": "foo"}}', label=None) == {
'repository': {'id': 'foo', 'label': ''}
}
def test_parse_json_injects_label_into_parsed_data():
flexmock(module.json).should_receive('loads').and_return({'repository': {'id': 'foo'}})
assert module.parse_json('{"repository": {"id": "foo"}}', label='bar') == {
'repository': {'id': 'foo', 'label': 'bar'}
}
def test_parse_json_injects_nothing_when_repository_missing():
flexmock(module.json).should_receive('loads').and_return({'stuff': {'id': 'foo'}})
assert module.parse_json('{"stuff": {"id": "foo"}}', label='bar') == {'stuff': {'id': 'foo'}}

View File

@ -13,7 +13,9 @@ def test_run_list_does_not_raise():
flexmock() flexmock()
) )
flexmock(module.borgmatic.borg.list).should_receive('list_archive') flexmock(module.borgmatic.borg.list).should_receive('list_archive')
list_arguments = flexmock(repository=flexmock(), archive=flexmock(), json=flexmock()) list_arguments = flexmock(
repository=flexmock(), archive=flexmock(), json=False, find_paths=None
)
list( list(
module.run_list( module.run_list(
@ -26,3 +28,30 @@ def test_run_list_does_not_raise():
remote_path=None, remote_path=None,
) )
) )
def test_run_list_produces_json():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
flexmock()
)
flexmock(module.borgmatic.actions.arguments).should_receive('update_arguments').and_return(
flexmock()
)
flexmock(module.borgmatic.borg.list).should_receive('list_archive').and_return(flexmock())
parsed_json = flexmock()
flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json)
list_arguments = flexmock(repository=flexmock(), archive=flexmock(), json=True)
assert list(
module.run_list(
repository={'path': 'repo'},
config={},
local_borg_version=None,
list_arguments=list_arguments,
global_arguments=flexmock(log_json=False),
local_path=None,
remote_path=None,
)
) == [parsed_json]

View File

@ -7,7 +7,7 @@ def test_run_rinfo_does_not_raise():
flexmock(module.logger).answer = lambda message: None flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.borg.rinfo).should_receive('display_repository_info') flexmock(module.borgmatic.borg.rinfo).should_receive('display_repository_info')
rinfo_arguments = flexmock(repository=flexmock(), json=flexmock()) rinfo_arguments = flexmock(repository=flexmock(), json=False)
list( list(
module.run_rinfo( module.run_rinfo(
@ -20,3 +20,26 @@ def test_run_rinfo_does_not_raise():
remote_path=None, remote_path=None,
) )
) )
def test_run_rinfo_parses_json():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.borg.rinfo).should_receive('display_repository_info').and_return(
flexmock()
)
parsed_json = flexmock()
flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json)
rinfo_arguments = flexmock(repository=flexmock(), json=True)
list(
module.run_rinfo(
repository={'path': 'repo'},
config={},
local_borg_version=None,
rinfo_arguments=rinfo_arguments,
global_arguments=flexmock(log_json=False),
local_path=None,
remote_path=None,
)
) == [parsed_json]

View File

@ -7,7 +7,7 @@ def test_run_rlist_does_not_raise():
flexmock(module.logger).answer = lambda message: None flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True) flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.borg.rlist).should_receive('list_repository') flexmock(module.borgmatic.borg.rlist).should_receive('list_repository')
rlist_arguments = flexmock(repository=flexmock(), json=flexmock()) rlist_arguments = flexmock(repository=flexmock(), json=False)
list( list(
module.run_rlist( module.run_rlist(
@ -20,3 +20,24 @@ def test_run_rlist_does_not_raise():
remote_path=None, remote_path=None,
) )
) )
def test_run_rlist_produces_json():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
flexmock(module.borgmatic.borg.rlist).should_receive('list_repository').and_return(flexmock())
parsed_json = flexmock()
flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json)
rlist_arguments = flexmock(repository=flexmock(), json=True)
assert list(
module.run_rlist(
repository={'path': 'repo'},
config={},
local_borg_version=None,
rlist_arguments=rlist_arguments,
global_arguments=flexmock(),
local_path=None,
remote_path=None,
)
) == [parsed_json]

View File

@ -193,6 +193,19 @@ def test_filter_checks_on_frequency_restains_check_with_unelapsed_frequency_and_
) == ('archives',) ) == ('archives',)
def test_filter_checks_on_frequency_passes_through_empty_checks():
assert (
module.filter_checks_on_frequency(
config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
checks=(),
force=False,
archives_check_id='1234',
)
== ()
)
def test_make_archive_filter_flags_with_default_checks_and_prefix_returns_default_flags(): def test_make_archive_filter_flags_with_default_checks_and_prefix_returns_default_flags():
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
@ -201,6 +214,7 @@ def test_make_archive_filter_flags_with_default_checks_and_prefix_returns_defaul
'1.2.3', '1.2.3',
{}, {},
('repository', 'archives'), ('repository', 'archives'),
check_arguments=flexmock(match_archives=None),
prefix='foo', prefix='foo',
) )
@ -215,6 +229,7 @@ def test_make_archive_filter_flags_with_all_checks_and_prefix_returns_default_fl
'1.2.3', '1.2.3',
{}, {},
('repository', 'archives', 'extract'), ('repository', 'archives', 'extract'),
check_arguments=flexmock(match_archives=None),
prefix='foo', prefix='foo',
) )
@ -229,6 +244,7 @@ def test_make_archive_filter_flags_with_all_checks_and_prefix_without_borg_featu
'1.2.3', '1.2.3',
{}, {},
('repository', 'archives', 'extract'), ('repository', 'archives', 'extract'),
check_arguments=flexmock(match_archives=None),
prefix='foo', prefix='foo',
) )
@ -239,7 +255,9 @@ def test_make_archive_filter_flags_with_archives_check_and_last_includes_last_fl
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('archives',), check_last=3) flags = module.make_archive_filter_flags(
'1.2.3', {}, ('archives',), check_arguments=flexmock(match_archives=None), check_last=3
)
assert flags == ('--last', '3') assert flags == ('--last', '3')
@ -248,7 +266,9 @@ def test_make_archive_filter_flags_with_data_check_and_last_includes_last_flag()
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('data',), check_last=3) flags = module.make_archive_filter_flags(
'1.2.3', {}, ('data',), check_arguments=flexmock(match_archives=None), check_last=3
)
assert flags == ('--last', '3') assert flags == ('--last', '3')
@ -257,7 +277,9 @@ def test_make_archive_filter_flags_with_repository_check_and_last_omits_last_fla
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('repository',), check_last=3) flags = module.make_archive_filter_flags(
'1.2.3', {}, ('repository',), check_arguments=flexmock(match_archives=None), check_last=3
)
assert flags == () assert flags == ()
@ -266,7 +288,13 @@ def test_make_archive_filter_flags_with_default_checks_and_last_includes_last_fl
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('repository', 'archives'), check_last=3) flags = module.make_archive_filter_flags(
'1.2.3',
{},
('repository', 'archives'),
check_arguments=flexmock(match_archives=None),
check_last=3,
)
assert flags == ('--last', '3') assert flags == ('--last', '3')
@ -275,7 +303,9 @@ def test_make_archive_filter_flags_with_archives_check_and_prefix_includes_match
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('archives',), prefix='foo-') flags = module.make_archive_filter_flags(
'1.2.3', {}, ('archives',), check_arguments=flexmock(match_archives=None), prefix='foo-'
)
assert flags == ('--match-archives', 'sh:foo-*') assert flags == ('--match-archives', 'sh:foo-*')
@ -284,11 +314,30 @@ def test_make_archive_filter_flags_with_data_check_and_prefix_includes_match_arc
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('data',), prefix='foo-') flags = module.make_archive_filter_flags(
'1.2.3', {}, ('data',), check_arguments=flexmock(match_archives=None), prefix='foo-'
)
assert flags == ('--match-archives', 'sh:foo-*') assert flags == ('--match-archives', 'sh:foo-*')
def test_make_archive_filter_flags_prefers_check_arguments_match_archives_to_config_match_archives():
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
'baz-*', None, '1.2.3'
).and_return(('--match-archives', 'sh:baz-*'))
flags = module.make_archive_filter_flags(
'1.2.3',
{'match_archives': 'bar-{now}'}, # noqa: FS003
('archives',),
check_arguments=flexmock(match_archives='baz-*'),
prefix='',
)
assert flags == ('--match-archives', 'sh:baz-*')
def test_make_archive_filter_flags_with_archives_check_and_empty_prefix_uses_archive_name_format_instead(): def test_make_archive_filter_flags_with_archives_check_and_empty_prefix_uses_archive_name_format_instead():
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
@ -296,7 +345,11 @@ def test_make_archive_filter_flags_with_archives_check_and_empty_prefix_uses_arc
).and_return(('--match-archives', 'sh:bar-*')) ).and_return(('--match-archives', 'sh:bar-*'))
flags = module.make_archive_filter_flags( flags = module.make_archive_filter_flags(
'1.2.3', {'archive_name_format': 'bar-{now}'}, ('archives',), prefix='' # noqa: FS003 '1.2.3',
{'archive_name_format': 'bar-{now}'}, # noqa: FS003
('archives',),
check_arguments=flexmock(match_archives=None),
prefix='',
) )
assert flags == ('--match-archives', 'sh:bar-*') assert flags == ('--match-archives', 'sh:bar-*')
@ -306,7 +359,9 @@ def test_make_archive_filter_flags_with_archives_check_and_none_prefix_omits_mat
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('archives',), prefix=None) flags = module.make_archive_filter_flags(
'1.2.3', {}, ('archives',), check_arguments=flexmock(match_archives=None), prefix=None
)
assert flags == () assert flags == ()
@ -315,7 +370,9 @@ def test_make_archive_filter_flags_with_repository_check_and_prefix_omits_match_
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('repository',), prefix='foo-') flags = module.make_archive_filter_flags(
'1.2.3', {}, ('repository',), check_arguments=flexmock(match_archives=None), prefix='foo-'
)
assert flags == () assert flags == ()
@ -324,7 +381,13 @@ def test_make_archive_filter_flags_with_default_checks_and_prefix_includes_match
flexmock(module.feature).should_receive('available').and_return(True) flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(()) flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
flags = module.make_archive_filter_flags('1.2.3', {}, ('repository', 'archives'), prefix='foo-') flags = module.make_archive_filter_flags(
'1.2.3',
{},
('repository', 'archives'),
check_arguments=flexmock(match_archives=None),
prefix='foo-',
)
assert flags == ('--match-archives', 'sh:foo-*') assert flags == ('--match-archives', 'sh:foo-*')
@ -607,7 +670,7 @@ def test_upgrade_check_times_renames_stale_temporary_check_path():
module.upgrade_check_times(flexmock(), flexmock()) module.upgrade_check_times(flexmock(), flexmock())
def test_check_archives_with_progress_calls_borg_with_progress_parameter(): def test_check_archives_with_progress_passes_through_to_borg():
checks = ('repository',) checks = ('repository',)
config = {'check_last': None} config = {'check_last': None}
flexmock(module.rinfo).should_receive('display_repository_info').and_return( flexmock(module.rinfo).should_receive('display_repository_info').and_return(
@ -634,12 +697,14 @@ def test_check_archives_with_progress_calls_borg_with_progress_parameter():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=True, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
progress=True,
) )
def test_check_archives_with_repair_calls_borg_with_repair_parameter(): def test_check_archives_with_repair_passes_through_to_borg():
checks = ('repository',) checks = ('repository',)
config = {'check_last': None} config = {'check_last': None}
flexmock(module.rinfo).should_receive('display_repository_info').and_return( flexmock(module.rinfo).should_receive('display_repository_info').and_return(
@ -666,8 +731,10 @@ def test_check_archives_with_repair_calls_borg_with_repair_parameter():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=True, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
repair=True,
) )
@ -701,6 +768,9 @@ def test_check_archives_calls_borg_with_parameters(checks):
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
) )
@ -723,6 +793,9 @@ def test_check_archives_with_json_error_raises():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
) )
@ -743,6 +816,9 @@ def test_check_archives_with_missing_json_keys_raises():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
) )
@ -769,11 +845,14 @@ def test_check_archives_with_extract_check_calls_extract_only():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
) )
def test_check_archives_with_log_info_calls_borg_with_info_parameter(): def test_check_archives_with_log_info_passes_through_to_borg():
checks = ('repository',) checks = ('repository',)
config = {'check_last': None} config = {'check_last': None}
flexmock(module.rinfo).should_receive('display_repository_info').and_return( flexmock(module.rinfo).should_receive('display_repository_info').and_return(
@ -795,11 +874,14 @@ def test_check_archives_with_log_info_calls_borg_with_info_parameter():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
) )
def test_check_archives_with_log_debug_calls_borg_with_debug_parameter(): def test_check_archives_with_log_debug_passes_through_to_borg():
checks = ('repository',) checks = ('repository',)
config = {'check_last': None} config = {'check_last': None}
flexmock(module.rinfo).should_receive('display_repository_info').and_return( flexmock(module.rinfo).should_receive('display_repository_info').and_return(
@ -821,6 +903,9 @@ def test_check_archives_with_log_debug_calls_borg_with_debug_parameter():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
) )
@ -841,6 +926,9 @@ def test_check_archives_without_any_checks_bails():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
) )
@ -867,12 +955,15 @@ def test_check_archives_with_local_path_calls_borg_via_local_path():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
local_path='borg1', local_path='borg1',
) )
def test_check_archives_with_remote_path_calls_borg_with_remote_path_parameters(): def test_check_archives_with_remote_path_passes_through_to_borg():
checks = ('repository',) checks = ('repository',)
check_last = flexmock() check_last = flexmock()
config = {'check_last': check_last} config = {'check_last': check_last}
@ -894,12 +985,15 @@ def test_check_archives_with_remote_path_calls_borg_with_remote_path_parameters(
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
remote_path='borg1', remote_path='borg1',
) )
def test_check_archives_with_log_json_calls_borg_with_log_json_parameters(): def test_check_archives_with_log_json_passes_through_to_borg():
checks = ('repository',) checks = ('repository',)
check_last = flexmock() check_last = flexmock()
config = {'check_last': check_last} config = {'check_last': check_last}
@ -921,11 +1015,14 @@ def test_check_archives_with_log_json_calls_borg_with_log_json_parameters():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=True), global_arguments=flexmock(log_json=True),
) )
def test_check_archives_with_lock_wait_calls_borg_with_lock_wait_parameters(): def test_check_archives_with_lock_wait_passes_through_to_borg():
checks = ('repository',) checks = ('repository',)
check_last = flexmock() check_last = flexmock()
config = {'lock_wait': 5, 'check_last': check_last} config = {'lock_wait': 5, 'check_last': check_last}
@ -947,6 +1044,9 @@ def test_check_archives_with_lock_wait_calls_borg_with_lock_wait_parameters():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
) )
@ -974,11 +1074,14 @@ def test_check_archives_with_retention_prefix():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
) )
def test_check_archives_with_extra_borg_options_calls_borg_with_extra_options(): def test_check_archives_with_extra_borg_options_passes_through_to_borg():
checks = ('repository',) checks = ('repository',)
config = {'check_last': None, 'extra_borg_options': {'check': '--extra --options'}} config = {'check_last': None, 'extra_borg_options': {'check': '--extra --options'}}
flexmock(module.rinfo).should_receive('display_repository_info').and_return( flexmock(module.rinfo).should_receive('display_repository_info').and_return(
@ -999,5 +1102,42 @@ def test_check_archives_with_extra_borg_options_calls_borg_with_extra_options():
repository_path='repo', repository_path='repo',
config=config, config=config,
local_borg_version='1.2.3', local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives=None
),
global_arguments=flexmock(log_json=False),
)
def test_check_archives_with_match_archives_passes_through_to_borg():
checks = ('archives',)
config = {'check_last': None}
flexmock(module.rinfo).should_receive('display_repository_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('upgrade_check_times')
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('make_archive_filter_flags').and_return(
('--match-archives', 'foo-*')
)
flexmock(module).should_receive('make_archives_check_id').and_return(None)
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module).should_receive('make_check_flags').and_return(('--match-archives', 'foo-*'))
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args(
('borg', 'check', '--match-archives', 'foo-*', 'repo'),
extra_environment=None,
).once()
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository_path='repo',
config=config,
local_borg_version='1.2.3',
check_arguments=flexmock(
progress=None, repair=None, only_checks=None, force=None, match_archives='foo-*'
),
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
) )

View File

@ -88,8 +88,8 @@ def test_make_repository_archive_flags_with_borg_features_joins_repository_and_a
@pytest.mark.parametrize( @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, None, True, ('--match-archives', 'sh:{hostname}-*')), # noqa: FS003
(None, '', True, ()), (None, '', True, ('--match-archives', 'sh:{hostname}-*')), # noqa: FS003
( (
're:foo-.*', 're:foo-.*',
'{hostname}-{now}', # noqa: FS003 '{hostname}-{now}', # noqa: FS003
@ -145,7 +145,12 @@ def test_make_repository_archive_flags_with_borg_features_joins_repository_and_a
True, True,
(), (),
), ),
(None, '{utcnow}-docs-{user}', False, ('--glob-archives', '*-docs-{user}')), # noqa: FS003 (
None,
'{utcnow}-docs-{user}', # noqa: FS003
False,
('--glob-archives', '*-docs-{user}'), # noqa: FS003
),
), ),
) )
def test_make_match_archives_flags_makes_flags_with_globs( def test_make_match_archives_flags_makes_flags_with_globs(
@ -159,3 +164,53 @@ def test_make_match_archives_flags_makes_flags_with_globs(
) )
== expected_result == 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*'), '{}')

View File

@ -8,224 +8,178 @@ from borgmatic.borg import info as module
from ..test_verbosity import insert_logging_mock from ..test_verbosity import insert_logging_mock
def test_display_archives_info_calls_borg_with_parameters(): def test_make_info_command_constructs_borg_info_command():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4' None, None, '2.3.4'
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').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.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', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), 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') def test_make_info_command_with_log_info_passes_through_to_command():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4' None, None, '2.3.4'
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').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.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) insert_logging_mock(logging.INFO)
module.display_archives_info(
command = module.make_info_command(
repository_path='repo', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), 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') def test_make_info_command_with_log_info_and_json_omits_borg_logging_flags():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4' None, None, '2.3.4'
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) 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.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) insert_logging_mock(logging.INFO)
json_output = module.display_archives_info(
command = module.make_info_command(
repository_path='repo', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None), 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(): def test_make_info_command_with_log_debug_passes_through_to_command():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4' None, None, '2.3.4'
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').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.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) insert_logging_mock(logging.DEBUG)
module.display_archives_info( command = module.make_info_command(
repository_path='repo', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), 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') def test_make_info_command_with_log_debug_and_json_omits_borg_logging_flags():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4' None, None, '2.3.4'
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) 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.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) command = module.make_info_command(
json_output = module.display_archives_info(
repository_path='repo', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None), 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(): def test_make_info_command_with_json_passes_through_to_command():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4' None, None, '2.3.4'
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').and_return(('--json',)) 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.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', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=True, prefix=None, match_archives=None), 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(): def test_make_info_command_with_archive_uses_match_archives_flags():
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
'archive', None, '2.3.4' 'archive', None, '2.3.4'
).and_return(('--match-archives', 'archive')) ).and_return(('--match-archives', 'archive'))
flexmock(module.flags).should_receive('make_flags_from_arguments').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.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', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive='archive', json=False, prefix=None, match_archives=None), 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') def test_make_info_command_with_local_path_passes_through_to_command():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4' None, None, '2.3.4'
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').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.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', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg1', 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') def test_make_info_command_with_remote_path_passes_through_to_command():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args( flexmock(module.flags).should_receive('make_flags').with_args(
'remote-path', 'borg1' 'remote-path', 'borg1'
@ -235,27 +189,21 @@ def test_display_archives_info_with_remote_path_calls_borg_with_remote_path_para
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').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.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', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None),
local_path='borg',
remote_path='borg1', 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') def test_make_info_command_with_log_json_passes_through_to_command():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args('log-json', True).and_return( flexmock(module.flags).should_receive('make_flags').with_args('log-json', True).and_return(
('--log-json',) ('--log-json',)
@ -265,26 +213,21 @@ def test_display_archives_info_with_log_json_calls_borg_with_log_json_parameters
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').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.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', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=True), global_arguments=flexmock(log_json=True),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), 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') def test_make_info_command_with_lock_wait_passes_through_to_command():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args('lock-wait', 5).and_return( flexmock(module.flags).should_receive('make_flags').with_args('lock-wait', 5).and_return(
('--lock-wait', '5') ('--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_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
config = {'lock_wait': 5} 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', repository_path='repo',
config=config, config=config,
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), 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') def test_make_info_command_transforms_prefix_into_match_archives_flags():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args( flexmock(module.flags).should_receive('make_flags').with_args(
'match-archives', 'sh:foo*' 'match-archives', 'sh:foo*'
@ -324,26 +262,21 @@ def test_display_archives_info_transforms_prefix_into_match_archives_parameters(
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').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.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', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix='foo'), 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') def test_make_info_command_prefers_prefix_over_archive_name_format():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_flags').with_args( flexmock(module.flags).should_receive('make_flags').with_args(
'match-archives', 'sh:foo*' 'match-archives', 'sh:foo*'
@ -353,52 +286,42 @@ def test_display_archives_info_prefers_prefix_over_archive_name_format():
).and_return(()) ).and_return(())
flexmock(module.flags).should_receive('make_flags_from_arguments').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.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', repository_path='repo',
config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 config={'archive_name_format': 'bar-{now}'}, # noqa: FS003
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix='foo'), 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') def test_make_info_command_transforms_archive_name_format_into_match_archives_flags():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, 'bar-{now}', '2.3.4' # noqa: FS003 None, 'bar-{now}', '2.3.4' # noqa: FS003
).and_return(('--match-archives', 'sh:bar-*')) ).and_return(('--match-archives', 'sh:bar-*'))
flexmock(module.flags).should_receive('make_flags_from_arguments').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.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', repository_path='repo',
config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 config={'archive_name_format': 'bar-{now}'}, # noqa: FS003
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), 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') def test_make_info_command_with_match_archives_option_passes_through_to_command():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
'sh:foo-*', 'bar-{now}', '2.3.4' # noqa: FS003 '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_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment') 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', repository_path='repo',
config={ config={
'archive_name_format': 'bar-{now}', # noqa: FS003 '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', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives=None), 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') def test_make_info_command_with_match_archives_flag_passes_through_to_command():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
'sh:foo-*', 'bar-{now}', '2.3.4' # noqa: FS003 '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_flags_from_arguments').and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment') 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', repository_path='repo',
config={'archive_name_format': 'bar-{now}'}, # noqa: FS003 config={'archive_name_format': 'bar-{now}'}, # noqa: FS003
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=flexmock(archive=None, json=False, prefix=None, match_archives='sh:foo-*'), 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')) @pytest.mark.parametrize('argument_name', ('sort_by', 'first', 'last'))
def test_display_archives_info_passes_through_arguments_to_borg(argument_name): def test_make_info_command_passes_arguments_through_to_command(argument_name):
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flag_name = f"--{argument_name.replace('_', ' ')}" flag_name = f"--{argument_name.replace('_', ' ')}"
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( 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.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo'))
flexmock(module.environment).should_receive('make_environment') 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', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', 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( info_arguments=flexmock(
archive=None, json=False, prefix=None, match_archives=None, **{argument_name: 'value'} 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') def test_make_info_command_with_date_based_matching_passes_through_to_command():
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module.flags).should_receive('make_flags').and_return(()) flexmock(module.flags).should_receive('make_flags').and_return(())
flexmock(module.flags).should_receive('make_match_archives_flags').with_args( flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
None, None, '2.3.4' 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') ('--newer', '1d', '--newest', '1y', '--older', '1m', '--oldest', '1w')
) )
flexmock(module.flags).should_receive('make_repository_flags').and_return(('--repo', 'repo')) 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( info_arguments = flexmock(
archive=None, archive=None,
json=False, json=False,
@ -524,10 +415,66 @@ def test_display_archives_info_with_date_based_matching_calls_borg_with_date_bas
older='1m', older='1m',
oldest='1w', oldest='1w',
) )
module.display_archives_info(
command = module.make_info_command(
repository_path='repo', repository_path='repo',
config={}, config={},
local_borg_version='2.3.4', local_borg_version='2.3.4',
global_arguments=flexmock(log_json=False), global_arguments=flexmock(log_json=False),
info_arguments=info_arguments, 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
) )

View File

@ -18,6 +18,12 @@ def test_display_repository_info_calls_borg_with_flags():
) )
) )
flexmock(module.environment).should_receive('make_environment') 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( flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--repo', 'repo'), ('borg', 'rinfo', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER, 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.feature).should_receive('available').and_return(False)
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',)) flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.environment).should_receive('make_environment') 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( flexmock(module).should_receive('execute_command').with_args(
('borg', 'info', 'repo'), ('borg', 'info', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER, 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.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( flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--info', '--repo', 'repo'), ('borg', 'rinfo', '--info', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER, 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, extra_environment=None,
borg_local_path='borg', borg_local_path='borg',
).and_return('[]') ).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never()
insert_logging_mock(logging.INFO) insert_logging_mock(logging.INFO)
json_output = module.display_repository_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.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( flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--debug', '--show-rc', '--repo', 'repo'), ('borg', 'rinfo', '--debug', '--show-rc', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER, 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, extra_environment=None,
borg_local_path='borg', borg_local_path='borg',
).and_return('[]') ).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never()
insert_logging_mock(logging.DEBUG) insert_logging_mock(logging.DEBUG)
json_output = module.display_repository_info( 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, extra_environment=None,
borg_local_path='borg', borg_local_path='borg',
).and_return('[]') ).and_return('[]')
flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags').never()
json_output = module.display_repository_info( json_output = module.display_repository_info(
repository_path='repo', 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.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( flexmock(module).should_receive('execute_command').with_args(
('borg1', 'rinfo', '--repo', 'repo'), ('borg1', 'rinfo', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER, 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.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( flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--remote-path', 'borg1', '--repo', 'repo'), ('borg', 'rinfo', '--remote-path', 'borg1', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER, 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.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( flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--log-json', '--repo', 'repo'), ('borg', 'rinfo', '--log-json', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER, 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.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( flexmock(module).should_receive('execute_command').with_args(
('borg', 'rinfo', '--lock-wait', '5', '--repo', 'repo'), ('borg', 'rinfo', '--lock-wait', '5', '--repo', 'repo'),
output_log_level=module.borgmatic.logger.ANSWER, output_log_level=module.borgmatic.logger.ANSWER,

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') 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.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_rlist_command')
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.environment).should_receive('make_environment') flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('execute_command').with_args( flexmock(module).should_receive('execute_command_and_capture_output').once()
('borg', 'rlist', 'repo'), flexmock(module.flags).should_receive('warn_for_aggressive_archive_flags')
output_log_level=module.borgmatic.logger.ANSWER, flexmock(module).should_receive('execute_command').once()
borg_local_path='borg',
extra_environment=None,
).once()
module.list_repository( module.list_repository(
repository_path='repo', repository_path='repo',
config={}, config={},
local_borg_version='1.2.3', local_borg_version='1.2.3',
rlist_arguments=rlist_arguments, rlist_arguments=argparse.Namespace(json=False),
global_arguments=global_arguments, 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.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER flexmock(module).should_receive('make_rlist_command')
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.environment).should_receive('make_environment') 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).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 ( assert (
module.list_repository( module.list_repository(
repository_path='repo', repository_path='repo',
config={}, config={},
local_borg_version='1.2.3', local_borg_version='1.2.3',
rlist_arguments=rlist_arguments, rlist_arguments=argparse.Namespace(json=True),
global_arguments=global_arguments, global_arguments=flexmock(),
) )
== json_output == json_output
) )

View File

@ -2,14 +2,34 @@ import logging
import subprocess import subprocess
import time import time
import pytest
from flexmock import flexmock from flexmock import flexmock
import borgmatic.hooks.command import borgmatic.hooks.command
from borgmatic.commands import borgmatic as module from borgmatic.commands import borgmatic as module
@pytest.mark.parametrize(
'config,arguments,expected_actions',
(
({}, {}, []),
({'skip_actions': []}, {}, []),
({'skip_actions': ['prune', 'check']}, {}, ['prune', 'check']),
(
{'skip_actions': ['prune', 'check']},
{'check': flexmock(force=False)},
['prune', 'check'],
),
({'skip_actions': ['prune', 'check']}, {'check': flexmock(force=True)}, ['prune']),
),
)
def test_get_skip_actions_uses_config_and_arguments(config, arguments, expected_actions):
assert module.get_skip_actions(config, arguments) == expected_actions
def test_run_configuration_runs_actions_for_each_repository(): def test_run_configuration_runs_actions_for_each_repository():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
expected_results = [flexmock(), flexmock()] expected_results = [flexmock(), flexmock()]
flexmock(module).should_receive('run_actions').and_return(expected_results[:1]).and_return( flexmock(module).should_receive('run_actions').and_return(expected_results[:1]).and_return(
@ -23,8 +43,20 @@ def test_run_configuration_runs_actions_for_each_repository():
assert results == expected_results assert results == expected_results
def test_run_configuration_with_skip_actions_does_not_raise():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return(['compact'])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module).should_receive('run_actions').and_return(flexmock()).and_return(flexmock())
config = {'repositories': [{'path': 'foo'}, {'path': 'bar'}], 'skip_actions': ['compact']}
arguments = {'global': flexmock(monitoring_verbosity=1)}
list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_with_invalid_borg_version_errors(): def test_run_configuration_with_invalid_borg_version_errors():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_raise(ValueError) flexmock(module.borg_version).should_receive('local_borg_version').and_raise(ValueError)
flexmock(module.command).should_receive('execute_hook').never() flexmock(module.command).should_receive('execute_hook').never()
flexmock(module.dispatch).should_receive('call_hooks').never() flexmock(module.dispatch).should_receive('call_hooks').never()
@ -37,6 +69,7 @@ def test_run_configuration_with_invalid_borg_version_errors():
def test_run_configuration_logs_monitor_start_error(): def test_run_configuration_logs_monitor_start_error():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').and_raise(OSError).and_return( flexmock(module.dispatch).should_receive('call_hooks').and_raise(OSError).and_return(
None None
@ -54,6 +87,7 @@ def test_run_configuration_logs_monitor_start_error():
def test_run_configuration_bails_for_monitor_start_soft_failure(): def test_run_configuration_bails_for_monitor_start_soft_failure():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.dispatch).should_receive('call_hooks').and_raise(error) flexmock(module.dispatch).should_receive('call_hooks').and_raise(error)
@ -69,6 +103,7 @@ def test_run_configuration_bails_for_monitor_start_soft_failure():
def test_run_configuration_logs_actions_error(): def test_run_configuration_logs_actions_error():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module.dispatch).should_receive('call_hooks') flexmock(module.dispatch).should_receive('call_hooks')
@ -85,6 +120,7 @@ def test_run_configuration_logs_actions_error():
def test_run_configuration_bails_for_actions_soft_failure(): def test_run_configuration_bails_for_actions_soft_failure():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks') flexmock(module.dispatch).should_receive('call_hooks')
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
@ -101,6 +137,7 @@ def test_run_configuration_bails_for_actions_soft_failure():
def test_run_configuration_logs_monitor_log_error(): def test_run_configuration_logs_monitor_log_error():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return( flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
None None
@ -118,6 +155,7 @@ def test_run_configuration_logs_monitor_log_error():
def test_run_configuration_bails_for_monitor_log_soft_failure(): def test_run_configuration_bails_for_monitor_log_soft_failure():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return( flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
@ -136,6 +174,7 @@ def test_run_configuration_bails_for_monitor_log_soft_failure():
def test_run_configuration_logs_monitor_finish_error(): def test_run_configuration_logs_monitor_finish_error():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return( flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
None None
@ -153,6 +192,7 @@ def test_run_configuration_logs_monitor_finish_error():
def test_run_configuration_bails_for_monitor_finish_soft_failure(): def test_run_configuration_bails_for_monitor_finish_soft_failure():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return( flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
@ -171,6 +211,7 @@ def test_run_configuration_bails_for_monitor_finish_soft_failure():
def test_run_configuration_does_not_call_monitoring_hooks_if_monitoring_hooks_are_disabled(): def test_run_configuration_does_not_call_monitoring_hooks_if_monitoring_hooks_are_disabled():
flexmock(module).should_receive('verbosity_to_log_level').and_return(module.DISABLED) flexmock(module).should_receive('verbosity_to_log_level').and_return(module.DISABLED)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').never() flexmock(module.dispatch).should_receive('call_hooks').never()
@ -184,6 +225,7 @@ def test_run_configuration_does_not_call_monitoring_hooks_if_monitoring_hooks_ar
def test_run_configuration_logs_on_error_hook_error(): def test_run_configuration_logs_on_error_hook_error():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook').and_raise(OSError) flexmock(module.command).should_receive('execute_hook').and_raise(OSError)
expected_results = [flexmock(), flexmock()] expected_results = [flexmock(), flexmock()]
@ -201,6 +243,7 @@ def test_run_configuration_logs_on_error_hook_error():
def test_run_configuration_bails_for_on_error_hook_soft_failure(): def test_run_configuration_bails_for_on_error_hook_soft_failure():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again') error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.command).should_receive('execute_hook').and_raise(error) flexmock(module.command).should_receive('execute_hook').and_raise(error)
@ -218,6 +261,7 @@ def test_run_configuration_bails_for_on_error_hook_soft_failure():
def test_run_configuration_retries_soft_error(): def test_run_configuration_retries_soft_error():
# Run action first fails, second passes # Run action first fails, second passes
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).and_return([]) flexmock(module).should_receive('run_actions').and_raise(OSError).and_return([])
@ -231,6 +275,7 @@ def test_run_configuration_retries_soft_error():
def test_run_configuration_retries_hard_error(): def test_run_configuration_retries_hard_error():
# Run action fails twice # Run action fails twice
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).times(2) flexmock(module).should_receive('run_actions').and_raise(OSError).times(2)
@ -253,6 +298,7 @@ def test_run_configuration_retries_hard_error():
def test_run_configuration_repos_ordered(): def test_run_configuration_repos_ordered():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).times(2) flexmock(module).should_receive('run_actions').and_raise(OSError).times(2)
@ -271,6 +317,7 @@ def test_run_configuration_repos_ordered():
def test_run_configuration_retries_round_robin(): def test_run_configuration_retries_round_robin():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).times(4) flexmock(module).should_receive('run_actions').and_raise(OSError).times(4)
@ -305,6 +352,7 @@ def test_run_configuration_retries_round_robin():
def test_run_configuration_retries_one_passes(): def test_run_configuration_retries_one_passes():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return( flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return(
@ -337,6 +385,7 @@ def test_run_configuration_retries_one_passes():
def test_run_configuration_retry_wait(): def test_run_configuration_retry_wait():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).times(4) flexmock(module).should_receive('run_actions').and_raise(OSError).times(4)
@ -380,6 +429,7 @@ def test_run_configuration_retry_wait():
def test_run_configuration_retries_timeout_multiple_repos(): def test_run_configuration_retries_timeout_multiple_repos():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO) flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return( flexmock(module).should_receive('run_actions').and_raise(OSError).and_raise(OSError).and_return(
@ -419,6 +469,7 @@ def test_run_configuration_retries_timeout_multiple_repos():
def test_run_actions_runs_rcreate(): def test_run_actions_runs_rcreate():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.rcreate).should_receive('run_rcreate').once() flexmock(borgmatic.actions.rcreate).should_receive('run_rcreate').once()
@ -437,6 +488,7 @@ def test_run_actions_runs_rcreate():
def test_run_actions_adds_log_file_to_hook_context(): def test_run_actions_adds_log_file_to_hook_context():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
expected = flexmock() expected = flexmock()
flexmock(borgmatic.actions.create).should_receive('run_create').with_args( flexmock(borgmatic.actions.create).should_receive('run_create').with_args(
@ -468,6 +520,7 @@ def test_run_actions_adds_log_file_to_hook_context():
def test_run_actions_runs_transfer(): def test_run_actions_runs_transfer():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.transfer).should_receive('run_transfer').once() flexmock(borgmatic.actions.transfer).should_receive('run_transfer').once()
@ -486,6 +539,7 @@ def test_run_actions_runs_transfer():
def test_run_actions_runs_create(): def test_run_actions_runs_create():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
expected = flexmock() expected = flexmock()
flexmock(borgmatic.actions.create).should_receive('run_create').and_yield(expected).once() flexmock(borgmatic.actions.create).should_receive('run_create').and_yield(expected).once()
@ -504,8 +558,28 @@ def test_run_actions_runs_create():
assert result == (expected,) assert result == (expected,)
def test_run_actions_with_skip_actions_skips_create():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return(['create'])
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.create).should_receive('run_create').never()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'create': flexmock()},
config_filename=flexmock(),
config={'repositories': [], 'skip_actions': ['create']},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_prune(): def test_run_actions_runs_prune():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.prune).should_receive('run_prune').once() flexmock(borgmatic.actions.prune).should_receive('run_prune').once()
@ -522,8 +596,28 @@ def test_run_actions_runs_prune():
) )
def test_run_actions_with_skip_actions_skips_prune():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return(['prune'])
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.prune).should_receive('run_prune').never()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'prune': flexmock()},
config_filename=flexmock(),
config={'repositories': [], 'skip_actions': ['prune']},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_compact(): def test_run_actions_runs_compact():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.compact).should_receive('run_compact').once() flexmock(borgmatic.actions.compact).should_receive('run_compact').once()
@ -540,8 +634,28 @@ def test_run_actions_runs_compact():
) )
def test_run_actions_with_skip_actions_skips_compact():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return(['compact'])
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.compact).should_receive('run_compact').never()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'compact': flexmock()},
config_filename=flexmock(),
config={'repositories': [], 'skip_actions': ['compact']},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_check_when_repository_enabled_for_checks(): def test_run_actions_runs_check_when_repository_enabled_for_checks():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(True) flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(True)
flexmock(borgmatic.actions.check).should_receive('run_check').once() flexmock(borgmatic.actions.check).should_receive('run_check').once()
@ -561,6 +675,7 @@ def test_run_actions_runs_check_when_repository_enabled_for_checks():
def test_run_actions_skips_check_when_repository_not_enabled_for_checks(): def test_run_actions_skips_check_when_repository_not_enabled_for_checks():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(False) flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(False)
flexmock(borgmatic.actions.check).should_receive('run_check').never() flexmock(borgmatic.actions.check).should_receive('run_check').never()
@ -578,8 +693,29 @@ def test_run_actions_skips_check_when_repository_not_enabled_for_checks():
) )
def test_run_actions_with_skip_actions_skips_check():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return(['check'])
flexmock(module.command).should_receive('execute_hook')
flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(True)
flexmock(borgmatic.actions.check).should_receive('run_check').never()
tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False, log_file='foo'), 'check': flexmock()},
config_filename=flexmock(),
config={'repositories': [], 'skip_actions': ['check']},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository={'path': 'repo'},
)
)
def test_run_actions_runs_extract(): def test_run_actions_runs_extract():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.extract).should_receive('run_extract').once() flexmock(borgmatic.actions.extract).should_receive('run_extract').once()
@ -598,6 +734,7 @@ def test_run_actions_runs_extract():
def test_run_actions_runs_export_tar(): def test_run_actions_runs_export_tar():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.export_tar).should_receive('run_export_tar').once() flexmock(borgmatic.actions.export_tar).should_receive('run_export_tar').once()
@ -616,6 +753,7 @@ def test_run_actions_runs_export_tar():
def test_run_actions_runs_mount(): def test_run_actions_runs_mount():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.mount).should_receive('run_mount').once() flexmock(borgmatic.actions.mount).should_receive('run_mount').once()
@ -634,6 +772,7 @@ def test_run_actions_runs_mount():
def test_run_actions_runs_restore(): def test_run_actions_runs_restore():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.restore).should_receive('run_restore').once() flexmock(borgmatic.actions.restore).should_receive('run_restore').once()
@ -652,6 +791,7 @@ def test_run_actions_runs_restore():
def test_run_actions_runs_rlist(): def test_run_actions_runs_rlist():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
expected = flexmock() expected = flexmock()
flexmock(borgmatic.actions.rlist).should_receive('run_rlist').and_yield(expected).once() flexmock(borgmatic.actions.rlist).should_receive('run_rlist').and_yield(expected).once()
@ -672,6 +812,7 @@ def test_run_actions_runs_rlist():
def test_run_actions_runs_list(): def test_run_actions_runs_list():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
expected = flexmock() expected = flexmock()
flexmock(borgmatic.actions.list).should_receive('run_list').and_yield(expected).once() flexmock(borgmatic.actions.list).should_receive('run_list').and_yield(expected).once()
@ -692,6 +833,7 @@ def test_run_actions_runs_list():
def test_run_actions_runs_rinfo(): def test_run_actions_runs_rinfo():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
expected = flexmock() expected = flexmock()
flexmock(borgmatic.actions.rinfo).should_receive('run_rinfo').and_yield(expected).once() flexmock(borgmatic.actions.rinfo).should_receive('run_rinfo').and_yield(expected).once()
@ -712,6 +854,7 @@ def test_run_actions_runs_rinfo():
def test_run_actions_runs_info(): def test_run_actions_runs_info():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
expected = flexmock() expected = flexmock()
flexmock(borgmatic.actions.info).should_receive('run_info').and_yield(expected).once() flexmock(borgmatic.actions.info).should_receive('run_info').and_yield(expected).once()
@ -732,6 +875,7 @@ def test_run_actions_runs_info():
def test_run_actions_runs_break_lock(): def test_run_actions_runs_break_lock():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.break_lock).should_receive('run_break_lock').once() flexmock(borgmatic.actions.break_lock).should_receive('run_break_lock').once()
@ -750,6 +894,7 @@ def test_run_actions_runs_break_lock():
def test_run_actions_runs_export_key(): def test_run_actions_runs_export_key():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.export_key).should_receive('run_export_key').once() flexmock(borgmatic.actions.export_key).should_receive('run_export_key').once()
@ -768,6 +913,7 @@ def test_run_actions_runs_export_key():
def test_run_actions_runs_borg(): def test_run_actions_runs_borg():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.borg).should_receive('run_borg').once() flexmock(borgmatic.actions.borg).should_receive('run_borg').once()
@ -786,6 +932,7 @@ def test_run_actions_runs_borg():
def test_run_actions_runs_multiple_actions_in_argument_order(): def test_run_actions_runs_multiple_actions_in_argument_order():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module).should_receive('get_skip_actions').and_return([])
flexmock(module.command).should_receive('execute_hook') flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.borg).should_receive('run_borg').once().ordered() flexmock(borgmatic.actions.borg).should_receive('run_borg').once().ordered()
flexmock(borgmatic.actions.restore).should_receive('run_restore').once().ordered() flexmock(borgmatic.actions.restore).should_receive('run_restore').once().ordered()
@ -849,7 +996,7 @@ def test_log_record_with_suppress_does_not_raise():
def test_log_error_records_generates_output_logs_for_message_only(): 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')) logs = tuple(module.log_error_records('Error'))
@ -857,7 +1004,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(): 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) flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING)
logs = tuple( logs = tuple(
@ -869,7 +1016,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(): 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) flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING)
logs = tuple( logs = tuple(
@ -880,8 +1027,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)) 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(): 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())) logs = tuple(module.log_error_records('Error', ValueError()))
@ -889,7 +1050,7 @@ def test_log_error_records_generates_logs_for_value_error():
def test_log_error_records_generates_logs_for_os_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())) logs = tuple(module.log_error_records('Error', OSError()))
@ -897,7 +1058,7 @@ def test_log_error_records_generates_logs_for_os_error():
def test_log_error_records_generates_nothing_for_other_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())) logs = tuple(module.log_error_records('Error', KeyError()))

View File

@ -2,14 +2,14 @@ from borgmatic.config import checks as module
def test_repository_enabled_for_checks_defaults_to_enabled_for_all_repositories(): def test_repository_enabled_for_checks_defaults_to_enabled_for_all_repositories():
enabled = module.repository_enabled_for_checks('repo.borg', consistency={}) enabled = module.repository_enabled_for_checks('repo.borg', config={})
assert enabled assert enabled
def test_repository_enabled_for_checks_is_enabled_for_specified_repositories(): def test_repository_enabled_for_checks_is_enabled_for_specified_repositories():
enabled = module.repository_enabled_for_checks( enabled = module.repository_enabled_for_checks(
'repo.borg', consistency={'check_repositories': ['repo.borg', 'other.borg']} 'repo.borg', config={'check_repositories': ['repo.borg', 'other.borg']}
) )
assert enabled assert enabled
@ -17,7 +17,7 @@ def test_repository_enabled_for_checks_is_enabled_for_specified_repositories():
def test_repository_enabled_for_checks_is_disabled_for_other_repositories(): def test_repository_enabled_for_checks_is_disabled_for_other_repositories():
enabled = module.repository_enabled_for_checks( enabled = module.repository_enabled_for_checks(
'repo.borg', consistency={'check_repositories': ['other.borg']} 'repo.borg', config={'check_repositories': ['other.borg']}
) )
assert not enabled assert not enabled

View File

@ -0,0 +1,58 @@
import pytest
from flexmock import flexmock
from borgmatic.config import constants as module
@pytest.mark.parametrize(
'value,expected_value',
(
('3', 3),
('0', 0),
('-3', -3),
('1234', 1234),
('true', True),
('True', True),
('false', False),
('False', False),
('thing', 'thing'),
({}, {}),
({'foo': 'bar'}, {'foo': 'bar'}),
([], []),
(['foo', 'bar'], ['foo', 'bar']),
),
)
def test_coerce_scalar_converts_value(value, expected_value):
assert module.coerce_scalar(value) == expected_value
def test_apply_constants_with_empty_constants_passes_through_value():
assert module.apply_constants(value='thing', constants={}) == 'thing'
@pytest.mark.parametrize(
'value,expected_value',
(
(None, None),
('thing', 'thing'),
('{foo}', 'bar'),
('abc{foo}', 'abcbar'),
('{foo}xyz', 'barxyz'),
('{foo}{baz}', 'barquux'),
('{int}', '3'),
('{bool}', 'True'),
(['thing', 'other'], ['thing', 'other']),
(['thing', '{foo}'], ['thing', 'bar']),
(['{foo}', '{baz}'], ['bar', 'quux']),
({'key': 'value'}, {'key': 'value'}),
({'key': '{foo}'}, {'key': 'bar'}),
(3, 3),
(True, True),
(False, False),
),
)
def test_apply_constants_makes_string_substitutions(value, expected_value):
flexmock(module).should_receive('coerce_scalar').replace_with(lambda value: value)
constants = {'foo': 'bar', 'baz': 'quux', 'int': 3, 'bool': True}
assert module.apply_constants(value, constants) == expected_value

View File

@ -7,7 +7,7 @@ from borgmatic.config import generate as module
def test_schema_to_sample_configuration_generates_config_map_with_examples(): def test_schema_to_sample_configuration_generates_config_map_with_examples():
flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict) flexmock(module.ruamel.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
flexmock(module).should_receive('add_comments_to_configuration_object') flexmock(module).should_receive('add_comments_to_configuration_object')
schema = { schema = {
'type': 'object', 'type': 'object',
@ -32,7 +32,7 @@ def test_schema_to_sample_configuration_generates_config_map_with_examples():
def test_schema_to_sample_configuration_generates_config_sequence_of_strings_with_example(): def test_schema_to_sample_configuration_generates_config_sequence_of_strings_with_example():
flexmock(module.yaml.comments).should_receive('CommentedSeq').replace_with(list) flexmock(module.ruamel.yaml.comments).should_receive('CommentedSeq').replace_with(list)
flexmock(module).should_receive('add_comments_to_configuration_sequence') flexmock(module).should_receive('add_comments_to_configuration_sequence')
schema = {'type': 'array', 'items': {'type': 'string'}, 'example': ['hi']} schema = {'type': 'array', 'items': {'type': 'string'}, 'example': ['hi']}
@ -42,7 +42,7 @@ def test_schema_to_sample_configuration_generates_config_sequence_of_strings_wit
def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_examples(): def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_examples():
flexmock(module.yaml.comments).should_receive('CommentedSeq').replace_with(list) flexmock(module.ruamel.yaml.comments).should_receive('CommentedSeq').replace_with(list)
flexmock(module).should_receive('add_comments_to_configuration_sequence') flexmock(module).should_receive('add_comments_to_configuration_sequence')
flexmock(module).should_receive('add_comments_to_configuration_object') flexmock(module).should_receive('add_comments_to_configuration_object')
schema = { schema = {
@ -71,7 +71,7 @@ def test_merge_source_configuration_into_destination_inserts_map_fields():
destination_config = {'foo': 'dest1', 'bar': 'dest2'} destination_config = {'foo': 'dest1', 'bar': 'dest2'}
source_config = {'foo': 'source1', 'baz': 'source2'} source_config = {'foo': 'source1', 'baz': 'source2'}
flexmock(module).should_receive('remove_commented_out_sentinel') flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list) flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config) module.merge_source_configuration_into_destination(destination_config, source_config)
@ -82,7 +82,7 @@ def test_merge_source_configuration_into_destination_inserts_nested_map_fields()
destination_config = {'foo': {'first': 'dest1', 'second': 'dest2'}, 'bar': 'dest3'} destination_config = {'foo': {'first': 'dest1', 'second': 'dest2'}, 'bar': 'dest3'}
source_config = {'foo': {'first': 'source1'}} source_config = {'foo': {'first': 'source1'}}
flexmock(module).should_receive('remove_commented_out_sentinel') flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list) flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config) module.merge_source_configuration_into_destination(destination_config, source_config)
@ -93,7 +93,7 @@ def test_merge_source_configuration_into_destination_inserts_sequence_fields():
destination_config = {'foo': ['dest1', 'dest2'], 'bar': ['dest3'], 'baz': ['dest4']} destination_config = {'foo': ['dest1', 'dest2'], 'bar': ['dest3'], 'baz': ['dest4']}
source_config = {'foo': ['source1'], 'bar': ['source2', 'source3']} source_config = {'foo': ['source1'], 'bar': ['source2', 'source3']}
flexmock(module).should_receive('remove_commented_out_sentinel') flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list) flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config) module.merge_source_configuration_into_destination(destination_config, source_config)
@ -108,7 +108,7 @@ def test_merge_source_configuration_into_destination_inserts_sequence_of_maps():
destination_config = {'foo': [{'first': 'dest1', 'second': 'dest2'}], 'bar': 'dest3'} destination_config = {'foo': [{'first': 'dest1', 'second': 'dest2'}], 'bar': 'dest3'}
source_config = {'foo': [{'first': 'source1'}, {'other': 'source2'}]} source_config = {'foo': [{'first': 'source1'}, {'other': 'source2'}]}
flexmock(module).should_receive('remove_commented_out_sentinel') flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list) flexmock(module).should_receive('ruamel.yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config) module.merge_source_configuration_into_destination(destination_config, source_config)

View File

@ -77,6 +77,11 @@ from borgmatic.config import normalize as module
{'bar': 'baz', 'prefix': 'foo'}, {'bar': 'baz', 'prefix': 'foo'},
True, True,
), ),
(
{'location': {}, 'consistency': {'prefix': 'foo'}},
{'prefix': 'foo'},
True,
),
( (
{}, {},
{}, {},
@ -211,6 +216,11 @@ def test_normalize_sections_with_only_scalar_raises():
{'repositories': [{'path': '/repo'}]}, {'repositories': [{'path': '/repo'}]},
True, True,
), ),
(
{'repositories': [{'path': 'first'}, 'file:///repo']},
{'repositories': [{'path': 'first'}, {'path': '/repo'}]},
True,
),
( (
{'repositories': [{'path': 'foo@bar:/repo', 'label': 'foo'}]}, {'repositories': [{'path': 'foo@bar:/repo', 'label': 'foo'}]},
{'repositories': [{'path': 'ssh://foo@bar/repo', 'label': 'foo'}]}, {'repositories': [{'path': 'ssh://foo@bar/repo', 'label': 'foo'}]},
@ -246,15 +256,3 @@ def test_normalize_applies_hard_coded_normalization_to_config(
assert logs assert logs
else: else:
assert logs == [] assert logs == []
def test_normalize_raises_error_if_repository_data_is_not_consistent():
flexmock(module).should_receive('normalize_sections').and_return([])
with pytest.raises(TypeError):
module.normalize(
'test.yaml',
{
'repositories': [{'path': 'foo@bar:/repo', 'label': 'foo'}, 'file:///repo'],
},
)

View File

@ -44,6 +44,24 @@ def test_set_values_with_multiple_keys_updates_hierarchy():
assert config == {'option': {'key': 'value', 'other': 'other_value'}} assert config == {'option': {'key': 'value', 'other': 'other_value'}}
@pytest.mark.parametrize(
'schema,option_keys,expected_type',
(
({'properties': {'foo': {'type': 'array'}}}, ('foo',), 'array'),
(
{'properties': {'foo': {'properties': {'bar': {'type': 'array'}}}}},
('foo', 'bar'),
'array',
),
({'properties': {'foo': {'type': 'array'}}}, ('other',), None),
({'properties': {'foo': {'description': 'stuff'}}}, ('foo',), None),
({}, ('foo',), None),
),
)
def test_type_for_option_grabs_type_if_found_in_schema(schema, option_keys, expected_type):
assert module.type_for_option(schema, option_keys) == expected_type
@pytest.mark.parametrize( @pytest.mark.parametrize(
'key,expected_key', 'key,expected_key',
( (
@ -63,51 +81,64 @@ def test_strip_section_names_passes_through_key_without_section_name(key, expect
def test_parse_overrides_splits_keys_and_values(): def test_parse_overrides_splits_keys_and_values():
flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value) flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value)
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value) flexmock(module).should_receive('type_for_option').and_return('string')
flexmock(module).should_receive('convert_value_type').replace_with(
lambda value, option_type: value
)
raw_overrides = ['option.my_option=value1', 'other_option=value2'] raw_overrides = ['option.my_option=value1', 'other_option=value2']
expected_result = ( expected_result = (
(('option', 'my_option'), 'value1'), (('option', 'my_option'), 'value1'),
(('other_option'), 'value2'), (('other_option'), 'value2'),
) )
module.parse_overrides(raw_overrides) == expected_result module.parse_overrides(raw_overrides, schema={}) == expected_result
def test_parse_overrides_allows_value_with_equal_sign(): def test_parse_overrides_allows_value_with_equal_sign():
flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value) flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value)
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value) flexmock(module).should_receive('type_for_option').and_return('string')
flexmock(module).should_receive('convert_value_type').replace_with(
lambda value, option_type: value
)
raw_overrides = ['option=this===value'] raw_overrides = ['option=this===value']
expected_result = ((('option',), 'this===value'),) expected_result = ((('option',), 'this===value'),)
module.parse_overrides(raw_overrides) == expected_result module.parse_overrides(raw_overrides, schema={}) == expected_result
def test_parse_overrides_raises_on_missing_equal_sign(): def test_parse_overrides_raises_on_missing_equal_sign():
flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value) flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value)
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value) flexmock(module).should_receive('type_for_option').and_return('string')
flexmock(module).should_receive('convert_value_type').replace_with(
lambda value, option_type: value
)
raw_overrides = ['option'] raw_overrides = ['option']
with pytest.raises(ValueError): with pytest.raises(ValueError):
module.parse_overrides(raw_overrides) module.parse_overrides(raw_overrides, schema={})
def test_parse_overrides_raises_on_invalid_override_value(): def test_parse_overrides_raises_on_invalid_override_value():
flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value) flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value)
flexmock(module).should_receive('type_for_option').and_return('string')
flexmock(module).should_receive('convert_value_type').and_raise(ruamel.yaml.parser.ParserError) flexmock(module).should_receive('convert_value_type').and_raise(ruamel.yaml.parser.ParserError)
raw_overrides = ['option=[in valid]'] raw_overrides = ['option=[in valid]']
with pytest.raises(ValueError): with pytest.raises(ValueError):
module.parse_overrides(raw_overrides) module.parse_overrides(raw_overrides, schema={})
def test_parse_overrides_allows_value_with_single_key(): def test_parse_overrides_allows_value_with_single_key():
flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value) flexmock(module).should_receive('strip_section_names').replace_with(lambda value: value)
flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value) flexmock(module).should_receive('type_for_option').and_return('string')
flexmock(module).should_receive('convert_value_type').replace_with(
lambda value, option_type: value
)
raw_overrides = ['option=value'] raw_overrides = ['option=value']
expected_result = ((('option',), 'value'),) expected_result = ((('option',), 'value'),)
module.parse_overrides(raw_overrides) == expected_result module.parse_overrides(raw_overrides, schema={}) == expected_result
def test_parse_overrides_handles_empty_overrides(): def test_parse_overrides_handles_empty_overrides():
module.parse_overrides(raw_overrides=None) == () module.parse_overrides(raw_overrides=None, schema={}) == ()

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,
)

View File

@ -100,8 +100,39 @@ def test_append_last_lines_with_output_log_level_none_appends_captured_output():
assert captured_output == ['captured', 'line'] assert captured_output == ['captured', 'line']
@pytest.mark.parametrize(
'full_command,input_file,output_file,environment,expected_result',
(
(('foo', 'bar'), None, None, None, 'foo bar'),
(('foo', 'bar'), flexmock(name='input'), None, None, 'foo bar < input'),
(('foo', 'bar'), None, flexmock(name='output'), None, 'foo bar > output'),
(
('foo', 'bar'),
flexmock(name='input'),
flexmock(name='output'),
None,
'foo bar < input > output',
),
(
('foo', 'bar'),
None,
None,
{'DBPASS': 'secret', 'OTHER': 'thing'},
'DBPASS=*** OTHER=*** foo bar',
),
),
)
def test_log_command_logs_command_constructed_from_arguments(
full_command, input_file, output_file, environment, expected_result
):
flexmock(module.logger).should_receive('debug').with_args(expected_result).once()
module.log_command(full_command, input_file, output_file, environment)
def test_execute_command_calls_full_command(): def test_execute_command_calls_full_command():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -122,6 +153,7 @@ def test_execute_command_calls_full_command():
def test_execute_command_calls_full_command_with_output_file(): def test_execute_command_calls_full_command_with_output_file():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
output_file = flexmock(name='test') output_file = flexmock(name='test')
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -141,6 +173,7 @@ def test_execute_command_calls_full_command_with_output_file():
def test_execute_command_calls_full_command_without_capturing_output(): def test_execute_command_calls_full_command_without_capturing_output():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None
@ -156,6 +189,7 @@ def test_execute_command_calls_full_command_without_capturing_output():
def test_execute_command_calls_full_command_with_input_file(): def test_execute_command_calls_full_command_with_input_file():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
input_file = flexmock(name='test') input_file = flexmock(name='test')
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -175,6 +209,7 @@ def test_execute_command_calls_full_command_with_input_file():
def test_execute_command_calls_full_command_with_shell(): def test_execute_command_calls_full_command_with_shell():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
' '.join(full_command), ' '.join(full_command),
@ -194,6 +229,7 @@ def test_execute_command_calls_full_command_with_shell():
def test_execute_command_calls_full_command_with_extra_environment(): def test_execute_command_calls_full_command_with_extra_environment():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -213,6 +249,7 @@ def test_execute_command_calls_full_command_with_extra_environment():
def test_execute_command_calls_full_command_with_working_directory(): def test_execute_command_calls_full_command_with_working_directory():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -233,6 +270,7 @@ def test_execute_command_calls_full_command_with_working_directory():
def test_execute_command_without_run_to_completion_returns_process(): def test_execute_command_without_run_to_completion_returns_process():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
process = flexmock() process = flexmock()
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -251,6 +289,7 @@ def test_execute_command_without_run_to_completion_returns_process():
def test_execute_command_and_capture_output_returns_stdout(): def test_execute_command_and_capture_output_returns_stdout():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
expected_output = '[]' expected_output = '[]'
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('check_output').with_args( flexmock(module.subprocess).should_receive('check_output').with_args(
full_command, stderr=None, shell=False, env=None, cwd=None full_command, stderr=None, shell=False, env=None, cwd=None
@ -264,6 +303,7 @@ def test_execute_command_and_capture_output_returns_stdout():
def test_execute_command_and_capture_output_with_capture_stderr_returns_stderr(): def test_execute_command_and_capture_output_with_capture_stderr_returns_stderr():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
expected_output = '[]' expected_output = '[]'
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('check_output').with_args( flexmock(module.subprocess).should_receive('check_output').with_args(
full_command, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None full_command, stderr=module.subprocess.STDOUT, shell=False, env=None, cwd=None
@ -278,6 +318,7 @@ def test_execute_command_and_capture_output_returns_output_when_process_error_is
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
expected_output = '[]' expected_output = '[]'
err_output = b'[]' err_output = b'[]'
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('check_output').with_args( flexmock(module.subprocess).should_receive('check_output').with_args(
full_command, stderr=None, shell=False, env=None, cwd=None full_command, stderr=None, shell=False, env=None, cwd=None
@ -292,6 +333,7 @@ def test_execute_command_and_capture_output_returns_output_when_process_error_is
def test_execute_command_and_capture_output_raises_when_command_errors(): def test_execute_command_and_capture_output_raises_when_command_errors():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
expected_output = '[]' expected_output = '[]'
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('check_output').with_args( flexmock(module.subprocess).should_receive('check_output').with_args(
full_command, stderr=None, shell=False, env=None, cwd=None full_command, stderr=None, shell=False, env=None, cwd=None
@ -305,6 +347,7 @@ def test_execute_command_and_capture_output_raises_when_command_errors():
def test_execute_command_and_capture_output_returns_output_with_shell(): def test_execute_command_and_capture_output_returns_output_with_shell():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
expected_output = '[]' expected_output = '[]'
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('check_output').with_args( flexmock(module.subprocess).should_receive('check_output').with_args(
'foo bar', stderr=None, shell=True, env=None, cwd=None 'foo bar', stderr=None, shell=True, env=None, cwd=None
@ -318,6 +361,7 @@ def test_execute_command_and_capture_output_returns_output_with_shell():
def test_execute_command_and_capture_output_returns_output_with_extra_environment(): def test_execute_command_and_capture_output_returns_output_with_extra_environment():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
expected_output = '[]' expected_output = '[]'
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('check_output').with_args( flexmock(module.subprocess).should_receive('check_output').with_args(
full_command, full_command,
@ -337,6 +381,7 @@ def test_execute_command_and_capture_output_returns_output_with_extra_environmen
def test_execute_command_and_capture_output_returns_output_with_working_directory(): def test_execute_command_and_capture_output_returns_output_with_working_directory():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
expected_output = '[]' expected_output = '[]'
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('check_output').with_args( flexmock(module.subprocess).should_receive('check_output').with_args(
full_command, stderr=None, shell=False, env=None, cwd='/working' full_command, stderr=None, shell=False, env=None, cwd='/working'
@ -352,6 +397,7 @@ def test_execute_command_and_capture_output_returns_output_with_working_director
def test_execute_command_with_processes_calls_full_command(): def test_execute_command_with_processes_calls_full_command():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
processes = (flexmock(),) processes = (flexmock(),)
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -372,6 +418,7 @@ def test_execute_command_with_processes_calls_full_command():
def test_execute_command_with_processes_returns_output_with_output_log_level_none(): def test_execute_command_with_processes_returns_output_with_output_log_level_none():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
processes = (flexmock(),) processes = (flexmock(),)
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
process = flexmock(stdout=None) process = flexmock(stdout=None)
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
@ -394,6 +441,7 @@ def test_execute_command_with_processes_calls_full_command_with_output_file():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
processes = (flexmock(),) processes = (flexmock(),)
output_file = flexmock(name='test') output_file = flexmock(name='test')
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -414,6 +462,7 @@ def test_execute_command_with_processes_calls_full_command_with_output_file():
def test_execute_command_with_processes_calls_full_command_without_capturing_output(): def test_execute_command_with_processes_calls_full_command_without_capturing_output():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
processes = (flexmock(),) processes = (flexmock(),)
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None full_command, stdin=None, stdout=None, stderr=None, shell=False, env=None, cwd=None
@ -432,6 +481,7 @@ def test_execute_command_with_processes_calls_full_command_with_input_file():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
processes = (flexmock(),) processes = (flexmock(),)
input_file = flexmock(name='test') input_file = flexmock(name='test')
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -452,6 +502,7 @@ def test_execute_command_with_processes_calls_full_command_with_input_file():
def test_execute_command_with_processes_calls_full_command_with_shell(): def test_execute_command_with_processes_calls_full_command_with_shell():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
processes = (flexmock(),) processes = (flexmock(),)
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
' '.join(full_command), ' '.join(full_command),
@ -472,6 +523,7 @@ def test_execute_command_with_processes_calls_full_command_with_shell():
def test_execute_command_with_processes_calls_full_command_with_extra_environment(): def test_execute_command_with_processes_calls_full_command_with_extra_environment():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
processes = (flexmock(),) processes = (flexmock(),)
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -494,6 +546,7 @@ def test_execute_command_with_processes_calls_full_command_with_extra_environmen
def test_execute_command_with_processes_calls_full_command_with_working_directory(): def test_execute_command_with_processes_calls_full_command_with_working_directory():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
processes = (flexmock(),) processes = (flexmock(),)
flexmock(module).should_receive('log_command')
flexmock(module.os, environ={'a': 'b'}) flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args( flexmock(module.subprocess).should_receive('Popen').with_args(
full_command, full_command,
@ -515,6 +568,7 @@ def test_execute_command_with_processes_calls_full_command_with_working_director
def test_execute_command_with_processes_kills_processes_on_error(): def test_execute_command_with_processes_kills_processes_on_error():
full_command = ['foo', 'bar'] full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
process = flexmock(stdout=flexmock(read=lambda count: None)) process = flexmock(stdout=flexmock(read=lambda count: None))
process.should_receive('poll') process.should_receive('poll')
process.should_receive('kill').once() process.should_receive('kill').once()

View File

@ -174,16 +174,18 @@ def test_add_logging_level_skips_global_setting_if_already_set():
module.add_logging_level('PLAID', 99) 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).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return( 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('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(False) flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args( 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) flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True)
syslog_handler = logging.handlers.SysLogHandler() syslog_handler = logging.handlers.SysLogHandler()
@ -191,19 +193,21 @@ def test_configure_logging_probes_for_log_socket_on_linux():
address='/dev/log' address='/dev/log'
).and_return(syslog_handler).once() ).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).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return( 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('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(False) flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args( 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('/dev/log').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/var/run/syslog').and_return(True) 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' address='/var/run/syslog'
).and_return(syslog_handler).once() ).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).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return( 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('Console_color_formatter')
flexmock(module).should_receive('interactive_console').and_return(False) flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args( 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('/dev/log').and_return(False)
flexmock(module.os.path).should_receive('exists').with_args('/var/run/syslog').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' address='/var/run/log'
).and_return(syslog_handler).once() ).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).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return( 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('Console_color_formatter')
flexmock(module.logging).should_receive('basicConfig').with_args( flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.DEBUG, handlers=tuple level=logging.INFO, handlers=list
).once() )
flexmock(module.os.path).should_receive('exists').and_return(False) 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(): def test_configure_logging_skips_syslog_if_not_found():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return( 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('Console_color_formatter')
flexmock(module.logging).should_receive('basicConfig').with_args( 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.os.path).should_receive('exists').and_return(False)
flexmock(module.logging.handlers).should_receive('SysLogHandler').never() flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
module.configure_logging(console_log_level=logging.INFO) module.configure_logging(console_log_level=logging.INFO, syslog_log_level=logging.DEBUG)
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)
def test_configure_logging_skips_log_file_if_log_file_logging_is_disabled(): def test_configure_logging_skips_log_file_if_log_file_logging_is_disabled():
flexmock(module).should_receive('add_custom_log_levels') flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).DISABLED = module.DISABLED flexmock(module.logging).DISABLED = module.DISABLED
flexmock(module).should_receive('Multi_stream_handler').and_return( 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( 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.os.path).should_receive('exists').never()
flexmock(module.logging.handlers).should_receive('SysLogHandler').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).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return( 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( 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.os.path).should_receive('exists').never()
flexmock(module.logging.handlers).should_receive('SysLogHandler').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() ).and_return(file_handler).once()
module.configure_logging( 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 '{message}', style='{' # noqa: FS003
).once() ).once()
flexmock(module).should_receive('Multi_stream_handler').and_return( 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).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args( 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.os.path).should_receive('exists').with_args('/dev/log').and_return(True)
flexmock(module.logging.handlers).should_receive('SysLogHandler').never() 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).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Multi_stream_handler').and_return( 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( 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.os.path).should_receive('exists').and_return(False)
flexmock(module.logging.handlers).should_receive('WatchedFileHandler').never() flexmock(module.logging.handlers).should_receive('WatchedFileHandler').never()

View File

@ -1,3 +1,4 @@
import pytest
from flexmock import flexmock from flexmock import flexmock
from borgmatic import signals as module from borgmatic import signals as module
@ -34,6 +35,17 @@ def test_handle_signal_exits_on_sigterm():
module.handle_signal(signal_number, frame) 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(): def test_configure_signals_installs_signal_handlers():
flexmock(module.signal).should_receive('signal').at_least().once() flexmock(module.signal).should_receive('signal').at_least().once()

20
tox.ini
View File

@ -1,19 +1,19 @@
[tox] [tox]
envlist = py37,py38,py39,py310,py311 env_list = py38,py39,py310,py311,py312
skip_missing_interpreters = True skip_missing_interpreters = True
skipsdist = True package = editable
minversion = 3.14.1 min_version = 4.0
[testenv] [testenv]
usedevelop = True deps =
deps = -rtest_requirements.txt -r test_requirements.txt
whitelist_externals = whitelist_externals =
find find
sh sh
passenv = COVERAGE_FILE passenv = COVERAGE_FILE
commands = commands =
pytest {posargs} pytest {posargs}
py38,py39,py310,py311: black --check . black --check .
isort --check-only --settings-path setup.cfg . isort --check-only --settings-path setup.cfg .
flake8 borgmatic tests flake8 borgmatic tests
codespell codespell
@ -27,10 +27,12 @@ commands =
pytest {posargs} pytest {posargs}
[testenv:end-to-end] [testenv:end-to-end]
usedevelop = False package = editable
deps = -rtest_requirements.txt deps =
-r test_requirements.txt
pymongo==4.4.1
. .
passenv = COVERAGE_FILE pass_env = COVERAGE_FILE
commands = commands =
pytest {posargs} --no-cov tests/end-to-end pytest {posargs} --no-cov tests/end-to-end