diff --git a/NEWS b/NEWS
index a0eac709..1be3d09c 100644
--- a/NEWS
+++ b/NEWS
@@ -1,3 +1,38 @@
+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.
+ * In "borgmatic --help", don't expand $HOME in listing of default "--config" paths.
+
+1.4.21
+ * #268: Override particular configuration options from the command-line via "--override" flag. See
+ the documentation for more information:
+ https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides
+ * #270: Only trigger "on_error" hooks and monitoring failures for "prune", "create", and "check"
+ actions, and not for other actions.
+ * When pruning with verbosity level 1, list pruned and kept archives. Previously, this information
+ was only shown at verbosity level 2.
+
+1.4.20
+ * Fix repository probing during "borgmatic init" to respect verbosity flag and remote_path option.
+ * #249: Update Healthchecks/Cronitor/Cronhub monitoring integrations to fire for "check" and
+ "prune" actions, not just "create".
+
1.4.19
* #259: Optionally change the internal database dump path via "borgmatic_source_directory" option
in location configuration section.
diff --git a/README.md b/README.md
index 09f4e656..b2ba6e0a 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,11 @@ location:
- /home
- /etc
- # Paths to local or remote repositories.
+ # Paths of local or remote repositories to backup to.
repositories:
- - user@backupserver:sourcehostname.borg
+ - 1234@usw-s001.rsync.net:backups.borg
+ - k8pDxu32@k8pDxu32.repo.borgbase.com:repo
+ - /var/lib/backups/local.borg
retention:
# Retention policy for how many backups to keep.
@@ -64,6 +66,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
+
@@ -78,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/)
diff --git a/borgmatic/borg/check.py b/borgmatic/borg/check.py
index 45e59f28..55332157 100644
--- a/borgmatic/borg/check.py
+++ b/borgmatic/borg/check.py
@@ -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)
diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py
index f582fb7b..7a3d2749 100644
--- a/borgmatic/borg/create.py
+++ b/borgmatic/borg/create.py
@@ -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
diff --git a/borgmatic/borg/init.py b/borgmatic/borg/init.py
index 152f8c1b..08256aef 100644
--- a/borgmatic/borg/init.py
+++ b/borgmatic/borg/init.py
@@ -23,7 +23,13 @@ def initialize_repository(
whether the repository should be append-only, and the storage quota to use, initialize the
repository. If the repository already exists, then log and skip initialization.
'''
- info_command = (local_path, 'info', repository)
+ info_command = (
+ (local_path, 'info')
+ + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ + (('--debug',) if logger.isEnabledFor(logging.DEBUG) else ())
+ + (('--remote-path', remote_path) if remote_path else ())
+ + (repository,)
+ )
logger.debug(' '.join(info_command))
try:
diff --git a/borgmatic/borg/prune.py b/borgmatic/borg/prune.py
index 0913cdeb..83e421d2 100644
--- a/borgmatic/borg/prune.py
+++ b/borgmatic/borg/prune.py
@@ -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 ())
+ + (('--stats',) if stats and not dry_run else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
- + (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) 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)
diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py
index 738981ca..cde8539e 100644
--- a/borgmatic/commands/arguments.py
+++ b/borgmatic/commands/arguments.py
@@ -106,7 +106,8 @@ def parse_arguments(*unparsed_arguments):
Given command-line arguments with which this script was invoked, parse the arguments and return
them as a dict mapping from subparser name (or "global") to an argparse.Namespace instance.
'''
- config_paths = collect.get_default_config_paths()
+ config_paths = collect.get_default_config_paths(expand_home=True)
+ unexpanded_config_paths = collect.get_default_config_paths(expand_home=False)
global_parser = ArgumentParser(add_help=False)
global_group = global_parser.add_argument_group('global arguments')
@@ -118,7 +119,7 @@ def parse_arguments(*unparsed_arguments):
dest='config_paths',
default=config_paths,
help='Configuration filenames or directories, defaults to: {}'.format(
- ' '.join(config_paths)
+ ' '.join(unexpanded_config_paths)
),
)
global_group.add_argument(
@@ -158,12 +159,26 @@ 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=1,
+ 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,
default=None,
help='Write log messages to this file instead of syslog',
)
+ global_group.add_argument(
+ '--override',
+ metavar='SECTION.OPTION=VALUE',
+ nargs='+',
+ dest='overrides',
+ help='One or more configuration file options to override with specified values',
+ )
global_group.add_argument(
'--version',
dest='version',
@@ -229,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(
@@ -244,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',
@@ -253,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'
)
@@ -266,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',
@@ -315,7 +343,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'
diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py
index 3b539764..8a2489cd 100644
--- a/borgmatic/commands/borgmatic.py
+++ b/borgmatic/commands/borgmatic.py
@@ -52,17 +52,29 @@ def run_configuration(config_filename, config, arguments):
borg_environment.initialize(storage)
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)
- if 'create' in arguments:
- try:
+ try:
+ if prune_create_or_check:
dispatch.call_hooks(
'ping_monitor',
hooks,
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:
command.execute_hook(
hooks.get('before_backup'),
hooks.get('umask'),
@@ -78,11 +90,22 @@ def run_configuration(config_filename, config, arguments):
location,
global_arguments.dry_run,
)
- except (OSError, CalledProcessError) as error:
- encountered_error = error
- yield from make_error_log_records(
- '{}: Error running pre-backup hook'.format(config_filename), error
+ 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 hook'.format(config_filename), error
+ )
if not encountered_error:
for repository_path in location['repositories']:
@@ -105,38 +128,60 @@ def run_configuration(config_filename, config, arguments):
'{}: Error running actions for repository'.format(repository_path), error
)
- if 'create' in arguments and not encountered_error:
+ if not encountered_error:
try:
- dispatch.call_hooks(
- 'remove_database_dumps',
- hooks,
- config_filename,
- dump.DATABASE_HOOK_NAMES,
- location,
- global_arguments.dry_run,
- )
- command.execute_hook(
- hooks.get('after_backup'),
- hooks.get('umask'),
- config_filename,
- 'post-backup',
- global_arguments.dry_run,
- )
- dispatch.call_hooks(
- 'ping_monitor',
- hooks,
- config_filename,
- monitor.MONITOR_HOOK_NAMES,
- monitor.State.FINISH,
- global_arguments.dry_run,
- )
+ 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',
+ hooks,
+ config_filename,
+ dump.DATABASE_HOOK_NAMES,
+ location,
+ global_arguments.dry_run,
+ )
+ command.execute_hook(
+ hooks.get('after_backup'),
+ hooks.get('umask'),
+ config_filename,
+ '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',
+ hooks,
+ 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:
+ if encountered_error and prune_create_or_check:
try:
command.execute_hook(
hooks.get('on_error'),
@@ -154,9 +199,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
)
@@ -208,6 +257,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))
@@ -221,6 +271,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)
@@ -232,6 +283,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,
)
@@ -343,7 +395,8 @@ def run_actions(
if arguments['list'].repository is None or validate.repositories_match(
repository, arguments['list'].repository
):
- logger.info('{}: Listing archives'.format(repository))
+ if not arguments['list'].json:
+ logger.warning('{}: Listing archives'.format(repository))
json_output = borg_list.list_archives(
repository,
storage,
@@ -357,7 +410,8 @@ 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))
+ if not arguments['info'].json:
+ logger.warning('{}: Displaying summary info for archives'.format(repository))
json_output = borg_info.display_archives_info(
repository,
storage,
@@ -369,7 +423,7 @@ def run_actions(
yield json.loads(json_output)
-def load_configurations(config_filenames):
+def load_configurations(config_filenames, overrides=None):
'''
Given a sequence of configuration filenames, load and validate each configuration file. Return
the results as a tuple of: dict of configuration filename to corresponding parsed configuration,
@@ -383,7 +437,7 @@ def load_configurations(config_filenames):
for config_filename in config_filenames:
try:
configs[config_filename] = validate.parse_configuration(
- config_filename, validate.schema_filename()
+ config_filename, validate.schema_filename(), overrides
)
except (ValueError, OSError, validate.Validation_error) as error:
logs.extend(
@@ -581,14 +635,21 @@ def main(): # pragma: no cover
sys.exit(0)
config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
- configs, parse_logs = load_configurations(config_filenames)
+ configs, parse_logs = load_configurations(config_filenames, global_arguments.overrides)
- colorama.init(autoreset=True, strip=not should_do_markup(global_arguments.no_color, configs))
+ any_json_flags = any(
+ getattr(sub_arguments, 'json', False) for sub_arguments in arguments.values()
+ )
+ colorama.init(
+ autoreset=True,
+ strip=not should_do_markup(global_arguments.no_color or any_json_flags, configs),
+ )
try:
configure_logging(
verbosity_to_log_level(global_arguments.verbosity),
verbosity_to_log_level(global_arguments.syslog_verbosity),
verbosity_to_log_level(global_arguments.log_file_verbosity),
+ verbosity_to_log_level(global_arguments.monitoring_verbosity),
global_arguments.log_file,
)
except (FileNotFoundError, PermissionError) as error:
diff --git a/borgmatic/config/collect.py b/borgmatic/config/collect.py
index 59d7dfe5..97c94106 100644
--- a/borgmatic/config/collect.py
+++ b/borgmatic/config/collect.py
@@ -1,20 +1,23 @@
import os
-def get_default_config_paths():
+def get_default_config_paths(expand_home=True):
'''
Based on the value of the XDG_CONFIG_HOME and HOME environment variables, return a list of
default configuration paths. This includes both system-wide configuration and configuration in
the current user's home directory.
+
+ Don't expand the home directory ($HOME) if the expand home flag is False.
'''
- user_config_directory = os.getenv('XDG_CONFIG_HOME') or os.path.expandvars(
- os.path.join('$HOME', '.config')
- )
+ user_config_directory = os.getenv('XDG_CONFIG_HOME') or os.path.join('$HOME', '.config')
+ if expand_home:
+ user_config_directory = os.path.expandvars(user_config_directory)
return [
'/etc/borgmatic/config.yaml',
'/etc/borgmatic.d',
'%s/borgmatic/config.yaml' % user_config_directory,
+ '%s/borgmatic.d' % user_config_directory,
]
diff --git a/borgmatic/config/normalize.py b/borgmatic/config/normalize.py
new file mode 100644
index 00000000..05080bb2
--- /dev/null
+++ b/borgmatic/config/normalize.py
@@ -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]
diff --git a/borgmatic/config/override.py b/borgmatic/config/override.py
new file mode 100644
index 00000000..eb86077b
--- /dev/null
+++ b/borgmatic/config/override.py
@@ -0,0 +1,71 @@
+import io
+
+import ruamel.yaml
+
+
+def set_values(config, keys, value):
+ '''
+ Given a hierarchy of configuration dicts, a sequence of parsed key strings, and a string value,
+ descend into the hierarchy based on the keys to set the value into the right place.
+ '''
+ if not keys:
+ return
+
+ first_key = keys[0]
+ if len(keys) == 1:
+ config[first_key] = value
+ return
+
+ if first_key not in config:
+ config[first_key] = {}
+
+ set_values(config[first_key], keys[1:], value)
+
+
+def convert_value_type(value):
+ '''
+ Given a string value, determine its logical type (string, boolean, integer, etc.), and return it
+ converted to that type.
+ '''
+ return ruamel.yaml.YAML(typ='safe').load(io.StringIO(value))
+
+
+def parse_overrides(raw_overrides):
+ '''
+ Given a sequence of configuration file override strings in the form of "section.option=value",
+ parse and return a sequence of tuples (keys, values), where keys is a sequence of strings. For
+ instance, given the following raw overrides:
+
+ ['section.my_option=value1', 'section.other_option=value2']
+
+ ... return this:
+
+ (
+ (('section', 'my_option'), 'value1'),
+ (('section', 'other_option'), 'value2'),
+ )
+
+ Raise ValueError if an override can't be parsed.
+ '''
+ if not raw_overrides:
+ return ()
+
+ try:
+ return tuple(
+ (tuple(raw_keys.split('.')), convert_value_type(value))
+ for raw_override in raw_overrides
+ for raw_keys, value in (raw_override.split('=', 1),)
+ )
+ except ValueError:
+ raise ValueError('Invalid override. Make sure you use the form: SECTION.OPTION=VALUE')
+
+
+def apply_overrides(config, raw_overrides):
+ '''
+ Given a sequence of configuration file override strings in the form of "section.option=value"
+ and a configuration dict, parse each override and set it the configuration dict.
+ '''
+ overrides = parse_overrides(raw_overrides)
+
+ for (keys, value) in overrides:
+ set_values(config, keys, value)
diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml
index c58ebdec..a228e7a3 100644
--- a/borgmatic/config/schema.yaml
+++ b/borgmatic/config/schema.yaml
@@ -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:
diff --git a/borgmatic/config/validate.py b/borgmatic/config/validate.py
index 1e421d18..504e1818 100644
--- a/borgmatic/config/validate.py
+++ b/borgmatic/config/validate.py
@@ -6,7 +6,7 @@ import pykwalify.core
import pykwalify.errors
import ruamel.yaml
-from borgmatic.config import load
+from borgmatic.config import load, normalize, override
def schema_filename():
@@ -82,11 +82,12 @@ def remove_examples(schema):
return schema
-def parse_configuration(config_filename, schema_filename):
+def parse_configuration(config_filename, schema_filename, overrides=None):
'''
- Given the path to a config filename in YAML format and the path to a schema filename in
- pykwalify YAML schema format, return the parsed configuration as a data structure of nested
- dicts and lists corresponding to the schema. Example return value:
+ Given the path to a config filename in YAML format, the path to a schema filename in pykwalify
+ YAML schema format, a sequence of configuration file override strings in the form of
+ "section.option=value", return the parsed configuration as a data structure of nested dicts and
+ lists corresponding to the schema. Example return value:
{'location': {'source_directories': ['/home', '/etc'], 'repository': 'hostname.borg'},
'retention': {'keep_daily': 7}, 'consistency': {'checks': ['repository', 'archives']}}
@@ -102,6 +103,9 @@ def parse_configuration(config_filename, schema_filename):
except (ruamel.yaml.error.YAMLError, RecursionError) as error:
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)
diff --git a/borgmatic/hooks/command.py b/borgmatic/hooks/command.py
index 16dc3769..aaa777ac 100644
--- a/borgmatic/hooks/command.py
+++ b/borgmatic/hooks/command.py
@@ -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
diff --git a/borgmatic/hooks/cronhub.py b/borgmatic/hooks/cronhub.py
index a0d0ac96..f95abe62 100644
--- a/borgmatic/hooks/cronhub.py
+++ b/borgmatic/hooks/cronhub.py
@@ -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.
diff --git a/borgmatic/hooks/cronitor.py b/borgmatic/hooks/cronitor.py
index 65ad1095..e4bc7c4b 100644
--- a/borgmatic/hooks/cronitor.py
+++ b/borgmatic/hooks/cronitor.py
@@ -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.
diff --git a/borgmatic/hooks/dispatch.py b/borgmatic/hooks/dispatch.py
index 206b0d1c..6c05cad9 100644
--- a/borgmatic/hooks/dispatch.py
+++ b/borgmatic/hooks/dispatch.py
@@ -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,
}
diff --git a/borgmatic/hooks/dump.py b/borgmatic/hooks/dump.py
index 54db1d26..bd5ea084 100644
--- a/borgmatic/hooks/dump.py
+++ b/borgmatic/hooks/dump.py
@@ -1,6 +1,7 @@
import glob
import logging
import os
+import shutil
from borgmatic.borg.create import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
@@ -83,7 +84,10 @@ def remove_database_dumps(dump_path, databases, database_type_name, log_prefix,
if dry_run:
continue
- os.remove(dump_filename)
+ if os.path.isdir(dump_filename):
+ shutil.rmtree(dump_filename)
+ else:
+ os.remove(dump_filename)
dump_file_dir = os.path.dirname(dump_filename)
if len(os.listdir(dump_file_dir)) == 0:
diff --git a/borgmatic/hooks/healthchecks.py b/borgmatic/hooks/healthchecks.py
index a116205a..e16dfc7e 100644
--- a/borgmatic/hooks/healthchecks.py
+++ b/borgmatic/hooks/healthchecks.py
@@ -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 = (
diff --git a/borgmatic/hooks/monitor.py b/borgmatic/hooks/monitor.py
index aee2b8f5..c4cf576e 100644
--- a/borgmatic/hooks/monitor.py
+++ b/borgmatic/hooks/monitor.py
@@ -1,6 +1,6 @@
from enum import Enum
-MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub')
+MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty')
class State(Enum):
diff --git a/borgmatic/hooks/pagerduty.py b/borgmatic/hooks/pagerduty.py
new file mode 100644
index 00000000..0e613cc5
--- /dev/null
+++ b/borgmatic/hooks/pagerduty.py
@@ -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'))
diff --git a/borgmatic/logger.py b/borgmatic/logger.py
index b20f89e3..70992305 100644
--- a/borgmatic/logger.py
+++ b/borgmatic/logger.py
@@ -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,
)
diff --git a/docs/_includes/components/suggestion-form.html b/docs/_includes/components/suggestion-form.html
index c4e59b20..8e3a73a6 100644
--- a/docs/_includes/components/suggestion-form.html
+++ b/docs/_includes/components/suggestion-form.html
@@ -1,12 +1,12 @@
Have an idea on how to make this documentation even better? Send your -feedback below! (But if you need help installing or using borgmatic, please -use our issue tracker -instead.)
+feedback below! But if you need help with borgmatic, or have an idea for a +borgmatic feature, please use our issue +tracker instead.