diff -Nru borgmatic-1.4.21/borgmatic/borg/check.py borgmatic-1.5.1/borgmatic/borg/check.py --- borgmatic-1.4.21/borgmatic/borg/check.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/borg/check.py 2020-02-03 17:45:10.000000000 +0000 @@ -91,13 +91,15 @@ 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 @@ + (('--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 -Nru borgmatic-1.4.21/borgmatic/borg/create.py borgmatic-1.5.1/borgmatic/borg/create.py --- borgmatic-1.4.21/borgmatic/borg/create.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/borg/create.py 2020-02-03 17:45:10.000000000 +0000 @@ -88,8 +88,12 @@ ) ) 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 @@ 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 @@ + (('--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 @@ 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 -Nru borgmatic-1.4.21/borgmatic/borg/list.py borgmatic-1.5.1/borgmatic/borg/list.py --- borgmatic-1.4.21/borgmatic/borg/list.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/borg/list.py 2020-02-03 17:45:10.000000000 +0000 @@ -11,6 +11,42 @@ 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 diff -Nru borgmatic-1.4.21/borgmatic/borg/prune.py borgmatic-1.5.1/borgmatic/borg/prune.py --- borgmatic-1.4.21/borgmatic/borg/prune.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/borg/prune.py 2020-02-03 17:45:10.000000000 +0000 @@ -41,6 +41,7 @@ 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 @@ + (('--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) diff -Nru borgmatic-1.4.21/borgmatic/commands/arguments.py borgmatic-1.5.1/borgmatic/commands/arguments.py --- borgmatic-1.4.21/borgmatic/commands/arguments.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/commands/arguments.py 2020-02-03 17:45:10.000000000 +0000 @@ -106,7 +106,8 @@ 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 @@ 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( @@ -159,6 +160,13 @@ 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, default=None, @@ -236,6 +244,9 @@ 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( @@ -251,7 +262,7 @@ 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 +272,9 @@ 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' ) create_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') @@ -274,6 +288,13 @@ ) 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', default=False, @@ -302,7 +323,9 @@ '--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', @@ -322,7 +345,7 @@ 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' @@ -340,7 +363,7 @@ '--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', @@ -394,7 +417,9 @@ '--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', @@ -425,7 +450,7 @@ '--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', @@ -487,7 +512,7 @@ '--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' ) diff -Nru borgmatic-1.4.21/borgmatic/commands/borgmatic.py borgmatic-1.5.1/borgmatic/commands/borgmatic.py --- borgmatic-1.4.21/borgmatic/commands/borgmatic.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/commands/borgmatic.py 2020-02-03 17:45:10.000000000 +0000 @@ -1,4 +1,5 @@ import collections +import copy import json import logging import os @@ -53,6 +54,7 @@ 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 @@ 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 @@ 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 @@ 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 @@ '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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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, ) @@ -587,12 +652,19 @@ config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths)) 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 -Nru borgmatic-1.4.21/borgmatic/config/collect.py borgmatic-1.5.1/borgmatic/config/collect.py --- borgmatic-1.4.21/borgmatic/config/collect.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/config/collect.py 2020-02-03 17:45:10.000000000 +0000 @@ -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 -Nru borgmatic-1.4.21/borgmatic/config/normalize.py borgmatic-1.5.1/borgmatic/config/normalize.py --- borgmatic-1.4.21/borgmatic/config/normalize.py 1970-01-01 00:00:00.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/config/normalize.py 2020-02-03 17:45:10.000000000 +0000 @@ -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 -Nru borgmatic-1.4.21/borgmatic/config/schema.yaml borgmatic-1.5.1/borgmatic/config/schema.yaml --- borgmatic-1.4.21/borgmatic/config/schema.yaml 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/config/schema.yaml 2020-02-03 17:45:10.000000000 +0000 @@ -121,11 +121,13 @@ 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 @@ 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 @@ 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 @@ 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 @@ - 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 @@ - 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 -Nru borgmatic-1.4.21/borgmatic/config/validate.py borgmatic-1.5.1/borgmatic/config/validate.py --- borgmatic-1.4.21/borgmatic/config/validate.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/config/validate.py 2020-02-03 17:45:10.000000000 +0000 @@ -6,7 +6,7 @@ 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 @@ 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 -Nru borgmatic-1.4.21/borgmatic/hooks/command.py borgmatic-1.5.1/borgmatic/hooks/command.py --- borgmatic-1.4.21/borgmatic/hooks/command.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/hooks/command.py 2020-02-03 17:45:10.000000000 +0000 @@ -6,6 +6,9 @@ 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 @@ 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 -Nru borgmatic-1.4.21/borgmatic/hooks/cronhub.py borgmatic-1.5.1/borgmatic/hooks/cronhub.py --- borgmatic-1.4.21/borgmatic/hooks/cronhub.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/hooks/cronhub.py 2020-02-03 17:45:10.000000000 +0000 @@ -13,7 +13,7 @@ } -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 -Nru borgmatic-1.4.21/borgmatic/hooks/cronitor.py borgmatic-1.5.1/borgmatic/hooks/cronitor.py --- borgmatic-1.4.21/borgmatic/hooks/cronitor.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/hooks/cronitor.py 2020-02-03 17:45:10.000000000 +0000 @@ -13,7 +13,7 @@ } -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 -Nru borgmatic-1.4.21/borgmatic/hooks/dispatch.py borgmatic-1.5.1/borgmatic/hooks/dispatch.py --- borgmatic-1.4.21/borgmatic/hooks/dispatch.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/hooks/dispatch.py 2020-02-03 17:45:10.000000000 +0000 @@ -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 @@ 'healthchecks': healthchecks, 'cronitor': cronitor, 'cronhub': cronhub, + 'pagerduty': pagerduty, 'postgresql_databases': postgresql, 'mysql_databases': mysql, } diff -Nru borgmatic-1.4.21/borgmatic/hooks/dump.py borgmatic-1.5.1/borgmatic/hooks/dump.py --- borgmatic-1.4.21/borgmatic/hooks/dump.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/hooks/dump.py 2020-02-03 17:45:10.000000000 +0000 @@ -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 @@ 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 -Nru borgmatic-1.4.21/borgmatic/hooks/healthchecks.py borgmatic-1.5.1/borgmatic/hooks/healthchecks.py --- borgmatic-1.4.21/borgmatic/hooks/healthchecks.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/hooks/healthchecks.py 2020-02-03 17:45:10.000000000 +0000 @@ -22,13 +22,14 @@ 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 @@ 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 -Nru borgmatic-1.4.21/borgmatic/hooks/monitor.py borgmatic-1.5.1/borgmatic/hooks/monitor.py --- borgmatic-1.4.21/borgmatic/hooks/monitor.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/hooks/monitor.py 2020-02-03 17:45:10.000000000 +0000 @@ -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 -Nru borgmatic-1.4.21/borgmatic/hooks/pagerduty.py borgmatic-1.5.1/borgmatic/hooks/pagerduty.py --- borgmatic-1.4.21/borgmatic/hooks/pagerduty.py 1970-01-01 00:00:00.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/hooks/pagerduty.py 2020-02-03 17:45:10.000000000 +0000 @@ -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 -Nru borgmatic-1.4.21/borgmatic/logger.py borgmatic-1.5.1/borgmatic/logger.py --- borgmatic-1.4.21/borgmatic/logger.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/borgmatic/logger.py 2020-02-03 17:45:10.000000000 +0000 @@ -110,7 +110,11 @@ 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 @@ 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 @@ 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 -Nru borgmatic-1.4.21/debian/changelog borgmatic-1.5.1/debian/changelog --- borgmatic-1.4.21/debian/changelog 2020-01-16 13:43:07.000000000 +0000 +++ borgmatic-1.5.1/debian/changelog 2020-02-04 17:25:04.000000000 +0000 @@ -1,3 +1,11 @@ +borgmatic (1.5.1-1) unstable; urgency=medium + + * Bump Standards-Version to 4.5.0 (no changes needed) + * New upstream version 1.5.1 + * d/salsa: Enable salsa ci + + -- Sebastien Badia Tue, 04 Feb 2020 18:25:04 +0100 + borgmatic (1.4.21-1) unstable; urgency=medium * New upstream release. diff -Nru borgmatic-1.4.21/debian/control borgmatic-1.5.1/debian/control --- borgmatic-1.4.21/debian/control 2020-01-16 13:43:07.000000000 +0000 +++ borgmatic-1.5.1/debian/control 2020-02-04 17:12:32.000000000 +0000 @@ -17,7 +17,7 @@ python3-pytest, python3-ruamel.yaml, python3-setuptools -Standards-Version: 4.4.1 +Standards-Version: 4.5.0 Rules-Requires-Root: no Homepage: https://torsion.org/borgmatic X-Python3-Version: >= 3.6 diff -Nru borgmatic-1.4.21/debian/salsa-ci.yml borgmatic-1.5.1/debian/salsa-ci.yml --- borgmatic-1.4.21/debian/salsa-ci.yml 1970-01-01 00:00:00.000000000 +0000 +++ borgmatic-1.5.1/debian/salsa-ci.yml 2020-02-04 17:22:48.000000000 +0000 @@ -0,0 +1,4 @@ +--- +include: + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/salsa-ci.yml + - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/pipeline-jobs.yml diff -Nru borgmatic-1.4.21/docs/Dockerfile borgmatic-1.5.1/docs/Dockerfile --- borgmatic-1.4.21/docs/Dockerfile 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/docs/Dockerfile 2020-02-03 17:45:10.000000000 +0000 @@ -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 @@ 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 diff -Nru borgmatic-1.4.21/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md borgmatic-1.5.1/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md --- borgmatic-1.4.21/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md 2020-02-03 17:45:10.000000000 +0000 @@ -29,6 +29,10 @@ 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: diff -Nru borgmatic-1.4.21/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md borgmatic-1.5.1/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md --- borgmatic-1.4.21/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md 1970-01-01 00:00:00.000000000 +0000 +++ borgmatic-1.5.1/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md 2020-02-03 17:45:10.000000000 +0000 @@ -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/) diff -Nru borgmatic-1.4.21/docs/how-to/backup-your-databases.md borgmatic-1.5.1/docs/how-to/backup-your-databases.md --- borgmatic-1.4.21/docs/how-to/backup-your-databases.md 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/docs/how-to/backup-your-databases.md 2020-02-03 17:45:10.000000000 +0000 @@ -112,6 +112,12 @@ (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. diff -Nru borgmatic-1.4.21/docs/how-to/extract-a-backup.md borgmatic-1.5.1/docs/how-to/extract-a-backup.md --- borgmatic-1.4.21/docs/how-to/extract-a-backup.md 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/docs/how-to/extract-a-backup.md 2020-02-03 17:45:10.000000000 +0000 @@ -31,6 +31,12 @@ (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 @@ 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: diff -Nru borgmatic-1.4.21/docs/how-to/inspect-your-backups.md borgmatic-1.5.1/docs/how-to/inspect-your-backups.md --- borgmatic-1.4.21/docs/how-to/inspect-your-backups.md 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/docs/how-to/inspect-your-backups.md 2020-02-03 17:45:10.000000000 +0000 @@ -95,7 +95,8 @@ ``` Note that if you use the `--log-file` flag, you are responsible for rotating -the log file so it doesn't grow too large. Also, there is a +the log file so it doesn't grow too large, for example with +[logrotate](https://wiki.archlinux.org/index.php/Logrotate). Also, there is a `--log-file-verbosity` flag to customize the log file's log level. diff -Nru borgmatic-1.4.21/docs/how-to/make-per-application-backups.md borgmatic-1.5.1/docs/how-to/make-per-application-backups.md --- borgmatic-1.4.21/docs/how-to/make-per-application-backups.md 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/docs/how-to/make-per-application-backups.md 2020-02-03 17:45:10.000000000 +0000 @@ -27,9 +27,10 @@ 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 diff -Nru borgmatic-1.4.21/docs/how-to/monitor-your-backups.md borgmatic-1.5.1/docs/how-to/monitor-your-backups.md --- borgmatic-1.4.21/docs/how-to/monitor-your-backups.md 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/docs/how-to/monitor-your-backups.md 2020-02-03 17:45:10.000000000 +0000 @@ -28,14 +28,15 @@ 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 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,32 @@ or it doesn't hear from borgmatic for a certain period of time. +## PagerDuty hook + +[PagerDuty](https://www.pagerduty.com/) provides incident monitoring and +alerting, and borgmatic has built-in integration with it. Once you create a +PagerDuty account and service +on their site, all you need to do is 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. + + ## Scripting borgmatic To consume the output of borgmatic in other software, you can include an @@ -234,6 +261,18 @@ 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/) diff -Nru borgmatic-1.4.21/docs/how-to/set-up-backups.md borgmatic-1.5.1/docs/how-to/set-up-backups.md --- borgmatic-1.4.21/docs/how-to/set-up-backups.md 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/docs/how-to/set-up-backups.md 2020-02-03 17:45:10.000000000 +0000 @@ -68,10 +68,13 @@ 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 +82,11 @@ 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). @@ -173,6 +175,9 @@ 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 @@ -204,8 +209,7 @@ ```bash sudo mv borgmatic.service borgmatic.timer /etc/systemd/system/ -sudo systemctl enable borgmatic.timer -sudo systemctl start borgmatic.timer +sudo systemctl enable --now borgmatic.timer ``` Feel free to modify the timer file based on how frequently you'd like @@ -214,10 +218,10 @@ ## Colored output Borgmatic produces colored terminal output by default. It is disabled when a -non-interactive terminal is detected (like a cron job). Otherwise, you can -disable it by passing the `--no-color` flag, setting the environment variable -`PY_COLORS=False`, or setting the `color` option to `false` in the `output` -section of configuration. +non-interactive terminal is detected (like a cron job), or when you use the +`--json` flag. Otherwise, you can disable it by passing the `--no-color` flag, +setting the environment variable `PY_COLORS=False`, or setting the `color` +option to `false` in the `output` section of configuration. ## Troubleshooting diff -Nru borgmatic-1.4.21/docs/README.md borgmatic-1.5.1/docs/README.md --- borgmatic-1.4.21/docs/README.md 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/docs/README.md 2020-02-03 17:45:10.000000000 +0000 @@ -20,9 +20,11 @@ - /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 @@ Healthchecks      Cronitor      Cronhub      +PagerDuty      rsync.net      BorgBase      @@ -78,6 +81,7 @@ * [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/) @@ -115,7 +119,12 @@ href="https://webchat.freenode.net/?channels=borgmatic">web chat or a native IRC client. -Other questions or comments? Contact . +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 diff -Nru borgmatic-1.4.21/docs/SECURITY.md borgmatic-1.5.1/docs/SECURITY.md --- borgmatic-1.4.21/docs/SECURITY.md 1970-01-01 00:00:00.000000000 +0000 +++ borgmatic-1.5.1/docs/SECURITY.md 2020-02-03 17:45:10.000000000 +0000 @@ -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. Binary files /tmp/tmpKoGV4A/1Zo5mjd3sM/borgmatic-1.4.21/docs/static/pagerduty.png and /tmp/tmpKoGV4A/wn8jY1Gun6/borgmatic-1.5.1/docs/static/pagerduty.png differ diff -Nru borgmatic-1.4.21/NEWS borgmatic-1.5.1/NEWS --- borgmatic-1.4.21/NEWS 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/NEWS 2020-02-03 17:45:10.000000000 +0000 @@ -1,3 +1,33 @@ +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. + * 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: diff -Nru borgmatic-1.4.21/README.md borgmatic-1.5.1/README.md --- borgmatic-1.4.21/README.md 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/README.md 2020-02-03 17:45:10.000000000 +0000 @@ -20,9 +20,11 @@ - /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 @@ Healthchecks      Cronitor      Cronhub      +PagerDuty      rsync.net      BorgBase      @@ -78,6 +81,7 @@ * [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/) @@ -115,7 +119,12 @@ href="https://webchat.freenode.net/?channels=borgmatic">web chat or a native IRC client. -Other questions or comments? Contact . +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 diff -Nru borgmatic-1.4.21/sample/systemd/borgmatic.service borgmatic-1.5.1/sample/systemd/borgmatic.service --- borgmatic-1.4.21/sample/systemd/borgmatic.service 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/sample/systemd/borgmatic.service 2020-02-03 17:45:10.000000000 +0000 @@ -15,6 +15,8 @@ IOWeight=100 Restart=no +# Prevent rate limiting of borgmatic log events. If you are using an older version of systemd that +# doesn't support this (pre-240 or so), you may have to remove this option. LogRateLimitIntervalSec=0 # Delay start to prevent backups running during boot. diff -Nru borgmatic-1.4.21/scripts/run-full-tests borgmatic-1.5.1/scripts/run-full-tests --- borgmatic-1.4.21/scripts/run-full-tests 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/scripts/run-full-tests 2020-02-03 17:45:10.000000000 +0000 @@ -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 diff -Nru borgmatic-1.4.21/SECURITY.md borgmatic-1.5.1/SECURITY.md --- borgmatic-1.4.21/SECURITY.md 1970-01-01 00:00:00.000000000 +0000 +++ borgmatic-1.5.1/SECURITY.md 2020-02-03 17:45:10.000000000 +0000 @@ -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. diff -Nru borgmatic-1.4.21/setup.py borgmatic-1.5.1/setup.py --- borgmatic-1.4.21/setup.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/setup.py 2020-02-03 17:45:10.000000000 +0000 @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.4.21' +VERSION = '1.5.1' setup( diff -Nru borgmatic-1.4.21/tests/integration/commands/test_arguments.py borgmatic-1.5.1/tests/integration/commands/test_arguments.py --- borgmatic-1.4.21/tests/integration/commands/test_arguments.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/integration/commands/test_arguments.py 2020-02-03 17:45:10.000000000 +0000 @@ -98,12 +98,14 @@ 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 @@ 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']) diff -Nru borgmatic-1.4.21/tests/integration/config/test_validate.py borgmatic-1.5.1/tests/integration/config/test_validate.py --- borgmatic-1.4.21/tests/integration/config/test_validate.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/integration/config/test_validate.py 2020-02-03 17:45:10.000000000 +0000 @@ -239,3 +239,28 @@ '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'], + } + } diff -Nru borgmatic-1.4.21/tests/unit/borg/test_check.py borgmatic-1.5.1/tests/unit/borg/test_check.py --- borgmatic-1.4.21/tests/unit/borg/test_check.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/borg/test_check.py 2020-02-03 17:45:10.000000000 +0000 @@ -158,6 +158,21 @@ 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} diff -Nru borgmatic-1.4.21/tests/unit/borg/test_create.py borgmatic-1.5.1/tests/unit/borg/test_create.py --- borgmatic-1.4.21/tests/unit/borg/test_create.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/borg/test_create.py 2020-02-03 17:45:10.000000000 +0000 @@ -145,9 +145,16 @@ 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 @@ 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 @@ 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_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 @@ 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 @@ '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_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_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 @@ 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) diff -Nru borgmatic-1.4.21/tests/unit/borg/test_list.py borgmatic-1.5.1/tests/unit/borg/test_list.py --- borgmatic-1.4.21/tests/unit/borg/test_list.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/borg/test_list.py 2020-02-03 17:45:10.000000000 +0000 @@ -7,6 +7,110 @@ 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( diff -Nru borgmatic-1.4.21/tests/unit/borg/test_prune.py borgmatic-1.5.1/tests/unit/borg/test_prune.py --- borgmatic-1.4.21/tests/unit/borg/test_prune.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/borg/test_prune.py 2020-02-03 17:45:10.000000000 +0000 @@ -75,9 +75,7 @@ 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 @@ 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_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_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() diff -Nru borgmatic-1.4.21/tests/unit/commands/test_borgmatic.py borgmatic-1.5.1/tests/unit/commands/test_borgmatic.py --- borgmatic-1.4.21/tests/unit/commands/test_borgmatic.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/commands/test_borgmatic.py 2020-02-03 17:45:10.000000000 +0000 @@ -3,6 +3,7 @@ from flexmock import flexmock +import borgmatic.hooks.command from borgmatic.commands import borgmatic as module @@ -13,7 +14,7 @@ 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_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 @@ 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 @@ 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 @@ 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 @@ 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 @@ 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 @@ ).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_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 @@ 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 @@ 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) diff -Nru borgmatic-1.4.21/tests/unit/config/test_collect.py borgmatic-1.5.1/tests/unit/config/test_collect.py --- borgmatic-1.4.21/tests/unit/config/test_collect.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/config/test_collect.py 2020-02-03 17:45:10.000000000 +0000 @@ -21,6 +21,14 @@ assert '/home/user/.etc/borgmatic/config.yaml' in config_paths +def test_get_default_config_paths_does_not_expand_home_when_false(): + flexmock(module.os, environ={'HOME': '/home/user'}) + + config_paths = module.get_default_config_paths(expand_home=False) + + assert '$HOME/.config/borgmatic/config.yaml' in config_paths + + def test_collect_config_filenames_collects_given_files(): config_paths = ('config.yaml', 'other.yaml') flexmock(module.os.path).should_receive('isdir').and_return(False) diff -Nru borgmatic-1.4.21/tests/unit/config/test_normalize.py borgmatic-1.5.1/tests/unit/config/test_normalize.py --- borgmatic-1.4.21/tests/unit/config/test_normalize.py 1970-01-01 00:00:00.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/config/test_normalize.py 2020-02-03 17:45:10.000000000 +0000 @@ -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 diff -Nru borgmatic-1.4.21/tests/unit/hooks/test_command.py borgmatic-1.5.1/tests/unit/hooks/test_command.py --- borgmatic-1.4.21/tests/unit/hooks/test_command.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/hooks/test_command.py 2020-02-03 17:45:10.000000000 +0000 @@ -1,4 +1,5 @@ import logging +import subprocess from flexmock import flexmock @@ -79,3 +80,19 @@ ).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()) diff -Nru borgmatic-1.4.21/tests/unit/hooks/test_cronhub.py borgmatic-1.5.1/tests/unit/hooks/test_cronhub.py --- borgmatic-1.4.21/tests/unit/hooks/test_cronhub.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/hooks/test_cronhub.py 2020-02-03 17:45:10.000000000 +0000 @@ -7,32 +7,42 @@ 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 + ) diff -Nru borgmatic-1.4.21/tests/unit/hooks/test_cronitor.py borgmatic-1.5.1/tests/unit/hooks/test_cronitor.py --- borgmatic-1.4.21/tests/unit/hooks/test_cronitor.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/hooks/test_cronitor.py 2020-02-03 17:45:10.000000000 +0000 @@ -7,25 +7,33 @@ 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 + ) diff -Nru borgmatic-1.4.21/tests/unit/hooks/test_dump.py borgmatic-1.5.1/tests/unit/hooks/test_dump.py --- borgmatic-1.4.21/tests/unit/hooks/test_dump.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/hooks/test_dump.py 2020-02-03 17:45:10.000000000 +0000 @@ -66,6 +66,7 @@ 'databases', 'bar', None ).and_return('databases/localhost/bar') + flexmock(module.os.path).should_receive('isdir').and_return(False) flexmock(module.os).should_receive('remove').with_args('databases/localhost/foo').once() flexmock(module.os).should_receive('remove').with_args('databases/localhost/bar').once() flexmock(module.os).should_receive('listdir').with_args('databases/localhost').and_return( @@ -75,6 +76,21 @@ flexmock(module.os).should_receive('rmdir').with_args('databases/localhost').once() module.remove_database_dumps('databases', databases, 'SuperDB', 'test.yaml', dry_run=False) + + +def test_remove_database_dumps_removes_dump_in_directory_format(): + databases = [{'name': 'foo'}] + flexmock(module).should_receive('make_database_dump_filename').with_args( + 'databases', 'foo', None + ).and_return('databases/localhost/foo') + + flexmock(module.os.path).should_receive('isdir').and_return(True) + flexmock(module.os).should_receive('remove').never() + flexmock(module.shutil).should_receive('rmtree').with_args('databases/localhost/foo').once() + flexmock(module.os).should_receive('listdir').with_args('databases/localhost').and_return([]) + flexmock(module.os).should_receive('rmdir').with_args('databases/localhost').once() + + module.remove_database_dumps('databases', databases, 'SuperDB', 'test.yaml', dry_run=False) def test_remove_database_dumps_with_dry_run_skips_removal(): diff -Nru borgmatic-1.4.21/tests/unit/hooks/test_healthchecks.py borgmatic-1.5.1/tests/unit/hooks/test_healthchecks.py --- borgmatic-1.4.21/tests/unit/hooks/test_healthchecks.py 2019-12-20 22:04:49.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/hooks/test_healthchecks.py 2020-02-03 17:45:10.000000000 +0000 @@ -4,7 +4,7 @@ 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_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_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_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 @@ '{}/{}'.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 @@ 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 @@ '{}/{}'.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 @@ '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 @@ 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, + ) diff -Nru borgmatic-1.4.21/tests/unit/hooks/test_pagerduty.py borgmatic-1.5.1/tests/unit/hooks/test_pagerduty.py --- borgmatic-1.4.21/tests/unit/hooks/test_pagerduty.py 1970-01-01 00:00:00.000000000 +0000 +++ borgmatic-1.5.1/tests/unit/hooks/test_pagerduty.py 2020-02-03 17:45:10.000000000 +0000 @@ -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 + )