Compare commits

..
55 Commits
Author SHA1 Message Date
Dan Helfman 55ebfdda39 Show full error logs at "--verbosity 0" so you can see command output without upping the verbosity level. 2019-11-19 10:09:25 -08:00
Dan Helfman e63e2e0852 Rephrasing verbosity levels. 2019-11-17 22:52:26 -08:00
Dan Helfman edc4b9e60e Update the command-line help description. 2019-11-17 22:49:16 -08:00
Dan Helfman 78ff734e6c Add note about error logs only flowing to Healthchecks for the "create" action. 2019-11-17 19:10:11 -08:00
Dan Helfman 2cc743cf47 With "borgmatic check", consider Borg warnings as errors (#247). 2019-11-17 19:06:28 -08:00
Dan Helfman d99e6d1994 Remove obnoxious log message when Healthchecks hook sends failure for check action. 2019-11-17 18:55:40 -08:00
Dan Helfman 50f62d73b7 When using the Healthchecks monitoring hook, include borgmatic logs in the payloads for completion and failure pings (#241). 2019-11-17 16:54:27 -08:00
Dan Helfman 26a89de790 Higher resolution BorgBase logo. 2019-11-14 12:39:33 -08:00
Dan Helfman c2276b18c5 Add troubleshooting documentation for MySQL lock table errors (#244). 2019-11-14 10:33:47 -08:00
Dan Helfman 693434f8aa Icon spacing hack that'll show up on GitHub. 2019-11-14 09:45:38 -08:00
Dan Helfman 1e8edc05e9 Marketing. 2019-11-14 09:34:53 -08:00
Dan Helfman 1f166a47e9 Fix failing test due to dictionary order (or the lack thereof..) in Python 3.5. 2019-11-13 16:42:58 -08:00
Dan Helfman 9ee6151999 Add link to unofficial Fedora package of borgmatic that's more up-to-date. 2019-11-13 14:59:49 -08:00
Dan Helfman 6cdc92bd0c Fix for "borgmatic restore" showing success and incorrectly extracting archive files, even when no databases are configured to restore (#246). 2019-11-13 10:41:57 -08:00
Dan Helfman 612e1fea67 Add database version to issue template. 2019-11-13 08:38:58 -08:00
Dan Helfman 0a9f4e8708 Reopen the file given by "--log-file" flag if an external program rotates the log file while borgmatic is running. 2019-11-12 16:13:25 -08:00
Dan Helfman 781fac3266 Refactor ping monitors (Healthchecks, Cronitor, Cronhub) to share a common invocation and function signature. 2019-11-12 15:31:07 -08:00
Dan Helfman 4c38810a32 Add note about rsyslog rate limiting. 2019-11-12 12:32:32 -08:00
Dan Helfman bf0d38ff2a Update docs about MySQL/MariaDB database dumps and restores (#228). 2019-11-12 11:59:01 -08:00
Dan Helfman 04e5b42606 Fix repository does not exist error with "borgmatic extract" when repository is remote (#243). 2019-11-12 11:47:24 -08:00
Dan Helfman 30525c43bf Another edge case: Don't error on non-matching restore path globs (#228). 2019-11-12 11:24:31 -08:00
Dan Helfman ebeb5efe05 More accurately detecting restore of unconfigured database (#228). 2019-11-12 11:10:47 -08:00
Dan Helfman a3e939f34b Factor out filtering of database configuration to function with tests (#228). 2019-11-12 10:39:36 -08:00
Dan Helfman 2a771161e7 Finish test coverage for MySQL restore (#228). 2019-11-12 09:50:26 -08:00
Dan Helfman ded042d8cc First crack at MySQL/MariaDB database restore (#228). 2019-11-11 21:59:30 -08:00
Dan Helfman 4ed43ae4dc Indicate in AUTHORS that it's not an exhaustive list. 2019-11-10 14:50:28 -08:00
Dan Helfman 9d29ecf304 Remove MySQL/MariaDB database dumps after backing them up (#228). 2019-11-08 11:53:27 -08:00
Dan Helfman 427b57e2a9 Database dump hooks for MySQL/MariaDB (#228). 2019-11-08 11:17:52 -08:00
Dan Helfman e4f0a336c2 Fix installation path in docs. 2019-11-07 11:09:27 -08:00
Dan Helfman 68459c6795 Add note about setting PATH environment variable when installing borgmatic. 2019-11-07 11:05:41 -08:00
Dan Helfman 17fda7281a Monitor backups with Cronhub hook integration. Fix Healthchecks/Cronitor hooks to respect dry run. 2019-11-07 10:08:44 -08:00
Dan Helfman ac777965d0 Fix regression of generate-borgmatic-config working without --source flag (#239). 2019-11-06 09:52:21 -08:00
Dan Helfman 31d3bc9bd8 In documentation, clarify when Healthchecks/Cronitor hooks fire in relation to other hooks (#238). 2019-11-06 09:44:46 -08:00
Dan Helfman 2115eeb6a2 Upgrade your borgmatic configuration to get new options and comments via "generate-borgmatic-config --source" (#239). 2019-11-06 09:31:00 -08:00
Dan Helfman 08f017bc3e Remove broken Tox Python version filter when running Black (code formatter) environment directly. 2019-11-05 09:36:30 -08:00
Dan Helfman 7bc9482970 Add verbosity level -1 to NEWS. 2019-11-03 17:03:19 -08:00
Dan HelfmanandGitea 57ffad4e04 Verbosity level "-1" for even quieter output: Errors only (#236). 2019-11-04 01:02:13 +00:00
Dan Helfman 5422d14f93 Add Python 3.8. 2019-11-03 14:12:46 -08:00
Dan Helfman e6d8c736d0 Documentation feedback: Don't hard-code Python 3.7 for Black. 2019-11-03 14:08:19 -08:00
palto42 18d3542fbc new verbosity level "-1" for errors 2019-11-03 09:55:19 +01:00
Dan Helfman 93f453cecf Add Python 3.8 to build matrix. 2019-11-02 16:38:50 -07:00
Dan Helfman 505bb778fa Fix logging docs typo. 2019-11-02 12:54:03 -07:00
Dan Helfman b09d464162 Add missing PR link. 2019-11-02 11:27:05 -07:00
Dan Helfman a9104ed090 Handle log file error more consistently with other error. Add --log-file-verbosity flag. Add docs. 2019-11-02 11:23:18 -07:00
Dan HelfmanandGitea 06f134cc71 Log to file instead of syslog via command-line "--log-file" flag (#233). 2019-11-02 17:43:39 +00:00
palto42 584359b6c0 .gitignore 2019-11-02 14:39:16 +01:00
palto42 26a1a3d1e0 test cases for logfile option 2019-11-02 14:34:16 +01:00
palto42 6da05cbe2d Exception handling for logfile option 2019-11-02 14:33:57 +01:00
Dan Helfman f48f52079d Support for Borg --keep-exclude-tags and --exclude-nodump options (#234). 2019-11-01 15:50:59 -07:00
Dan Helfman 76c569cf84 Update test requirements. 2019-11-01 12:18:35 -07:00
palto42 b121290c0f Merge remote-tracking branch 'upstream/master' into logging 2019-11-01 19:44:04 +01:00
Dan Helfman 8fd46b8c70 Monitor backups with Cronitor hook integration. 2019-11-01 11:33:15 -07:00
Dan Helfman 603f525352 Clarify --archive help. 2019-11-01 10:50:20 -07:00
palto42 8c8640d0ab file-logger replaces syslog 2019-11-01 18:42:24 +01:00
palto42 22640a9ca0 new option for log-file 2019-10-31 10:44:22 +01:00
63 changed files with 2200 additions and 532 deletions
+10
View File
@@ -40,6 +40,16 @@ steps:
- scripts/run-tests
---
kind: pipeline
name: python-3-8-alpine-3-10
steps:
- name: build
image: python:3.8-alpine3.10
pull: always
commands:
- scripts/run-tests
---
kind: pipeline
name: documentation
steps:
+2
View File
@@ -32,6 +32,8 @@ module.exports = function(eleventyConfig) {
.use(markdownItReplaceLink)
);
eleventyConfig.addPassthroughCopy({"docs/static": "static"});
return {
templateFormats: [
"md",
+4
View File
@@ -28,4 +28,8 @@ Use `sudo borg --version`
Use `python3 --version`
**Database version (if applicable):** [version here]
Use `psql --version` or `mysql --version` on client and server.
**operating system and version:** [OS here]
+2
View File
@@ -10,3 +10,5 @@ newtonne: Read encryption password from external file
Robin `ypid` Schneider: Support additional options of Borg and add validate-borgmatic-config command
Scott Squires: Custom archive names
Thomas LÉVEIL: Support for a keep_minutely prune option. Support for the --json option
And many others! See the output of "git log".
+52
View File
@@ -1,3 +1,55 @@
1.4.13
* Show full error logs at "--verbosity 0" so you can see command output without upping the
verbosity level.
1.4.12
* #247: With "borgmatic check", consider Borg warnings as errors.
* Dial back the display of inline error logs a bit, so failed command output doesn't appear
multiple times in the logs (well, except for the summary).
1.4.11
* #241: When using the Healthchecks monitoring hook, include borgmatic logs in the payloads for
completion and failure pings.
* With --verbosity level 1 or 2, show error logs both inline when they occur and in the summary
logs at the bottom. With lower verbosity levels, suppress the summary and show error logs when
they occur.
1.4.10
* #246: Fix for "borgmatic restore" showing success and incorrectly extracting archive files, even
when no databases are configured to restore. As this can overwrite files from the archive and
lead to data loss, please upgrade to get the fix before using "borgmatic restore".
* Reopen the file given by "--log-file" flag if an external program rotates the log file while
borgmatic is running.
1.4.9
* #228: Database dump hooks for MySQL/MariaDB, so you can easily dump your databases before backups
run.
* #243: Fix repository does not exist error with "borgmatic extract" when repository is remote.
1.4.8
* Monitor backups with Cronhub hook integration. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook
* Fix Healthchecks/Cronitor hooks to skip actions when the borgmatic "--dry-run" flag is used.
1.4.7
* #238: In documentation, clarify when Healthchecks/Cronitor hooks fire in relation to other hooks.
* #239: Upgrade your borgmatic configuration to get new options and comments via
"generate-borgmatic-config --source". See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-your-configuration
1.4.6
* Verbosity level "-1" for even quieter output: Errors only (#236).
1.4.5
* Log to file instead of syslog via command-line "--log-file" flag (#233).
1.4.4
* #234: Support for Borg --keep-exclude-tags and --exclude-nodump options.
1.4.3
* Monitor backups with Cronitor hook integration. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook
1.4.2
* Extract files to a particular directory via "borgmatic extract --destination" flag.
* Rename "borgmatic extract --restore-path" flag to "--path" to reduce confusion with the separate
+33 -23
View File
@@ -2,68 +2,71 @@
title: borgmatic
permalink: index.html
---
<a href="https://build.torsion.org/witten/borgmatic" alt="build status">![Build Status](https://build.torsion.org/api/badges/witten/borgmatic/status.svg?ref=refs/heads/master)</a>
## Overview
## It's your data. Keep it that way.
<img src="https://projects.torsion.org/witten/borgmatic/raw/branch/master/static/borgmatic.png" alt="borgmatic logo" width="150px" style="float: right; padding-left: 1em;">
<img src="docs/static/borgmatic.png" alt="borgmatic logo" width="150px" style="float: right; padding-left: 1em;">
borgmatic is simple, configuration-driven backup software for servers and
workstations. Backup all of your machines from the command-line or scheduled
jobs. No GUI required. Built atop [Borg Backup](https://www.borgbackup.org/),
borgmatic initiates a backup, prunes any old backups according to a retention
policy, and validates backups for consistency. borgmatic supports specifying
your settings in a declarative configuration file, rather than having to put
them all on the command-line, and handles common errors.
workstations. Protect your files with client-side encryption. Backup your
databases too. Monitor it all with integrated third-party services.
Here's an example config file:
Here's an example configuration file:
```yaml
location:
# List of source directories to backup. Globs are expanded.
# List of source directories to backup.
source_directories:
- /home
- /etc
- /var/log/syslog*
# Paths to local or remote repositories.
repositories:
- user@backupserver:sourcehostname.borg
# Any paths matching these patterns are excluded from backups.
exclude_patterns:
- /home/*/.cache
retention:
# Retention policy for how many backups to keep in each category.
# Retention policy for how many backups to keep.
keep_daily: 7
keep_weekly: 4
keep_monthly: 6
consistency:
# List of consistency checks to run: "repository", "archives", etc.
# List of checks to run to validate your backups.
checks:
- repository
- archives
hooks:
# Preparation scripts to run, databases to dump, and monitoring to perform.
# Custom preparation scripts to run.
before_backup:
- prepare-for-backup.sh
# Databases to dump and include in backups.
postgresql_databases:
- name: users
# Third-party services to notify you if backups aren't happening.
healthchecks: https://hc-ping.com/be067061-cf96-4412-8eae-62b0c50d6a8c
```
borgmatic is hosted at <https://torsion.org/borgmatic> with [source code
available](https://projects.torsion.org/witten/borgmatic). It's also mirrored
on [GitHub](https://github.com/witten/borgmatic) for convenience.
Want to see borgmatic in action? Check out the <a
href="https://asciinema.org/a/203761" target="_blank">screencast</a>.
<script src="https://asciinema.org/a/203761.js" id="asciicast-203761" async></script>
borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
## Integrations
<a href="https://www.postgresql.org/"><img src="docs/static/postgresql.png" alt="PostgreSQL" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.mysql.com/"><img src="docs/static/mysql.png" alt="MySQL" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.rsync.net/cgi-bin/borg.cgi?campaign=borg&adgroup=borgmatic"><img src="docs/static/rsyncnet.png" alt="rsync.net" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.borgbase.com/?utm_source=borgmatic"><img src="docs/static/borgbase.png" alt="BorgBase" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
## How-to guides
@@ -117,6 +120,10 @@ Other questions or comments? Contact <mailto:witten@torsion.org>.
### Contributing
borgmatic is hosted at <https://torsion.org/borgmatic> with [source code
available](https://projects.torsion.org/witten/borgmatic). It's also mirrored
on [GitHub](https://github.com/witten/borgmatic) for convenience.
If you'd like to contribute to borgmatic development, please feel free to
submit a [Pull Request](https://projects.torsion.org/witten/borgmatic/pulls)
or open an [issue](https://projects.torsion.org/witten/borgmatic/issues) first
@@ -126,3 +133,6 @@ your thing. In general, contributions are very welcome. We don't bite!
Also, please check out the [borgmatic development
how-to](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) for
info on cloning source code, running tests, etc.
<a href="https://build.torsion.org/witten/borgmatic" alt="build status">![Build Status](https://build.torsion.org/api/badges/witten/borgmatic/status.svg?ref=refs/heads/master)</a>
+1 -1
View File
@@ -126,7 +126,7 @@ def check_archives(
+ (repository,)
)
execute_command(full_command)
execute_command(full_command, error_on_warnings=True)
if 'extract' in checks:
extract.extract_last_archive_dry_run(repository, lock_wait, local_path, remote_path)
+11 -1
View File
@@ -90,8 +90,18 @@ def _make_exclude_flags(location_config, exclude_filename=None):
caches_flag = ('--exclude-caches',) if location_config.get('exclude_caches') else ()
if_present = location_config.get('exclude_if_present')
if_present_flags = ('--exclude-if-present', if_present) if if_present else ()
keep_exclude_tags_flags = (
('--keep-exclude-tags',) if location_config.get('keep_exclude_tags') else ()
)
exclude_nodump_flags = ('--exclude-nodump',) if location_config.get('exclude_nodump') else ()
return exclude_from_flags + caches_flag + if_present_flags
return (
exclude_from_flags
+ caches_flag
+ if_present_flags
+ keep_exclude_tags_flags
+ exclude_nodump_flags
)
BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic'
+8 -5
View File
@@ -62,6 +62,7 @@ def extract_archive(
remote_path=None,
destination_path=None,
progress=False,
error_on_warnings=True,
):
'''
Given a dry-run flag, a local or remote repository path, an archive name, zero or more paths to
@@ -82,7 +83,7 @@ def extract_archive(
+ (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--dry-run',) if dry_run else ())
+ (('--progress',) if progress else ())
+ ('::'.join((os.path.abspath(repository), archive)),)
+ ('::'.join((repository if ':' in repository else os.path.abspath(repository), archive)),)
+ (tuple(paths) if paths else ())
)
@@ -90,10 +91,12 @@ def extract_archive(
# the terminal directly.
if progress:
execute_command_without_capture(
full_command, working_directory=destination_path, error_on_warnings=True
full_command, working_directory=destination_path, error_on_warnings=error_on_warnings
)
return
# Error on warnings, as Borg only gives a warning if the restore paths don't exist in the
# archive!
execute_command(full_command, working_directory=destination_path, error_on_warnings=True)
# Error on warnings by default, as Borg only gives a warning if the restore paths don't exist in
# the archive!
execute_command(
full_command, working_directory=destination_path, error_on_warnings=error_on_warnings
)
+22 -9
View File
@@ -138,16 +138,29 @@ def parse_arguments(*unparsed_arguments):
'-v',
'--verbosity',
type=int,
choices=range(0, 3),
choices=range(-1, 3),
default=0,
help='Display verbose progress to the console (from none to lots: 0, 1, or 2)',
help='Display verbose progress to the console (from only errors to very verbose: -1, 0, 1, or 2)',
)
global_group.add_argument(
'--syslog-verbosity',
type=int,
choices=range(0, 3),
choices=range(-1, 3),
default=0,
help='Display verbose progress to syslog (from none to lots: 0, 1, or 2). Ignored when console is interactive',
help='Log verbose progress to syslog (from only errors to very verbose: -1, 0, 1, or 2). Ignored when console is interactive or --log-file is given',
)
global_group.add_argument(
'--log-file-verbosity',
type=int,
choices=range(-1, 3),
default=0,
help='Log verbose progress to log file (from only errors to very verbose: -1, 0, 1, or 2). Only used when --log-file is given',
)
global_group.add_argument(
'--log-file',
type=str,
default=None,
help='Write log messages to this file instead of syslog',
)
global_group.add_argument(
'--version',
@@ -159,9 +172,9 @@ def parse_arguments(*unparsed_arguments):
top_level_parser = ArgumentParser(
description='''
A simple wrapper script for the Borg backup software that creates and prunes backups.
If none of the action options are given, then borgmatic defaults to: prune, create, and
check archives.
Simple, configuration-driven backup software for servers and workstations. If none of
the action options are given, then borgmatic defaults to: prune, create, and check
archives.
''',
parents=[global_parser],
)
@@ -273,7 +286,7 @@ def parse_arguments(*unparsed_arguments):
'--repository',
help='Path of repository to extract, defaults to the configured repository if there is only one',
)
extract_group.add_argument('--archive', help='Name of archive to operate on', required=True)
extract_group.add_argument('--archive', help='Name of archive to extract', required=True)
extract_group.add_argument(
'--path',
'--restore-path',
@@ -311,7 +324,7 @@ def parse_arguments(*unparsed_arguments):
'--repository',
help='Path of repository to restore from, defaults to the configured repository if there is only one',
)
restore_group.add_argument('--archive', help='Name of archive to operate on', required=True)
restore_group.add_argument('--archive', help='Name of archive to restore from', required=True)
restore_group.add_argument(
'--database',
metavar='NAME',
+107 -46
View File
@@ -18,7 +18,7 @@ from borgmatic.borg import list as borg_list
from borgmatic.borg import prune as borg_prune
from borgmatic.commands.arguments import parse_arguments
from borgmatic.config import checks, collect, convert, validate
from borgmatic.hooks import command, healthchecks, postgresql
from borgmatic.hooks import command, dispatch, dump, monitor
from borgmatic.logger import configure_logging, should_do_markup
from borgmatic.signals import configure_signals
from borgmatic.verbosity import verbosity_to_log_level
@@ -53,8 +53,13 @@ def run_configuration(config_filename, config, arguments):
if 'create' in arguments:
try:
healthchecks.ping_healthchecks(
hooks.get('healthchecks'), config_filename, global_arguments.dry_run, 'start'
dispatch.call_hooks(
'ping_monitor',
hooks,
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.START,
global_arguments.dry_run,
)
command.execute_hook(
hooks.get('before_backup'),
@@ -63,8 +68,12 @@ def run_configuration(config_filename, config, arguments):
'pre-backup',
global_arguments.dry_run,
)
postgresql.dump_databases(
hooks.get('postgresql_databases'), config_filename, global_arguments.dry_run
dispatch.call_hooks(
'dump_databases',
hooks,
config_filename,
dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
encountered_error = error
@@ -95,8 +104,12 @@ def run_configuration(config_filename, config, arguments):
if 'create' in arguments and not encountered_error:
try:
postgresql.remove_database_dumps(
hooks.get('postgresql_databases'), config_filename, global_arguments.dry_run
dispatch.call_hooks(
'remove_database_dumps',
hooks,
config_filename,
dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
)
command.execute_hook(
hooks.get('after_backup'),
@@ -105,8 +118,13 @@ def run_configuration(config_filename, config, arguments):
'post-backup',
global_arguments.dry_run,
)
healthchecks.ping_healthchecks(
hooks.get('healthchecks'), config_filename, global_arguments.dry_run
dispatch.call_hooks(
'ping_monitor',
hooks,
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.FINISH,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
encountered_error = error
@@ -126,8 +144,13 @@ def run_configuration(config_filename, config, arguments):
error=encountered_error,
output=getattr(encountered_error, 'output', ''),
)
healthchecks.ping_healthchecks(
hooks.get('healthchecks'), config_filename, global_arguments.dry_run, 'fail'
dispatch.call_hooks(
'ping_monitor',
hooks,
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.FAIL,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
yield from make_error_log_records(
@@ -236,31 +259,51 @@ def run_actions(
restore_names = []
# Extract dumps for the named databases from the archive.
dump_patterns = postgresql.make_database_dump_patterns(restore_names)
dump_patterns = dispatch.call_hooks(
'make_database_dump_patterns',
hooks,
repository,
dump.DATABASE_HOOK_NAMES,
restore_names,
)
borg_extract.extract_archive(
global_arguments.dry_run,
repository,
arguments['restore'].archive,
postgresql.convert_glob_patterns_to_borg_patterns(dump_patterns),
dump.convert_glob_patterns_to_borg_patterns(
dump.flatten_dump_patterns(dump_patterns, restore_names)
),
location,
storage,
local_path=local_path,
remote_path=remote_path,
destination_path='/',
progress=arguments['restore'].progress,
# We don't want glob patterns that don't match to error.
error_on_warnings=False,
)
# Map the restore names to the corresponding database configurations.
databases = list(
postgresql.get_database_configurations(
hooks.get('postgresql_databases'),
restore_names or postgresql.get_database_names_from_dumps(dump_patterns),
)
# Map the restore names or detected dumps to the corresponding database configurations.
restore_databases = dump.get_per_hook_database_configurations(
hooks, restore_names, dump_patterns
)
# Finally, restore the databases and cleanup the dumps.
postgresql.restore_database_dumps(databases, repository, global_arguments.dry_run)
postgresql.remove_database_dumps(databases, repository, global_arguments.dry_run)
dispatch.call_hooks(
'restore_database_dumps',
restore_databases,
repository,
dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
)
dispatch.call_hooks(
'remove_database_dumps',
restore_databases,
repository,
dump.DATABASE_HOOK_NAMES,
global_arguments.dry_run,
)
if 'list' in arguments:
if arguments['list'].repository is None or repository == arguments['list'].repository:
logger.info('{}: Listing archives'.format(repository))
@@ -322,33 +365,43 @@ def load_configurations(config_filenames):
return (configs, logs)
def log_record(suppress_log=False, **kwargs):
'''
Create a log record based on the given makeLogRecord() arguments, one of which must be
named "levelno". Log the record (unless suppress log is set) and return it.
'''
record = logging.makeLogRecord(kwargs)
if suppress_log:
return record
logger.handle(record)
return record
def make_error_log_records(message, error=None):
'''
Given error message text and an optional exception object, yield a series of logging.LogRecord
instances with error summary information.
instances with error summary information. As a side effect, log each record.
'''
if not error:
yield logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
)
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
return
try:
raise error
except CalledProcessError as error:
yield logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
)
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
if error.output:
yield logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error.output)
yield log_record(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg=error.output,
suppress_log=bool(logger.getEffectiveLevel() < logging.WARNING),
)
yield logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error))
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
except (ValueError, OSError) as error:
yield logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
)
yield logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error))
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
yield log_record(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
except: # noqa: E722
# Raising above only as a means of determining the error type. Swallow the exception here
# because we don't want the exception to propagate out of this function.
@@ -475,23 +528,31 @@ def main(): # pragma: no cover
configs, parse_logs = load_configurations(config_filenames)
colorama.init(autoreset=True, strip=not should_do_markup(global_arguments.no_color, configs))
configure_logging(
verbosity_to_log_level(global_arguments.verbosity),
verbosity_to_log_level(global_arguments.syslog_verbosity),
)
try:
configure_logging(
verbosity_to_log_level(global_arguments.verbosity),
verbosity_to_log_level(global_arguments.syslog_verbosity),
verbosity_to_log_level(global_arguments.log_file_verbosity),
global_arguments.log_file,
)
except (FileNotFoundError, PermissionError) as error:
configure_logging(logging.CRITICAL)
logger.critical('Error configuring logging: {}'.format(error))
exit_with_help_link()
logger.debug('Ensuring legacy configuration is upgraded')
convert.guard_configuration_upgraded(LEGACY_CONFIG_PATH, config_filenames)
summary_logs = list(collect_configuration_run_summary_logs(configs, arguments))
logger.info('')
logger.info('summary:')
[
logger.handle(log)
for log in parse_logs + summary_logs
if log.levelno >= logger.getEffectiveLevel()
]
if logger.getEffectiveLevel() < logging.WARNING:
logger.info('')
logger.info('summary:')
[
logger.handle(log)
for log in parse_logs + summary_logs
if log.levelno >= logger.getEffectiveLevel()
]
if any(log.levelno == logging.CRITICAL for log in summary_logs):
exit_with_help_link()
+18 -2
View File
@@ -12,12 +12,18 @@ def parse_arguments(*arguments):
them as an ArgumentParser instance.
'''
parser = ArgumentParser(description='Generate a sample borgmatic YAML configuration file.')
parser.add_argument(
'-s',
'--source',
dest='source_filename',
help='Optional YAML configuration file to merge into the generated configuration, useful for upgrading your configuration',
)
parser.add_argument(
'-d',
'--destination',
dest='destination_filename',
default=DEFAULT_DESTINATION_CONFIG_FILENAME,
help='Destination YAML configuration filename. Default: {}'.format(
help='Destination YAML configuration file. Default: {}'.format(
DEFAULT_DESTINATION_CONFIG_FILENAME
),
)
@@ -30,11 +36,21 @@ def main(): # pragma: no cover
args = parse_arguments(*sys.argv[1:])
generate.generate_sample_configuration(
args.destination_filename, validate.schema_filename()
args.source_filename, args.destination_filename, validate.schema_filename()
)
print('Generated a sample configuration file at {}.'.format(args.destination_filename))
print()
if args.source_filename:
print(
'Merged in the contents of configuration file at {}.'.format(args.source_filename)
)
print('To review the changes made, run:')
print()
print(
' diff --unified {} {}'.format(args.source_filename, args.destination_filename)
)
print()
print('Please edit the file to suit your needs. The values are representative.')
print('All fields are optional except where indicated.')
print()
+117 -30
View File
@@ -1,9 +1,12 @@
import collections
import io
import os
import re
from ruamel import yaml
from borgmatic.config import load
INDENT = 4
SEQUENCE_INDENT = 2
@@ -40,8 +43,8 @@ def _schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
elif 'map' in schema:
config = yaml.comments.CommentedMap(
[
(section_name, _schema_to_sample_configuration(section_schema, level + 1))
for section_name, section_schema in schema['map'].items()
(field_name, _schema_to_sample_configuration(sub_schema, level + 1))
for field_name, sub_schema in schema['map'].items()
]
)
indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
@@ -68,37 +71,32 @@ def _comment_out_line(line):
return '# '.join((indent_spaces, line[count_indent_spaces:]))
REQUIRED_KEYS = {'source_directories', 'repositories', 'keep_daily'}
REQUIRED_SECTION_NAMES = {'location', 'retention'}
def _comment_out_optional_configuration(rendered_config):
'''
Post-process a rendered configuration string to comment out optional key/values. The idea is
that this prevents the user from having to comment out a bunch of configuration they don't care
about to get to a minimal viable configuration file.
Post-process a rendered configuration string to comment out optional key/values, as determined
by a sentinel in the comment before each key.
Ideally ruamel.yaml would support this during configuration generation, but it's not terribly
easy to accomplish that way.
The idea is that the pre-commented configuration prevents the user from having to comment out a
bunch of configuration they don't care about to get to a minimal viable configuration file.
Ideally ruamel.yaml would support commenting out keys during configuration generation, but it's
not terribly easy to accomplish that way.
'''
lines = []
required = False
optional = False
for line in rendered_config.split('\n'):
key = line.strip().split(':')[0]
if key in REQUIRED_SECTION_NAMES:
lines.append(line)
# Upon encountering an optional configuration option, commenting out lines until the next
# blank line.
if line.strip().startswith('# {}'.format(COMMENTED_OUT_SENTINEL)):
optional = True
continue
# Upon encountering a required configuration option, skip commenting out lines until the
# next blank line.
if key in REQUIRED_KEYS:
required = True
elif not key:
required = False
# Hit a blank line, so reset commenting.
if not line.strip():
optional = False
lines.append(_comment_out_line(line) if not required else line)
lines.append(_comment_out_line(line) if optional else line)
return '\n'.join(lines)
@@ -168,6 +166,11 @@ def add_comments_to_configuration_sequence(config, schema, indent=0):
return
REQUIRED_SECTION_NAMES = {'location', 'retention'}
REQUIRED_KEYS = {'source_directories', 'repositories', 'keep_daily'}
COMMENTED_OUT_SENTINEL = 'COMMENT_OUT'
def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False):
'''
Using descriptions from a schema as a source, add those descriptions as comments to the given
@@ -178,10 +181,20 @@ def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False
continue
field_schema = schema['map'].get(field_name, {})
description = field_schema.get('desc')
description = field_schema.get('desc', '').strip()
# If this is an optional key, add an indicator to the comment flagging it to be commented
# out from the sample configuration. This sentinel is consumed by downstream processing that
# does the actual commenting out.
if field_name not in REQUIRED_SECTION_NAMES and field_name not in REQUIRED_KEYS:
description = (
'\n'.join((description, COMMENTED_OUT_SENTINEL))
if description
else COMMENTED_OUT_SENTINEL
)
# No description to use? Skip it.
if not field_schema or not description:
if not field_schema or not description: # pragma: no cover
continue
config.yaml_set_comment_before_after_key(key=field_name, before=description, indent=indent)
@@ -190,14 +203,88 @@ def add_comments_to_configuration_map(config, schema, indent=0, skip_first=False
_insert_newline_before_comment(config, field_name)
def generate_sample_configuration(config_filename, schema_filename):
RUAMEL_YAML_COMMENTS_INDEX = 1
def remove_commented_out_sentinel(config, field_name):
'''
Given a target config filename and the path to a schema filename in pykwalify YAML schema
format, write out a sample configuration file based on that schema.
Given a configuration CommentedMap and a top-level field name in it, remove any "commented out"
sentinel found at the end of its YAML comments. This prevents the given field name from getting
commented out by downstream processing that consumes the sentinel.
'''
try:
last_comment_value = config.ca.items[field_name][RUAMEL_YAML_COMMENTS_INDEX][-1].value
except KeyError:
return
if last_comment_value == '# {}\n'.format(COMMENTED_OUT_SENTINEL):
config.ca.items[field_name][RUAMEL_YAML_COMMENTS_INDEX].pop()
def merge_source_configuration_into_destination(destination_config, source_config):
'''
Deep merge the given source configuration dict into the destination configuration CommentedMap,
favoring values from the source when there are collisions.
The purpose of this is to upgrade configuration files from old versions of borgmatic by adding
new
configuration keys and comments.
'''
if not source_config:
return destination_config
if not destination_config or not isinstance(source_config, collections.abc.Mapping):
return source_config
for field_name, source_value in source_config.items():
# Since this key/value is from the source configuration, leave it uncommented and remove any
# sentinel that would cause it to get commented out.
remove_commented_out_sentinel(destination_config, field_name)
# This is a mapping. Recurse for this key/value.
if isinstance(source_value, collections.abc.Mapping):
destination_config[field_name] = merge_source_configuration_into_destination(
destination_config[field_name], source_value
)
continue
# This is a sequence. Recurse for each item in it.
if isinstance(source_value, collections.abc.Sequence) and not isinstance(source_value, str):
destination_value = destination_config[field_name]
destination_config[field_name] = yaml.comments.CommentedSeq(
[
merge_source_configuration_into_destination(
destination_value[index] if index < len(destination_value) else None,
source_item,
)
for index, source_item in enumerate(source_value)
]
)
continue
# This is some sort of scalar. Simply set it into the destination.
destination_config[field_name] = source_config[field_name]
return destination_config
def generate_sample_configuration(source_filename, destination_filename, schema_filename):
'''
Given an optional source configuration filename, and a required destination configuration
filename, and the path to a schema filename in pykwalify YAML schema format, write out a
sample configuration file based on that schema. If a source filename is provided, merge the
parsed contents of that configuration into the generated configuration.
'''
schema = yaml.round_trip_load(open(schema_filename))
config = _schema_to_sample_configuration(schema)
source_config = None
if source_filename:
source_config = load.load_configuration(source_filename)
destination_config = merge_source_configuration_into_destination(
_schema_to_sample_configuration(schema), source_config
)
write_configuration(
config_filename, _comment_out_optional_configuration(_render_configuration(config))
destination_filename,
_comment_out_optional_configuration(_render_configuration(destination_config)),
)
+80
View File
@@ -126,6 +126,17 @@ map:
Exclude directories that contain a file with the given filename. Defaults to not
set.
example: .nobackup
keep_exclude_tags:
type: bool
desc: |
If true, the exclude_if_present filename is included in backups. Defaults to
false, meaning that the exclude_if_present filename is omitted from backups.
example: true
exclude_nodump:
type: bool
desc: |
Exclude files with the NODUMP flag. Defaults to false.
example: true
storage:
desc: |
Repository storage options. See
@@ -423,13 +434,82 @@ map:
directories at runtime, backed up, and then removed afterwards. Requires
pg_dump/pg_dumpall/pg_restore commands. See
https://www.postgresql.org/docs/current/app-pgdump.html for details.
mysql_databases:
seq:
- map:
name:
required: true
type: str
desc: |
Database name (required if using this hook). Or "all" to dump all
databases on the host.
example: users
hostname:
type: str
desc: |
Database hostname to connect to. Defaults to connecting via local
Unix socket.
example: database.example.org
port:
type: int
desc: Port to connect to. Defaults to 3306.
example: 3307
username:
type: str
desc: |
Username with which to connect to the database. Defaults to the
username of the current user.
example: dbuser
password:
type: str
desc: |
Password with which to connect to the database. Omitting a password
will only work if MySQL is configured to trust the configured
username without a password.
example: trustsome1
options:
type: str
desc: |
Additional mysqldump options to pass directly to the dump command,
without performing any validation on them. See
https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html or
https://mariadb.com/kb/en/library/mysqldump/ for details.
example: --skip-comments
desc: |
List of one or more MySQL/MariaDB databases to dump before creating a backup,
run once per configuration file. The database dumps are added to your source
directories at runtime, backed up, and then removed afterwards. Requires
mysqldump/mysql commands (from either MySQL or MariaDB). See
https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html or
https://mariadb.com/kb/en/library/mysqldump/ for details.
healthchecks:
type: str
desc: |
Healthchecks ping URL or UUID to notify when a backup begins, ends, or errors.
Create an account at https://healthchecks.io if you'd like to use this service.
See
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook
for details.
example:
https://hc-ping.com/your-uuid-here
cronitor:
type: str
desc: |
Cronitor ping URL to notify when a backup begins, ends, or errors. Create an
account at https://cronitor.io if you'd like to use this service. See
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook
for details.
example:
https://cronitor.link/d3x0c1
cronhub:
type: str
desc: |
Cronhub ping URL to notify when a backup begins, ends, or errors. Create an
account at https://cronhub.io if you'd like to use this service. See
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook for
details.
example:
https://cronhub.io/start/1f5e3410-254c-11e8-b61d-55875966d031
before_everything:
seq:
- type: str
+38 -27
View File
@@ -20,21 +20,17 @@ def exit_code_indicates_error(command, exit_code, error_on_warnings=False):
return bool(exit_code != 0)
def execute_and_log_output(
full_command, output_log_level, shell, environment, working_directory, error_on_warnings
):
def log_output(command, process, output_buffer, output_log_level, error_on_warnings):
'''
Given a command already executed, its process opened by subprocess.Popen(), and the process'
relevant output buffer (stderr or stdout), log its output with the requested log level.
Additionally, raise a CalledProcessException if the process exits with an error (or a warning,
if error on warnings is True).
'''
last_lines = []
process = subprocess.Popen(
full_command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=shell,
env=environment,
cwd=working_directory,
)
while process.poll() is None:
line = process.stdout.readline().rstrip().decode()
line = output_buffer.readline().rstrip().decode()
if not line:
continue
@@ -46,26 +42,26 @@ def execute_and_log_output(
logger.log(output_log_level, line)
remaining_output = process.stdout.read().rstrip().decode()
remaining_output = output_buffer.read().rstrip().decode()
if remaining_output: # pragma: no cover
logger.log(output_log_level, remaining_output)
exit_code = process.poll()
if exit_code_indicates_error(full_command, exit_code, error_on_warnings):
if exit_code_indicates_error(command, exit_code, error_on_warnings):
# If an error occurs, include its output in the raised exception so that we don't
# inadvertently hide error output.
if len(last_lines) == ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.insert(0, '...')
raise subprocess.CalledProcessError(
exit_code, ' '.join(full_command), '\n'.join(last_lines)
)
raise subprocess.CalledProcessError(exit_code, ' '.join(command), '\n'.join(last_lines))
def execute_command(
full_command,
output_log_level=logging.INFO,
output_file=None,
input_file=None,
shell=False,
extra_environment=None,
working_directory=None,
@@ -73,14 +69,21 @@ def execute_command(
):
'''
Execute the given command (a sequence of command/argument strings) and log its output at the
given log level. If output log level is None, instead capture and return the output. If
shell is True, execute the command within a shell. If an extra environment dict is given, then
use it to augment the current environment, and pass the result into the command. If a working
directory is given, use that as the present working directory when running the command.
given log level. If output log level is None, instead capture and return the output. If an
open output file object is given, then write stdout to the file and only log stderr (but only
if an output log level is set). If an open input file object is given, then read stdin from the
file. If shell is True, execute the command within a shell. If an extra environment dict is
given, then use it to augment the current environment, and pass the result into the command. If
a working directory is given, use that as the present working directory when running the
command.
Raise subprocesses.CalledProcessError if an error occurs while running the command.
'''
logger.debug(' '.join(full_command))
logger.debug(
' '.join(full_command)
+ (' < {}'.format(input_file.name) if input_file else '')
+ (' > {}'.format(output_file.name) if output_file else '')
)
environment = {**os.environ, **extra_environment} if extra_environment else None
if output_log_level is None:
@@ -89,13 +92,21 @@ def execute_command(
)
return output.decode() if output is not None else None
else:
execute_and_log_output(
process = subprocess.Popen(
full_command,
output_log_level,
stdin=input_file,
stdout=output_file or subprocess.PIPE,
stderr=subprocess.PIPE if output_file else subprocess.STDOUT,
shell=shell,
environment=environment,
working_directory=working_directory,
error_on_warnings=error_on_warnings,
env=environment,
cwd=working_directory,
)
log_output(
full_command,
process,
process.stderr if output_file else process.stdout,
output_log_level,
error_on_warnings,
)
+32
View File
@@ -0,0 +1,32 @@
import logging
import requests
from borgmatic.hooks import monitor
logger = logging.getLogger(__name__)
MONITOR_STATE_TO_CRONHUB = {
monitor.State.START: 'start',
monitor.State.FINISH: 'finish',
monitor.State.FAIL: 'fail',
}
def ping_monitor(ping_url, config_filename, state, dry_run):
'''
Ping the given Cronhub URL, modified with the monitor.State. Use the given configuration
filename in any log entries. If this is a dry run, then don't actually ping anything.
'''
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
formatted_state = '/{}/'.format(MONITOR_STATE_TO_CRONHUB[state])
ping_url = ping_url.replace('/start/', formatted_state).replace('/ping/', formatted_state)
logger.info(
'{}: Pinging Cronhub {}{}'.format(config_filename, state.name.lower(), dry_run_label)
)
logger.debug('{}: Using Cronhub ping URL {}'.format(config_filename, ping_url))
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.get(ping_url)
+31
View File
@@ -0,0 +1,31 @@
import logging
import requests
from borgmatic.hooks import monitor
logger = logging.getLogger(__name__)
MONITOR_STATE_TO_CRONITOR = {
monitor.State.START: 'run',
monitor.State.FINISH: 'complete',
monitor.State.FAIL: 'fail',
}
def ping_monitor(ping_url, config_filename, state, dry_run):
'''
Ping the given Cronitor URL, modified with the monitor.State. Use the given configuration
filename in any log entries. If this is a dry run, then don't actually ping anything.
'''
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
ping_url = '{}/{}'.format(ping_url, MONITOR_STATE_TO_CRONITOR[state])
logger.info(
'{}: Pinging Cronitor {}{}'.format(config_filename, state.name.lower(), dry_run_label)
)
logger.debug('{}: Using Cronitor ping URL {}'.format(config_filename, ping_url))
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.get(ping_url)
+61
View File
@@ -0,0 +1,61 @@
import logging
from borgmatic.hooks import cronhub, cronitor, healthchecks, mysql, postgresql
logger = logging.getLogger(__name__)
HOOK_NAME_TO_MODULE = {
'healthchecks': healthchecks,
'cronitor': cronitor,
'cronhub': cronhub,
'postgresql_databases': postgresql,
'mysql_databases': mysql,
}
def call_hook(function_name, hooks, log_prefix, hook_name, *args, **kwargs):
'''
Given the hooks configuration dict and a prefix to use in log entries, call the requested
function of the Python module corresponding to the given hook name. Supply that call with the
configuration for this hook, the log prefix, and any given args and kwargs. Return any return
value.
If the hook name is not present in the hooks configuration, then bail without calling anything.
Raise ValueError if the hook name is unknown.
Raise AttributeError if the function name is not found in the module.
Raise anything else that the called function raises.
'''
config = hooks.get(hook_name)
if not config:
logger.debug('{}: No {} hook configured.'.format(log_prefix, hook_name))
return
try:
module = HOOK_NAME_TO_MODULE[hook_name]
except KeyError:
raise ValueError('Unknown hook name: {}'.format(hook_name))
logger.debug('{}: Calling {} hook function {}'.format(log_prefix, hook_name, function_name))
return getattr(module, function_name)(config, log_prefix, *args, **kwargs)
def call_hooks(function_name, hooks, log_prefix, hook_names, *args, **kwargs):
'''
Given the hooks configuration dict and a prefix to use in log entries, call the requested
function of the Python module corresponding to each given hook name. Supply each call with the
configuration for that hook, the log prefix, and any given args and kwargs. Collect any return
values into a dict from hook name to return value.
If the hook name is not present in the hooks configuration, then don't call the function for it,
and omit it from the return values.
Raise ValueError if the hook name is unknown.
Raise AttributeError if the function name is not found in the module.
Raise anything else that a called function raises. An error stops calls to subsequent functions.
'''
return {
hook_name: call_hook(function_name, hooks, log_prefix, hook_name, *args, **kwargs)
for hook_name in hook_names
if hook_name in hooks
}
+162
View File
@@ -0,0 +1,162 @@
import glob
import logging
import os
logger = logging.getLogger(__name__)
DATABASE_HOOK_NAMES = ('postgresql_databases', 'mysql_databases')
def make_database_dump_filename(dump_path, name, hostname=None):
'''
Based on the given dump directory path, database name, and hostname, return a filename to use
for the database dump. The hostname defaults to localhost.
Raise ValueError if the database name is invalid.
'''
if os.path.sep in name:
raise ValueError('Invalid database name {}'.format(name))
return os.path.join(os.path.expanduser(dump_path), hostname or 'localhost', name)
def flatten_dump_patterns(dump_patterns, names):
'''
Given a dict from a database hook name to glob patterns matching the dumps for the named
databases, flatten out all the glob patterns into a single sequence, and return it.
Raise ValueError if there are no resulting glob patterns, which indicates that databases are not
configured in borgmatic's configuration.
'''
flattened = [pattern for patterns in dump_patterns.values() for pattern in patterns]
if not flattened:
raise ValueError(
'Cannot restore database(s) {} missing from borgmatic\'s configuration'.format(
', '.join(names) or '"all"'
)
)
return flattened
def remove_database_dumps(dump_path, databases, database_type_name, log_prefix, dry_run):
'''
Remove the database dumps for the given databases in the dump directory path. The databases are
supplied as a sequence of dicts, one dict describing each database as per the configuration
schema. Use the name of the database type and the log prefix in any log entries. If this is a
dry run, then don't actually remove anything.
'''
if not databases:
logger.debug('{}: No {} databases configured'.format(log_prefix, database_type_name))
return
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
logger.info(
'{}: Removing {} database dumps{}'.format(log_prefix, database_type_name, dry_run_label)
)
for database in databases:
dump_filename = make_database_dump_filename(
dump_path, database['name'], database.get('hostname')
)
logger.debug(
'{}: Removing {} database dump {} from {}{}'.format(
log_prefix, database_type_name, database['name'], dump_filename, dry_run_label
)
)
if dry_run:
continue
os.remove(dump_filename)
dump_path = os.path.dirname(dump_filename)
if len(os.listdir(dump_path)) == 0:
os.rmdir(dump_path)
def convert_glob_patterns_to_borg_patterns(patterns):
'''
Convert a sequence of shell glob patterns like "/etc/*" to the corresponding Borg archive
patterns like "sh:etc/*".
'''
return ['sh:{}'.format(pattern.lstrip(os.path.sep)) for pattern in patterns]
def get_database_names_from_dumps(patterns):
'''
Given a sequence of database dump patterns, find the corresponding database dumps on disk and
return the database names from their filenames.
'''
return [os.path.basename(dump_path) for pattern in patterns for dump_path in glob.glob(pattern)]
def get_database_configurations(databases, names):
'''
Given the full database configuration dicts as per the configuration schema, and a sequence of
database names, filter down and yield the configuration for just the named databases.
Additionally, if a database configuration is named "all", project out that configuration for
each named database.
'''
named_databases = {database['name']: database for database in databases}
for name in names:
database = named_databases.get(name)
if database:
yield database
continue
if 'all' in named_databases:
yield {**named_databases['all'], **{'name': name}}
continue
def get_per_hook_database_configurations(hooks, names, dump_patterns):
'''
Given the hooks configuration dict as per the configuration schema, a sequence of database
names to restore, and a dict from database hook name to glob patterns for matching dumps,
filter down the configuration for just the named databases.
If there are no named databases given, then find the corresponding database dumps on disk and
use the database names from their filenames. Additionally, if a database configuration is named
"all", project out that configuration for each named database.
Return the results as a dict from database hook name to a sequence of database configuration
dicts for that database type.
Raise ValueError if one of the database names cannot be matched to a database in borgmatic's
database configuration.
'''
hook_databases = {
hook_name: list(
get_database_configurations(
hooks.get(hook_name),
names or get_database_names_from_dumps(dump_patterns[hook_name]),
)
)
for hook_name in DATABASE_HOOK_NAMES
if hook_name in hooks
}
if not names or 'all' in names:
if not any(hook_databases.values()):
raise ValueError(
'Cannot restore database "all", as there are no database dumps in the archive'
)
return hook_databases
found_names = {
database['name'] for databases in hook_databases.values() for database in databases
}
missing_names = sorted(set(names) - found_names)
if missing_names:
raise ValueError(
'Cannot restore database(s) {} missing from borgmatic\'s configuration'.format(
', '.join(missing_names)
)
)
return hook_databases
+77 -13
View File
@@ -2,18 +2,79 @@ import logging
import requests
from borgmatic.hooks import monitor
logger = logging.getLogger(__name__)
MONITOR_STATE_TO_HEALTHCHECKS = {
monitor.State.START: 'start',
monitor.State.FINISH: None, # Healthchecks doesn't append to the URL for the finished state.
monitor.State.FAIL: 'fail',
}
def ping_healthchecks(ping_url_or_uuid, config_filename, dry_run, append=None):
PAYLOAD_TRUNCATION_INDICATOR = '...\n'
PAYLOAD_LIMIT_BYTES = 10 * 1024 - len(PAYLOAD_TRUNCATION_INDICATOR)
class Forgetful_buffering_handler(logging.Handler):
'''
Ping the given healthchecks.io URL or UUID, appending the append string if any. Use the given
A buffering log handler that stores log messages in memory, and throws away messages (oldest
first) once a particular capacity in bytes is reached.
'''
def __init__(self, byte_capacity):
super().__init__()
self.byte_capacity = byte_capacity
self.byte_count = 0
self.buffer = []
self.forgot = False
def emit(self, record):
message = record.getMessage() + '\n'
self.byte_count += len(message)
self.buffer.append(message)
while self.byte_count > self.byte_capacity and self.buffer:
self.byte_count -= len(self.buffer[0])
self.buffer.pop(0)
self.forgot = True
def format_buffered_logs_for_payload():
'''
Get the handler previously added to the root logger, and slurp buffered logs out of it to
send to Healthchecks.
'''
try:
buffering_handler = next(
handler
for handler in logging.getLogger().handlers
if isinstance(handler, Forgetful_buffering_handler)
)
except StopIteration:
# No handler means no payload.
return ''
payload = ''.join(message for message in buffering_handler.buffer)
if buffering_handler.forgot:
return PAYLOAD_TRUNCATION_INDICATOR + payload
return payload
def ping_monitor(ping_url_or_uuid, config_filename, state, dry_run):
'''
Ping the given Healthchecks URL or UUID, modified with the monitor.State. Use the given
configuration filename in any log entries. If this is a dry run, then don't actually ping
anything.
'''
if not ping_url_or_uuid:
logger.debug('{}: No healthchecks hook set'.format(config_filename))
return
if state is monitor.State.START:
# Add a handler to the root logger that stores in memory the most recent logs emitted. That
# way, we can send them all to Healthchecks upon a finish or failure state.
logging.getLogger().addHandler(Forgetful_buffering_handler(PAYLOAD_LIMIT_BYTES))
payload = ''
ping_url = (
ping_url_or_uuid
@@ -22,15 +83,18 @@ def ping_healthchecks(ping_url_or_uuid, config_filename, dry_run, append=None):
)
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
if append:
ping_url = '{}/{}'.format(ping_url, append)
healthchecks_state = MONITOR_STATE_TO_HEALTHCHECKS.get(state)
if healthchecks_state:
ping_url = '{}/{}'.format(ping_url, healthchecks_state)
logger.info(
'{}: Pinging healthchecks.io{}{}'.format(
config_filename, ' ' + append if append else '', dry_run_label
)
'{}: Pinging Healthchecks {}{}'.format(config_filename, state.name.lower(), dry_run_label)
)
logger.debug('{}: Using healthchecks.io ping URL {}'.format(config_filename, ping_url))
logger.debug('{}: Using Healthchecks ping URL {}'.format(config_filename, ping_url))
logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.get(ping_url)
if state in (monitor.State.FINISH, monitor.State.FAIL):
payload = format_buffered_logs_for_payload()
if not dry_run:
logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.post(ping_url, data=payload)
+9
View File
@@ -0,0 +1,9 @@
from enum import Enum
MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub')
class State(Enum):
START = 1
FINISH = 2
FAIL = 3
+94
View File
@@ -0,0 +1,94 @@
import logging
import os
from borgmatic.execute import execute_command
from borgmatic.hooks import dump
DUMP_PATH = '~/.borgmatic/mysql_databases'
logger = logging.getLogger(__name__)
def dump_databases(databases, log_prefix, dry_run):
'''
Dump the given MySQL/MariaDB databases to disk. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given log
prefix in any log entries. If this is a dry run, then don't actually dump anything.
'''
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
logger.info('{}: Dumping MySQL databases{}'.format(log_prefix, dry_run_label))
for database in databases:
name = database['name']
dump_filename = dump.make_database_dump_filename(DUMP_PATH, name, database.get('hostname'))
command = (
('mysqldump', '--add-drop-database')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
+ (tuple(database['options'].split(' ')) if 'options' in database else ())
+ (('--all-databases',) if name == 'all' else ('--databases', name))
)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
logger.debug(
'{}: Dumping MySQL database {} to {}{}'.format(
log_prefix, name, dump_filename, dry_run_label
)
)
if not dry_run:
os.makedirs(os.path.dirname(dump_filename), mode=0o700, exist_ok=True)
execute_command(
command, output_file=open(dump_filename, 'w'), extra_environment=extra_environment
)
def remove_database_dumps(databases, log_prefix, dry_run): # pragma: no cover
'''
Remove the database dumps for the given databases. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the log prefix in
any log entries. If this is a dry run, then don't actually remove anything.
'''
dump.remove_database_dumps(DUMP_PATH, databases, 'MySQL', log_prefix, dry_run)
def make_database_dump_patterns(databases, log_prefix, names):
'''
Given a sequence of configurations dicts, a prefix to log with, and a sequence of database
names to match, return the corresponding glob patterns to match the database dumps in an
archive. An empty sequence of names indicates that the patterns should match all dumps.
'''
return [
dump.make_database_dump_filename(DUMP_PATH, name, hostname='*') for name in (names or ['*'])
]
def restore_database_dumps(databases, log_prefix, dry_run):
'''
Restore the given MySQL/MariaDB databases from disk. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given log
prefix in any log entries. If this is a dry run, then don't actually restore anything.
'''
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
for database in databases:
dump_filename = dump.make_database_dump_filename(
DUMP_PATH, database['name'], database.get('hostname')
)
restore_command = (
('mysql', '--batch')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
)
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
logger.debug(
'{}: Restoring MySQL database {}{}'.format(log_prefix, database['name'], dry_run_label)
)
if not dry_run:
execute_command(
restore_command, input_file=open(dump_filename), extra_environment=extra_environment
)
+19 -99
View File
@@ -1,42 +1,26 @@
import glob
import logging
import os
from borgmatic.execute import execute_command
from borgmatic.hooks import dump
DUMP_PATH = '~/.borgmatic/postgresql_databases'
logger = logging.getLogger(__name__)
def make_database_dump_filename(name, hostname=None):
'''
Based on the given database name and hostname, return a filename to use for the database dump.
Raise ValueError if the database name is invalid.
'''
if os.path.sep in name:
raise ValueError('Invalid database name {}'.format(name))
return os.path.join(os.path.expanduser(DUMP_PATH), hostname or 'localhost', name)
def dump_databases(databases, log_prefix, dry_run):
'''
Dump the given PostgreSQL databases to disk. The databases are supplied as a sequence of dicts,
one dict describing each database as per the configuration schema. Use the given log prefix in
any log entries. If this is a dry run, then don't actually dump anything.
'''
if not databases:
logger.debug('{}: No PostgreSQL databases configured'.format(log_prefix))
return
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
logger.info('{}: Dumping PostgreSQL databases{}'.format(log_prefix, dry_run_label))
for database in databases:
name = database['name']
dump_filename = make_database_dump_filename(name, database.get('hostname'))
dump_filename = dump.make_database_dump_filename(DUMP_PATH, name, database.get('hostname'))
all_databases = bool(name == 'all')
command = (
('pg_dumpall' if all_databases else 'pg_dump', '--no-password', '--clean')
@@ -50,96 +34,34 @@ def dump_databases(databases, log_prefix, dry_run):
)
extra_environment = {'PGPASSWORD': database['password']} if 'password' in database else None
logger.debug('{}: Dumping PostgreSQL database {}{}'.format(log_prefix, name, dry_run_label))
logger.debug(
'{}: Dumping PostgreSQL database {} to {}{}'.format(
log_prefix, name, dump_filename, dry_run_label
)
)
if not dry_run:
os.makedirs(os.path.dirname(dump_filename), mode=0o700, exist_ok=True)
execute_command(command, extra_environment=extra_environment)
def remove_database_dumps(databases, log_prefix, dry_run):
def remove_database_dumps(databases, log_prefix, dry_run): # pragma: no cover
'''
Remove the database dumps for the given databases. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the log prefix in
any log entries. If this is a dry run, then don't actually remove anything.
'''
if not databases:
logger.debug('{}: No PostgreSQL databases configured'.format(log_prefix))
return
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
logger.info('{}: Removing PostgreSQL database dumps{}'.format(log_prefix, dry_run_label))
for database in databases:
dump_filename = make_database_dump_filename(database['name'], database.get('hostname'))
logger.debug(
'{}: Removing PostgreSQL database dump {} from {}{}'.format(
log_prefix, database['name'], dump_filename, dry_run_label
)
)
if dry_run:
continue
os.remove(dump_filename)
dump_path = os.path.dirname(dump_filename)
if len(os.listdir(dump_path)) == 0:
os.rmdir(dump_path)
dump.remove_database_dumps(DUMP_PATH, databases, 'PostgreSQL', log_prefix, dry_run)
def make_database_dump_patterns(names):
def make_database_dump_patterns(databases, log_prefix, names):
'''
Given a sequence of database names, return the corresponding glob patterns to match the database
dumps in an archive. An empty sequence of names indicates that the patterns should match all
dumps.
Given a sequence of configurations dicts, a prefix to log with, and a sequence of database
names to match, return the corresponding glob patterns to match the database dumps in an
archive. An empty sequence of names indicates that the patterns should match all dumps.
'''
return [make_database_dump_filename(name, hostname='*') for name in (names or ['*'])]
def convert_glob_patterns_to_borg_patterns(patterns):
'''
Convert a sequence of shell glob patterns like "/etc/*" to the corresponding Borg archive
patterns like "sh:etc/*".
'''
return ['sh:{}'.format(pattern.lstrip(os.path.sep)) for pattern in patterns]
def get_database_names_from_dumps(patterns):
'''
Given a sequence of database dump patterns, find the corresponding database dumps on disk and
return the database names from their filenames.
'''
return [os.path.basename(dump_path) for pattern in patterns for dump_path in glob.glob(pattern)]
def get_database_configurations(databases, names):
'''
Given the full database configuration dicts as per the configuration schema, and a sequence of
database names, filter down and yield the configuration for just the named databases.
Additionally, if a database configuration is named "all", project out that configuration for
each named database.
Raise ValueError if one of the database names cannot be matched to a database in borgmatic's
database configuration.
'''
named_databases = {database['name']: database for database in databases}
for name in names:
database = named_databases.get(name)
if database:
yield database
continue
if 'all' in named_databases:
yield {**named_databases['all'], **{'name': name}}
continue
raise ValueError(
'Cannot restore database "{}", as it is not defined in borgmatic\'s configuration'.format(
name
)
)
return [
dump.make_database_dump_filename(DUMP_PATH, name, hostname='*') for name in (names or ['*'])
]
def restore_database_dumps(databases, log_prefix, dry_run):
@@ -148,14 +70,12 @@ def restore_database_dumps(databases, log_prefix, dry_run):
dicts, one dict describing each database as per the configuration schema. Use the given log
prefix in any log entries. If this is a dry run, then don't actually restore anything.
'''
if not databases:
logger.debug('{}: No PostgreSQL databases configured'.format(log_prefix))
return
dry_run_label = ' (dry run; not actually restoring anything)' if dry_run else ''
for database in databases:
dump_filename = make_database_dump_filename(database['name'], database.get('hostname'))
dump_filename = dump.make_database_dump_filename(
DUMP_PATH, database['name'], database.get('hostname')
)
restore_command = (
('pg_restore', '--no-password', '--clean', '--if-exists', '--exit-on-error')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+22 -7
View File
@@ -73,29 +73,44 @@ def color_text(color, message):
return '{}{}{}'.format(color, message, colorama.Style.RESET_ALL)
def configure_logging(console_log_level, syslog_log_level=None):
def configure_logging(
console_log_level, syslog_log_level=None, log_file_log_level=None, log_file=None
):
'''
Configure logging to go to both the console and syslog. Use the given log levels, respectively.
Configure logging to go to both the console and (syslog or log file). Use the given log levels,
respectively.
Raise FileNotFoundError or PermissionError if the log file could not be opened for writing.
'''
if syslog_log_level is None:
syslog_log_level = console_log_level
if log_file_log_level is None:
log_file_log_level = console_log_level
console_handler = logging.StreamHandler()
console_handler.setFormatter(Console_color_formatter())
console_handler.setLevel(console_log_level)
syslog_path = None
if os.path.exists('/dev/log'):
syslog_path = '/dev/log'
elif os.path.exists('/var/run/syslog'):
syslog_path = '/var/run/syslog'
if log_file is None:
if os.path.exists('/dev/log'):
syslog_path = '/dev/log'
elif os.path.exists('/var/run/syslog'):
syslog_path = '/var/run/syslog'
if syslog_path and not interactive_console():
syslog_handler = logging.handlers.SysLogHandler(address=syslog_path)
syslog_handler.setFormatter(logging.Formatter('borgmatic: %(levelname)s %(message)s'))
syslog_handler.setLevel(syslog_log_level)
handlers = (console_handler, syslog_handler)
elif log_file:
file_handler = logging.handlers.WatchedFileHandler(log_file)
file_handler.setFormatter(logging.Formatter('[%(asctime)s] %(levelname)s: %(message)s'))
file_handler.setLevel(log_file_log_level)
handlers = (console_handler, file_handler)
else:
handlers = (console_handler,)
logging.basicConfig(level=min(console_log_level, syslog_log_level), handlers=handlers)
logging.basicConfig(
level=min(console_log_level, syslog_log_level, log_file_log_level), handlers=handlers
)
+2
View File
@@ -1,5 +1,6 @@
import logging
VERBOSITY_ERROR = -1
VERBOSITY_WARNING = 0
VERBOSITY_SOME = 1
VERBOSITY_LOTS = 2
@@ -10,6 +11,7 @@ def verbosity_to_log_level(verbosity):
Given a borgmatic verbosity value, return the corresponding Python log level.
'''
return {
VERBOSITY_ERROR: logging.ERROR,
VERBOSITY_WARNING: logging.WARNING,
VERBOSITY_SOME: logging.INFO,
VERBOSITY_LOTS: logging.DEBUG,
+39 -9
View File
@@ -11,31 +11,40 @@ consistent snapshot that is more suited for backups.
Fortunately, borgmatic includes built-in support for creating database dumps
prior to running backups. For example, here is everything you need to dump and
backup a couple of local PostgreSQL databases:
backup a couple of local PostgreSQL databases and a MySQL/MariaDB database:
```yaml
hooks:
postgresql_databases:
- name: users
- name: orders
mysql_databases:
- name: posts
```
Prior to each backup, borgmatic dumps each configured database to a file
(located in `~/.borgmatic/`) and includes it in the backup. After the backup
completes, borgmatic removes the database dump files to recover disk space.
Here's a more involved example that connects to a remote database:
Here's a more involved example that connects to remote databases:
```yaml
hooks:
postgresql_databases:
- name: users
hostname: database.example.org
hostname: database1.example.org
port: 5433
username: dbuser
username: postgres
password: trustsome1
format: tar
options: "--role=someone"
mysql_databases:
- name: posts
hostname: database2.example.org
port: 3307
username: root
password: trustsome1
options: "--skip-comments"
```
If you want to dump all databases on a host, use `all` for the database name:
@@ -44,10 +53,12 @@ If you want to dump all databases on a host, use `all` for the database name:
hooks:
postgresql_databases:
- name: all
mysql_databases:
- name: all
```
Note that you may need to use a `username` of the `postgres` superuser for
this to work.
this to work with PostgreSQL.
### Configuration backups
@@ -61,9 +72,9 @@ bring back any missing configuration files in order to restore a database.
## Supported databases
As of now, borgmatic only supports PostgreSQL databases directly. But see
below about general-purpose preparation and cleanup hooks as a work-around
with other database systems. Also, please [file a
As of now, borgmatic supports PostgreSQL and MySQL/MariaDB databases
directly. But see below about general-purpose preparation and cleanup hooks as
a work-around with other database systems. Also, please [file a
ticket](https://torsion.org/borgmatic/#issues) for additional database systems
that you'd like supported.
@@ -148,7 +159,8 @@ databases that share the exact same name on different hosts.
If you prefer to restore a database without the help of borgmatic, first
[extract](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) an
archive containing a database dump, and then manually restore the dump file
found within the extracted `~/.borgmatic/` path (e.g. with `pg_restore`).
found within the extracted `~/.borgmatic/` path (e.g. with `pg_restore` or
`mysql` commands).
## Preparation and cleanup hooks
@@ -161,6 +173,24 @@ after backups. So if necessary, you can use these hooks to create database
dumps with any database system.
## Troubleshooting
### MySQL table lock errors
If you encounter table lock errors during a database dump with MySQL/MariaDB,
you may need to [use a
transaction](https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html#option_mysqldump_single-transaction).
You can add any additional flags to the `options:` in your database
configuration. Here's an example:
```yaml
hooks:
mysql_databases:
- name: posts
options: "--single-transaction --quick"
```
## Related documentation
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
+2
View File
@@ -62,6 +62,8 @@ following:
tox -e black
```
Note that Black requires at minimum Python 3.6.
And if you get a complaint from the
[isort](https://github.com/timothycrosley/isort) Python import orderer, you
can ask isort to order your imports for you:
+22 -7
View File
@@ -70,18 +70,33 @@ Or to increase syslog logging to include debug spew:
borgmatic --syslog-verbosity 2
```
### systemd journal
### Rate limiting
If your local syslog daemon is systemd's journal, be aware that journald by
default throttles the rate at which a particular program can log. So you may
need to [change the journald rate
If you are using rsyslog or systemd's journal, be aware that by default they
both throttle the rate at which logging occurs. So you may need to change
either [the global rate
limit](https://www.rootusers.com/how-to-change-log-rate-limiting-in-linux/) or
[the per-service rate
limit](https://www.freedesktop.org/software/systemd/man/journald.conf.html#RateLimitIntervalSec=)
in `/etc/systemd/journald.conf` if you're finding that borgmatic journald logs
are missing.
if you're finding that borgmatic logs are missing.
Note that the [sample borgmatic systemd service
file](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#systemd)
already has this rate limit disabled.
already has this rate limit disabled for systemd's journal.
### Logging to file
If you don't want to use syslog, and you'd rather borgmatic log to a plain
file, use the `--log-file` flag:
```bash
borgmatic --log-file /path/to/file.log
```
Note that if you use the `--log-file` flag, you are responsible for rotating
the log file so it doesn't grow too large. Also, there is a
`--log-file-verbosity` flag to customize the log file's log level.
## Related documentation
+96 -15
View File
@@ -26,12 +26,16 @@ alert. But note that if borgmatic doesn't actually run, this alert won't fire.
See [error
hooks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#error-hooks)
below for how to configure this.
4. **borgmatic Healthchecks hook**: This feature integrates with the
[Healthchecks](https://healthchecks.io/) service, and pings Healthchecks
whenever borgmatic runs. That way, Healthchecks can alert you when something
goes wrong or it doesn't hear from borgmatic for a configured interval. See
4. **borgmatic monitoring hooks**: This feature integrates with monitoring
services like [Healthchecks](https://healthchecks.io/),
[Cronitor](https://cronitor.io), and [Cronhub](https://cronhub.io), and pings
these services whenever borgmatic runs. That way, you'll receive an alert when
something goes wrong or the service doesn't hear from borgmatic for a
configured interval. See
[Healthchecks
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook)
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook), [Cronitor
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook), and [Cronhub
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook)
below for how to configure this.
3. **Third-party monitoring software**: You can use traditional monitoring
software to consume borgmatic JSON output and track when the last
@@ -53,10 +57,10 @@ tests](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/).
## Error hooks
When an error occurs during a backup, borgmatic can run configurable shell
commands to fire off custom error notifications or take other actions, so you
can get alerted as soon as something goes wrong. Here's a not-so-useful
example:
When an error occurs during a backup or another action, borgmatic can run
configurable shell commands to fire off custom error notifications or take
other actions, so you can get alerted as soon as something goes wrong. Here's
a not-so-useful example:
```yaml
hooks:
@@ -87,9 +91,10 @@ here:
* `output`: output of the command that failed (may be blank if an error
occurred without running a command)
Note that borgmatic does not run `on_error` hooks if an error occurs within a
`before_everything` or `after_everything` hook. For more about hooks, see the
[borgmatic hooks
Note that borgmatic runs the `on_error` hooks for any action in which an error
occurs, not just the `create` action. But borgmatic does not run `on_error`
hooks if an error occurs within a `before_everything` or `after_everything`
hook. For more about hooks, see the [borgmatic hooks
documentation](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/),
especially the security information.
@@ -108,13 +113,89 @@ hooks:
healthchecks: https://hc-ping.com/addffa72-da17-40ae-be9c-ff591afb942a
```
With this hook in place, borgmatic will ping your Healthchecks project when a
backup begins, ends, or errors. Then you can configure Healthchecks to notify
you by a [variety of
With this hook in place, borgmatic pings your Healthchecks project when a
backup begins, ends, or errors. Specifically, before the <a
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
hooks</a> run, borgmatic lets Healthchecks know that a backup has started.
Then, if the backup completes successfully, borgmatic notifies Healthchecks of
the success after the `after_backup` hooks run, and includes borgmatic logs in
the payload data sent to Healthchecks. This means that borgmatic logs show up
in the Healthchecks UI, although be aware that Healthchecks currently has a
10-kilobyte limit for the logs in each ping.
If an error occurs during the backup, borgmatic notifies Healthchecks after
the `on_error` hooks run, also tacking on logs including the error itself. But
the logs are only included for errors that occur within the borgmatic `create`
action (and not other actions).
Note that borgmatic sends logs to Healthchecks by applying the maximum of any
other borgmatic verbosity level (`--verbosity`, `--syslog-verbosity`, etc.),
as there is not currently a dedicated Healthchecks verbosity setting.
You can configure Healthchecks to notify you by a [variety of
mechanisms](https://healthchecks.io/#welcome-integrations) when backups fail
or it doesn't hear from borgmatic for a certain period of time.
## Cronitor hook
[Cronitor](https://cronitor.io/) provides "Cron monitoring and uptime healthchecks
for websites, services and APIs", and borgmatic has built-in
integration with it. Once you create a Cronitor account and cron job monitor on
their site, all you need to do is configure borgmatic with the unique "Ping
API URL" for your monitor. Here's an example:
```yaml
hooks:
cronitor: https://cronitor.link/d3x0c1
```
With this hook in place, borgmatic pings your Cronitor monitor when a backup
begins, ends, or errors. Specifically, before the <a
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
hooks</a> run, borgmatic lets Cronitor know that a backup has started. Then,
if the backup completes successfully, borgmatic notifies Cronitor of the
success after the `after_backup` hooks run. And if an error occurs during the
backup, borgmatic notifies Cronitor after the `on_error` hooks run.
You can configure Cronitor to notify you by a [variety of
mechanisms](https://cronitor.io/docs/cron-job-notifications) when backups fail
or it doesn't hear from borgmatic for a certain period of time.
## Cronhub hook
[Cronhub](https://cronhub.io/) provides "instant alerts when any of your
background jobs fail silently or run longer than expected", and borgmatic has
built-in integration with it. Once you create a Cronhub account and monitor on
their site, all you need to do is configure borgmatic with the unique "Ping
URL" for your monitor. Here's an example:
```yaml
hooks:
cronhub: https://cronhub.io/start/1f5e3410-254c-11e8-b61d-55875966d031
```
With this hook in place, borgmatic pings your Cronhub monitor when a backup
begins, ends, or errors. Specifically, before the <a
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
hooks</a> run, borgmatic lets Cronhub know that a backup has started. Then,
if the backup completes successfully, borgmatic notifies Cronhub of the
success after the `after_backup` hooks run. And if an error occurs during the
backup, borgmatic notifies Cronhub after the `on_error` hooks run.
Note that even though you configure borgmatic with the "start" variant of the
ping URL, borgmatic substitutes the correct state into the URL when pinging
Cronhub ("start", "finish", or "fail").
You can configure Cronhub to notify you by a [variety of
mechanisms](https://docs.cronhub.io/integrations.html) when backups fail
or it doesn't hear from borgmatic for a certain period of time.
## Scripting borgmatic
To consume the output of borgmatic in other software, you can include an
+19 -8
View File
@@ -19,8 +19,14 @@ sudo pip3 install --user --upgrade borgmatic
This is a [recommended user site
installation](https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site).
You will need to ensure that `/root/.local/bin` is available on your `$PATH` so
that the borgmatic executable is available.
You will need to ensure that `/root/.local/bin` is available on your `$PATH`
so
that the borgmatic executable is available. For instance, adding this to
root's `~/.profile` or `~/.bash_profile` may do the trick:
```bash
export PATH="$PATH:~/.local/bin"
```
Note that your pip binary may have a different name than "pip3". Make sure
you're using Python 3, as borgmatic does not support Python 2.
@@ -30,11 +36,12 @@ you're using Python 3, as borgmatic does not support Python 2.
Along with the above process, you have several other options for installing
borgmatic:
* [Docker image with scheduled backups](https://hub.docker.com/r/b3vis/borgmatic/)
* [Docker base image](https://hub.docker.com/r/monachus/borgmatic/)
* [Docker image with support for scheduled backups](https://hub.docker.com/r/b3vis/borgmatic/)
* [Debian](https://tracker.debian.org/pkg/borgmatic)
* [Ubuntu](https://launchpad.net/ubuntu/+source/borgmatic)
* [Fedora](https://bodhi.fedoraproject.org/updates/?search=borgmatic)
* [Fedora official](https://bodhi.fedoraproject.org/updates/?search=borgmatic)
* [Fedora unofficial](https://copr.fedorainfracloud.org/coprs/heffer/borgmatic/)
* [Arch Linux](https://www.archlinux.org/packages/community/any/borgmatic/)
* [OpenBSD](http://ports.su/sysutils/borgmatic)
* [openSUSE](https://software.opensuse.org/package/borgmatic)
@@ -63,7 +70,7 @@ sudo generate-borgmatic-config
```
If that command is not found, then it may be installed in a location that's
not in your system `PATH`. Try looking in `/usr/local/bin/`.
not in your system `PATH` (see above). Try looking in `~/.local/bin/`.
This generates a sample configuration file at /etc/borgmatic/config.yaml (by
default). You should edit the file to suit your needs, as the values are
@@ -77,9 +84,12 @@ else borgmatic won't recognize the option. Also be sure to use spaces rather
than tabs for indentation; YAML does not allow tabs.
You can also get the same sample configuration file from the [configuration
reference](https://torsion.org/borgmatic/docs/reference/configuration/), the authoritative set of
all configuration options. This is handy if borgmatic has added new options
since you originally created your configuration file.
reference](https://torsion.org/borgmatic/docs/reference/configuration/), the
authoritative set of all configuration options. This is handy if borgmatic has
added new options
since you originally created your configuration file. Also check out how to
[upgrade your
configuration](https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-your-configuration).
### Encryption
@@ -248,5 +258,6 @@ it.
* [Deal with very large backups](https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/)
* [Inspect your backups](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/)
* [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
* [Upgrade borgmatic](https://torsion.org/borgmatic/docs/how-to/upgrade/)
* [borgmatic configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/)
* [borgmatic command-line reference](https://torsion.org/borgmatic/docs/reference/command-line/)
+40 -1
View File
@@ -10,7 +10,46 @@ following:
sudo pip3 install --user --upgrade borgmatic
```
See below about special cases.
See below about special cases with old versions of borgmatic. Additionally, if
you installed borgmatic [without using `pip3 install
--user`](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#other-ways-to-install),
then your upgrade process may be different.
### Upgrading your configuration
The borgmatic configuration file format is almost always backwards-compatible
from release to release without any changes, but you may still want to update
your configuration file when you upgrade to take advantage of new
configuration options. This is completely optional. If you prefer, you can add
new configuration options manually.
If you do want to upgrade your configuration file to include new options, use
the `generate-borgmatic-config` script with its optional `--source` flag that
takes the path to your original configuration file. If provided with this
path, `generate-borgmatic-config` merges your original configuration into the
generated configuration file, so you get all the newest options and comments.
Here's an example:
```bash
generate-borgmatic-config --source config.yaml --destination config-new.yaml
```
New options start as commented out, so you can edit the file and decide
whether you want to use each one.
There are a few caveats to this process. First, when generating the new
configuration file, `generate-borgmatic-config` replaces any comments you've
written in your original configuration file with the newest generated
comments. Second, the script adds back any options you had originally deleted,
although it does so with the options commented out. And finally, any YAML
includes you've used in the source configuration get flattened out into a
single generated file.
As a safety measure, `generate-borgmatic-config` refuses to modify
configuration files in-place. So it's up to you to review the generated file
and, if desired, replace your original configuration file with it.
### Upgrading from borgmatic 1.0.x
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup
VERSION = '1.4.2'
VERSION = '1.4.13'
setup(
+4 -4
View File
@@ -1,12 +1,12 @@
appdirs==1.4.3
atomicwrites==1.3.0
attrs==19.1.0
attrs==19.3.0
black==19.3b0; python_version >= '3.6'
click==7.0
colorama==0.4.1
coverage==4.5.4
docopt==0.6.2
flake8==3.7.8
flake8==3.7.9
flexmock==0.10.4
isort==4.3.21
mccabe==0.6.1
@@ -16,8 +16,8 @@ py==1.8.0
pycodestyle==2.5.0
pyflakes==2.1.1
pykwalify==1.7.0
pytest==5.1.2
pytest-cov==2.7.1
pytest==5.2.2
pytest-cov==2.8.1
python-dateutil==2.8.0
PyYAML==5.1.2
requests==2.22.0
@@ -15,6 +15,7 @@ def test_parse_arguments_with_no_arguments_uses_defaults():
assert global_arguments.excludes_filename is None
assert global_arguments.verbosity == 0
assert global_arguments.syslog_verbosity == 0
assert global_arguments.log_file_verbosity == 0
def test_parse_arguments_with_multiple_config_paths_parses_as_list():
@@ -26,6 +27,7 @@ def test_parse_arguments_with_multiple_config_paths_parses_as_list():
assert global_arguments.config_paths == ['myconfig', 'otherconfig']
assert global_arguments.verbosity == 0
assert global_arguments.syslog_verbosity == 0
assert global_arguments.log_file_verbosity == 0
def test_parse_arguments_with_verbosity_overrides_default():
@@ -39,6 +41,7 @@ def test_parse_arguments_with_verbosity_overrides_default():
assert global_arguments.excludes_filename is None
assert global_arguments.verbosity == 1
assert global_arguments.syslog_verbosity == 0
assert global_arguments.log_file_verbosity == 0
def test_parse_arguments_with_syslog_verbosity_overrides_default():
@@ -54,6 +57,20 @@ def test_parse_arguments_with_syslog_verbosity_overrides_default():
assert global_arguments.syslog_verbosity == 2
def test_parse_arguments_with_log_file_verbosity_overrides_default():
config_paths = ['default']
flexmock(module.collect).should_receive('get_default_config_paths').and_return(config_paths)
arguments = module.parse_arguments('--log-file-verbosity', '-1')
global_arguments = arguments['global']
assert global_arguments.config_paths == config_paths
assert global_arguments.excludes_filename is None
assert global_arguments.verbosity == 0
assert global_arguments.syslog_verbosity == 0
assert global_arguments.log_file_verbosity == -1
def test_parse_arguments_with_list_json_overrides_default():
arguments = module.parse_arguments('list', '--json')
+65 -3
View File
@@ -47,9 +47,13 @@ def test_comment_out_line_comments_twice_indented_option():
def test_comment_out_optional_configuration_comments_optional_config_only():
# The "# COMMENT_OUT" comment is a sentinel used to express that the following key is optional.
# It's stripped out of the final output.
flexmock(module)._comment_out_line = lambda line: '# ' + line
config = '''
# COMMENT_OUT
foo:
# COMMENT_OUT
bar:
- baz
- quux
@@ -59,6 +63,8 @@ location:
- one
- two
# This comment should be kept.
# COMMENT_OUT
other: thing
'''
@@ -68,12 +74,13 @@ location:
# bar:
# - baz
# - quux
#
location:
repositories:
- one
- two
#
# This comment should be kept.
# other: thing
'''
@@ -142,12 +149,67 @@ def test_add_comments_to_configuration_map_does_not_raise():
module.add_comments_to_configuration_map(config, schema)
def test_add_comments_to_configuration_map_with_skip_first_does_not_raise():
config = module.yaml.comments.CommentedMap([('foo', 33)])
schema = {'map': {'foo': {'desc': 'Foo'}}}
module.add_comments_to_configuration_map(config, schema, skip_first=True)
def test_remove_commented_out_sentinel_keeps_other_comments():
field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.\nCOMMENT_OUT')
module.remove_commented_out_sentinel(config, field_name)
comments = config.ca.items[field_name][module.RUAMEL_YAML_COMMENTS_INDEX]
assert len(comments) == 1
assert comments[0].value == '# Actual comment.\n'
def test_remove_commented_out_sentinel_without_sentinel_keeps_other_comments():
field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.')
module.remove_commented_out_sentinel(config, field_name)
comments = config.ca.items[field_name][module.RUAMEL_YAML_COMMENTS_INDEX]
assert len(comments) == 1
assert comments[0].value == '# Actual comment.\n'
def test_remove_commented_out_sentinel_on_unknown_field_does_not_raise():
field_name = 'foo'
config = module.yaml.comments.CommentedMap([(field_name, 33)])
config.yaml_set_comment_before_after_key(key=field_name, before='Actual comment.')
module.remove_commented_out_sentinel(config, 'unknown')
def test_generate_sample_configuration_does_not_raise():
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.yaml).should_receive('round_trip_load')
flexmock(module).should_receive('_schema_to_sample_configuration')
flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('_render_configuration')
flexmock(module).should_receive('_comment_out_optional_configuration')
flexmock(module).should_receive('write_configuration')
module.generate_sample_configuration('config.yaml', 'schema.yaml')
module.generate_sample_configuration(None, 'dest.yaml', 'schema.yaml')
def test_generate_sample_configuration_with_source_filename_does_not_raise():
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.yaml).should_receive('round_trip_load')
flexmock(module.load).should_receive('load_configuration')
flexmock(module).should_receive('_schema_to_sample_configuration')
flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('_render_configuration')
flexmock(module).should_receive('_comment_out_optional_configuration')
flexmock(module).should_receive('write_configuration')
module.generate_sample_configuration('source.yaml', 'dest.yaml', 'schema.yaml')
+26 -27
View File
@@ -7,40 +7,42 @@ from flexmock import flexmock
from borgmatic import execute as module
def test_execute_and_log_output_logs_each_line_separately():
def test_log_output_logs_each_line_separately():
flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'hi').once()
flexmock(module.logger).should_receive('log').with_args(logging.INFO, 'there').once()
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
module.execute_and_log_output(
hi_process = subprocess.Popen(['echo', 'hi'], stdout=subprocess.PIPE)
module.log_output(
['echo', 'hi'],
hi_process,
hi_process.stdout,
output_log_level=logging.INFO,
shell=False,
environment=None,
working_directory=None,
error_on_warnings=False,
)
module.execute_and_log_output(
there_process = subprocess.Popen(['echo', 'there'], stdout=subprocess.PIPE)
module.log_output(
['echo', 'there'],
there_process,
there_process.stdout,
output_log_level=logging.INFO,
shell=False,
environment=None,
working_directory=None,
error_on_warnings=False,
)
def test_execute_and_log_output_includes_error_output_in_exception():
def test_log_output_includes_error_output_in_exception():
flexmock(module.logger).should_receive('log')
flexmock(module).should_receive('exit_code_indicates_error').and_return(True)
process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
with pytest.raises(subprocess.CalledProcessError) as error:
module.execute_and_log_output(
module.log_output(
['grep'],
process,
process.stdout,
output_log_level=logging.INFO,
shell=False,
environment=None,
working_directory=None,
error_on_warnings=False,
)
@@ -48,18 +50,19 @@ def test_execute_and_log_output_includes_error_output_in_exception():
assert error.value.output
def test_execute_and_log_output_truncates_long_error_output():
def test_log_output_truncates_long_error_output():
flexmock(module).ERROR_OUTPUT_MAX_LINE_COUNT = 0
flexmock(module.logger).should_receive('log')
flexmock(module).should_receive('exit_code_indicates_error').and_return(True)
process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
with pytest.raises(subprocess.CalledProcessError) as error:
module.execute_and_log_output(
module.log_output(
['grep'],
process,
process.stdout,
output_log_level=logging.INFO,
shell=False,
environment=None,
working_directory=None,
error_on_warnings=False,
)
@@ -67,15 +70,11 @@ def test_execute_and_log_output_truncates_long_error_output():
assert error.value.output.startswith('...')
def test_execute_and_log_output_with_no_output_logs_nothing():
def test_log_output_with_no_output_logs_nothing():
flexmock(module.logger).should_receive('log').never()
flexmock(module).should_receive('exit_code_indicates_error').and_return(False)
module.execute_and_log_output(
['true'],
output_log_level=logging.INFO,
shell=False,
environment=None,
working_directory=None,
error_on_warnings=False,
process = subprocess.Popen(['true'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
module.log_output(
['true'], process, process.stdout, output_log_level=logging.INFO, error_on_warnings=False
)
+3 -1
View File
@@ -9,7 +9,9 @@ from ..test_verbosity import insert_logging_mock
def insert_execute_command_mock(command):
flexmock(module).should_receive('execute_command').with_args(command).once()
flexmock(module).should_receive('execute_command').with_args(
command, error_on_warnings=True
).once()
def insert_execute_command_never():
+24
View File
@@ -150,6 +150,30 @@ def test_make_exclude_flags_includes_exclude_if_present_when_in_config():
assert exclude_flags == ('--exclude-if-present', 'exclude_me')
def test_make_exclude_flags_includes_keep_exclude_tags_when_true_in_config():
exclude_flags = module._make_exclude_flags(location_config={'keep_exclude_tags': True})
assert exclude_flags == ('--keep-exclude-tags',)
def test_make_exclude_flags_does_not_include_keep_exclude_tags_when_false_in_config():
exclude_flags = module._make_exclude_flags(location_config={'keep_exclude_tags': False})
assert exclude_flags == ()
def test_make_exclude_flags_includes_exclude_nodump_when_true_in_config():
exclude_flags = module._make_exclude_flags(location_config={'exclude_nodump': True})
assert exclude_flags == ('--exclude-nodump',)
def test_make_exclude_flags_does_not_include_exclude_nodump_when_false_in_config():
exclude_flags = module._make_exclude_flags(location_config={'exclude_nodump': False})
assert exclude_flags == ()
def test_make_exclude_flags_is_empty_when_config_has_no_excludes():
exclude_flags = module._make_exclude_flags(location_config={})
+16
View File
@@ -236,3 +236,19 @@ def test_extract_archive_calls_borg_with_progress_parameter():
storage_config={},
progress=True,
)
def test_extract_archive_skips_abspath_for_remote_repository():
flexmock(module.os.path).should_receive('abspath').never()
flexmock(module).should_receive('execute_command').with_args(
('borg', 'extract', 'server:repo::archive'), working_directory=None, error_on_warnings=True
).once()
module.extract_archive(
dry_run=False,
repository='server:repo',
archive='archive',
paths=None,
location_config={},
storage_config={},
)
+23 -9
View File
@@ -23,9 +23,7 @@ def test_run_configuration_runs_actions_for_each_repository():
def test_run_configuration_executes_hooks_for_create_action():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').twice()
flexmock(module.postgresql).should_receive('dump_databases').once()
flexmock(module.healthchecks).should_receive('ping_healthchecks').twice()
flexmock(module.postgresql).should_receive('remove_database_dumps').once()
flexmock(module.dispatch).should_receive('call_hooks').at_least().twice()
flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(dry_run=False), 'create': flexmock()}
@@ -36,8 +34,7 @@ def test_run_configuration_executes_hooks_for_create_action():
def test_run_configuration_logs_actions_error():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook')
flexmock(module.postgresql).should_receive('dump_databases')
flexmock(module.healthchecks).should_receive('ping_healthchecks')
flexmock(module.dispatch).should_receive('call_hooks')
expected_results = [flexmock()]
flexmock(module).should_receive('make_error_log_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_raise(OSError)
@@ -68,6 +65,7 @@ def test_run_configuration_logs_post_hook_error():
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(
OSError
).and_return(None)
flexmock(module.dispatch).should_receive('call_hooks')
expected_results = [flexmock()]
flexmock(module).should_receive('make_error_log_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_return([])
@@ -117,36 +115,51 @@ def test_load_configurations_logs_critical_for_parse_error():
assert {log.levelno for log in logs} == {logging.CRITICAL}
def test_log_record_does_not_raise():
module.log_record(levelno=1, foo='bar', baz='quux')
def test_make_error_log_records_generates_output_logs_for_message_only():
flexmock(module).should_receive('log_record').replace_with(dict)
logs = tuple(module.make_error_log_records('Error'))
assert {log.levelno for log in logs} == {logging.CRITICAL}
assert {log['levelno'] for log in logs} == {logging.CRITICAL}
def test_make_error_log_records_generates_output_logs_for_called_process_error():
flexmock(module).should_receive('log_record').replace_with(dict)
flexmock(module.logger).should_receive('getEffectiveLevel').and_return(logging.WARNING)
logs = tuple(
module.make_error_log_records(
'Error', subprocess.CalledProcessError(1, 'ls', 'error output')
)
)
assert {log.levelno for log in logs} == {logging.CRITICAL}
assert {log['levelno'] for log in logs} == {logging.CRITICAL}
assert any(log for log in logs if 'error output' in str(log))
def test_make_error_log_records_generates_logs_for_value_error():
flexmock(module).should_receive('log_record').replace_with(dict)
logs = tuple(module.make_error_log_records('Error', ValueError()))
assert {log.levelno for log in logs} == {logging.CRITICAL}
assert {log['levelno'] for log in logs} == {logging.CRITICAL}
def test_make_error_log_records_generates_logs_for_os_error():
flexmock(module).should_receive('log_record').replace_with(dict)
logs = tuple(module.make_error_log_records('Error', OSError()))
assert {log.levelno for log in logs} == {logging.CRITICAL}
assert {log['levelno'] for log in logs} == {logging.CRITICAL}
def test_make_error_log_records_generates_nothing_for_other_error():
flexmock(module).should_receive('log_record').replace_with(dict)
logs = tuple(module.make_error_log_records('Error', KeyError()))
assert logs == ()
@@ -270,6 +283,7 @@ def test_collect_configuration_run_summary_logs_run_configuration_error():
flexmock(module).should_receive('run_configuration').and_return(
[logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg='Error'))]
)
flexmock(module).should_receive('make_error_log_records').and_return([])
arguments = {}
logs = tuple(
+2
View File
@@ -21,6 +21,7 @@ def test_convert_section_generates_integer_value_for_integer_type_in_schema():
def test_convert_legacy_parsed_config_transforms_source_config_to_mapping():
flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
flexmock(module.generate).should_receive('add_comments_to_configuration_map')
source_config = Parsed_config(
location=OrderedDict([('source_directories', '/home'), ('repository', 'hostname.borg')]),
storage=OrderedDict([('encryption_passphrase', 'supersecret')]),
@@ -53,6 +54,7 @@ def test_convert_legacy_parsed_config_transforms_source_config_to_mapping():
def test_convert_legacy_parsed_config_splits_space_separated_values():
flexmock(module.yaml.comments).should_receive('CommentedMap').replace_with(OrderedDict)
flexmock(module.generate).should_receive('add_comments_to_configuration_map')
source_config = Parsed_config(
location=OrderedDict(
[('source_directories', '/home /etc'), ('repository', 'hostname.borg')]
+61
View File
@@ -51,6 +51,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():
flexmock(module.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_map')
schema = {
'seq': [
{
@@ -71,3 +72,63 @@ def test_schema_to_sample_configuration_with_unsupported_schema_raises():
with pytest.raises(ValueError):
module._schema_to_sample_configuration(schema)
def test_merge_source_configuration_into_destination_inserts_map_fields():
destination_config = {'foo': 'dest1', 'bar': 'dest2'}
source_config = {'foo': 'source1', 'baz': 'source2'}
flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config)
assert destination_config == {'foo': 'source1', 'bar': 'dest2', 'baz': 'source2'}
def test_merge_source_configuration_into_destination_inserts_nested_map_fields():
destination_config = {'foo': {'first': 'dest1', 'second': 'dest2'}, 'bar': 'dest3'}
source_config = {'foo': {'first': 'source1'}}
flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config)
assert destination_config == {'foo': {'first': 'source1', 'second': 'dest2'}, 'bar': 'dest3'}
def test_merge_source_configuration_into_destination_inserts_sequence_fields():
destination_config = {'foo': ['dest1', 'dest2'], 'bar': ['dest3'], 'baz': ['dest4']}
source_config = {'foo': ['source1'], 'bar': ['source2', 'source3']}
flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config)
assert destination_config == {
'foo': ['source1'],
'bar': ['source2', 'source3'],
'baz': ['dest4'],
}
def test_merge_source_configuration_into_destination_inserts_sequence_of_maps():
destination_config = {'foo': [{'first': 'dest1', 'second': 'dest2'}], 'bar': 'dest3'}
source_config = {'foo': [{'first': 'source1'}, {'other': 'source2'}]}
flexmock(module).should_receive('remove_commented_out_sentinel')
flexmock(module).should_receive('yaml.comments.CommentedSeq').replace_with(list)
module.merge_source_configuration_into_destination(destination_config, source_config)
assert destination_config == {
'foo': [{'first': 'source1', 'second': 'dest2'}, {'other': 'source2'}],
'bar': 'dest3',
}
def test_merge_source_configuration_into_destination_without_source_does_nothing():
original_destination_config = {'foo': 'dest1', 'bar': 'dest2'}
destination_config = dict(original_destination_config)
module.merge_source_configuration_into_destination(destination_config, None)
assert destination_config == original_destination_config
+38
View File
@@ -0,0 +1,38 @@
from flexmock import flexmock
from borgmatic.hooks import cronhub as module
def test_ping_monitor_rewrites_ping_url_for_start_state():
ping_url = 'https://example.com/start/abcdef'
flexmock(module.requests).should_receive('get').with_args('https://example.com/start/abcdef')
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=False)
def test_ping_monitor_rewrites_ping_url_and_state_for_start_state():
ping_url = 'https://example.com/ping/abcdef'
flexmock(module.requests).should_receive('get').with_args('https://example.com/start/abcdef')
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=False)
def test_ping_monitor_rewrites_ping_url_for_finish_state():
ping_url = 'https://example.com/start/abcdef'
flexmock(module.requests).should_receive('get').with_args('https://example.com/finish/abcdef')
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FINISH, dry_run=False)
def test_ping_monitor_rewrites_ping_url_for_fail_state():
ping_url = 'https://example.com/start/abcdef'
flexmock(module.requests).should_receive('get').with_args('https://example.com/fail/abcdef')
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FAIL, dry_run=False)
def test_ping_monitor_dry_run_does_not_hit_ping_url():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').never()
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=True)
+31
View File
@@ -0,0 +1,31 @@
from flexmock import flexmock
from borgmatic.hooks import cronitor as module
def test_ping_monitor_hits_ping_url_for_start_state():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'run'))
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=False)
def test_ping_monitor_hits_ping_url_for_finish_state():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'complete'))
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FINISH, dry_run=False)
def test_ping_monitor_hits_ping_url_for_fail_state():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'fail'))
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FAIL, dry_run=False)
def test_ping_monitor_dry_run_does_not_hit_ping_url():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').never()
module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=True)
+68
View File
@@ -0,0 +1,68 @@
import sys
import pytest
from flexmock import flexmock
from borgmatic.hooks import dispatch as module
def hook_function(config, log_prefix, thing, value):
'''
This test function gets mocked out below.
'''
pass
def test_call_hook_invokes_module_function_with_arguments_and_returns_value():
hooks = {'super_hook': flexmock(), 'other_hook': flexmock()}
expected_return_value = flexmock()
test_module = sys.modules[__name__]
flexmock(module).HOOK_NAME_TO_MODULE = {'super_hook': test_module}
flexmock(test_module).should_receive('hook_function').with_args(
hooks['super_hook'], 'prefix', 55, value=66
).and_return(expected_return_value).once()
return_value = module.call_hook('hook_function', hooks, 'prefix', 'super_hook', 55, value=66)
assert return_value == expected_return_value
def test_call_hook_without_hook_config_skips_call():
hooks = {'other_hook': flexmock()}
test_module = sys.modules[__name__]
flexmock(module).HOOK_NAME_TO_MODULE = {'super_hook': test_module}
flexmock(test_module).should_receive('hook_function').never()
module.call_hook('hook_function', hooks, 'prefix', 'super_hook', 55, value=66)
def test_call_hook_without_corresponding_module_raises():
hooks = {'super_hook': flexmock(), 'other_hook': flexmock()}
test_module = sys.modules[__name__]
flexmock(module).HOOK_NAME_TO_MODULE = {'other_hook': test_module}
flexmock(test_module).should_receive('hook_function').never()
with pytest.raises(ValueError):
module.call_hook('hook_function', hooks, 'prefix', 'super_hook', 55, value=66)
def test_call_hooks_calls_each_hook_and_collects_return_values():
hooks = {'super_hook': flexmock(), 'other_hook': flexmock()}
expected_return_values = {'super_hook': flexmock(), 'other_hook': flexmock()}
flexmock(module).should_receive('call_hook').and_return(
expected_return_values['super_hook']
).and_return(expected_return_values['other_hook'])
return_values = module.call_hooks('do_stuff', hooks, 'prefix', ('super_hook', 'other_hook'), 55)
assert return_values == expected_return_values
def test_call_hooks_calls_skips_return_values_for_unconfigured_hooks():
hooks = {'super_hook': flexmock()}
expected_return_values = {'super_hook': flexmock()}
flexmock(module).should_receive('call_hook').and_return(expected_return_values['super_hook'])
return_values = module.call_hooks('do_stuff', hooks, 'prefix', ('super_hook', 'other_hook'), 55)
assert return_values == expected_return_values
+171
View File
@@ -0,0 +1,171 @@
import pytest
from flexmock import flexmock
from borgmatic.hooks import dump as module
def test_make_database_dump_filename_uses_name_and_hostname():
flexmock(module.os.path).should_receive('expanduser').and_return('databases')
assert (
module.make_database_dump_filename('databases', 'test', 'hostname')
== 'databases/hostname/test'
)
def test_make_database_dump_filename_without_hostname_defaults_to_localhost():
flexmock(module.os.path).should_receive('expanduser').and_return('databases')
assert module.make_database_dump_filename('databases', 'test') == 'databases/localhost/test'
def test_make_database_dump_filename_with_invalid_name_raises():
flexmock(module.os.path).should_receive('expanduser').and_return('databases')
with pytest.raises(ValueError):
module.make_database_dump_filename('databases', 'invalid/name')
def test_flatten_dump_patterns_produces_list_of_all_patterns():
dump_patterns = {'postgresql_databases': ['*/glob', 'glob/*'], 'mysql_databases': ['*/*/*']}
expected_patterns = sorted(
dump_patterns['postgresql_databases'] + dump_patterns['mysql_databases']
)
assert sorted(module.flatten_dump_patterns(dump_patterns, ('bob',))) == expected_patterns
def test_flatten_dump_patterns_with_no_patterns_errors():
dump_patterns = {'postgresql_databases': [], 'mysql_databases': []}
with pytest.raises(ValueError):
assert module.flatten_dump_patterns(dump_patterns, ('bob',))
def test_flatten_dump_patterns_with_no_hooks_errors():
dump_patterns = {}
with pytest.raises(ValueError):
assert module.flatten_dump_patterns(dump_patterns, ('bob',))
def test_remove_database_dumps_removes_dump_for_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.os).should_receive('listdir').and_return([])
flexmock(module.os).should_receive('rmdir')
for name in ('foo', 'bar'):
flexmock(module.os).should_receive('remove').with_args(
'databases/localhost/{}'.format(name)
).once()
module.remove_database_dumps('databases', databases, 'SuperDB', 'test.yaml', dry_run=False)
def test_remove_database_dumps_with_dry_run_skips_removal():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module.os).should_receive('rmdir').never()
flexmock(module.os).should_receive('remove').never()
module.remove_database_dumps('databases', databases, 'SuperDB', 'test.yaml', dry_run=True)
def test_remove_database_dumps_without_databases_does_not_raise():
module.remove_database_dumps('databases', [], 'SuperDB', 'test.yaml', dry_run=False)
def test_convert_glob_patterns_to_borg_patterns_removes_leading_slash():
assert module.convert_glob_patterns_to_borg_patterns(('/etc/foo/bar',)) == ['sh:etc/foo/bar']
def test_get_database_names_from_dumps_gets_names_from_filenames_matching_globs():
flexmock(module.glob).should_receive('glob').and_return(
('databases/localhost/foo',)
).and_return(('databases/localhost/bar',)).and_return(())
assert module.get_database_names_from_dumps(
('databases/*/foo', 'databases/*/bar', 'databases/*/baz')
) == ['foo', 'bar']
def test_get_database_configurations_only_produces_named_databases():
databases = [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'bar', 'hostname': 'example.com'},
{'name': 'baz', 'hostname': 'example.org'},
]
assert list(module.get_database_configurations(databases, ('foo', 'baz'))) == [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'baz', 'hostname': 'example.org'},
]
def test_get_database_configurations_matches_all_database():
databases = [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'all', 'hostname': 'example.com'},
]
assert list(module.get_database_configurations(databases, ('foo', 'bar', 'baz'))) == [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'bar', 'hostname': 'example.com'},
{'name': 'baz', 'hostname': 'example.com'},
]
def test_get_per_hook_database_configurations_partitions_by_hook():
hooks = {'postgresql_databases': [flexmock()]}
names = ('foo', 'bar')
dump_patterns = flexmock()
expected_config = {'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}]}
flexmock(module).should_receive('get_database_configurations').with_args(
hooks['postgresql_databases'], names
).and_return(expected_config['postgresql_databases'])
config = module.get_per_hook_database_configurations(hooks, names, dump_patterns)
assert config == expected_config
def test_get_per_hook_database_configurations_defaults_to_detected_database_names():
hooks = {'postgresql_databases': [flexmock()]}
names = ()
detected_names = flexmock()
dump_patterns = {'postgresql_databases': [flexmock()]}
expected_config = {'postgresql_databases': [flexmock()]}
flexmock(module).should_receive('get_database_names_from_dumps').and_return(detected_names)
flexmock(module).should_receive('get_database_configurations').with_args(
hooks['postgresql_databases'], detected_names
).and_return(expected_config['postgresql_databases'])
config = module.get_per_hook_database_configurations(hooks, names, dump_patterns)
assert config == expected_config
def test_get_per_hook_database_configurations_with_unknown_database_name_raises():
hooks = {'postgresql_databases': [flexmock()]}
names = ('foo', 'bar')
dump_patterns = flexmock()
flexmock(module).should_receive('get_database_configurations').with_args(
hooks['postgresql_databases'], names
).and_return([])
with pytest.raises(ValueError):
module.get_per_hook_database_configurations(hooks, names, dump_patterns)
def test_get_per_hook_database_configurations_with_all_and_no_archive_dumps_raises():
hooks = {'postgresql_databases': [flexmock()]}
names = ('foo', 'all')
dump_patterns = flexmock()
flexmock(module).should_receive('get_database_configurations').with_args(
hooks['postgresql_databases'], names
).and_return([])
with pytest.raises(ValueError):
module.get_per_hook_database_configurations(hooks, names, dump_patterns)
+89 -17
View File
@@ -3,31 +3,103 @@ from flexmock import flexmock
from borgmatic.hooks import healthchecks as module
def test_ping_healthchecks_hits_ping_url():
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('get').with_args(ping_url)
def test_forgetful_buffering_handler_emit_collects_log_records():
handler = module.Forgetful_buffering_handler(byte_capacity=100)
handler.emit(flexmock(getMessage=lambda: 'foo'))
handler.emit(flexmock(getMessage=lambda: 'bar'))
module.ping_healthchecks(ping_url, 'config.yaml', dry_run=False)
assert handler.buffer == ['foo\n', 'bar\n']
assert not handler.forgot
def test_ping_healthchecks_without_ping_url_does_not_raise():
flexmock(module.requests).should_receive('get').never()
module.ping_healthchecks(ping_url_or_uuid=None, config_filename='config.yaml', dry_run=False)
def test_forgetful_buffering_handler_emit_forgets_log_records_when_capacity_reached():
handler = module.Forgetful_buffering_handler(byte_capacity=len('foo\nbar\n'))
handler.emit(flexmock(getMessage=lambda: 'foo'))
assert handler.buffer == ['foo\n']
handler.emit(flexmock(getMessage=lambda: 'bar'))
assert handler.buffer == ['foo\n', 'bar\n']
handler.emit(flexmock(getMessage=lambda: 'baz'))
assert handler.buffer == ['bar\n', 'baz\n']
handler.emit(flexmock(getMessage=lambda: 'quux'))
assert handler.buffer == ['quux\n']
assert handler.forgot
def test_ping_healthchecks_with_ping_uuid_hits_corresponding_url():
ping_uuid = 'abcd-efgh-ijkl-mnop'
flexmock(module.requests).should_receive('get').with_args(
'https://hc-ping.com/{}'.format(ping_uuid)
def test_format_buffered_logs_for_payload_flattens_log_buffer():
handler = module.Forgetful_buffering_handler(byte_capacity=100)
handler.buffer = ['foo\n', 'bar\n']
flexmock(module.logging).should_receive('getLogger').and_return(flexmock(handlers=[handler]))
payload = module.format_buffered_logs_for_payload()
assert payload == 'foo\nbar\n'
def test_format_buffered_logs_for_payload_inserts_truncation_indicator_when_logs_forgotten():
handler = module.Forgetful_buffering_handler(byte_capacity=100)
handler.buffer = ['foo\n', 'bar\n']
handler.forgot = True
flexmock(module.logging).should_receive('getLogger').and_return(flexmock(handlers=[handler]))
payload = module.format_buffered_logs_for_payload()
assert payload == '...\nfoo\nbar\n'
def test_format_buffered_logs_for_payload_without_handler_produces_empty_payload():
flexmock(module.logging).should_receive('getLogger').and_return(
flexmock(handlers=[module.logging.Handler()])
)
module.ping_healthchecks(ping_uuid, 'config.yaml', dry_run=False)
payload = module.format_buffered_logs_for_payload()
assert payload == ''
def test_ping_healthchecks_hits_ping_url_with_append():
def test_ping_monitor_hits_ping_url_for_start_state():
flexmock(module).should_receive('Forgetful_buffering_handler')
ping_url = 'https://example.com'
append = 'failed-so-hard'
flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, append))
flexmock(module.requests).should_receive('post').with_args(
'{}/{}'.format(ping_url, 'start'), data=''
)
module.ping_healthchecks(ping_url, 'config.yaml', dry_run=False, append=append)
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.START, dry_run=False)
def test_ping_monitor_hits_ping_url_for_finish_state():
ping_url = 'https://example.com'
payload = flexmock()
flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
flexmock(module.requests).should_receive('post').with_args(ping_url, data=payload)
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.FINISH, dry_run=False)
def test_ping_monitor_hits_ping_url_for_fail_state():
ping_url = 'https://example.com'
payload = flexmock()
flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
'{}/{}'.format(ping_url, 'fail'), data=payload
)
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.FAIL, dry_run=False)
def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
ping_uuid = 'abcd-efgh-ijkl-mnop'
payload = flexmock()
flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
'https://hc-ping.com/{}'.format(ping_uuid), data=payload
)
module.ping_monitor(ping_uuid, 'config.yaml', state=module.monitor.State.FINISH, dry_run=False)
def test_ping_monitor_dry_run_does_not_hit_ping_url():
flexmock(module).should_receive('Forgetful_buffering_handler')
ping_url = 'https://example.com'
flexmock(module.requests).should_receive('post').never()
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.START, dry_run=True)
+205
View File
@@ -0,0 +1,205 @@
import sys
from flexmock import flexmock
from borgmatic.hooks import mysql as module
def test_dump_databases_runs_mysqldump_for_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
output_file = flexmock()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
for name in ('foo', 'bar'):
flexmock(module).should_receive('execute_command').with_args(
('mysqldump', '--add-drop-database', '--databases', name),
output_file=output_file,
extra_environment=None,
).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
def test_dump_databases_with_dry_run_skips_mysqldump():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.os).should_receive('makedirs').never()
flexmock(module).should_receive('execute_command').never()
module.dump_databases(databases, 'test.yaml', dry_run=True)
def test_dump_databases_runs_mysqldump_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
output_file = flexmock()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/database.example.org/foo'
)
flexmock(module.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
flexmock(module).should_receive('execute_command').with_args(
(
'mysqldump',
'--add-drop-database',
'--host',
'database.example.org',
'--port',
'5433',
'--protocol',
'tcp',
'--databases',
'foo',
),
output_file=output_file,
extra_environment=None,
).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
def test_dump_databases_runs_mysqldump_with_username_and_password():
databases = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
output_file = flexmock()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
flexmock(module).should_receive('execute_command').with_args(
('mysqldump', '--add-drop-database', '--user', 'root', '--databases', 'foo'),
output_file=output_file,
extra_environment={'MYSQL_PWD': 'trustsome1'},
).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
def test_dump_databases_runs_mysqldump_with_options():
databases = [{'name': 'foo', 'options': '--stuff=such'}]
output_file = flexmock()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
flexmock(module).should_receive('execute_command').with_args(
('mysqldump', '--add-drop-database', '--stuff=such', '--databases', 'foo'),
output_file=output_file,
extra_environment=None,
).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
def test_dump_databases_runs_mysqldump_for_all_databases():
databases = [{'name': 'all'}]
output_file = flexmock()
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
flexmock(module).should_receive('execute_command').with_args(
('mysqldump', '--add-drop-database', '--all-databases'),
output_file=output_file,
extra_environment=None,
).once()
module.dump_databases(databases, 'test.yaml', dry_run=False)
def test_make_database_dump_patterns_converts_names_to_glob_paths():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/*/foo'
).and_return('databases/*/bar')
assert module.make_database_dump_patterns(flexmock(), flexmock(), ('foo', 'bar')) == [
'databases/*/foo',
'databases/*/bar',
]
def test_make_database_dump_patterns_treats_empty_names_as_matching_all_databases():
flexmock(module.dump).should_receive('make_database_dump_filename').with_args(
module.DUMP_PATH, '*', '*'
).and_return('databases/*/*')
assert module.make_database_dump_patterns(flexmock(), flexmock(), ()) == ['databases/*/*']
def test_restore_database_dumps_restores_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
for name in ('foo', 'bar'):
dump_filename = 'databases/localhost/{}'.format(name)
input_file = flexmock()
flexmock(sys.modules['builtins']).should_receive('open').with_args(
dump_filename
).and_return(input_file)
flexmock(module).should_receive('execute_command').with_args(
('mysql', '--batch'), input_file=input_file, extra_environment=None
).once()
module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
def test_restore_database_dumps_runs_mysql_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
dump_filename = 'databases/localhost/foo'
input_file = flexmock()
flexmock(sys.modules['builtins']).should_receive('open').with_args(dump_filename).and_return(
input_file
)
flexmock(module).should_receive('execute_command').with_args(
(
'mysql',
'--batch',
'--host',
'database.example.org',
'--port',
'5433',
'--protocol',
'tcp',
),
input_file=input_file,
extra_environment=None,
).once()
module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
def test_restore_database_dumps_runs_mysql_with_username_and_password():
databases = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
dump_filename = 'databases/localhost/foo'
input_file = flexmock()
flexmock(sys.modules['builtins']).should_receive('open').with_args(dump_filename).and_return(
input_file
)
flexmock(module).should_receive('execute_command').with_args(
('mysql', '--batch', '--user', 'root'),
input_file=input_file,
extra_environment={'MYSQL_PWD': 'trustsome1'},
).once()
module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
+19 -121
View File
@@ -1,31 +1,11 @@
import pytest
from flexmock import flexmock
from borgmatic.hooks import postgresql as module
def test_make_database_dump_filename_uses_name_and_hostname():
flexmock(module.os.path).should_receive('expanduser').and_return('databases')
assert module.make_database_dump_filename('test', 'hostname') == 'databases/hostname/test'
def test_make_database_dump_filename_without_hostname_defaults_to_localhost():
flexmock(module.os.path).should_receive('expanduser').and_return('databases')
assert module.make_database_dump_filename('test') == 'databases/localhost/test'
def test_make_database_dump_filename_with_invalid_name_raises():
flexmock(module.os.path).should_receive('expanduser').and_return('databases')
with pytest.raises(ValueError):
module.make_database_dump_filename('invalid/name')
def test_dump_databases_runs_pg_dump_for_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.os).should_receive('makedirs')
@@ -50,22 +30,18 @@ def test_dump_databases_runs_pg_dump_for_each_database():
def test_dump_databases_with_dry_run_skips_pg_dump():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.os).should_receive('makedirs')
flexmock(module.os).should_receive('makedirs').never()
flexmock(module).should_receive('execute_command').never()
module.dump_databases(databases, 'test.yaml', dry_run=True)
def test_dump_databases_without_databases_does_not_raise():
module.dump_databases([], 'test.yaml', dry_run=False)
def test_dump_databases_runs_pg_dump_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/database.example.org/foo'
)
flexmock(module.os).should_receive('makedirs')
@@ -93,7 +69,7 @@ def test_dump_databases_runs_pg_dump_with_hostname_and_port():
def test_dump_databases_runs_pg_dump_with_username_and_password():
databases = [{'name': 'foo', 'username': 'postgres', 'password': 'trustsome1'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.os).should_receive('makedirs')
@@ -119,7 +95,7 @@ def test_dump_databases_runs_pg_dump_with_username_and_password():
def test_dump_databases_runs_pg_dump_with_format():
databases = [{'name': 'foo', 'format': 'tar'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.os).should_receive('makedirs')
@@ -143,7 +119,7 @@ def test_dump_databases_runs_pg_dump_with_format():
def test_dump_databases_runs_pg_dump_with_options():
databases = [{'name': 'foo', 'options': '--stuff=such'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module.os).should_receive('makedirs')
@@ -168,7 +144,7 @@ def test_dump_databases_runs_pg_dump_with_options():
def test_dump_databases_runs_pg_dumpall_for_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module.os).should_receive('makedirs')
@@ -181,102 +157,28 @@ def test_dump_databases_runs_pg_dumpall_for_all_databases():
module.dump_databases(databases, 'test.yaml', dry_run=False)
def test_remove_database_dumps_removes_dump_for_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module.os).should_receive('listdir').and_return([])
flexmock(module.os).should_receive('rmdir')
for name in ('foo', 'bar'):
flexmock(module.os).should_receive('remove').with_args(
'databases/localhost/{}'.format(name)
).once()
module.remove_database_dumps(databases, 'test.yaml', dry_run=False)
def test_remove_database_dumps_with_dry_run_skips_removal():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module.os).should_receive('remove').never()
module.remove_database_dumps(databases, 'test.yaml', dry_run=True)
def test_remove_database_dumps_without_databases_does_not_raise():
module.remove_database_dumps([], 'test.yaml', dry_run=False)
def test_make_database_dump_patterns_converts_names_to_glob_paths():
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/*/foo'
).and_return('databases/*/bar')
assert module.make_database_dump_patterns(('foo', 'bar')) == [
assert module.make_database_dump_patterns(flexmock(), flexmock(), ('foo', 'bar')) == [
'databases/*/foo',
'databases/*/bar',
]
def test_make_database_dump_patterns_treats_empty_names_as_matching_all_databases():
flexmock(module).should_receive('make_database_dump_filename').with_args('*', '*').and_return(
'databases/*/*'
)
flexmock(module.dump).should_receive('make_database_dump_filename').with_args(
module.DUMP_PATH, '*', '*'
).and_return('databases/*/*')
assert module.make_database_dump_patterns(()) == ['databases/*/*']
def test_convert_glob_patterns_to_borg_patterns_removes_leading_slash():
assert module.convert_glob_patterns_to_borg_patterns(('/etc/foo/bar',)) == ['sh:etc/foo/bar']
def test_get_database_names_from_dumps_gets_names_from_filenames_matching_globs():
flexmock(module.glob).should_receive('glob').and_return(
('databases/localhost/foo',)
).and_return(('databases/localhost/bar',)).and_return(())
assert module.get_database_names_from_dumps(
('databases/*/foo', 'databases/*/bar', 'databases/*/baz')
) == ['foo', 'bar']
def test_get_database_configurations_only_produces_named_databases():
databases = [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'bar', 'hostname': 'example.com'},
{'name': 'baz', 'hostname': 'example.org'},
]
assert list(module.get_database_configurations(databases, ('foo', 'baz'))) == [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'baz', 'hostname': 'example.org'},
]
def test_get_database_configurations_matches_all_database():
databases = [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'all', 'hostname': 'example.com'},
]
assert list(module.get_database_configurations(databases, ('foo', 'bar', 'baz'))) == [
{'name': 'foo', 'hostname': 'example.org'},
{'name': 'bar', 'hostname': 'example.com'},
{'name': 'baz', 'hostname': 'example.com'},
]
def test_get_database_configurations_with_unknown_database_name_raises():
databases = [{'name': 'foo', 'hostname': 'example.org'}]
with pytest.raises(ValueError):
list(module.get_database_configurations(databases, ('foo', 'bar')))
assert module.make_database_dump_patterns(flexmock(), flexmock(), ()) == ['databases/*/*']
def test_restore_database_dumps_restores_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
@@ -302,15 +204,11 @@ def test_restore_database_dumps_restores_each_database():
module.restore_database_dumps(databases, 'test.yaml', dry_run=False)
def test_restore_database_dumps_without_databases_does_not_raise():
module.restore_database_dumps({}, 'test.yaml', dry_run=False)
def test_restore_database_dumps_runs_pg_restore_with_hostname_and_port():
databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
)
flexmock(module).should_receive('execute_command').with_args(
(
@@ -351,9 +249,9 @@ def test_restore_database_dumps_runs_pg_restore_with_hostname_and_port():
def test_restore_database_dumps_runs_pg_restore_with_username_and_password():
databases = [{'name': 'foo', 'username': 'postgres', 'password': 'trustsome1'}]
flexmock(module).should_receive('make_database_dump_filename').and_return(
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
)
flexmock(module).should_receive('execute_command').with_args(
(
+72 -43
View File
@@ -1,5 +1,3 @@
import logging
import pytest
from flexmock import flexmock
@@ -47,31 +45,75 @@ def test_exit_code_indicates_error_with_non_borg_success_is_false():
def test_execute_command_calls_full_command():
full_command = ['foo', 'bar']
flexmock(module.os, environ={'a': 'b'})
flexmock(module).should_receive('execute_and_log_output').with_args(
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
output_log_level=logging.INFO,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
environment=None,
working_directory=None,
error_on_warnings=False,
).once()
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
output = module.execute_command(full_command)
assert output is None
def test_execute_command_calls_full_command_with_output_file():
full_command = ['foo', 'bar']
output_file = flexmock(name='test')
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=None,
stdout=output_file,
stderr=module.subprocess.PIPE,
shell=False,
env=None,
cwd=None,
).and_return(flexmock(stderr=None)).once()
flexmock(module).should_receive('log_output')
output = module.execute_command(full_command, output_file=output_file)
assert output is None
def test_execute_command_calls_full_command_with_input_file():
full_command = ['foo', 'bar']
input_file = flexmock(name='test')
flexmock(module.os, environ={'a': 'b'})
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
stdin=input_file,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
output = module.execute_command(full_command, input_file=input_file)
assert output is None
def test_execute_command_calls_full_command_with_shell():
full_command = ['foo', 'bar']
flexmock(module.os, environ={'a': 'b'})
flexmock(module).should_receive('execute_and_log_output').with_args(
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
output_log_level=logging.INFO,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=True,
environment=None,
working_directory=None,
error_on_warnings=False,
).once()
env=None,
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
output = module.execute_command(full_command, shell=True)
@@ -81,14 +123,16 @@ def test_execute_command_calls_full_command_with_shell():
def test_execute_command_calls_full_command_with_extra_environment():
full_command = ['foo', 'bar']
flexmock(module.os, environ={'a': 'b'})
flexmock(module).should_receive('execute_and_log_output').with_args(
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
output_log_level=logging.INFO,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
environment={'a': 'b', 'c': 'd'},
working_directory=None,
error_on_warnings=False,
).once()
env={'a': 'b', 'c': 'd'},
cwd=None,
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
output = module.execute_command(full_command, extra_environment={'c': 'd'})
@@ -98,37 +142,22 @@ def test_execute_command_calls_full_command_with_extra_environment():
def test_execute_command_calls_full_command_with_working_directory():
full_command = ['foo', 'bar']
flexmock(module.os, environ={'a': 'b'})
flexmock(module).should_receive('execute_and_log_output').with_args(
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
output_log_level=logging.INFO,
stdin=None,
stdout=module.subprocess.PIPE,
stderr=module.subprocess.STDOUT,
shell=False,
environment=None,
working_directory='/working',
error_on_warnings=False,
).once()
env=None,
cwd='/working',
).and_return(flexmock(stdout=None)).once()
flexmock(module).should_receive('log_output')
output = module.execute_command(full_command, working_directory='/working')
assert output is None
def test_execute_command_calls_full_command_with_error_on_warnings():
full_command = ['foo', 'bar']
flexmock(module.os, environ={'a': 'b'})
flexmock(module).should_receive('execute_and_log_output').with_args(
full_command,
output_log_level=logging.INFO,
shell=False,
environment=None,
working_directory=None,
error_on_warnings=True,
).once()
output = module.execute_command(full_command, error_on_warnings=True)
assert output is None
def test_execute_command_captures_output():
full_command = ['foo', 'bar']
expected_output = '[]'
+30
View File
@@ -193,3 +193,33 @@ def test_configure_logging_skips_syslog_if_interactive_console():
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
module.configure_logging(console_log_level=logging.INFO)
def test_configure_logging_to_logfile_instead_of_syslog():
# syslog skipped in non-interactive console if --log-file argument provided
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.DEBUG, handlers=tuple
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True)
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
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, log_file_log_level=logging.DEBUG, log_file='/tmp/logfile'
)
def test_configure_logging_skips_logfile_if_argument_is_none():
# No WatchedFileHandler added if argument --log-file is None
flexmock(module).should_receive('interactive_console').and_return(False)
flexmock(module.logging).should_receive('basicConfig').with_args(
level=logging.INFO, handlers=tuple
)
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.logging.handlers).should_receive('WatchedFileHandler').never()
module.configure_logging(console_log_level=logging.INFO, log_file=None)
+1
View File
@@ -17,6 +17,7 @@ def insert_logging_mock(log_level):
def test_verbosity_to_log_level_maps_known_verbosity_to_log_level():
assert module.verbosity_to_log_level(module.VERBOSITY_SOME) == logging.INFO
assert module.verbosity_to_log_level(module.VERBOSITY_LOTS) == logging.DEBUG
assert module.verbosity_to_log_level(module.VERBOSITY_ERROR) == logging.ERROR
def test_verbosity_to_log_level_maps_unknown_verbosity_to_warning_level():
+2 -3
View File
@@ -1,5 +1,5 @@
[tox]
envlist = py35,py36,py37
envlist = py35,py36,py37,py38
skip_missing_interpreters = True
skipsdist = True
minversion = 3.14.0
@@ -14,12 +14,11 @@ commands_pre =
find {toxinidir} -type f -not -path '{toxinidir}/.tox/*' -path '*/__pycache__/*' -name '*.py[c|o]' -delete
commands =
pytest {posargs}
py36,py37: black --check .
py36,py37,py38: black --check .
isort --recursive --check-only --settings-path setup.cfg .
flake8 borgmatic tests
[testenv:black]
basepython = python3.7
commands =
black {posargs} .