Compare commits

..
45 Commits
Author SHA1 Message Date
Dan Helfman e4eff0e3dc Bump version for release. 2020-04-24 15:56:56 -07:00
Dan HelfmanandGitHub dce1928dc4 Fix PostgreSQL restore error on "all" database dump. 2020-04-24 15:50:33 -07:00
Nathan Beals 3c8dc4929f Added test_restore_all_database_dump unit test.
Updated the other unit tests, as I had to re-arrange argument order
Added an 'all' test for the postgres end-to-end test.

Ran black formatter on it all.
2020-04-24 18:32:53 -04:00
Dan Helfman e511014a28 Fix MySQL restore error on "all" database dump by excluding system tables (#301). 2020-04-22 12:17:22 -07:00
Dan Helfman bae5f88824 Upgrade test database versions. 2020-04-21 10:01:26 -07:00
Dan Helfman 41ad98653a https://github.com/docker/compose/issues/2127 2020-04-21 09:39:20 -07:00
Dan Helfman 6a138aeb6e Move root vs. non-root instructions. 2020-04-18 13:14:35 -07:00
Dan Helfman f0ce37801b Add root vs. non-root to set up guide. 2020-04-17 20:30:10 -07:00
Dan Helfman 35f6aba365 Clarify that borgmatic should be run with sudo after a root --user install. 2020-04-17 09:46:50 -07:00
Nathan Beals f6407bafcb Remove the --create flag, was causing an error 2020-04-10 11:24:13 -04:00
Nathan Beals d5e9f67cec Finished. Now uses 'psql' to run the plain-text scripts that pg_dumpall creates 2020-04-10 10:55:53 -04:00
Nathan Beals b14f371c05 First attempt at fixing this pg_dumpall/restoring issue 2020-04-10 09:20:00 -04:00
Dan Helfman 31a5d1b9c4 Docs feedback: Clarify PagerDuty integration instructions. 2020-03-26 14:14:53 -07:00
Dan Helfman fb4305a953 Add link for Alpine packages of borgmatic to README. 2020-03-10 21:10:02 -07:00
Dan Helfman eab872823c Clarify license version. 2020-03-09 15:50:54 -07:00
Dan Helfman 3332750243 More documentation examples of a la carte actions. 2020-03-09 11:20:18 -07:00
Dan Helfman 4942b7ce4d Feedback on PagerDuty hook documentation. 2020-02-13 13:11:25 -08:00
Dan Helfman a2af77f363 Maybe fix release signing. 2020-02-03 09:57:34 -08:00
Dan Helfman a7490b56d1 Bump version for release. 2020-02-03 09:45:10 -08:00
Dan Helfman 66eb18d5ea Upgrade pip and tox in tests. 2020-02-02 14:30:26 -08:00
Dan Helfman 46486138b6 Fix PagerDuty link. 2020-02-01 13:43:37 -08:00
Dan Helfman d6562c4b1e Fix the "--stats" and "--files" flags so that they yield output at verbosity 0 (#290). 2020-01-31 10:23:36 -08:00
Dan Helfman 1ddde0910c Add security policy, such as it is. 2020-01-30 15:42:48 -08:00
Dan Helfman 79f3b84ca2 Documentation for "--archive latest" (#289). 2020-01-29 17:08:03 -08:00
Dan Helfman 55141bda67 Specify "--archive latest" to all actions that accept an archive (#289). 2020-01-29 16:59:02 -08:00
Dan Helfman bc02c123e6 Monitor backups with PagerDuty hook integration (#245). 2020-01-27 15:32:09 -08:00
Dan Helfman e76d5ad988 Fix tests. 2020-01-27 12:56:12 -08:00
Dan Helfman 8ad8a9c422 Add per-action hooks: "before_prune", "after_prune", "before_check", and "after_check" (#255). 2020-01-27 11:07:07 -08:00
Dan Helfman b15c9b7dab Add missing "how to" text. 2020-01-24 21:02:56 -08:00
Dan Helfman 2405e97c38 Backup to a removable drive or intermittent server via "soft failure" feature (#284). 2020-01-24 20:52:48 -08:00
Dan Helfman fdbb2ee905 View consistency check progress via "--progress" flag for "check" action (#287). 2020-01-24 11:27:16 -08:00
Dan Helfman 94b9ef56be Change "exclude_if_present" option to support multiple filenames, rather than just a single filename (#280). 2020-01-23 13:41:37 -08:00
Dan Helfman 952168ce25 Fix unwanted console log messages with "list --json" and "info --json". 2020-01-23 13:40:54 -08:00
Dan Helfman 5273037a94 For "list" and "info" actions, show repository names even at verbosity 0. 2020-01-23 11:17:39 -08:00
Dan Helfman 53e6ff9524 No longer list files or show stats by default at verbosity 2. 2020-01-22 15:23:49 -08:00
Dan Helfman f66fd1caaa Customize Healthchecks log level via borgmatic "--monitoring-verbosity" flag (#277). 2020-01-22 15:10:47 -08:00
Dan Helfman d93fdbc5ad Support "--files" and "--stats" flags at verbosity level 0. 2020-01-22 13:28:24 -08:00
Dan HelfmanandGitea 58e0439daf Disable per-file logging by default at verbosity 1; opt-in via new --files option. 2020-01-22 20:36:59 +00:00
palto42 75b5e7254e changes as per comments in PR #283 2020-01-22 19:03:26 +01:00
Dan Helfman 39550a7fe9 Add ~/.config/borgmatic.d as another configuration directory default (#274). 2020-01-22 09:26:58 -08:00
palto42 5f0c084bee Merge 'upstream/master' into list-files 2020-01-22 18:12:26 +01:00
Dan Helfman 88f06f7921 Revert "Use absolute paths in systemd commands."
This reverts commit 24e1516ec5.
2020-01-21 16:03:24 -08:00
palto42 83632448be updated NEWS for mod. --stats & new --files opt. 2020-01-18 14:57:50 +01:00
palto42 e108526bab disable --stats by default 2020-01-18 14:38:59 +01:00
palto42 e27ba0d08a less detail at v1 + option "--files" for details 2020-01-11 16:38:07 +01:00
56 changed files with 1350 additions and 218 deletions
+30
View File
@@ -1,3 +1,33 @@
1.5.2
* #301: Fix MySQL restore error on "all" database dump by excluding system tables.
* Fix PostgreSQL restore error on "all" database dump by using "psql" for the restore instead of
"pg_restore".
1.5.1
* #289: Tired of looking up the latest successful archive name in order to pass it to borgmatic
actions? Me too. Now you can specify "--archive latest" to all actions that accept an archive
flag.
* #290: Fix the "--stats" and "--files" flags so that they yield output at verbosity 0.
* Reduce the default verbosity of borgmatic logs sent to Healthchecks monitoring hook. Now, it's
warnings and errors only. You can increase the verbosity via the "--monitoring-verbosity" flag.
* Add security policy documentation in SECURITY.md.
1.5.0
* #245: Monitor backups with PagerDuty hook integration. See the documentation for more
information: https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook
* #255: Add per-action hooks: "before_prune", "after_prune", "before_check", and "after_check".
* #274: Add ~/.config/borgmatic.d as another configuration directory default.
* #277: Customize Healthchecks log level via borgmatic "--monitoring-verbosity" flag.
* #280: Change "exclude_if_present" option to support multiple filenames that indicate a directory
should be excluded from backups, rather than just a single filename.
* #284: Backup to a removable drive or intermittent server via "soft failure" feature. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/
* #287: View consistency check progress via "--progress" flag for "check" action.
* For "create" and "prune" actions, no longer list files or show detailed stats at any verbosities
by default. You can opt back in with "--files" or "--stats" flags.
* For "list" and "info" actions, show repository names even at verbosity 0.
1.4.22
* #276, #285: Disable colored output when "--json" flag is used, so as to produce valid JSON ouput.
* After a backup of a database dump in directory format, properly remove the dump directory.
+14 -4
View File
@@ -24,7 +24,7 @@ location:
repositories:
- 1234@usw-s001.rsync.net:backups.borg
- k8pDxu32@k8pDxu32.repo.borgbase.com:repo
- /var/lib/backups/backups.borg
- /var/lib/backups/local.borg
retention:
# Retention policy for how many backups to keep.
@@ -66,6 +66,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://cronhub.io/"><img src="docs/static/cronhub.png" alt="Cronhub" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://www.pagerduty.com/"><img src="docs/static/pagerduty.png" alt="PagerDuty" height="60px" style="margin-bottom:20px;"></a>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://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;
@@ -80,6 +81,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
* [Extract a backup](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/)
* [Backup your databases](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/)
* [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/)
* [Backup to a removable drive or an intermittent server](https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/)
* [Upgrade borgmatic](https://torsion.org/borgmatic/docs/how-to/upgrade/)
* [Develop on borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/)
@@ -117,14 +119,22 @@ If you'd like to chat with borgmatic developers or users, head on over to the
href="https://webchat.freenode.net/?channels=borgmatic">web chat</a> or a
native <a href="irc://chat.freenode.net:6697">IRC client</a>.
Other questions or comments? Contact <mailto:witten@torsion.org>.
Also see the [security
policy](https://torsion.org/borgmatic/docs/security-policy/) for any security
issues.
Other questions or comments? Contact
[witten@torsion.org](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.
available](https://projects.torsion.org/witten/borgmatic), and is also
mirrored on [GitHub](https://github.com/witten/borgmatic) for convenience.
borgmatic is licensed under the GNU General Public License version 3 or any
later version.
If you'd like to contribute to borgmatic development, please feel free to
submit a [Pull Request](https://projects.torsion.org/witten/borgmatic/pulls)
+19
View File
@@ -0,0 +1,19 @@
---
title: Security policy
permalink: security-policy/index.html
---
## Supported versions
While we want to hear about security vulnerabilities in all versions of
borgmatic, security fixes will only be made to the most recently released
version. It's not practical for our small volunteer effort to maintain
multiple different release branches and put out separate security patches for
each.
## Reporting a vulnerability
If you find a security vulnerability, please [file a
ticket](https://torsion.org/borgmatic/#issues) or [send email
directly](mailto:witten@torsion.org) as appropriate. You should expect to hear
back within a few days at most, and generally sooner.
+9 -7
View File
@@ -91,13 +91,15 @@ def check_archives(
consistency_config,
local_path='borg',
remote_path=None,
progress=None,
repair=None,
only_checks=None,
):
'''
Given a local or remote repository path, a storage config dict, a consistency config dict,
local/remote commands to run, whether to attempt a repair, and an optional list of checks
to use instead of configured checks, check the contained Borg archives for consistency.
local/remote commands to run, whether to include progress information, whether to attempt a
repair, and an optional list of checks to use instead of configured checks, check the contained
Borg archives for consistency.
If there are no consistency checks to run, skip running them.
'''
@@ -124,17 +126,17 @@ def check_archives(
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags
+ (('--progress',) if progress else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ (repository,)
)
# The Borg repair option trigger an interactive prompt, which won't work when output is
# captured.
if repair:
# captured. And progress messes with the terminal directly.
if repair or progress:
execute_command_without_capture(full_command, error_on_warnings=True)
return
execute_command(full_command, error_on_warnings=True)
else:
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)
+10 -13
View File
@@ -88,8 +88,12 @@ 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 ()
if_present_flags = tuple(
itertools.chain.from_iterable(
('--exclude-if-present', if_present)
for if_present in location_config.get('exclude_if_present', ())
)
)
keep_exclude_tags_flags = (
('--keep-exclude-tags',) if location_config.get('keep_exclude_tags') else ()
)
@@ -131,6 +135,7 @@ def create_archive(
progress=False,
stats=False,
json=False,
files=False,
):
'''
Given vebosity/dry-run flags, a local or remote repository path, a location config dict, and a
@@ -175,17 +180,9 @@ def create_archive(
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (
('--list', '--filter', 'AME-')
if logger.isEnabledFor(logging.INFO) and not json and not progress
else ()
)
+ (('--list', '--filter', 'AME-') if files and not json and not progress else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ())
+ (
('--stats',)
if not dry_run and (logger.isEnabledFor(logging.INFO) or stats) and not json
else ()
)
+ (('--stats',) if stats and not json and not dry_run else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ())
+ (('--dry-run',) if dry_run else ())
+ (('--progress',) if progress else ())
@@ -207,7 +204,7 @@ def create_archive(
if json:
output_log_level = None
elif stats:
elif (stats or files) and logger.getEffectiveLevel() == logging.WARNING:
output_log_level = logging.WARNING
else:
output_log_level = logging.INFO
+36
View File
@@ -11,6 +11,42 @@ logger = logging.getLogger(__name__)
BORG_EXCLUDE_CHECKPOINTS_GLOB = '*[0123456789]'
def resolve_archive_name(repository, archive, storage_config, local_path='borg', remote_path=None):
'''
Given a local or remote repository path, an archive name, a storage config dict, a local Borg
path, and a remote Borg path, simply return the archive name. But if the archive name is
"latest", then instead introspect the repository for the latest successful (non-checkpoint)
archive, and return its name.
Raise ValueError if "latest" is given but there are no archives in the repository.
'''
if archive != "latest":
return archive
lock_wait = storage_config.get('lock_wait', None)
full_command = (
(local_path, 'list')
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ make_flags('remote-path', remote_path)
+ make_flags('lock-wait', lock_wait)
+ make_flags('glob-archives', BORG_EXCLUDE_CHECKPOINTS_GLOB)
+ make_flags('last', 1)
+ ('--short', repository)
)
output = execute_command(full_command, output_log_level=None, error_on_warnings=False)
try:
latest_archive = output.strip().splitlines()[-1]
except IndexError:
raise ValueError('No archives found in the repository')
logger.debug('{}: Latest archive is {}'.format(repository, latest_archive))
return latest_archive
def list_archives(repository, storage_config, list_arguments, local_path='borg', remote_path=None):
'''
Given a local or remote repository path, a storage config dict, and the arguments to the list
+11 -9
View File
@@ -41,6 +41,7 @@ def prune_archives(
local_path='borg',
remote_path=None,
stats=False,
files=False,
):
'''
Given dry-run flag, a local or remote repository path, a storage config dict, and a
@@ -57,17 +58,18 @@ def prune_archives(
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--umask', str(umask)) if umask else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ (('--stats',) if not dry_run and logger.isEnabledFor(logging.INFO) else ())
+ (('--info', '--list') if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--stats',) if stats and not dry_run else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--list',) if files else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (('--dry-run',) if dry_run else ())
+ (('--stats',) if stats else ())
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ (repository,)
)
execute_command(
full_command,
output_log_level=logging.WARNING if stats else logging.INFO,
error_on_warnings=False,
)
if (stats or files) and logger.getEffectiveLevel() == logging.WARNING:
output_log_level = logging.WARNING
else:
output_log_level = logging.INFO
execute_command(full_command, output_log_level=output_log_level, error_on_warnings=False)
+31 -7
View File
@@ -159,6 +159,13 @@ def parse_arguments(*unparsed_arguments):
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(
'--monitoring-verbosity',
type=int,
choices=range(-1, 3),
default=0,
help='Log verbose progress to monitoring integrations that support logging (from only errors to very verbose: -1, 0, 1, or 2)',
)
global_group.add_argument(
'--log-file',
type=str,
@@ -237,6 +244,9 @@ def parse_arguments(*unparsed_arguments):
action='store_true',
help='Display statistics of archive',
)
prune_group.add_argument(
'--files', dest='files', default=False, action='store_true', help='Show per-file details'
)
prune_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
create_parser = subparsers.add_parser(
@@ -252,7 +262,7 @@ def parse_arguments(*unparsed_arguments):
dest='progress',
default=False,
action='store_true',
help='Display progress for each file as it is processed',
help='Display progress for each file as it is backed up',
)
create_group.add_argument(
'--stats',
@@ -261,6 +271,9 @@ def parse_arguments(*unparsed_arguments):
action='store_true',
help='Display statistics of archive',
)
create_group.add_argument(
'--files', dest='files', default=False, action='store_true', help='Show per-file details'
)
create_group.add_argument(
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
)
@@ -274,6 +287,13 @@ def parse_arguments(*unparsed_arguments):
add_help=False,
)
check_group = check_parser.add_argument_group('check arguments')
check_group.add_argument(
'--progress',
dest='progress',
default=False,
action='store_true',
help='Display progress for each file as it is checked',
)
check_group.add_argument(
'--repair',
dest='repair',
@@ -303,7 +323,9 @@ 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 extract', required=True)
extract_group.add_argument(
'--archive', help='Name of archive to extract (or "latest")', required=True
)
extract_group.add_argument(
'--path',
'--restore-path',
@@ -323,7 +345,7 @@ def parse_arguments(*unparsed_arguments):
dest='progress',
default=False,
action='store_true',
help='Display progress for each file as it is processed',
help='Display progress for each file as it is extracted',
)
extract_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
@@ -341,7 +363,7 @@ def parse_arguments(*unparsed_arguments):
'--repository',
help='Path of repository to use, defaults to the configured repository if there is only one',
)
mount_group.add_argument('--archive', help='Name of archive to mount')
mount_group.add_argument('--archive', help='Name of archive to mount (or "latest")')
mount_group.add_argument(
'--mount-point',
metavar='PATH',
@@ -395,7 +417,9 @@ 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 restore from', required=True)
restore_group.add_argument(
'--archive', help='Name of archive to restore from (or "latest")', required=True
)
restore_group.add_argument(
'--database',
metavar='NAME',
@@ -426,7 +450,7 @@ def parse_arguments(*unparsed_arguments):
'--repository',
help='Path of repository to list, defaults to the configured repository if there is only one',
)
list_group.add_argument('--archive', help='Name of archive to list')
list_group.add_argument('--archive', help='Name of archive to list (or "latest")')
list_group.add_argument(
'--path',
metavar='PATH',
@@ -488,7 +512,7 @@ def parse_arguments(*unparsed_arguments):
'--repository',
help='Path of repository to show info for, defaults to the configured repository if there is only one',
)
info_group.add_argument('--archive', help='Name of archive to show info for')
info_group.add_argument('--archive', help='Name of archive to show info for (or "latest")')
info_group.add_argument(
'--json', dest='json', default=False, action='store_true', help='Output results as JSON'
)
+75 -9
View File
@@ -1,4 +1,5 @@
import collections
import copy
import json
import logging
import os
@@ -53,6 +54,7 @@ def run_configuration(config_filename, config, arguments):
encountered_error = None
error_repository = ''
prune_create_or_check = {'prune', 'create', 'check'}.intersection(arguments)
monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
try:
if prune_create_or_check:
@@ -62,6 +64,15 @@ def run_configuration(config_filename, config, arguments):
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.START,
monitoring_log_level,
global_arguments.dry_run,
)
if 'prune' in arguments:
command.execute_hook(
hooks.get('before_prune'),
hooks.get('umask'),
config_filename,
'pre-prune',
global_arguments.dry_run,
)
if 'create' in arguments:
@@ -80,10 +91,21 @@ def run_configuration(config_filename, config, arguments):
location,
global_arguments.dry_run,
)
if 'check' in arguments:
command.execute_hook(
hooks.get('before_check'),
hooks.get('umask'),
config_filename,
'pre-check',
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if command.considered_soft_failure(config_filename, error):
return
encountered_error = error
yield from make_error_log_records(
'{}: Error running pre-backup hook'.format(config_filename), error
'{}: Error running pre hook'.format(config_filename), error
)
if not encountered_error:
@@ -109,6 +131,14 @@ def run_configuration(config_filename, config, arguments):
if not encountered_error:
try:
if 'prune' in arguments:
command.execute_hook(
hooks.get('after_prune'),
hooks.get('umask'),
config_filename,
'post-prune',
global_arguments.dry_run,
)
if 'create' in arguments:
dispatch.call_hooks(
'remove_database_dumps',
@@ -125,6 +155,14 @@ def run_configuration(config_filename, config, arguments):
'post-backup',
global_arguments.dry_run,
)
if 'check' in arguments:
command.execute_hook(
hooks.get('after_check'),
hooks.get('umask'),
config_filename,
'post-check',
global_arguments.dry_run,
)
if {'prune', 'create', 'check'}.intersection(arguments):
dispatch.call_hooks(
'ping_monitor',
@@ -132,12 +170,16 @@ def run_configuration(config_filename, config, arguments):
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.FINISH,
monitoring_log_level,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if command.considered_soft_failure(config_filename, error):
return
encountered_error = error
yield from make_error_log_records(
'{}: Error running post-backup hook'.format(config_filename), error
'{}: Error running post hook'.format(config_filename), error
)
if encountered_error and prune_create_or_check:
@@ -158,9 +200,13 @@ def run_configuration(config_filename, config, arguments):
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.FAIL,
monitoring_log_level,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if command.considered_soft_failure(config_filename, error):
return
yield from make_error_log_records(
'{}: Error running on-error hook'.format(config_filename), error
)
@@ -212,6 +258,7 @@ def run_actions(
local_path=local_path,
remote_path=remote_path,
stats=arguments['prune'].stats,
files=arguments['prune'].files,
)
if 'create' in arguments:
logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
@@ -225,6 +272,7 @@ def run_actions(
progress=arguments['create'].progress,
stats=arguments['create'].stats,
json=arguments['create'].json,
files=arguments['create'].files,
)
if json_output:
yield json.loads(json_output)
@@ -236,6 +284,7 @@ def run_actions(
consistency,
local_path=local_path,
remote_path=remote_path,
progress=arguments['check'].progress,
repair=arguments['check'].repair,
only_checks=arguments['check'].only,
)
@@ -249,7 +298,9 @@ def run_actions(
borg_extract.extract_archive(
global_arguments.dry_run,
repository,
arguments['extract'].archive,
borg_list.resolve_archive_name(
repository, arguments['extract'].archive, storage, local_path, remote_path
),
arguments['extract'].paths,
location,
storage,
@@ -271,7 +322,9 @@ def run_actions(
borg_mount.mount_archive(
repository,
arguments['mount'].archive,
borg_list.resolve_archive_name(
repository, arguments['mount'].archive, storage, local_path, remote_path
),
arguments['mount'].mount_point,
arguments['mount'].paths,
arguments['mount'].foreground,
@@ -307,7 +360,9 @@ def run_actions(
borg_extract.extract_archive(
global_arguments.dry_run,
repository,
arguments['restore'].archive,
borg_list.resolve_archive_name(
repository, arguments['restore'].archive, storage, local_path, remote_path
),
dump.convert_glob_patterns_to_borg_patterns(
dump.flatten_dump_patterns(dump_patterns, restore_names)
),
@@ -347,11 +402,16 @@ def run_actions(
if arguments['list'].repository is None or validate.repositories_match(
repository, arguments['list'].repository
):
logger.info('{}: Listing archives'.format(repository))
list_arguments = copy.copy(arguments['list'])
if not list_arguments.json:
logger.warning('{}: Listing archives'.format(repository))
list_arguments.archive = borg_list.resolve_archive_name(
repository, list_arguments.archive, storage, local_path, remote_path
)
json_output = borg_list.list_archives(
repository,
storage,
list_arguments=arguments['list'],
list_arguments=list_arguments,
local_path=local_path,
remote_path=remote_path,
)
@@ -361,11 +421,16 @@ def run_actions(
if arguments['info'].repository is None or validate.repositories_match(
repository, arguments['info'].repository
):
logger.info('{}: Displaying summary info for archives'.format(repository))
info_arguments = copy.copy(arguments['info'])
if not info_arguments.json:
logger.warning('{}: Displaying summary info for archives'.format(repository))
info_arguments.archive = borg_list.resolve_archive_name(
repository, info_arguments.archive, storage, local_path, remote_path
)
json_output = borg_info.display_archives_info(
repository,
storage,
info_arguments=arguments['info'],
info_arguments=info_arguments,
local_path=local_path,
remote_path=remote_path,
)
@@ -599,6 +664,7 @@ def main(): # pragma: no cover
verbosity_to_log_level(global_arguments.verbosity),
verbosity_to_log_level(global_arguments.syslog_verbosity),
verbosity_to_log_level(global_arguments.log_file_verbosity),
verbosity_to_log_level(global_arguments.monitoring_verbosity),
global_arguments.log_file,
)
except (FileNotFoundError, PermissionError) as error:
+1
View File
@@ -17,6 +17,7 @@ def get_default_config_paths(expand_home=True):
'/etc/borgmatic/config.yaml',
'/etc/borgmatic.d',
'%s/borgmatic/config.yaml' % user_config_directory,
'%s/borgmatic.d' % user_config_directory,
]
+10
View File
@@ -0,0 +1,10 @@
def normalize(config):
'''
Given a configuration dict, apply particular hard-coded rules to normalize its contents to
adhere to the configuration schema.
'''
exclude_if_present = config.get('location', {}).get('exclude_if_present')
# "Upgrade" exclude_if_present from a string to a list.
if isinstance(exclude_if_present, str):
config['location']['exclude_if_present'] = [exclude_if_present]
+54 -8
View File
@@ -121,11 +121,13 @@ map:
http://www.brynosaurus.com/cachedir/spec.html for details. Defaults to false.
example: true
exclude_if_present:
type: str
seq:
- type: str
desc: |
Exclude directories that contain a file with the given filename. Defaults to not
Exclude directories that contain a file with the given filenames. Defaults to not
set.
example: .nobackup
example:
- .nobackup
keep_exclude_tags:
type: bool
desc: |
@@ -393,6 +395,22 @@ map:
backup, run once per configuration file.
example:
- echo "Starting a backup."
before_prune:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute before pruning, run
once per configuration file.
example:
- echo "Starting pruning."
before_check:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute before consistency
checks, run once per configuration file.
example:
- echo "Starting checks."
after_backup:
seq:
- type: str
@@ -400,15 +418,32 @@ map:
List of one or more shell commands or scripts to execute after creating a
backup, run once per configuration file.
example:
- echo "Created a backup."
- echo "Finished a backup."
after_prune:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute after pruning, run once
per configuration file.
example:
- echo "Finished pruning."
after_check:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute after consistency
checks, run once per configuration file.
example:
- echo "Finished checks."
on_error:
seq:
- type: str
desc: |
List of one or more shell commands or scripts to execute when an exception
occurs during a backup or when running a before_backup or after_backup hook.
occurs during a "prune", "create", or "check" action or an associated
before/after hook.
example:
- echo "Error while creating a backup or running a backup hook."
- echo "Error during prune/create/check."
postgresql_databases:
seq:
- map:
@@ -532,6 +567,15 @@ map:
for details.
example:
https://cronitor.link/d3x0c1
pagerduty:
type: str
desc: |
PagerDuty integration key used to notify PagerDuty when a backup errors. Create
an account at https://www.pagerduty.com/ if you'd like to use this service. See
https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook
for details.
example:
a177cad45bd374409f78906a810a3074
cronhub:
type: str
desc: |
@@ -546,7 +590,8 @@ map:
- type: str
desc: |
List of one or more shell commands or scripts to execute before running all
actions (if one of them is "create"), run once before all configuration files.
actions (if one of them is "create"). These are collected from all configuration
files and then run once before all of them (prior to all actions).
example:
- echo "Starting actions."
after_everything:
@@ -554,7 +599,8 @@ map:
- type: str
desc: |
List of one or more shell commands or scripts to execute after running all
actions (if one of them is "create"), run once after all configuration files.
actions (if one of them is "create"). These are collected from all configuration
files and then run once before all of them (prior to all actions).
example:
- echo "Completed actions."
umask:
+2 -1
View File
@@ -6,7 +6,7 @@ import pykwalify.core
import pykwalify.errors
import ruamel.yaml
from borgmatic.config import load, override
from borgmatic.config import load, normalize, override
def schema_filename():
@@ -104,6 +104,7 @@ def parse_configuration(config_filename, schema_filename, overrides=None):
raise Validation_error(config_filename, (str(error),))
override.apply_overrides(config, overrides)
normalize.normalize(config)
validator = pykwalify.core.Core(source_data=config, schema_data=remove_examples(schema))
parsed_result = validator.validate(raise_exception=False)
+24
View File
@@ -6,6 +6,9 @@ from borgmatic import execute
logger = logging.getLogger(__name__)
SOFT_FAIL_EXIT_CODE = 75
def interpolate_context(command, context):
'''
Given a single hook command and a dict of context names/values, interpolate the values by
@@ -69,3 +72,24 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
finally:
if original_umask:
os.umask(original_umask)
def considered_soft_failure(config_filename, error):
'''
Given a configuration filename and an exception object, return whether the exception object
represents a subprocess.CalledProcessError with a return code of SOFT_FAIL_EXIT_CODE. If so,
that indicates that the error is a "soft failure", and should not result in an error.
'''
exit_code = getattr(error, 'returncode', None)
if exit_code is None:
return False
if exit_code == SOFT_FAIL_EXIT_CODE:
logger.info(
'{}: Command hook exited with soft failure exit code ({}); skipping remaining actions'.format(
config_filename, SOFT_FAIL_EXIT_CODE
)
)
return True
return False
+1 -1
View File
@@ -13,7 +13,7 @@ MONITOR_STATE_TO_CRONHUB = {
}
def ping_monitor(ping_url, config_filename, state, dry_run):
def ping_monitor(ping_url, config_filename, state, monitoring_log_level, 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.
+1 -1
View File
@@ -13,7 +13,7 @@ MONITOR_STATE_TO_CRONITOR = {
}
def ping_monitor(ping_url, config_filename, state, dry_run):
def ping_monitor(ping_url, config_filename, state, monitoring_log_level, 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.
+2 -1
View File
@@ -1,6 +1,6 @@
import logging
from borgmatic.hooks import cronhub, cronitor, healthchecks, mysql, postgresql
from borgmatic.hooks import cronhub, cronitor, healthchecks, mysql, pagerduty, postgresql
logger = logging.getLogger(__name__)
@@ -8,6 +8,7 @@ HOOK_NAME_TO_MODULE = {
'healthchecks': healthchecks,
'cronitor': cronitor,
'cronhub': cronhub,
'pagerduty': pagerduty,
'postgresql_databases': postgresql,
'mysql_databases': mysql,
}
+8 -5
View File
@@ -22,13 +22,14 @@ class Forgetful_buffering_handler(logging.Handler):
first) once a particular capacity in bytes is reached.
'''
def __init__(self, byte_capacity):
def __init__(self, byte_capacity, log_level):
super().__init__()
self.byte_capacity = byte_capacity
self.byte_count = 0
self.buffer = []
self.forgot = False
self.setLevel(log_level)
def emit(self, record):
message = record.getMessage() + '\n'
@@ -64,16 +65,18 @@ def format_buffered_logs_for_payload():
return payload
def ping_monitor(ping_url_or_uuid, config_filename, state, dry_run):
def ping_monitor(ping_url_or_uuid, config_filename, state, monitoring_log_level, 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.
configuration filename in any log entries, and log to Healthchecks with the giving log level.
If this is a dry run, then don't actually ping anything.
'''
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))
logging.getLogger().addHandler(
Forgetful_buffering_handler(PAYLOAD_LIMIT_BYTES, monitoring_log_level)
)
payload = ''
ping_url = (
+1 -1
View File
@@ -1,6 +1,6 @@
from enum import Enum
MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub')
MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty')
class State(Enum):
+51 -7
View File
@@ -16,6 +16,43 @@ def make_dump_path(location_config): # pragma: no cover
)
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
def database_names_to_dump(database, extra_environment, log_prefix, dry_run_label):
'''
Given a requested database name, return the corresponding sequence of database names to dump.
In the case of "all", query for the names of databases on the configured host and return them,
excluding any system databases that will cause problems during restore.
'''
requested_name = database['name']
if requested_name != 'all':
return (requested_name,)
show_command = (
('mysql',)
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--user', database['username']) if 'username' in database else ())
+ ('--skip-column-names', '--batch')
+ ('--execute', 'show schemas')
)
logger.debug(
'{}: Querying for "all" MySQL databases to dump{}'.format(log_prefix, dry_run_label)
)
show_output = execute_command(
show_command, output_log_level=None, extra_environment=extra_environment
)
return tuple(
show_name
for show_name in show_output.strip().splitlines()
if show_name not in SYSTEM_DATABASE_NAMES
)
def dump_databases(databases, log_prefix, location_config, dry_run):
'''
Dump the given MySQL/MariaDB databases to disk. The databases are supplied as a sequence of
@@ -28,30 +65,37 @@ def dump_databases(databases, log_prefix, location_config, dry_run):
logger.info('{}: Dumping MySQL databases{}'.format(log_prefix, dry_run_label))
for database in databases:
name = database['name']
requested_name = database['name']
dump_filename = dump.make_database_dump_filename(
make_dump_path(location_config), name, database.get('hostname')
make_dump_path(location_config), requested_name, database.get('hostname')
)
command = (
extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
dump_command_names = database_names_to_dump(
database, extra_environment, log_prefix, dry_run_label
)
dump_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))
+ ('--databases',)
+ dump_command_names
)
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
log_prefix, requested_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
dump_command,
output_file=open(dump_filename, 'w'),
extra_environment=extra_environment,
)
+62
View File
@@ -0,0 +1,62 @@
import datetime
import json
import logging
import platform
import requests
from borgmatic.hooks import monitor
logger = logging.getLogger(__name__)
EVENTS_API_URL = 'https://events.pagerduty.com/v2/enqueue'
def ping_monitor(integration_key, config_filename, state, monitoring_log_level, dry_run):
'''
If this is an error state, create a PagerDuty event with the given integration key. Use the
given configuration filename in any log entries. If this is a dry run, then don't actually
create an event.
'''
if state != monitor.State.FAIL:
logger.debug(
'{}: Ignoring unsupported monitoring {} in PagerDuty hook'.format(
config_filename, state.name.lower()
)
)
return
dry_run_label = ' (dry run; not actually sending)' if dry_run else ''
logger.info('{}: Sending failure event to PagerDuty {}'.format(config_filename, dry_run_label))
if dry_run:
return
hostname = platform.node()
local_timestamp = (
datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).astimezone().isoformat()
)
payload = json.dumps(
{
'routing_key': integration_key,
'event_action': 'trigger',
'payload': {
'summary': 'backup failed on {}'.format(hostname),
'severity': 'error',
'source': hostname,
'timestamp': local_timestamp,
'component': 'borgmatic',
'group': 'backups',
'class': 'backup failure',
'custom_details': {
'hostname': hostname,
'configuration filename': config_filename,
'server time': local_timestamp,
},
},
}
)
logger.debug('{}: Using PagerDuty payload: {}'.format(config_filename, payload))
logging.getLogger('urllib3').setLevel(logging.ERROR)
requests.post(EVENTS_API_URL, data=payload.encode('utf-8'))
+21 -11
View File
@@ -34,7 +34,12 @@ def dump_databases(databases, log_prefix, location_config, dry_run):
)
all_databases = bool(name == 'all')
command = (
('pg_dumpall' if all_databases else 'pg_dump', '--no-password', '--clean')
(
'pg_dumpall' if all_databases else 'pg_dump',
'--no-password',
'--clean',
'--if-exists',
)
+ ('--file', dump_filename)
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
@@ -93,23 +98,28 @@ def restore_database_dumps(databases, log_prefix, location_config, dry_run):
dump_filename = dump.make_database_dump_filename(
make_dump_path(location_config), 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 ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--username', database['username']) if 'username' in database else ())
+ ('--dbname', database['name'])
+ (dump_filename,)
)
extra_environment = {'PGPASSWORD': database['password']} if 'password' in database else None
all_databases = bool(database['name'] == 'all')
analyze_command = (
('psql', '--no-password', '--quiet')
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--username', database['username']) if 'username' in database else ())
+ ('--dbname', database['name'])
+ (('--dbname', database['name']) if not all_databases else ())
+ ('--command', 'ANALYZE')
)
restore_command = (
('psql' if all_databases else 'pg_restore', '--no-password')
+ (
('--if-exists', '--exit-on-error', '--clean', '--dbname', database['name'])
if not all_databases
else ()
)
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--username', database['username']) if 'username' in database else ())
+ (('-f', dump_filename) if all_databases else (dump_filename,))
)
extra_environment = {'PGPASSWORD': database['password']} if 'password' in database else None
logger.debug(
'{}: Restoring PostgreSQL database {}{}'.format(
+9 -2
View File
@@ -110,7 +110,11 @@ def color_text(color, message):
def configure_logging(
console_log_level, syslog_log_level=None, log_file_log_level=None, log_file=None
console_log_level,
syslog_log_level=None,
log_file_log_level=None,
monitoring_log_level=None,
log_file=None,
):
'''
Configure logging to go to both the console and (syslog or log file). Use the given log levels,
@@ -122,6 +126,8 @@ def configure_logging(
syslog_log_level = console_log_level
if log_file_log_level is None:
log_file_log_level = console_log_level
if monitoring_log_level is None:
monitoring_log_level = console_log_level
# Log certain log levels to console stderr and others to stdout. This supports use cases like
# grepping (non-error) output.
@@ -160,5 +166,6 @@ def configure_logging(
handlers = (console_handler,)
logging.basicConfig(
level=min(console_log_level, syslog_log_level, log_file_log_level), handlers=handlers
level=min(console_log_level, syslog_log_level, log_file_log_level, monitoring_log_level),
handlers=handlers,
)
+2 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.7.4-alpine3.10 as borgmatic
FROM python:3.8.1-alpine3.11 as borgmatic
COPY . /app
RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
@@ -7,7 +7,7 @@ RUN borgmatic --help > /command-line.txt \
echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
&& borgmatic "$action" --help >> /command-line.txt; done
FROM node:12.10.0-alpine as html
FROM node:13.7.0-alpine as html
ARG ENVIRONMENT=production
+19
View File
@@ -0,0 +1,19 @@
---
title: Security policy
permalink: security-policy/index.html
---
## Supported versions
While we want to hear about security vulnerabilities in all versions of
borgmatic, security fixes will only be made to the most recently released
version. It's not practical for our small volunteer effort to maintain
multiple different release branches and put out separate security patches for
each.
## Reporting a vulnerability
If you find a security vulnerability, please [file a
ticket](https://torsion.org/borgmatic/#issues) or [send email
directly](mailto:witten@torsion.org) as appropriate. You should expect to hear
back within a few days at most, and generally sooner.
@@ -29,6 +29,10 @@ configuration file, right before the `create` action. `after_backup` hooks run
afterwards, but not if an error occurs in a previous hook or in the backups
themselves.
There are additional hooks for the `prune` and `check` actions as well.
`before_prune` and `after_prune` run if there are any `prune` actions, while
`before_check` and `after_check` run if there are any `check` actions.
You can also use `before_everything` and `after_everything` hooks to perform
global setup or cleanup:
@@ -0,0 +1,107 @@
---
title: How to backup to a removable drive or an intermittent server
---
## Occasional backups
A common situation is backing up to a repository that's only sometimes online.
For instance, you might send most of your backups to the cloud, but
occasionally you want to plug in an external hard drive or backup to your
buddy's sometimes-online server for that extra level of redundancy.
But if you run borgmatic and your hard drive isn't plugged in, or your buddy's
server is offline, then you'll get an annoying error message and the overall
borgmatic run will fail (even if individual repositories still complete).
So what if you want borgmatic to swallow the error of a missing drive
or an offline server, and continue trucking along? That's where the concept of
"soft failure" come in.
## Soft failure command hooks
This feature leverages [borgmatic command
hooks](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/),
so first familiarize yourself with them. The idea is that you write a simple
test in the form of a borgmatic hook to see if backups should proceed or not.
The way the test works is that if any of your hook commands return a special
exit status of 75, that indicates to borgmatic that it's a temporary failure,
and borgmatic should skip all subsequent actions for that configuration file.
If you return any other status, then it's a standard success or error. (Zero is
success; anything else other than 75 is an error).
So for instance, if you have an external drive that's only sometimes mounted,
declare its repository in its own [separate configuration
file](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/),
say at `/etc/borgmatic.d/removable.yaml`:
```yaml
location:
source_directories:
- /home
repositories:
- /mnt/removable/backup.borg
```
Then, write a `before_backup` hook in that same configuration file that uses
the external `findmnt` utility to see whether the drive is mounted before
proceeding.
```yaml
hooks:
before_backup:
- findmnt /mnt/removable > /dev/null || exit 75
```
What this does is check if the `findmnt` command errors when probing for a
particular mount point. If it does error, then it returns exit code 75 to
borgmatic. borgmatic logs the soft failure, skips all further actions in that
configurable file, and proceeds onward to any other borgmatic configuration
files you may have.
You can imagine a similar check for the sometimes-online server case:
```yaml
location:
source_directories:
- /home
repositories:
- me@buddys-server.org:backup.borg
hooks:
before_backup:
- ping -q -c 1 buddys-server.org > /dev/null || exit 75
```
## Caveats and details
There are some caveats you should be aware of with this feature.
* You'll generally want to put a soft failure command in the `before_backup`
hook, so as to gate whether the backup action occurs. While a soft failure is
also supported in the `after_backup` hook, returning a soft failure there
won't prevent any actions from occuring, because they've already occurred!
Similiarly, you can return a soft failure from an `on_error` hook, but at
that point it's too late to prevent the error.
* Returning a soft failure does prevent further commands in the same hook from
executing. So, like a standard error, it is an "early out". Unlike a standard
error, borgmatic does not display it in angry red text or consider it a
failure.
* The soft failure only applies to the scope of a single borgmatic
configuration file. So put anything that you don't want soft-failed, like
always-online cloud backups, in separate configuration files from your
soft-failing repositories.
* The soft failure doesn't have to apply to a repository. You can even perform
a test to make sure that individual source directories are mounted and
available. Use your imagination!
* The soft failure feature also works for `before_prune`, `after_prune`,
`before_check`, and `after_check` hooks. But it is not implemented for
`before_everything` or `after_everything`.
## Related documentation
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
* [Make per-application backups](https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/)
* [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/)
* [Monitor your backups](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/)
+6
View File
@@ -112,6 +112,12 @@ borgmatic restore --archive host-2019-01-02T04:06:07.080910
(No borgmatic `restore` action? Upgrade borgmatic!)
With newer versions of borgmatic, you can simplify this to:
```bash
borgmatic restore --archive latest
```
The `--archive` value is the name of the archive to restore from. This
restores all databases dumps that borgmatic originally backed up to that
archive.
+13 -4
View File
@@ -7,7 +7,7 @@ Borg itself is great for efficiently de-duplicating data across successive
backup archives, even when dealing with very large repositories. But you may
find that while borgmatic's default mode of "prune, create, and check" works
well on small repositories, it's not so great on larger ones. That's because
running the default consistency checks takes a long time on large
running the default pruning and consistency checks take a long time on large
repositories.
### A la carte actions
@@ -27,9 +27,18 @@ borgmatic check
You can run with only one of these actions provided, or you can mix and match
any number of them in a single borgmatic run. This supports approaches like
making backups with `create` on a frequent schedule, while only running
expensive consistency checks with `check` on a much less frequent basis from
a separate cron job.
skipping certain actions while running others. For instance, this skips
`prune` and only runs `create` and `check`:
```bash
borgmatic create check
```
Or, you can make backups with `create` on a frequent schedule (e.g. with
`borgmatic create` called from one cron job), while only running expensive
consistency checks with `check` on a much less frequent basis (e.g. with
`borgmatic check` called from a separate cron job).
### Consistency check configuration
+12
View File
@@ -31,6 +31,12 @@ borgmatic extract --archive host-2019-01-02T04:06:07.080910
(No borgmatic `extract` action? Try the old-style `--extract`, or upgrade
borgmatic!)
With newer versions of borgmatic, you can simplify this to:
```bash
borgmatic extract --archive latest
```
The `--archive` value is the name of the archive to extract. This extracts the
entire contents of the archive to the current directory, so make sure you're
in the right place before running the command.
@@ -106,6 +112,12 @@ Omit the `--archive` flag to mount all archives (lazy-loaded):
borgmatic mount --mount-point /mnt
```
Or use the "latest" value for the archive to mount the latest successful archive:
```bash
borgmatic mount --archive latest --mount-point /mnt
```
If you'd like to restrict the mounted filesystem to only particular paths from
your archive, use the `--path` flag, similar to the `extract` action above.
For instance:
+4 -3
View File
@@ -27,9 +27,10 @@ for each configuration file one at a time. In other words, borgmatic does not
perform any merging of configuration files by default. If you'd like borgmatic
to merge your configuration files, see below about configuration includes.
And if you need even more customizability, you can specify alternate
configuration paths on the command-line with borgmatic's `--config` option.
See `borgmatic --help` for more information.
Additionally, the `~/.config/borgmatic.d/` directory works the same way as
`/etc/borgmatic.d`. If you need even more customizability, you can specify
alternate configuration paths on the command-line with borgmatic's `--config`
flag. See `borgmatic --help` for more information.
## Configuration includes
+61 -10
View File
@@ -28,14 +28,15 @@ hooks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#error-hoo
below for how to configure this.
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
[Cronitor](https://cronitor.io), [Cronhub](https://cronhub.io), and
[PagerDuty](https://www.pagerduty.com/) and pings these services whenever
borgmatic runs. That way, you'll receive an alert when something goes wrong or
(for certain hooks) the service doesn't hear from borgmatic for a configured
interval. See [Healthchecks
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook), [Cronitor
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook), and [Cronhub
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook)
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook), [Cronhub
hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook), and
[PagerDuty hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-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
@@ -131,9 +132,9 @@ the `on_error` hooks run, also tacking on logs including the error itself. But
the logs are only included for errors that occur when a `prune`, `create`, or
`check` action is run.
Note that borgmatic sends logs to Healthchecks by applying the maximum of any
other borgmatic verbosity levels (`--verbosity`, `--syslog-verbosity`, etc.),
as there is not currently a dedicated Healthchecks verbosity setting.
You can customize the verbosity of the logs that are sent to Healthchecks with
borgmatic's `--monitoring-verbosity` flag. The `--files` and `--stats` flags
may also be of use. See `borgmatic --help` for more information.
You can configure Healthchecks to notify you by a [variety of
mechanisms](https://healthchecks.io/#welcome-integrations) when backups fail
@@ -200,6 +201,44 @@ mechanisms](https://docs.cronhub.io/integrations.html) when backups fail
or it doesn't hear from borgmatic for a certain period of time.
## PagerDuty hook
In case you're new here: [borgmatic](https://torsion.org/borgmatic/) is
simple, configuration-driven backup software for servers and workstations,
powered by [Borg Backup](https://www.borgbackup.org/).
[PagerDuty](https://www.pagerduty.com/) provides incident monitoring and
alerting. borgmatic has built-in integration that can notify you via PagerDuty
as soon as a backup fails, so you can make sure your backups keep working.
First, create a PagerDuty account and <a
href="https://support.pagerduty.com/docs/services-and-integrations">service</a>
on their site. On the service, add an integration and set the Integration Type
to "borgmatic".
Then, configure borgmatic with the unique "Integration Key" for your service.
Here's an example:
```yaml
hooks:
pagerduty: a177cad45bd374409f78906a810a3074
```
With this hook in place, borgmatic creates a PagerDuty event for your service
whenever backups fail. Specifically, if an error occurs during a `create`,
`prune`, or `check` action, borgmatic sends an event to PagerDuty after the
`on_error` hooks run. Note that borgmatic does not contact PagerDuty when a
backup starts or ends without error.
You can configure PagerDuty to notify you by a [variety of
mechanisms](https://support.pagerduty.com/docs/notifications) when backups
fail.
If you have any issues with the integration, [please contact
us](https://torsion.org/borgmatic/#support-and-contributing).
## Scripting borgmatic
To consume the output of borgmatic in other software, you can include an
@@ -234,6 +273,18 @@ multiple different hosts into a single repository, then you'll need to get
fancier with your archive listing. See `borg list --help` for more flags.
### Latest backups
All borgmatic actions that accept an "--archive" flag allow you to specify an
archive name of "latest". This lets you get the latest successful archive
without having to first run "borgmatic list" manually, which can be handy in
automated scripts. Here's an example:
```bash
borgmatic info --archive latest
```
## Related documentation
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/)
+21 -10
View File
@@ -3,6 +3,11 @@ title: How to set up backups with borgmatic
---
## Installation
Many users need to backup system files that require privileged access, so
these instructions install and run borgmatic as root. If you don't need to
backup such files, then you are welcome to install and run borgmatic as a
non-root user.
First, [install
Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at least
version 1.1.
@@ -39,6 +44,7 @@ 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/)
* [Alpine Linux](https://pkgs.alpinelinux.org/packages?name=borgmatic)
* [OpenBSD](http://ports.su/sysutils/borgmatic)
* [openSUSE](https://software.opensuse.org/package/borgmatic)
* [stand-alone binary](https://github.com/cmarquardt/borgmatic-binary)
@@ -68,10 +74,13 @@ 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` (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
representative. All options are optional except where indicated, so feel free
to ignore anything you don't need.
This generates a sample configuration file at `/etc/borgmatic/config.yaml` by
default. If you'd like to use another path, use the `--destination` flag, for
instance: `--destination ~/.config/borgmatic/config.yaml`.
You should edit the configuration file to suit your needs, as the generated
values are only representative. All options are optional except where
indicated, so feel free to ignore anything you don't need.
Note that the configuration file is organized into distinct sections, each
with a section name like `location:` or `storage:`. So take care that if you
@@ -79,12 +88,11 @@ uncomment a particular option, also uncomment its containing section name, or
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
You can 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. Also check out how to
[upgrade your
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).
@@ -130,7 +138,7 @@ this step if you already have a Borg repository.) To create a repository, run
a command like the following:
```bash
borgmatic init --encryption repokey
sudo borgmatic init --encryption repokey
```
(No borgmatic `init` action? Try the old-style `--init` flag, or upgrade
@@ -162,7 +170,7 @@ good idea to test that borgmatic is working. So to run borgmatic and start a
backup, you can invoke it like this:
```bash
borgmatic --verbosity 1
sudo borgmatic --verbosity 1
```
By default, this will also prune any old backups as per the configured
@@ -173,6 +181,9 @@ The verbosity flag makes borgmatic list the files that it's archiving, which
are those that are new or changed since the last backup. Eyeball the list and
see if it matches your expectations based on the configuration.
If you'd like to specify an alternate configuration file path, use the
`--config` flag. See `borgmatic --help` for more information.
## Autopilot
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+2 -2
View File
@@ -20,5 +20,5 @@ Restart=no
LogRateLimitIntervalSec=0
# Delay start to prevent backups running during boot.
ExecStartPre=/usr/bin/sleep 1m
ExecStart=/usr/bin/systemd-inhibit --who="borgmatic" --why="Prevent interrupting scheduled backup" /root/.local/bin/borgmatic --syslog-verbosity 1
ExecStartPre=sleep 1m
ExecStart=systemd-inhibit --who="borgmatic" --why="Prevent interrupting scheduled backup" /root/.local/bin/borgmatic --syslog-verbosity 1
+4 -3
View File
@@ -23,9 +23,10 @@ git push github $version
rm -fr dist
python3 setup.py bdist_wheel
python3 setup.py sdist
gpg --detach-sign --armor dist/*
twine upload -r pypi dist/borgmatic-*.tar.gz
twine upload -r pypi dist/borgmatic-*-py3-none-any.whl
gpg --detach-sign --armor dist/borgmatic-*.tar.gz
gpg --detach-sign --armor dist/borgmatic-*-py3-none-any.whl
twine upload -r pypi dist/borgmatic-*.tar.gz dist/borgmatic-*.tar.gz.asc
twine upload -r pypi dist/borgmatic-*-py3-none-any.whl borgmatic-*-py3-none-any.whl.asc
# Set release changelogs on projects.torsion.org and GitHub.
release_changelog="$(cat NEWS | sed '/^$/q' | grep -v '^\S')"
+1 -1
View File
@@ -11,4 +11,4 @@
set -e
docker-compose --file tests/end-to-end/docker-compose.yaml up --force-recreate \
--abort-on-container-exit
--renew-anon-volumes --abort-on-container-exit
+2 -2
View File
@@ -10,8 +10,8 @@
set -e
python -m pip install --upgrade pip==19.3.1
pip install tox==3.14.1
python -m pip install --upgrade pip==20.0.2
pip install tox==3.14.3
export COVERAGE_FILE=/tmp/.coverage
tox --workdir /tmp/.tox
apk add --no-cache borgbackup postgresql-client mariadb-client
+1 -1
View File
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup
VERSION = '1.4.22'
VERSION = '1.5.2'
setup(
+3 -3
View File
@@ -1,17 +1,17 @@
version: '3'
services:
postgresql:
image: postgres:11.6-alpine
image: postgres:12.2-alpine
environment:
POSTGRES_PASSWORD: test
POSTGRES_DB: test
mysql:
image: mariadb:10.4
image: mariadb:10.5
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: test
tests:
image: python:3.7-alpine3.10
image: python:3.8-alpine3.11
volumes:
- "../..:/app:ro"
tmpfs:
+8
View File
@@ -29,11 +29,19 @@ hooks:
hostname: postgresql
username: postgres
password: test
- name: all
hostname: postgresql
username: postgres
password: test
mysql_databases:
- name: test
hostname: mysql
username: root
password: test
- name: all
hostname: mysql
username: root
password: test
'''.format(
config_path, repository_path, borgmatic_source_directory
)
+22 -1
View File
@@ -98,12 +98,14 @@ def test_parse_arguments_with_no_actions_defaults_to_all_actions_enabled():
def test_parse_arguments_with_no_actions_passes_argument_to_relevant_actions():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
arguments = module.parse_arguments('--stats')
arguments = module.parse_arguments('--stats', '--files')
assert 'prune' in arguments
assert arguments['prune'].stats
assert arguments['prune'].files
assert 'create' in arguments
assert arguments['create'].stats
assert arguments['create'].files
assert 'check' in arguments
@@ -423,6 +425,25 @@ def test_parse_arguments_with_stats_flag_but_no_create_or_prune_flag_raises_valu
module.parse_arguments('--stats', 'list')
def test_parse_arguments_with_files_and_create_flags_does_not_raise():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
module.parse_arguments('--files', 'create', 'list')
def test_parse_arguments_with_files_and_prune_flags_does_not_raise():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
module.parse_arguments('--files', 'prune', 'list')
def test_parse_arguments_with_files_flag_but_no_create_or_prune_or_restore_flag_raises_value_error():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit):
module.parse_arguments('--files', 'list')
def test_parse_arguments_allows_json_with_list_or_info():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
+25
View File
@@ -239,3 +239,28 @@ def test_parse_configuration_applies_overrides():
'local_path': 'borg2',
}
}
def test_parse_configuration_applies_normalization():
mock_config_and_schema(
'''
location:
source_directories:
- /home
repositories:
- hostname.borg
exclude_if_present: .nobackup
'''
)
result = module.parse_configuration('config.yaml', 'schema.yaml')
assert result == {
'location': {
'source_directories': ['/home'],
'repositories': ['hostname.borg'],
'exclude_if_present': ['.nobackup'],
}
}
+15
View File
@@ -158,6 +158,21 @@ def test_make_check_flags_with_default_checks_and_prefix_includes_prefix_flag():
assert flags == ('--prefix', 'foo-')
def test_check_archives_with_progress_calls_borg_with_progress_parameter():
checks = ('repository',)
consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').and_return(())
flexmock(module).should_receive('execute_command').never()
flexmock(module).should_receive('execute_command_without_capture').with_args(
('borg', 'check', '--progress', 'repo'), error_on_warnings=True
).once()
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config, progress=True
)
def test_check_archives_with_repair_calls_borg_with_repair_parameter():
checks = ('repository',)
consistency_config = {'check_last': None}
+96 -41
View File
@@ -145,9 +145,16 @@ def test_make_exclude_flags_does_not_include_exclude_caches_when_false_in_config
def test_make_exclude_flags_includes_exclude_if_present_when_in_config():
exclude_flags = module._make_exclude_flags(location_config={'exclude_if_present': 'exclude_me'})
exclude_flags = module._make_exclude_flags(
location_config={'exclude_if_present': ['exclude_me', 'also_me']}
)
assert exclude_flags == ('--exclude-if-present', 'exclude_me')
assert exclude_flags == (
'--exclude-if-present',
'exclude_me',
'--exclude-if-present',
'also_me',
)
def test_make_exclude_flags_includes_keep_exclude_tags_when_true_in_config():
@@ -295,7 +302,7 @@ def test_create_archive_with_log_info_calls_borg_with_info_parameter():
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-', '--info', '--stats') + ARCHIVE_WITH_PATHS,
('borg', 'create', '--info') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
error_on_warnings=False,
)
@@ -349,8 +356,7 @@ def test_create_archive_with_log_debug_calls_borg_with_debug_parameter():
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-', '--stats', '--debug', '--show-rc')
+ ARCHIVE_WITH_PATHS,
('borg', 'create', '--debug', '--show-rc') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
error_on_warnings=False,
)
@@ -421,7 +427,7 @@ def test_create_archive_with_dry_run_calls_borg_with_dry_run_parameter():
)
def test_create_archive_with_dry_run_and_log_info_calls_borg_without_stats_parameter():
def test_create_archive_with_stats_and_dry_run_calls_borg_without_stats_parameter():
# --dry-run and --stats are mutually exclusive, see:
# https://borgbackup.readthedocs.io/en/stable/usage/create.html#description
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
@@ -432,8 +438,7 @@ def test_create_archive_with_dry_run_and_log_info_calls_borg_without_stats_param
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-', '--info', '--dry-run')
+ ARCHIVE_WITH_PATHS,
('borg', 'create', '--info', '--dry-run') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
error_on_warnings=False,
)
@@ -448,36 +453,7 @@ def test_create_archive_with_dry_run_and_log_info_calls_borg_without_stats_param
'exclude_patterns': None,
},
storage_config={},
)
def test_create_archive_with_dry_run_and_log_debug_calls_borg_without_stats_parameter():
# --dry-run and --stats are mutually exclusive, see:
# https://borgbackup.readthedocs.io/en/stable/usage/create.html#description
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-', '--debug', '--show-rc', '--dry-run')
+ ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
error_on_warnings=False,
)
insert_logging_mock(logging.DEBUG)
module.create_archive(
dry_run=True,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
stats=True,
)
@@ -841,7 +817,7 @@ def test_create_archive_with_lock_wait_calls_borg_with_lock_wait_parameters():
)
def test_create_archive_with_stats_calls_borg_with_stats_parameter():
def test_create_archive_with_stats_calls_borg_with_stats_parameter_and_warning_output_log_level():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('_expand_home_directories').and_return(())
@@ -867,6 +843,86 @@ def test_create_archive_with_stats_calls_borg_with_stats_parameter():
)
def test_create_archive_with_stats_and_log_info_calls_borg_with_stats_parameter_and_info_output_log_level():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--info', '--stats') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
error_on_warnings=False,
)
insert_logging_mock(logging.INFO)
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
stats=True,
)
def test_create_archive_with_files_calls_borg_with_list_parameter_and_warning_output_log_level():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-') + ARCHIVE_WITH_PATHS,
output_log_level=logging.WARNING,
error_on_warnings=False,
)
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
files=True,
)
def test_create_archive_with_files_and_log_info_calls_borg_with_list_parameter_and_info_output_log_level():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('_expand_home_directories').and_return(())
flexmock(module).should_receive('_write_pattern_file').and_return(None)
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command').with_args(
('borg', 'create', '--list', '--filter', 'AME-', '--info') + ARCHIVE_WITH_PATHS,
output_log_level=logging.INFO,
error_on_warnings=False,
)
insert_logging_mock(logging.INFO)
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
},
storage_config={},
files=True,
)
def test_create_archive_with_progress_and_log_info_calls_borg_with_progress_parameter_and_no_list():
flexmock(module).should_receive('borgmatic_source_directories').and_return([])
flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar'))
@@ -875,8 +931,7 @@ def test_create_archive_with_progress_and_log_info_calls_borg_with_progress_para
flexmock(module).should_receive('_make_pattern_flags').and_return(())
flexmock(module).should_receive('_make_exclude_flags').and_return(())
flexmock(module).should_receive('execute_command_without_capture').with_args(
('borg', 'create', '--info', '--stats', '--progress') + ARCHIVE_WITH_PATHS,
error_on_warnings=False,
('borg', 'create', '--info', '--progress') + ARCHIVE_WITH_PATHS, error_on_warnings=False
)
insert_logging_mock(logging.INFO)
+104
View File
@@ -7,6 +7,110 @@ from borgmatic.borg import list as module
from ..test_verbosity import insert_logging_mock
BORG_LIST_LATEST_ARGUMENTS = (
'--glob-archives',
module.BORG_EXCLUDE_CHECKPOINTS_GLOB,
'--last',
'1',
'--short',
'repo',
)
def test_resolve_archive_name_passes_through_non_latest_archive_name():
archive = 'myhost-2030-01-01T14:41:17.647620'
assert module.resolve_archive_name('repo', archive, storage_config={}) == archive
def test_resolve_archive_name_calls_borg_with_parameters():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
error_on_warnings=False,
).and_return(expected_archive + '\n')
assert module.resolve_archive_name('repo', 'latest', storage_config={}) == expected_archive
def test_resolve_archive_name_with_log_info_calls_borg_with_info_parameter():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--info') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
error_on_warnings=False,
).and_return(expected_archive + '\n')
insert_logging_mock(logging.INFO)
assert module.resolve_archive_name('repo', 'latest', storage_config={}) == expected_archive
def test_resolve_archive_name_with_log_debug_calls_borg_with_debug_parameter():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--debug', '--show-rc') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
error_on_warnings=False,
).and_return(expected_archive + '\n')
insert_logging_mock(logging.DEBUG)
assert module.resolve_archive_name('repo', 'latest', storage_config={}) == expected_archive
def test_resolve_archive_name_with_local_path_calls_borg_via_local_path():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg1', 'list') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
error_on_warnings=False,
).and_return(expected_archive + '\n')
assert (
module.resolve_archive_name('repo', 'latest', storage_config={}, local_path='borg1')
== expected_archive
)
def test_resolve_archive_name_with_remote_path_calls_borg_with_remote_path_parameters():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--remote-path', 'borg1') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
error_on_warnings=False,
).and_return(expected_archive + '\n')
assert (
module.resolve_archive_name('repo', 'latest', storage_config={}, remote_path='borg1')
== expected_archive
)
def test_resolve_archive_name_without_archives_raises():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
error_on_warnings=False,
).and_return('')
with pytest.raises(ValueError):
module.resolve_archive_name('repo', 'latest', storage_config={})
def test_resolve_archive_name_with_lock_wait_calls_borg_with_lock_wait_parameters():
expected_archive = 'archive-name'
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--lock-wait', 'okay') + BORG_LIST_LATEST_ARGUMENTS,
output_log_level=None,
error_on_warnings=False,
).and_return(expected_archive + '\n')
assert (
module.resolve_archive_name('repo', 'latest', storage_config={'lock_wait': 'okay'})
== expected_archive
)
def test_list_archives_calls_borg_with_parameters():
flexmock(module).should_receive('execute_command').with_args(
+53 -7
View File
@@ -75,9 +75,7 @@ def test_prune_archives_with_log_info_calls_borg_with_info_parameter():
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_execute_command_mock(
PRUNE_COMMAND + ('--stats', '--info', '--list', 'repo'), logging.INFO
)
insert_execute_command_mock(PRUNE_COMMAND + ('--info', 'repo'), logging.INFO)
insert_logging_mock(logging.INFO)
module.prune_archives(
@@ -90,9 +88,7 @@ def test_prune_archives_with_log_debug_calls_borg_with_debug_parameter():
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_execute_command_mock(
PRUNE_COMMAND + ('--stats', '--debug', '--list', '--show-rc', 'repo'), logging.INFO
)
insert_execute_command_mock(PRUNE_COMMAND + ('--debug', '--show-rc', 'repo'), logging.INFO)
insert_logging_mock(logging.DEBUG)
module.prune_archives(
@@ -144,7 +140,7 @@ def test_prune_archives_with_remote_path_calls_borg_with_remote_path_parameters(
)
def test_prune_archives_with_stats_calls_borg_with_stats_parameter():
def test_prune_archives_with_stats_calls_borg_with_stats_parameter_and_warning_output_log_level():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
@@ -160,6 +156,56 @@ def test_prune_archives_with_stats_calls_borg_with_stats_parameter():
)
def test_prune_archives_with_stats_and_log_info_calls_borg_with_stats_parameter_and_info_output_log_level():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_logging_mock(logging.INFO)
insert_execute_command_mock(PRUNE_COMMAND + ('--stats', '--info', 'repo'), logging.INFO)
module.prune_archives(
dry_run=False,
repository='repo',
storage_config={},
retention_config=retention_config,
stats=True,
)
def test_prune_archives_with_files_calls_borg_with_list_parameter_and_warning_output_log_level():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_execute_command_mock(PRUNE_COMMAND + ('--list', 'repo'), logging.WARNING)
module.prune_archives(
dry_run=False,
repository='repo',
storage_config={},
retention_config=retention_config,
files=True,
)
def test_prune_archives_with_files_and_log_info_calls_borg_with_list_parameter_and_info_output_log_level():
retention_config = flexmock()
flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return(
BASE_PRUNE_FLAGS
)
insert_logging_mock(logging.INFO)
insert_execute_command_mock(PRUNE_COMMAND + ('--info', '--list', 'repo'), logging.INFO)
module.prune_archives(
dry_run=False,
repository='repo',
storage_config={},
retention_config=retention_config,
files=True,
)
def test_prune_archives_with_umask_calls_borg_with_umask_parameters():
storage_config = {'umask': '077'}
retention_config = flexmock()
+61 -14
View File
@@ -3,6 +3,7 @@ import subprocess
from flexmock import flexmock
import borgmatic.hooks.command
from borgmatic.commands import borgmatic as module
@@ -13,7 +14,7 @@ def test_run_configuration_runs_actions_for_each_repository():
expected_results[1:]
)
config = {'location': {'repositories': ['foo', 'bar']}}
arguments = {'global': flexmock()}
arguments = {'global': flexmock(monitoring_verbosity=1)}
results = list(module.run_configuration('test.yaml', config, arguments))
@@ -22,11 +23,11 @@ def test_run_configuration_runs_actions_for_each_repository():
def test_run_configuration_calls_hooks_for_prune_action():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').never()
flexmock(module.command).should_receive('execute_hook').twice()
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), 'prune': flexmock()}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'prune': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
@@ -37,18 +38,18 @@ def test_run_configuration_executes_and_calls_hooks_for_create_action():
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()}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
def test_run_configuration_calls_hooks_for_check_action():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').never()
flexmock(module.command).should_receive('execute_hook').twice()
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), 'check': flexmock()}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'check': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
@@ -59,7 +60,7 @@ def test_run_configuration_does_not_trigger_hooks_for_list_action():
flexmock(module.dispatch).should_receive('call_hooks').never()
flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(dry_run=False), 'list': flexmock()}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'list': flexmock()}
list(module.run_configuration('test.yaml', config, arguments))
@@ -72,7 +73,7 @@ def test_run_configuration_logs_actions_error():
flexmock(module).should_receive('make_error_log_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_raise(OSError)
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(dry_run=False)}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False)}
results = list(module.run_configuration('test.yaml', config, arguments))
@@ -86,13 +87,27 @@ def test_run_configuration_logs_pre_hook_error():
flexmock(module).should_receive('make_error_log_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').never()
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(dry_run=False), 'create': flexmock()}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results
def test_run_configuration_bails_for_pre_hook_soft_failure():
flexmock(module.borg_environment).should_receive('initialize')
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.command).should_receive('execute_hook').and_raise(error).and_return(None)
flexmock(module).should_receive('make_error_log_records').never()
flexmock(module).should_receive('run_actions').never()
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == []
def test_run_configuration_logs_post_hook_error():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(
@@ -103,13 +118,30 @@ def test_run_configuration_logs_post_hook_error():
flexmock(module).should_receive('make_error_log_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(dry_run=False), 'create': flexmock()}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results
def test_run_configuration_bails_for_post_hook_soft_failure():
flexmock(module.borg_environment).should_receive('initialize')
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(
error
).and_return(None)
flexmock(module.dispatch).should_receive('call_hooks')
flexmock(module).should_receive('make_error_log_records').never()
flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == []
def test_run_configuration_logs_on_error_hook_error():
flexmock(module.borg_environment).should_receive('initialize')
flexmock(module.command).should_receive('execute_hook').and_raise(OSError)
@@ -119,7 +151,22 @@ def test_run_configuration_logs_on_error_hook_error():
).and_return(expected_results[1:])
flexmock(module).should_receive('run_actions').and_raise(OSError)
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(dry_run=False), 'create': flexmock()}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results
def test_run_configuration_bails_for_on_error_hook_soft_failure():
flexmock(module.borg_environment).should_receive('initialize')
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.command).should_receive('execute_hook').and_return(None).and_raise(error)
expected_results = [flexmock()]
flexmock(module).should_receive('make_error_log_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_raise(OSError)
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
@@ -228,7 +275,7 @@ def test_collect_configuration_run_summary_logs_info_for_success():
def test_collect_configuration_run_summary_executes_hooks_for_create():
flexmock(module).should_receive('run_configuration').and_return([])
arguments = {'create': flexmock(), 'global': flexmock(dry_run=False)}
arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)}
logs = tuple(
module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments)
@@ -305,7 +352,7 @@ def test_collect_configuration_run_summary_logs_pre_hook_error():
flexmock(module.command).should_receive('execute_hook').and_raise(ValueError)
expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs)
arguments = {'create': flexmock(), 'global': flexmock(dry_run=False)}
arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)}
logs = tuple(
module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments)
@@ -319,7 +366,7 @@ def test_collect_configuration_run_summary_logs_post_hook_error():
flexmock(module).should_receive('run_configuration').and_return([])
expected_logs = (flexmock(),)
flexmock(module).should_receive('make_error_log_records').and_return(expected_logs)
arguments = {'create': flexmock(), 'global': flexmock(dry_run=False)}
arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)}
logs = tuple(
module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments)
+27
View File
@@ -0,0 +1,27 @@
import pytest
from borgmatic.config import normalize as module
@pytest.mark.parametrize(
'config,expected_config',
(
(
{'location': {'exclude_if_present': '.nobackup'}},
{'location': {'exclude_if_present': ['.nobackup']}},
),
(
{'location': {'exclude_if_present': ['.nobackup']}},
{'location': {'exclude_if_present': ['.nobackup']}},
),
(
{'location': {'source_directories': ['foo', 'bar']}},
{'location': {'source_directories': ['foo', 'bar']}},
),
({'storage': {'compression': 'yes_please'}}, {'storage': {'compression': 'yes_please'}}),
),
)
def test_normalize_applies_hard_coded_normalization_to_config(config, expected_config):
module.normalize(config)
assert config == expected_config
+17
View File
@@ -1,4 +1,5 @@
import logging
import subprocess
from flexmock import flexmock
@@ -79,3 +80,19 @@ def test_execute_hook_on_error_logs_as_error():
).once()
module.execute_hook([':'], None, 'config.yaml', 'on-error', dry_run=False)
def test_considered_soft_failure_treats_soft_fail_exit_code_as_soft_fail():
error = subprocess.CalledProcessError(module.SOFT_FAIL_EXIT_CODE, 'try again')
assert module.considered_soft_failure('config.yaml', error)
def test_considered_soft_failure_does_not_treat_other_exit_code_as_soft_fail():
error = subprocess.CalledProcessError(1, 'error')
assert not module.considered_soft_failure('config.yaml', error)
def test_considered_soft_failure_does_not_treat_other_exception_type_as_soft_fail():
assert not module.considered_soft_failure('config.yaml', Exception())
+15 -5
View File
@@ -7,32 +7,42 @@ 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)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, 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)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, 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)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, 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)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, 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)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True
)
+12 -4
View File
@@ -7,25 +7,33 @@ 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)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, 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)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, 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)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, 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)
module.ping_monitor(
ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True
)
+39 -9
View File
@@ -4,7 +4,7 @@ from borgmatic.hooks import healthchecks as module
def test_forgetful_buffering_handler_emit_collects_log_records():
handler = module.Forgetful_buffering_handler(byte_capacity=100)
handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1)
handler.emit(flexmock(getMessage=lambda: 'foo'))
handler.emit(flexmock(getMessage=lambda: 'bar'))
@@ -13,7 +13,7 @@ def test_forgetful_buffering_handler_emit_collects_log_records():
def test_forgetful_buffering_handler_emit_forgets_log_records_when_capacity_reached():
handler = module.Forgetful_buffering_handler(byte_capacity=len('foo\nbar\n'))
handler = module.Forgetful_buffering_handler(byte_capacity=len('foo\nbar\n'), log_level=1)
handler.emit(flexmock(getMessage=lambda: 'foo'))
assert handler.buffer == ['foo\n']
handler.emit(flexmock(getMessage=lambda: 'bar'))
@@ -26,7 +26,7 @@ def test_forgetful_buffering_handler_emit_forgets_log_records_when_capacity_reac
def test_format_buffered_logs_for_payload_flattens_log_buffer():
handler = module.Forgetful_buffering_handler(byte_capacity=100)
handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1)
handler.buffer = ['foo\n', 'bar\n']
flexmock(module.logging).should_receive('getLogger').and_return(flexmock(handlers=[handler]))
@@ -36,7 +36,7 @@ def test_format_buffered_logs_for_payload_flattens_log_buffer():
def test_format_buffered_logs_for_payload_inserts_truncation_indicator_when_logs_forgotten():
handler = module.Forgetful_buffering_handler(byte_capacity=100)
handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1)
handler.buffer = ['foo\n', 'bar\n']
handler.forgot = True
flexmock(module.logging).should_receive('getLogger').and_return(flexmock(handlers=[handler]))
@@ -63,7 +63,13 @@ def test_ping_monitor_hits_ping_url_for_start_state():
'{}/{}'.format(ping_url, 'start'), data=''.encode('utf-8')
)
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.START, dry_run=False)
module.ping_monitor(
ping_url,
'config.yaml',
state=module.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_hits_ping_url_for_finish_state():
@@ -74,7 +80,13 @@ def test_ping_monitor_hits_ping_url_for_finish_state():
ping_url, data=payload.encode('utf-8')
)
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.FINISH, dry_run=False)
module.ping_monitor(
ping_url,
'config.yaml',
state=module.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_hits_ping_url_for_fail_state():
@@ -85,7 +97,13 @@ def test_ping_monitor_hits_ping_url_for_fail_state():
'{}/{}'.format(ping_url, 'fail'), data=payload.encode('utf')
)
module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.FAIL, dry_run=False)
module.ping_monitor(
ping_url,
'config.yaml',
state=module.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
@@ -96,7 +114,13 @@ def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
'https://hc-ping.com/{}'.format(ping_uuid), data=payload.encode('utf-8')
)
module.ping_monitor(ping_uuid, 'config.yaml', state=module.monitor.State.FINISH, dry_run=False)
module.ping_monitor(
ping_uuid,
'config.yaml',
state=module.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_dry_run_does_not_hit_ping_url():
@@ -104,4 +128,10 @@ def test_ping_monitor_dry_run_does_not_hit_ping_url():
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)
module.ping_monitor(
ping_url,
'config.yaml',
state=module.monitor.State.START,
monitoring_log_level=1,
dry_run=True,
)
+40 -1
View File
@@ -5,6 +5,35 @@ from flexmock import flexmock
from borgmatic.hooks import mysql as module
def test_database_names_to_dump_passes_through_name():
extra_environment = flexmock()
log_prefix = ''
dry_run_label = ''
names = module.database_names_to_dump(
{'name': 'foo'}, extra_environment, log_prefix, dry_run_label
)
assert names == ('foo',)
def test_database_names_to_dump_queries_mysql_for_database_names():
extra_environment = flexmock()
log_prefix = ''
dry_run_label = ''
flexmock(module).should_receive('execute_command').with_args(
('mysql', '--skip-column-names', '--batch', '--execute', 'show schemas'),
output_log_level=None,
extra_environment=extra_environment,
).and_return('foo\nbar\nmysql\n').once()
names = module.database_names_to_dump(
{'name': 'all'}, extra_environment, log_prefix, dry_run_label
)
assert names == ('foo', 'bar')
def test_dump_databases_runs_mysqldump_for_each_database():
databases = [{'name': 'foo'}, {'name': 'bar'}]
output_file = flexmock()
@@ -12,6 +41,9 @@ def test_dump_databases_runs_mysqldump_for_each_database():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
@@ -31,6 +63,9 @@ def test_dump_databases_with_dry_run_skips_mysqldump():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
).and_return('databases/localhost/bar')
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module.os).should_receive('makedirs').never()
flexmock(module).should_receive('execute_command').never()
@@ -44,6 +79,7 @@ def test_dump_databases_runs_mysqldump_with_hostname_and_port():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/database.example.org/foo'
)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
@@ -74,6 +110,7 @@ def test_dump_databases_runs_mysqldump_with_username_and_password():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
@@ -93,6 +130,7 @@ def test_dump_databases_runs_mysqldump_with_options():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/foo'
)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
flexmock(module.os).should_receive('makedirs')
flexmock(sys.modules['builtins']).should_receive('open').and_return(output_file)
@@ -112,11 +150,12 @@ def test_dump_databases_runs_mysqldump_for_all_databases():
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
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'),
('mysqldump', '--add-drop-database', '--databases', 'foo', 'bar'),
output_file=output_file,
extra_environment=None,
).once()
+35
View File
@@ -0,0 +1,35 @@
from flexmock import flexmock
from borgmatic.hooks import pagerduty as module
def test_ping_monitor_ignores_start_state():
flexmock(module.requests).should_receive('post').never()
module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_ignores_finish_state():
flexmock(module.requests).should_receive('post').never()
module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_calls_api_for_fail_state():
flexmock(module.requests).should_receive('post')
module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
)
def test_ping_monitor_dry_run_does_not_call_api():
flexmock(module.requests).should_receive('post').never()
module.ping_monitor(
'abc123', 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=True
)
+37 -8
View File
@@ -17,6 +17,7 @@ def test_dump_databases_runs_pg_dump_for_each_database():
'pg_dump',
'--no-password',
'--clean',
'--if-exists',
'--file',
'databases/localhost/{}'.format(name),
'--format',
@@ -54,6 +55,7 @@ def test_dump_databases_runs_pg_dump_with_hostname_and_port():
'pg_dump',
'--no-password',
'--clean',
'--if-exists',
'--file',
'databases/database.example.org/foo',
'--host',
@@ -83,6 +85,7 @@ def test_dump_databases_runs_pg_dump_with_username_and_password():
'pg_dump',
'--no-password',
'--clean',
'--if-exists',
'--file',
'databases/localhost/foo',
'--username',
@@ -110,6 +113,7 @@ def test_dump_databases_runs_pg_dump_with_format():
'pg_dump',
'--no-password',
'--clean',
'--if-exists',
'--file',
'databases/localhost/foo',
'--format',
@@ -135,6 +139,7 @@ def test_dump_databases_runs_pg_dump_with_options():
'pg_dump',
'--no-password',
'--clean',
'--if-exists',
'--file',
'databases/localhost/foo',
'--format',
@@ -157,7 +162,14 @@ def test_dump_databases_runs_pg_dumpall_for_all_databases():
flexmock(module.os).should_receive('makedirs')
flexmock(module).should_receive('execute_command').with_args(
('pg_dumpall', '--no-password', '--clean', '--file', 'databases/localhost/all'),
(
'pg_dumpall',
'--no-password',
'--clean',
'--if-exists',
'--file',
'databases/localhost/all',
),
extra_environment=None,
).once()
@@ -197,9 +209,9 @@ def test_restore_database_dumps_restores_each_database():
(
'pg_restore',
'--no-password',
'--clean',
'--if-exists',
'--exit-on-error',
'--clean',
'--dbname',
name,
'databases/localhost/{}'.format(name),
@@ -225,15 +237,15 @@ def test_restore_database_dumps_runs_pg_restore_with_hostname_and_port():
(
'pg_restore',
'--no-password',
'--clean',
'--if-exists',
'--exit-on-error',
'--clean',
'--dbname',
'foo',
'--host',
'database.example.org',
'--port',
'5433',
'--dbname',
'foo',
'databases/localhost/foo',
),
extra_environment=None,
@@ -269,13 +281,13 @@ def test_restore_database_dumps_runs_pg_restore_with_username_and_password():
(
'pg_restore',
'--no-password',
'--clean',
'--if-exists',
'--exit-on-error',
'--username',
'postgres',
'--clean',
'--dbname',
'foo',
'--username',
'postgres',
'databases/localhost/foo',
),
extra_environment={'PGPASSWORD': 'trustsome1'},
@@ -296,3 +308,20 @@ def test_restore_database_dumps_runs_pg_restore_with_username_and_password():
).once()
module.restore_database_dumps(databases, 'test.yaml', {}, dry_run=False)
def test_restore_database_dumps_runs_psql_for_all_database_dump():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('execute_command').with_args(
('psql', '--no-password', '-f', 'databases/localhost/all'), extra_environment=None
).once()
flexmock(module).should_receive('execute_command').with_args(
('psql', '--no-password', '--quiet', '--command', 'ANALYZE'), extra_environment=None
).once()
module.restore_database_dumps(databases, 'test.yaml', {}, dry_run=False)