From dd16504329489c0921427f3ae414231039b08bd7 Mon Sep 17 00:00:00 2001 From: Matthew Daley Date: Fri, 13 Dec 2019 15:42:46 +1300 Subject: [PATCH 01/39] Use --remote-path, --debug and --info when checking for repo existence These are currently not being used in the call to `borg info` performed as part of the borgmatic init command to check whether or not the repo already exists. --- borgmatic/borg/init.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/borgmatic/borg/init.py b/borgmatic/borg/init.py index 152f8c1b..08256aef 100644 --- a/borgmatic/borg/init.py +++ b/borgmatic/borg/init.py @@ -23,7 +23,13 @@ def initialize_repository( whether the repository should be append-only, and the storage quota to use, initialize the repository. If the repository already exists, then log and skip initialization. ''' - info_command = (local_path, 'info', repository) + info_command = ( + (local_path, 'info') + + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + + (('--debug',) if logger.isEnabledFor(logging.DEBUG) else ()) + + (('--remote-path', remote_path) if remote_path else ()) + + (repository,) + ) logger.debug(' '.join(info_command)) try: From f1358d52aaa9e6926636a8660f57669fa2abe8ae Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 12 Dec 2019 21:50:24 -0800 Subject: [PATCH 02/39] Add "borgmatic init" repository probing fix to NEWS. --- NEWS | 3 +++ setup.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/NEWS b/NEWS index a0eac709..49a167f9 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,6 @@ +1.4.20 + * Fix repository probing during "borgmatic init" to respect verbosity flag and remote_path option. + 1.4.19 * #259: Optionally change the internal database dump path via "borgmatic_source_directory" option in location configuration section. diff --git a/setup.py b/setup.py index 18c8ff87..e61d3ac8 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.4.19' +VERSION = '1.4.20' setup( From e009bfeaa20d173ebe189cecb47c43819f747aa0 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 12 Dec 2019 22:54:45 -0800 Subject: [PATCH 03/39] Update Healthchecks/Cronitor/Cronhub monitoring integrations to fire for "check" and "prune" actions, not just "create" (#249). --- NEWS | 2 + borgmatic/commands/borgmatic.py | 65 ++++++++++++++------------- docs/how-to/monitor-your-backups.md | 31 +++++++------ tests/unit/commands/test_borgmatic.py | 35 ++++++++++++++- 4 files changed, 87 insertions(+), 46 deletions(-) diff --git a/NEWS b/NEWS index 49a167f9..61abaf45 100644 --- a/NEWS +++ b/NEWS @@ -1,5 +1,7 @@ 1.4.20 * Fix repository probing during "borgmatic init" to respect verbosity flag and remote_path option. + * #249: Update Healthchecks/Cronitor/Cronhub monitoring integrations to fire for "check" and + "prune" actions, not just "create". 1.4.19 * #259: Optionally change the internal database dump path via "borgmatic_source_directory" option diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 3b539764..f3da1618 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -53,8 +53,8 @@ def run_configuration(config_filename, config, arguments): encountered_error = None error_repository = '' - if 'create' in arguments: - try: + try: + if {'prune', 'create', 'check'}.intersection(arguments): dispatch.call_hooks( 'ping_monitor', hooks, @@ -63,6 +63,7 @@ def run_configuration(config_filename, config, arguments): monitor.State.START, global_arguments.dry_run, ) + if 'create' in arguments: command.execute_hook( hooks.get('before_backup'), hooks.get('umask'), @@ -78,11 +79,11 @@ def run_configuration(config_filename, config, arguments): location, global_arguments.dry_run, ) - except (OSError, CalledProcessError) as error: - encountered_error = error - yield from make_error_log_records( - '{}: Error running pre-backup hook'.format(config_filename), error - ) + except (OSError, CalledProcessError) as error: + encountered_error = error + yield from make_error_log_records( + '{}: Error running pre-backup hook'.format(config_filename), error + ) if not encountered_error: for repository_path in location['repositories']: @@ -105,31 +106,33 @@ def run_configuration(config_filename, config, arguments): '{}: Error running actions for repository'.format(repository_path), error ) - if 'create' in arguments and not encountered_error: + if not encountered_error: try: - dispatch.call_hooks( - 'remove_database_dumps', - hooks, - config_filename, - dump.DATABASE_HOOK_NAMES, - location, - global_arguments.dry_run, - ) - command.execute_hook( - hooks.get('after_backup'), - hooks.get('umask'), - config_filename, - 'post-backup', - global_arguments.dry_run, - ) - dispatch.call_hooks( - 'ping_monitor', - hooks, - config_filename, - monitor.MONITOR_HOOK_NAMES, - monitor.State.FINISH, - global_arguments.dry_run, - ) + if 'create' in arguments: + dispatch.call_hooks( + 'remove_database_dumps', + hooks, + config_filename, + dump.DATABASE_HOOK_NAMES, + location, + global_arguments.dry_run, + ) + command.execute_hook( + hooks.get('after_backup'), + hooks.get('umask'), + config_filename, + 'post-backup', + global_arguments.dry_run, + ) + if {'prune', 'create', 'check'}.intersection(arguments): + dispatch.call_hooks( + 'ping_monitor', + hooks, + config_filename, + monitor.MONITOR_HOOK_NAMES, + monitor.State.FINISH, + global_arguments.dry_run, + ) except (OSError, CalledProcessError) as error: encountered_error = error yield from make_error_log_records( diff --git a/docs/how-to/monitor-your-backups.md b/docs/how-to/monitor-your-backups.md index cddadb01..f2f105da 100644 --- a/docs/how-to/monitor-your-backups.md +++ b/docs/how-to/monitor-your-backups.md @@ -116,21 +116,22 @@ hooks: With this hook in place, borgmatic pings your Healthchecks project when a backup begins, ends, or errors. Specifically, before the `before_backup` -hooks run, borgmatic lets Healthchecks know that a backup has started. +hooks run, borgmatic lets Healthchecks know that it has started if any of +the `prune`, `create`, or `check` actions are run. -Then, if the backup completes successfully, borgmatic notifies Healthchecks of +Then, if the actions complete successfully, borgmatic notifies Healthchecks of the success after the `after_backup` hooks run, and includes borgmatic logs in the payload data sent to Healthchecks. This means that borgmatic logs show up in the Healthchecks UI, although be aware that Healthchecks currently has a 10-kilobyte limit for the logs in each ping. -If an error occurs during the backup, borgmatic notifies Healthchecks after +If an error occurs during any action, borgmatic notifies Healthchecks after the `on_error` hooks run, also tacking on logs including the error itself. But -the logs are only included for errors that occur within the borgmatic `create` -action (and not other actions). +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 level (`--verbosity`, `--syslog-verbosity`, etc.), +other borgmatic verbosity levels (`--verbosity`, `--syslog-verbosity`, etc.), as there is not currently a dedicated Healthchecks verbosity setting. You can configure Healthchecks to notify you by a [variety of @@ -155,10 +156,11 @@ hooks: With this hook in place, borgmatic pings your Cronitor monitor when a backup begins, ends, or errors. Specifically, before the `before_backup` -hooks run, borgmatic lets Cronitor know that a backup has started. Then, -if the backup completes successfully, borgmatic notifies Cronitor of the -success after the `after_backup` hooks run. And if an error occurs during the -backup, borgmatic notifies Cronitor after the `on_error` hooks run. +hooks run, borgmatic lets Cronitor know that it has started if any of the +`prune`, `create`, or `check` actions are run. Then, if the actions complete +successfully, borgmatic notifies Cronitor of the success after the +`after_backup` hooks run. And if an error occurs during any action, borgmatic +notifies Cronitor after the `on_error` hooks run. You can configure Cronitor to notify you by a [variety of mechanisms](https://cronitor.io/docs/cron-job-notifications) when backups fail @@ -182,10 +184,11 @@ hooks: With this hook in place, borgmatic pings your Cronhub monitor when a backup begins, ends, or errors. Specifically, before the `before_backup` -hooks run, borgmatic lets Cronhub know that a backup has started. Then, -if the backup completes successfully, borgmatic notifies Cronhub of the -success after the `after_backup` hooks run. And if an error occurs during the -backup, borgmatic notifies Cronhub after the `on_error` hooks run. +hooks run, borgmatic lets Cronhub know that it has started if any of the +`prune`, `create`, or `check` actions are run. Then, if the actions complete +successfully, borgmatic notifies Cronhub of the success after the +`after_backup` hooks run. And if an error occurs during any action, borgmatic +notifies Cronhub after the `on_error` hooks run. Note that even though you configure borgmatic with the "start" variant of the ping URL, borgmatic substitutes the correct state into the URL when pinging diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index 1bdb3213..a2b98f37 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -20,7 +20,18 @@ def test_run_configuration_runs_actions_for_each_repository(): assert results == expected_results -def test_run_configuration_executes_hooks_for_create_action(): +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.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()} + + list(module.run_configuration('test.yaml', config, arguments)) + + +def test_run_configuration_executes_and_calls_hooks_for_create_action(): flexmock(module.borg_environment).should_receive('initialize') flexmock(module.command).should_receive('execute_hook').twice() flexmock(module.dispatch).should_receive('call_hooks').at_least().twice() @@ -31,6 +42,28 @@ def test_run_configuration_executes_hooks_for_create_action(): 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.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()} + + list(module.run_configuration('test.yaml', config, arguments)) + + +def test_run_configuration_does_not_trigger_hooks_for_list_action(): + flexmock(module.borg_environment).should_receive('initialize') + flexmock(module.command).should_receive('execute_hook').never() + 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()} + + list(module.run_configuration('test.yaml', config, arguments)) + + def test_run_configuration_logs_actions_error(): flexmock(module.borg_environment).should_receive('initialize') flexmock(module.command).should_receive('execute_hook') From afaabd14a862764a7e8d57377deb3eb7c02799ba Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 13 Dec 2019 11:42:17 -0800 Subject: [PATCH 04/39] Clarify documentation on how /etc/borgmatic.d/ configuration files are interpreted. --- docs/how-to/make-per-application-backups.md | 5 +++++ docs/how-to/set-up-backups.md | 12 ++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/how-to/make-per-application-backups.md b/docs/how-to/make-per-application-backups.md index f962b813..840e2a6d 100644 --- a/docs/how-to/make-per-application-backups.md +++ b/docs/how-to/make-per-application-backups.md @@ -22,6 +22,11 @@ When you set up multiple configuration files like this, borgmatic will run each one in turn from a single borgmatic invocation. This includes, by default, the traditional `/etc/borgmatic/config.yaml` as well. +Each configuration file is interpreted independently, as if you ran borgmatic +for each configuration file one at a time. In other words, borgmatic does not +perform any merging of configuration files by default. If you'd like borgmatic +to merge your configuration files, see below about configuration includes. + And if you need even more customizability, you can specify alternate configuration paths on the command-line with borgmatic's `--config` option. See `borgmatic --help` for more information. diff --git a/docs/how-to/set-up-backups.md b/docs/how-to/set-up-backups.md index 2a6704dd..bc78237c 100644 --- a/docs/how-to/set-up-backups.md +++ b/docs/how-to/set-up-backups.md @@ -3,15 +3,11 @@ title: How to set up backups with borgmatic --- ## Installation -To get up and running, first [install -Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at -least version 1.1. +First, [install +Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at least +version 1.1. -By default, borgmatic looks for its configuration files in `/etc/borgmatic/` -and `/etc/borgmatic.d/`, where the root user typically has read access. - -So, to download and install borgmatic as the root user, run the following -commands: +Then, download and install borgmatic by running the following command: ```bash sudo pip3 install --user --upgrade borgmatic From f787dfe809561cb6d27005b43b9d58bd958bb818 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 17 Dec 2019 11:46:27 -0800 Subject: [PATCH 05/39] Override particular configuration options from the command-line via "--override" flag (#268). --- NEWS | 5 ++ borgmatic/commands/arguments.py | 7 ++ borgmatic/commands/borgmatic.py | 6 +- borgmatic/config/override.py | 71 ++++++++++++++++++ borgmatic/config/validate.py | 13 ++-- docs/how-to/make-per-application-backups.md | 34 +++++++++ setup.py | 2 +- tests/integration/config/test_override.py | 40 ++++++++++ tests/integration/config/test_validate.py | 27 +++++++ tests/unit/config/test_override.py | 82 +++++++++++++++++++++ 10 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 borgmatic/config/override.py create mode 100644 tests/integration/config/test_override.py create mode 100644 tests/unit/config/test_override.py diff --git a/NEWS b/NEWS index 61abaf45..dd1c9539 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,8 @@ +1.4.21.dev0 + * #268: Override particular configuration options from the command-line via "--override" flag. See + the documentation for more information: + https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides + 1.4.20 * Fix repository probing during "borgmatic init" to respect verbosity flag and remote_path option. * #249: Update Healthchecks/Cronitor/Cronhub monitoring integrations to fire for "check" and diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 738981ca..63030066 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -164,6 +164,13 @@ def parse_arguments(*unparsed_arguments): default=None, help='Write log messages to this file instead of syslog', ) + global_group.add_argument( + '--override', + metavar='SECTION.OPTION=VALUE', + nargs='+', + dest='overrides', + help='One or more configuration file options to override with specified values', + ) global_group.add_argument( '--version', dest='version', diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index f3da1618..ce3719d8 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -372,7 +372,7 @@ def run_actions( yield json.loads(json_output) -def load_configurations(config_filenames): +def load_configurations(config_filenames, overrides=None): ''' Given a sequence of configuration filenames, load and validate each configuration file. Return the results as a tuple of: dict of configuration filename to corresponding parsed configuration, @@ -386,7 +386,7 @@ def load_configurations(config_filenames): for config_filename in config_filenames: try: configs[config_filename] = validate.parse_configuration( - config_filename, validate.schema_filename() + config_filename, validate.schema_filename(), overrides ) except (ValueError, OSError, validate.Validation_error) as error: logs.extend( @@ -584,7 +584,7 @@ def main(): # pragma: no cover sys.exit(0) config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths)) - configs, parse_logs = load_configurations(config_filenames) + configs, parse_logs = load_configurations(config_filenames, global_arguments.overrides) colorama.init(autoreset=True, strip=not should_do_markup(global_arguments.no_color, configs)) try: diff --git a/borgmatic/config/override.py b/borgmatic/config/override.py new file mode 100644 index 00000000..eb86077b --- /dev/null +++ b/borgmatic/config/override.py @@ -0,0 +1,71 @@ +import io + +import ruamel.yaml + + +def set_values(config, keys, value): + ''' + Given a hierarchy of configuration dicts, a sequence of parsed key strings, and a string value, + descend into the hierarchy based on the keys to set the value into the right place. + ''' + if not keys: + return + + first_key = keys[0] + if len(keys) == 1: + config[first_key] = value + return + + if first_key not in config: + config[first_key] = {} + + set_values(config[first_key], keys[1:], value) + + +def convert_value_type(value): + ''' + Given a string value, determine its logical type (string, boolean, integer, etc.), and return it + converted to that type. + ''' + return ruamel.yaml.YAML(typ='safe').load(io.StringIO(value)) + + +def parse_overrides(raw_overrides): + ''' + Given a sequence of configuration file override strings in the form of "section.option=value", + parse and return a sequence of tuples (keys, values), where keys is a sequence of strings. For + instance, given the following raw overrides: + + ['section.my_option=value1', 'section.other_option=value2'] + + ... return this: + + ( + (('section', 'my_option'), 'value1'), + (('section', 'other_option'), 'value2'), + ) + + Raise ValueError if an override can't be parsed. + ''' + if not raw_overrides: + return () + + try: + return tuple( + (tuple(raw_keys.split('.')), convert_value_type(value)) + for raw_override in raw_overrides + for raw_keys, value in (raw_override.split('=', 1),) + ) + except ValueError: + raise ValueError('Invalid override. Make sure you use the form: SECTION.OPTION=VALUE') + + +def apply_overrides(config, raw_overrides): + ''' + Given a sequence of configuration file override strings in the form of "section.option=value" + and a configuration dict, parse each override and set it the configuration dict. + ''' + overrides = parse_overrides(raw_overrides) + + for (keys, value) in overrides: + set_values(config, keys, value) diff --git a/borgmatic/config/validate.py b/borgmatic/config/validate.py index 1e421d18..b7d34a9f 100644 --- a/borgmatic/config/validate.py +++ b/borgmatic/config/validate.py @@ -6,7 +6,7 @@ import pykwalify.core import pykwalify.errors import ruamel.yaml -from borgmatic.config import load +from borgmatic.config import load, override def schema_filename(): @@ -82,11 +82,12 @@ def remove_examples(schema): return schema -def parse_configuration(config_filename, schema_filename): +def parse_configuration(config_filename, schema_filename, overrides=None): ''' - Given the path to a config filename in YAML format and the path to a schema filename in - pykwalify YAML schema format, return the parsed configuration as a data structure of nested - dicts and lists corresponding to the schema. Example return value: + Given the path to a config filename in YAML format, the path to a schema filename in pykwalify + YAML schema format, a sequence of configuration file override strings in the form of + "section.option=value", return the parsed configuration as a data structure of nested dicts and + lists corresponding to the schema. Example return value: {'location': {'source_directories': ['/home', '/etc'], 'repository': 'hostname.borg'}, 'retention': {'keep_daily': 7}, 'consistency': {'checks': ['repository', 'archives']}} @@ -102,6 +103,8 @@ def parse_configuration(config_filename, schema_filename): except (ruamel.yaml.error.YAMLError, RecursionError) as error: raise Validation_error(config_filename, (str(error),)) + override.apply_overrides(config, overrides) + validator = pykwalify.core.Core(source_data=config, schema_data=remove_examples(schema)) parsed_result = validator.validate(raise_exception=False) diff --git a/docs/how-to/make-per-application-backups.md b/docs/how-to/make-per-application-backups.md index 840e2a6d..cd257460 100644 --- a/docs/how-to/make-per-application-backups.md +++ b/docs/how-to/make-per-application-backups.md @@ -115,6 +115,40 @@ Note that this `<<` include merging syntax is only for merging in mappings directly, please see the section above about standard includes. +## Configuration overrides + +In more complex multi-application setups, you may want to override particular +borgmatic configuration file options at the time you run borgmatic. For +instance, you could reuse a common configuration file for multiple +applications, but then set the repository for each application at runtime. Or +you might want to try a variant of an option for testing purposes without +actually touching your configuration file. + +Whatever the reason, you can override borgmatic configuration options at the +command-line via the `--override` flag. Here's an example: + +```bash +borgmatic create --override location.remote_path=borg1 +``` + +What this does is load your configuration files, and for each one, disregard +the configured value for the `remote_path` option in the `location` section, +and use the value of `borg1` instead. + +Note that the value is parsed as an actual YAML string, so you can even set +list values by using brackets. For instance: + +```bash +borgmatic create --override location.repositories=[test1.borg,test2.borg] +``` + +There is not currently a way to override a single element of a list without +replacing the whole list. + +Be sure to quote your overrides if they contain spaces or other characters +that your shell may interpret. + + ## Related documentation * [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/) diff --git a/setup.py b/setup.py index e61d3ac8..0d87bc59 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.4.20' +VERSION = '1.4.21.dev0' setup( diff --git a/tests/integration/config/test_override.py b/tests/integration/config/test_override.py new file mode 100644 index 00000000..cfcd3394 --- /dev/null +++ b/tests/integration/config/test_override.py @@ -0,0 +1,40 @@ +import pytest + +from borgmatic.config import override as module + + +@pytest.mark.parametrize( + 'value,expected_result', + ( + ('thing', 'thing'), + ('33', 33), + ('33b', '33b'), + ('true', True), + ('false', False), + ('[foo]', ['foo']), + ('[foo, bar]', ['foo', 'bar']), + ), +) +def test_convert_value_type_coerces_values(value, expected_result): + assert module.convert_value_type(value) == expected_result + + +def test_apply_overrides_updates_config(): + raw_overrides = [ + 'section.key=value1', + 'other_section.thing=value2', + 'section.nested.key=value3', + 'new.foo=bar', + ] + config = { + 'section': {'key': 'value', 'other': 'other_value'}, + 'other_section': {'thing': 'thing_value'}, + } + + module.apply_overrides(config, raw_overrides) + + assert config == { + 'section': {'key': 'value1', 'other': 'other_value', 'nested': {'key': 'value3'}}, + 'other_section': {'thing': 'value2'}, + 'new': {'foo': 'bar'}, + } diff --git a/tests/integration/config/test_validate.py b/tests/integration/config/test_validate.py index 706743bc..cbd4f5ba 100644 --- a/tests/integration/config/test_validate.py +++ b/tests/integration/config/test_validate.py @@ -212,3 +212,30 @@ def test_parse_configuration_raises_for_validation_error(): with pytest.raises(module.Validation_error): module.parse_configuration('config.yaml', 'schema.yaml') + + +def test_parse_configuration_applies_overrides(): + mock_config_and_schema( + ''' + location: + source_directories: + - /home + + repositories: + - hostname.borg + + local_path: borg1 + ''' + ) + + result = module.parse_configuration( + 'config.yaml', 'schema.yaml', overrides=['location.local_path=borg2'] + ) + + assert result == { + 'location': { + 'source_directories': ['/home'], + 'repositories': ['hostname.borg'], + 'local_path': 'borg2', + } + } diff --git a/tests/unit/config/test_override.py b/tests/unit/config/test_override.py new file mode 100644 index 00000000..6925ca42 --- /dev/null +++ b/tests/unit/config/test_override.py @@ -0,0 +1,82 @@ +import pytest +from flexmock import flexmock + +from borgmatic.config import override as module + + +def test_set_values_with_empty_keys_bails(): + config = {} + + module.set_values(config, keys=(), value='value') + + assert config == {} + + +def test_set_values_with_one_key_sets_it_into_config(): + config = {} + + module.set_values(config, keys=('key',), value='value') + + assert config == {'key': 'value'} + + +def test_set_values_with_one_key_overwrites_existing_key(): + config = {'key': 'old_value', 'other': 'other_value'} + + module.set_values(config, keys=('key',), value='value') + + assert config == {'key': 'value', 'other': 'other_value'} + + +def test_set_values_with_multiple_keys_creates_hierarchy(): + config = {} + + module.set_values(config, ('section', 'key'), 'value') + + assert config == {'section': {'key': 'value'}} + + +def test_set_values_with_multiple_keys_updates_hierarchy(): + config = {'section': {'other': 'other_value'}} + module.set_values(config, ('section', 'key'), 'value') + + assert config == {'section': {'key': 'value', 'other': 'other_value'}} + + +def test_parse_overrides_splits_keys_and_values(): + flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value) + raw_overrides = ['section.my_option=value1', 'section.other_option=value2'] + expected_result = ( + (('section', 'my_option'), 'value1'), + (('section', 'other_option'), 'value2'), + ) + + module.parse_overrides(raw_overrides) == expected_result + + +def test_parse_overrides_allows_value_with_equal_sign(): + flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value) + raw_overrides = ['section.option=this===value'] + expected_result = ((('section', 'option'), 'this===value'),) + + module.parse_overrides(raw_overrides) == expected_result + + +def test_parse_overrides_raises_on_missing_equal_sign(): + flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value) + raw_overrides = ['section.option'] + + with pytest.raises(ValueError): + module.parse_overrides(raw_overrides) + + +def test_parse_overrides_allows_value_with_single_key(): + flexmock(module).should_receive('convert_value_type').replace_with(lambda value: value) + raw_overrides = ['option=value'] + expected_result = ((('option',), 'value'),) + + module.parse_overrides(raw_overrides) == expected_result + + +def test_parse_overrides_handles_empty_overrides(): + module.parse_overrides(raw_overrides=None) == () From ed2ca9f47691702e7c8348cb009af907999e7308 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 17 Dec 2019 20:06:25 -0800 Subject: [PATCH 06/39] Sign release files. --- scripts/release | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/release b/scripts/release index 0477b6ba..afc99c30 100755 --- a/scripts/release +++ b/scripts/release @@ -23,10 +23,11 @@ git push github $version rm -fr dist python3 setup.py bdist_wheel python3 setup.py sdist +gpg --detach-sign --armor dist/* twine upload -r pypi dist/borgmatic-*.tar.gz twine upload -r pypi dist/borgmatic-*-py3-none-any.whl -# Set release changelogs on projects.evoworx.org and GitHub. +# Set release changelogs on projects.torsion.org and GitHub. release_changelog="$(cat NEWS | sed '/^$/q' | grep -v '^\S')" escaped_release_changelog="$(echo "$release_changelog" | sed -z 's/\n/\\n/g' | sed -z 's/\"/\\"/g')" curl --silent --request POST \ From d64bcd5e83990e9682815ba8f4abad73c4ca564f Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 17 Dec 2019 20:12:41 -0800 Subject: [PATCH 07/39] When pruning with verbosity level 1, list pruned and kept archives. --- NEWS | 2 ++ borgmatic/borg/prune.py | 2 +- tests/unit/borg/test_prune.py | 4 +++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/NEWS b/NEWS index dd1c9539..f27b9b1e 100644 --- a/NEWS +++ b/NEWS @@ -2,6 +2,8 @@ * #268: Override particular configuration options from the command-line via "--override" flag. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides + * When pruning with verbosity level 1, list pruned and kept archives. Previously, this information + was only shown at verbosity level 2. 1.4.20 * Fix repository probing during "borgmatic init" to respect verbosity flag and remote_path option. diff --git a/borgmatic/borg/prune.py b/borgmatic/borg/prune.py index 0913cdeb..2c4811eb 100644 --- a/borgmatic/borg/prune.py +++ b/borgmatic/borg/prune.py @@ -58,7 +58,7 @@ def prune_archives( + (('--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',) if logger.getEffectiveLevel() == logging.INFO else ()) + + (('--info', '--list') if logger.getEffectiveLevel() == logging.INFO else ()) + (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (('--dry-run',) if dry_run else ()) + (('--stats',) if stats else ()) diff --git a/tests/unit/borg/test_prune.py b/tests/unit/borg/test_prune.py index 80cce836..b2b4785a 100644 --- a/tests/unit/borg/test_prune.py +++ b/tests/unit/borg/test_prune.py @@ -75,7 +75,9 @@ def test_prune_archives_with_log_info_calls_borg_with_info_parameter(): flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return( BASE_PRUNE_FLAGS ) - insert_execute_command_mock(PRUNE_COMMAND + ('--stats', '--info', 'repo'), logging.INFO) + insert_execute_command_mock( + PRUNE_COMMAND + ('--stats', '--info', '--list', 'repo'), logging.INFO + ) insert_logging_mock(logging.INFO) module.prune_archives( From 6bfa0783b933cbb8282c219f84355c6b9c2285b0 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 17 Dec 2019 20:16:13 -0800 Subject: [PATCH 08/39] Clarify that the documentation suggestion form is only for documentation. --- docs/_includes/components/suggestion-form.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_includes/components/suggestion-form.html b/docs/_includes/components/suggestion-form.html index c4e59b20..8e3a73a6 100644 --- a/docs/_includes/components/suggestion-form.html +++ b/docs/_includes/components/suggestion-form.html @@ -1,12 +1,12 @@

Improve this documentation

Have an idea on how to make this documentation even better? Send your -feedback below! (But if you need help installing or using borgmatic, please -use our issue tracker -instead.)

+feedback below! But if you need help with borgmatic, or have an idea for a +borgmatic feature, please use our issue +tracker instead.

-
+
From 911668f0c8737ba7b3c955aa57f718a206a4f331 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 20 Dec 2019 13:58:02 -0800 Subject: [PATCH 09/39] Only trigger "on_error" hooks and monitoring failures for "prune", "create", and "check" actions, and not for other actions (#270). --- NEWS | 2 ++ borgmatic/commands/borgmatic.py | 5 +++-- docs/how-to/monitor-your-backups.md | 17 +++++++++-------- tests/unit/commands/test_borgmatic.py | 2 +- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/NEWS b/NEWS index f27b9b1e..275dcf3b 100644 --- a/NEWS +++ b/NEWS @@ -2,6 +2,8 @@ * #268: Override particular configuration options from the command-line via "--override" flag. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides + * #270: Only trigger "on_error" hooks and monitoring failures for "prune", "create", and "check" + actions, and not for other actions. * When pruning with verbosity level 1, list pruned and kept archives. Previously, this information was only shown at verbosity level 2. diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index ce3719d8..b39668ad 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -52,9 +52,10 @@ def run_configuration(config_filename, config, arguments): borg_environment.initialize(storage) encountered_error = None error_repository = '' + prune_create_or_check = {'prune', 'create', 'check'}.intersection(arguments) try: - if {'prune', 'create', 'check'}.intersection(arguments): + if prune_create_or_check: dispatch.call_hooks( 'ping_monitor', hooks, @@ -139,7 +140,7 @@ def run_configuration(config_filename, config, arguments): '{}: Error running post-backup hook'.format(config_filename), error ) - if encountered_error: + if encountered_error and prune_create_or_check: try: command.execute_hook( hooks.get('on_error'), diff --git a/docs/how-to/monitor-your-backups.md b/docs/how-to/monitor-your-backups.md index f2f105da..6f6f1505 100644 --- a/docs/how-to/monitor-your-backups.md +++ b/docs/how-to/monitor-your-backups.md @@ -57,10 +57,10 @@ tests](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/). ## Error hooks -When an error occurs during a backup or another action, borgmatic can run -configurable shell commands to fire off custom error notifications or take -other actions, so you can get alerted as soon as something goes wrong. Here's -a not-so-useful example: +When an error occurs during a `prune`, `create`, or `check` action, borgmatic +can run configurable shell commands to fire off custom error notifications or +take other actions, so you can get alerted as soon as something goes wrong. +Here's a not-so-useful example: ```yaml hooks: @@ -91,10 +91,11 @@ here: * `output`: output of the command that failed (may be blank if an error occurred without running a command) -Note that borgmatic runs the `on_error` hooks for any action in which an error -occurs, not just the `create` action. But borgmatic does not run `on_error` -hooks if an error occurs within a `before_everything` or `after_everything` -hook. For more about hooks, see the [borgmatic hooks +Note that borgmatic runs the `on_error` hooks only for `prune`, `create`, or +`check` actions or hooks in which an error occurs, and not other actions. +borgmatic does not run `on_error` hooks if an error occurs within a +`before_everything` or `after_everything` hook. For more about hooks, see the +[borgmatic hooks documentation](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/), especially the security information. diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index a2b98f37..ccc63754 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -119,7 +119,7 @@ def test_run_configuration_logs_on_error_hook_error(): ).and_return(expected_results[1:]) flexmock(module).should_receive('run_actions').and_raise(OSError) config = {'location': {'repositories': ['foo']}} - arguments = {'global': flexmock(dry_run=False)} + arguments = {'global': flexmock(dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, arguments)) From e4f1094569eda0110e6eeb315da42f03fd6746e3 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 20 Dec 2019 14:04:49 -0800 Subject: [PATCH 10/39] Bump version for release. --- NEWS | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/NEWS b/NEWS index 275dcf3b..8e8adce4 100644 --- a/NEWS +++ b/NEWS @@ -1,4 +1,4 @@ -1.4.21.dev0 +1.4.21 * #268: Override particular configuration options from the command-line via "--override" flag. See the documentation for more information: https://torsion.org/borgmatic/docs/how-to/make-per-application-backups/#configuration-overrides diff --git a/setup.py b/setup.py index 0d87bc59..d3ae2168 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.4.21.dev0' +VERSION = '1.4.21' setup( From 5b1beda82b50ebff55d9bdad05e489ee1f1a4032 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 31 Dec 2019 15:06:53 -0800 Subject: [PATCH 11/39] Add logrotate documentation suggestion. --- docs/how-to/inspect-your-backups.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/how-to/inspect-your-backups.md b/docs/how-to/inspect-your-backups.md index cb528080..d960303c 100644 --- a/docs/how-to/inspect-your-backups.md +++ b/docs/how-to/inspect-your-backups.md @@ -95,7 +95,8 @@ borgmatic --log-file /path/to/file.log ``` Note that if you use the `--log-file` flag, you are responsible for rotating -the log file so it doesn't grow too large. Also, there is a +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. From 24e1516ec50b73df7612862f95f4bff956268445 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 1 Jan 2020 17:14:55 -0800 Subject: [PATCH 12/39] Use absolute paths in systemd commands. --- sample/systemd/borgmatic.service | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sample/systemd/borgmatic.service b/sample/systemd/borgmatic.service index 0bbf5b44..a4ec7f09 100644 --- a/sample/systemd/borgmatic.service +++ b/sample/systemd/borgmatic.service @@ -18,5 +18,5 @@ Restart=no LogRateLimitIntervalSec=0 # Delay start to prevent backups running during boot. -ExecStartPre=sleep 1m -ExecStart=systemd-inhibit --who="borgmatic" --why="Prevent interrupting scheduled backup" /root/.local/bin/borgmatic --syslog-verbosity 1 +ExecStartPre=/usr/bin/sleep 1m +ExecStart=/usr/bin/systemd-inhibit --who="borgmatic" --why="Prevent interrupting scheduled backup" /root/.local/bin/borgmatic --syslog-verbosity 1 From 1995c80e60077f2798e9236f0a8f2158a3b056e5 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 2 Jan 2020 10:05:32 -0800 Subject: [PATCH 13/39] Add comment about old versions of systemd and option compatibility (#275). --- sample/systemd/borgmatic.service | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sample/systemd/borgmatic.service b/sample/systemd/borgmatic.service index a4ec7f09..11725e6e 100644 --- a/sample/systemd/borgmatic.service +++ b/sample/systemd/borgmatic.service @@ -15,6 +15,8 @@ IOSchedulingPriority=7 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. From d0c533555e39dd938c6b5bd45436a4c030d0b6d6 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 2 Jan 2020 10:37:31 -0800 Subject: [PATCH 14/39] In "borgmatic --help", don't expand $HOME in listing of default "--config" paths. --- NEWS | 3 +++ borgmatic/commands/arguments.py | 5 +++-- borgmatic/config/collect.py | 10 ++++++---- setup.py | 2 +- tests/unit/config/test_collect.py | 8 ++++++++ 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/NEWS b/NEWS index 8e8adce4..6ed814d2 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,6 @@ +1.4.22.dev0 + * 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 --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 63030066..142c3a7e 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -106,7 +106,8 @@ def parse_arguments(*unparsed_arguments): Given command-line arguments with which this script was invoked, parse the arguments and return them as a dict mapping from subparser name (or "global") to an argparse.Namespace instance. ''' - config_paths = collect.get_default_config_paths() + config_paths = collect.get_default_config_paths(expand_home=True) + unexpanded_config_paths = collect.get_default_config_paths(expand_home=False) global_parser = ArgumentParser(add_help=False) global_group = global_parser.add_argument_group('global arguments') @@ -118,7 +119,7 @@ def parse_arguments(*unparsed_arguments): dest='config_paths', default=config_paths, help='Configuration filenames or directories, defaults to: {}'.format( - ' '.join(config_paths) + ' '.join(unexpanded_config_paths) ), ) global_group.add_argument( diff --git a/borgmatic/config/collect.py b/borgmatic/config/collect.py index 59d7dfe5..ef04bd49 100644 --- a/borgmatic/config/collect.py +++ b/borgmatic/config/collect.py @@ -1,15 +1,17 @@ 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', diff --git a/setup.py b/setup.py index d3ae2168..15dfed55 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.4.21' +VERSION = '1.4.22.dev0' setup( diff --git a/tests/unit/config/test_collect.py b/tests/unit/config/test_collect.py index 2cb5b584..4c4420a4 100644 --- a/tests/unit/config/test_collect.py +++ b/tests/unit/config/test_collect.py @@ -21,6 +21,14 @@ def test_get_default_config_paths_prefers_xdg_config_home_for_user_config_path() 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) From c52f82f9cedc019a064d567ca4407b885fbe3789 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 4 Jan 2020 13:37:56 -0800 Subject: [PATCH 15/39] Documentation: Enable and start borgmatic with a single systemctl command. --- docs/how-to/set-up-backups.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/how-to/set-up-backups.md b/docs/how-to/set-up-backups.md index bc78237c..b87b8058 100644 --- a/docs/how-to/set-up-backups.md +++ b/docs/how-to/set-up-backups.md @@ -204,8 +204,7 @@ Then, from the directory where you downloaded them: ```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 From 5afe0e3d6394b1bb81daf62ba1a42f6682c83452 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 4 Jan 2020 15:50:41 -0800 Subject: [PATCH 16/39] Disable colored output when "--json" flag is used, so as to produce valid JSON ouput (#276). --- NEWS | 1 + borgmatic/commands/borgmatic.py | 8 +++++++- docs/how-to/set-up-backups.md | 8 ++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/NEWS b/NEWS index 6ed814d2..0e76bf01 100644 --- a/NEWS +++ b/NEWS @@ -1,4 +1,5 @@ 1.4.22.dev0 + * #276: Disable colored output when "--json" flag is used, so as to produce valid JSON ouput. * In "borgmatic --help", don't expand $HOME in listing of default "--config" paths. 1.4.21 diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index b39668ad..5ad1431d 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -587,7 +587,13 @@ def main(): # pragma: no cover 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), diff --git a/docs/how-to/set-up-backups.md b/docs/how-to/set-up-backups.md index b87b8058..2d288372 100644 --- a/docs/how-to/set-up-backups.md +++ b/docs/how-to/set-up-backups.md @@ -213,10 +213,10 @@ borgmatic to run. ## 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 From e27ba0d08a0b92b6211a277df40f3b4914a11b56 Mon Sep 17 00:00:00 2001 From: palto42 <12280188+palto42@users.noreply.github.com> Date: Sat, 11 Jan 2020 16:38:07 +0100 Subject: [PATCH 17/39] less detail at v1 + option "--files" for details --- NEWS | 3 +++ borgmatic/borg/create.py | 14 ++++++++++-- borgmatic/borg/prune.py | 14 +++++++++--- borgmatic/commands/arguments.py | 14 ++++++++++++ borgmatic/commands/borgmatic.py | 2 ++ setup.py | 2 +- tests/integration/commands/test_arguments.py | 23 +++++++++++++++++++- tests/unit/borg/test_create.py | 8 +++---- tests/unit/borg/test_prune.py | 4 +--- 9 files changed, 69 insertions(+), 15 deletions(-) diff --git a/NEWS b/NEWS index 0e76bf01..3de4d2ec 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,6 @@ +1.4.22.dev0-files + * Removed `borg --list --stats` option from `create`and `prune` actions at verbosity 1 + * New option `--files`to (re-)add the `borg` `--list` and `--stats` at verbosity 1 1.4.22.dev0 * #276: Disable colored output when "--json" flag is used, so as to produce valid JSON ouput. * In "borgmatic --help", don't expand $HOME in listing of default "--config" paths. diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py index f582fb7b..523ab6e8 100644 --- a/borgmatic/borg/create.py +++ b/borgmatic/borg/create.py @@ -131,6 +131,7 @@ def create_archive( progress=False, stats=False, json=False, + files=False, ): ''' Given vebosity/dry-run flags, a local or remote repository path, a location config dict, and a @@ -177,13 +178,22 @@ def create_archive( + (('--lock-wait', str(lock_wait)) if lock_wait else ()) + ( ('--list', '--filter', 'AME-') - if logger.isEnabledFor(logging.INFO) and not json and not progress + if logger.isEnabledFor(logging.INFO) + and not json + and not progress + and (files or logger.isEnabledFor(logging.DEBUG)) 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 + if not dry_run + and ( + (logger.isEnabledFor(logging.INFO) and files) + or logger.isEnabledFor(logging.DEBUG) + or stats + ) + and not json else () ) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ()) diff --git a/borgmatic/borg/prune.py b/borgmatic/borg/prune.py index 2c4811eb..5d8610bb 100644 --- a/borgmatic/borg/prune.py +++ b/borgmatic/borg/prune.py @@ -41,6 +41,7 @@ def prune_archives( local_path='borg', remote_path=None, stats=False, + files=False, ): ''' Given dry-run flag, a local or remote repository path, a storage config dict, and a @@ -57,11 +58,18 @@ def prune_archives( + (('--remote-path', remote_path) if remote_path else ()) + (('--umask', str(umask)) if umask else ()) + (('--lock-wait', str(lock_wait)) if lock_wait else ()) - + (('--stats',) if not dry_run and logger.isEnabledFor(logging.INFO) else ()) - + (('--info', '--list') if logger.getEffectiveLevel() == logging.INFO else ()) + + ( + ('--stats',) + if not dry_run + and (logger.isEnabledFor(logging.INFO) and files) + or logger.getEffectiveLevel() == logging.DEBUG + or stats + else () + ) + + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + + (('--list',) if logger.getEffectiveLevel() == logging.INFO and files else ()) + (('--debug', '--list', '--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,) ) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 142c3a7e..76f25a64 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -237,6 +237,13 @@ def parse_arguments(*unparsed_arguments): action='store_true', help='Display statistics of archive', ) + prune_group.add_argument( + '--files', + dest='files', + default=False, + action='store_true', + help='Show file details and stats at verbosity 1', + ) prune_group.add_argument('-h', '--help', action='help', help='Show this help message and exit') create_parser = subparsers.add_parser( @@ -261,6 +268,13 @@ def parse_arguments(*unparsed_arguments): action='store_true', help='Display statistics of archive', ) + create_group.add_argument( + '--files', + dest='files', + default=False, + action='store_true', + help='Show file details and stats at verbosity 1', + ) create_group.add_argument( '--json', dest='json', default=False, action='store_true', help='Output results as JSON' ) diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 5ad1431d..df50bb7a 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -212,6 +212,7 @@ def run_actions( local_path=local_path, remote_path=remote_path, stats=arguments['prune'].stats, + files=arguments['prune'].files, ) if 'create' in arguments: logger.info('{}: Creating archive{}'.format(repository, dry_run_label)) @@ -225,6 +226,7 @@ def run_actions( progress=arguments['create'].progress, stats=arguments['create'].stats, json=arguments['create'].json, + files=arguments['create'].files, ) if json_output: yield json.loads(json_output) diff --git a/setup.py b/setup.py index 15dfed55..293849d2 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.4.22.dev0' +VERSION = '1.4.22.dev0-files' setup( diff --git a/tests/integration/commands/test_arguments.py b/tests/integration/commands/test_arguments.py index 9c5bb948..46bc380c 100644 --- a/tests/integration/commands/test_arguments.py +++ b/tests/integration/commands/test_arguments.py @@ -98,12 +98,14 @@ def test_parse_arguments_with_no_actions_defaults_to_all_actions_enabled(): def test_parse_arguments_with_no_actions_passes_argument_to_relevant_actions(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) - arguments = module.parse_arguments('--stats') + arguments = module.parse_arguments('--stats', '--files') assert 'prune' in arguments assert arguments['prune'].stats + assert arguments['prune'].files assert 'create' in arguments assert arguments['create'].stats + assert arguments['create'].files assert 'check' in arguments @@ -423,6 +425,25 @@ def test_parse_arguments_with_stats_flag_but_no_create_or_prune_flag_raises_valu module.parse_arguments('--stats', 'list') +def test_parse_arguments_with_files_and_create_flags_does_not_raise(): + flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) + + module.parse_arguments('--files', 'create', 'list') + + +def test_parse_arguments_with_files_and_prune_flags_does_not_raise(): + flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) + + module.parse_arguments('--files', 'prune', 'list') + + +def test_parse_arguments_with_files_flag_but_no_create_or_prune_or_restore_flag_raises_value_error(): + flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) + + with pytest.raises(SystemExit): + module.parse_arguments('--files', 'list') + + def test_parse_arguments_allows_json_with_list_or_info(): flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default']) diff --git a/tests/unit/borg/test_create.py b/tests/unit/borg/test_create.py index f9044145..5cf8bb45 100644 --- a/tests/unit/borg/test_create.py +++ b/tests/unit/borg/test_create.py @@ -295,7 +295,7 @@ def test_create_archive_with_log_info_calls_borg_with_info_parameter(): flexmock(module).should_receive('_make_pattern_flags').and_return(()) flexmock(module).should_receive('_make_exclude_flags').and_return(()) flexmock(module).should_receive('execute_command').with_args( - ('borg', 'create', '--list', '--filter', 'AME-', '--info', '--stats') + ARCHIVE_WITH_PATHS, + ('borg', 'create', '--info') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, error_on_warnings=False, ) @@ -432,8 +432,7 @@ def test_create_archive_with_dry_run_and_log_info_calls_borg_without_stats_param flexmock(module).should_receive('_make_pattern_flags').and_return(()) flexmock(module).should_receive('_make_exclude_flags').and_return(()) flexmock(module).should_receive('execute_command').with_args( - ('borg', 'create', '--list', '--filter', 'AME-', '--info', '--dry-run') - + ARCHIVE_WITH_PATHS, + ('borg', 'create', '--info', '--dry-run') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, error_on_warnings=False, ) @@ -875,8 +874,7 @@ def test_create_archive_with_progress_and_log_info_calls_borg_with_progress_para flexmock(module).should_receive('_make_pattern_flags').and_return(()) flexmock(module).should_receive('_make_exclude_flags').and_return(()) flexmock(module).should_receive('execute_command_without_capture').with_args( - ('borg', 'create', '--info', '--stats', '--progress') + ARCHIVE_WITH_PATHS, - error_on_warnings=False, + ('borg', 'create', '--info', '--progress') + ARCHIVE_WITH_PATHS, error_on_warnings=False ) insert_logging_mock(logging.INFO) diff --git a/tests/unit/borg/test_prune.py b/tests/unit/borg/test_prune.py index b2b4785a..07ef41ec 100644 --- a/tests/unit/borg/test_prune.py +++ b/tests/unit/borg/test_prune.py @@ -75,9 +75,7 @@ def test_prune_archives_with_log_info_calls_borg_with_info_parameter(): flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return( BASE_PRUNE_FLAGS ) - insert_execute_command_mock( - PRUNE_COMMAND + ('--stats', '--info', '--list', 'repo'), logging.INFO - ) + insert_execute_command_mock(PRUNE_COMMAND + ('--info', 'repo'), logging.INFO) insert_logging_mock(logging.INFO) module.prune_archives( From e108526babd27fa46edb4ad49863e8b30e354e5e Mon Sep 17 00:00:00 2001 From: palto42 <12280188+palto42@users.noreply.github.com> Date: Sat, 18 Jan 2020 14:38:59 +0100 Subject: [PATCH 18/39] disable --stats by default --- borgmatic/borg/create.py | 10 ++-------- borgmatic/borg/prune.py | 9 ++++----- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py index 523ab6e8..09039bea 100644 --- a/borgmatic/borg/create.py +++ b/borgmatic/borg/create.py @@ -187,13 +187,7 @@ def create_archive( + (('--info',) if logger.getEffectiveLevel() == logging.INFO and not json else ()) + ( ('--stats',) - if not dry_run - and ( - (logger.isEnabledFor(logging.INFO) and files) - or logger.isEnabledFor(logging.DEBUG) - or stats - ) - and not json + if not dry_run and (logger.isEnabledFor(logging.DEBUG) or stats) and not json else () ) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ()) @@ -217,7 +211,7 @@ def create_archive( if json: output_log_level = None - elif stats: + elif stats and logger.getEffectiveLevel() == logging.WARNING: output_log_level = logging.WARNING else: output_log_level = logging.INFO diff --git a/borgmatic/borg/prune.py b/borgmatic/borg/prune.py index 5d8610bb..f3f17c0c 100644 --- a/borgmatic/borg/prune.py +++ b/borgmatic/borg/prune.py @@ -60,10 +60,7 @@ def prune_archives( + (('--lock-wait', str(lock_wait)) if lock_wait else ()) + ( ('--stats',) - if not dry_run - and (logger.isEnabledFor(logging.INFO) and files) - or logger.getEffectiveLevel() == logging.DEBUG - or stats + if not dry_run and logger.getEffectiveLevel() == logging.DEBUG or stats else () ) + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) @@ -76,6 +73,8 @@ def prune_archives( execute_command( full_command, - output_log_level=logging.WARNING if stats else logging.INFO, + output_log_level=logging.WARNING + if (stats and logger.getEffectiveLevel() == logging.WARNING) + else logging.INFO, error_on_warnings=False, ) From 83632448be386d13cff73d621bed23b023ddcc8f Mon Sep 17 00:00:00 2001 From: palto42 <12280188+palto42@users.noreply.github.com> Date: Sat, 18 Jan 2020 14:57:50 +0100 Subject: [PATCH 19/39] updated NEWS for mod. --stats & new --files opt. --- NEWS | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 3de4d2ec..7d375749 100644 --- a/NEWS +++ b/NEWS @@ -1,6 +1,8 @@ 1.4.22.dev0-files * Removed `borg --list --stats` option from `create`and `prune` actions at verbosity 1 - * New option `--files`to (re-)add the `borg` `--list` and `--stats` at verbosity 1 + * The `--stats` now always requires to use the `borgmatic --stats` option to be enabled. + * New option `--files` to (re-)add the `borg` `--list` at verbosity 1 + 1.4.22.dev0 * #276: Disable colored output when "--json" flag is used, so as to produce valid JSON ouput. * In "borgmatic --help", don't expand $HOME in listing of default "--config" paths. From ac1d63bb0dc2f6833d78dc821fe448047e02ddd8 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Sat, 18 Jan 2020 20:00:18 -0800 Subject: [PATCH 20/39] Use more realistic repository examples in README. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 09f4e656..9d088117 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,11 @@ location: - /home - /etc - # Paths to local or remote repositories. + # Paths of local or remote repositories to backup to. repositories: - - user@backupserver:sourcehostname.borg + - 1234@usw-s001.rsync.net:backups.borg + - k8pDxu32@k8pDxu32.repo.borgbase.com:repo + - /var/lib/backups/backups.borg retention: # Retention policy for how many backups to keep. From 8a91c79fb027ad58bc22c7827f3fa50af8d0511d Mon Sep 17 00:00:00 2001 From: Ronan Dunklau Date: Sun, 19 Jan 2020 15:15:47 +0100 Subject: [PATCH 21/39] Support directory format dump cleanup. Previously, only deleting a dump in a single-file format was supported. This led to errors when performing a PostgreSQL directory format backup. --- borgmatic/hooks/dump.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/borgmatic/hooks/dump.py b/borgmatic/hooks/dump.py index 54db1d26..bd5ea084 100644 --- a/borgmatic/hooks/dump.py +++ b/borgmatic/hooks/dump.py @@ -1,6 +1,7 @@ import glob import logging import os +import shutil from borgmatic.borg.create import DEFAULT_BORGMATIC_SOURCE_DIRECTORY @@ -83,7 +84,10 @@ def remove_database_dumps(dump_path, databases, database_type_name, log_prefix, if dry_run: continue - os.remove(dump_filename) + if os.path.isdir(dump_filename): + shutil.rmtree(dump_filename) + else: + os.remove(dump_filename) dump_file_dir = os.path.dirname(dump_filename) if len(os.listdir(dump_file_dir)) == 0: From cc384f4324a44acf34635c2ef140b791d744eba7 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 21 Jan 2020 08:33:41 -0800 Subject: [PATCH 22/39] Second ticket for --json color bug. --- NEWS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEWS b/NEWS index 0e76bf01..2d62f159 100644 --- a/NEWS +++ b/NEWS @@ -1,5 +1,5 @@ 1.4.22.dev0 - * #276: Disable colored output when "--json" flag is used, so as to produce valid JSON ouput. + * #276, #285: Disable colored output when "--json" flag is used, so as to produce valid JSON ouput. * In "borgmatic --help", don't expand $HOME in listing of default "--config" paths. 1.4.21 From 7824a034ca571848a4a58a2d166ff9b57176ea33 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 21 Jan 2020 10:34:46 -0800 Subject: [PATCH 23/39] Add test for database dump directory removal. --- NEWS | 1 + tests/unit/hooks/test_dump.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/NEWS b/NEWS index 2d62f159..2e507f2f 100644 --- a/NEWS +++ b/NEWS @@ -1,5 +1,6 @@ 1.4.22.dev0 * #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 diff --git a/tests/unit/hooks/test_dump.py b/tests/unit/hooks/test_dump.py index d36f8098..dc6e8dd3 100644 --- a/tests/unit/hooks/test_dump.py +++ b/tests/unit/hooks/test_dump.py @@ -66,6 +66,7 @@ def test_remove_database_dumps_removes_dump_for_each_database(): '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( @@ -77,6 +78,21 @@ def test_remove_database_dumps_removes_dump_for_each_database(): 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(): databases = [{'name': 'foo'}, {'name': 'bar'}] flexmock(module.os).should_receive('rmdir').never() From 8d120793869322d4c8472e45787a247015af7f8f Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 21 Jan 2020 10:47:29 -0800 Subject: [PATCH 24/39] Bump version. --- NEWS | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/NEWS b/NEWS index 2e507f2f..df65bf0f 100644 --- a/NEWS +++ b/NEWS @@ -1,4 +1,4 @@ -1.4.22.dev0 +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. diff --git a/setup.py b/setup.py index 15dfed55..2f07da61 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.4.22.dev0' +VERSION = '1.4.22' setup( From 88f06f7921f11987967ade92bc7d257941d535ae Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Tue, 21 Jan 2020 16:03:24 -0800 Subject: [PATCH 25/39] Revert "Use absolute paths in systemd commands." This reverts commit 24e1516ec50b73df7612862f95f4bff956268445. --- sample/systemd/borgmatic.service | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sample/systemd/borgmatic.service b/sample/systemd/borgmatic.service index 11725e6e..cd3f51b0 100644 --- a/sample/systemd/borgmatic.service +++ b/sample/systemd/borgmatic.service @@ -20,5 +20,5 @@ Restart=no LogRateLimitIntervalSec=0 # Delay start to prevent backups running during boot. -ExecStartPre=/usr/bin/sleep 1m -ExecStart=/usr/bin/systemd-inhibit --who="borgmatic" --why="Prevent interrupting scheduled backup" /root/.local/bin/borgmatic --syslog-verbosity 1 +ExecStartPre=sleep 1m +ExecStart=systemd-inhibit --who="borgmatic" --why="Prevent interrupting scheduled backup" /root/.local/bin/borgmatic --syslog-verbosity 1 From 39550a7fe9050bb3b10a1f15c2306775ec0c75fe Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 22 Jan 2020 09:26:58 -0800 Subject: [PATCH 26/39] Add ~/.config/borgmatic.d as another configuration directory default (#274). --- NEWS | 3 +++ borgmatic/config/collect.py | 1 + docs/how-to/make-per-application-backups.md | 7 ++++--- docs/how-to/set-up-backups.md | 21 +++++++++++++-------- setup.py | 2 +- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/NEWS b/NEWS index df65bf0f..4a7ddd17 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,6 @@ +1.4.23.dev0 + * #274: Add ~/.config/borgmatic.d as another configuration directory default. + 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. diff --git a/borgmatic/config/collect.py b/borgmatic/config/collect.py index ef04bd49..97c94106 100644 --- a/borgmatic/config/collect.py +++ b/borgmatic/config/collect.py @@ -17,6 +17,7 @@ def get_default_config_paths(expand_home=True): '/etc/borgmatic/config.yaml', '/etc/borgmatic.d', '%s/borgmatic/config.yaml' % user_config_directory, + '%s/borgmatic.d' % user_config_directory, ] diff --git a/docs/how-to/make-per-application-backups.md b/docs/how-to/make-per-application-backups.md index cd257460..8d08ac2c 100644 --- a/docs/how-to/make-per-application-backups.md +++ b/docs/how-to/make-per-application-backups.md @@ -27,9 +27,10 @@ for each configuration file one at a time. In other words, borgmatic does not perform any merging of configuration files by default. If you'd like borgmatic to merge your configuration files, see below about configuration includes. -And if you need even more customizability, you can specify alternate -configuration paths on the command-line with borgmatic's `--config` option. -See `borgmatic --help` for more information. +Additionally, the `~/.config/borgmatic.d/` directory works the same way as +`/etc/borgmatic.d`. If you need even more customizability, you can specify +alternate configuration paths on the command-line with borgmatic's `--config` +flag. See `borgmatic --help` for more information. ## Configuration includes diff --git a/docs/how-to/set-up-backups.md b/docs/how-to/set-up-backups.md index 2d288372..59d91620 100644 --- a/docs/how-to/set-up-backups.md +++ b/docs/how-to/set-up-backups.md @@ -68,10 +68,13 @@ sudo generate-borgmatic-config If that command is not found, then it may be installed in a location that's not in your system `PATH` (see above). Try looking in `~/.local/bin/`. -This generates a sample configuration file at /etc/borgmatic/config.yaml (by -default). You should edit the file to suit your needs, as the values are -representative. All options are optional except where indicated, so feel free -to ignore anything you don't need. +This generates a sample configuration file at `/etc/borgmatic/config.yaml` by +default. If you'd like to use another path, use the `--destination` flag, for +instance: `--destination ~/.config/borgmatic/config.yaml`. + +You should edit the configuration file to suit your needs, as the generated +values are only representative. All options are optional except where +indicated, so feel free to ignore anything you don't need. Note that the configuration file is organized into distinct sections, each with a section name like `location:` or `storage:`. So take care that if you @@ -79,12 +82,11 @@ uncomment a particular option, also uncomment its containing section name, or else borgmatic won't recognize the option. Also be sure to use spaces rather than tabs for indentation; YAML does not allow tabs. -You can also get the same sample configuration file from the [configuration +You can get the same sample configuration file from the [configuration reference](https://torsion.org/borgmatic/docs/reference/configuration/), the authoritative set of all configuration options. This is handy if borgmatic has -added new options -since you originally created your configuration file. Also check out how to -[upgrade your +added new options since you originally created your configuration file. Also +check out how to [upgrade your configuration](https://torsion.org/borgmatic/docs/how-to/upgrade/#upgrading-your-configuration). @@ -173,6 +175,9 @@ The verbosity flag makes borgmatic list the files that it's archiving, which are those that are new or changed since the last backup. Eyeball the list and see if it matches your expectations based on the configuration. +If you'd like to specify an alternate configuration file path, use the +`--config` flag. See `borgmatic --help` for more information. + ## Autopilot diff --git a/setup.py b/setup.py index 2f07da61..c73c9ea7 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.4.22' +VERSION = '1.4.23.dev0' setup( From 75b5e7254eb858629087d3bcc760ce221539bfea Mon Sep 17 00:00:00 2001 From: palto42 <12280188+palto42@users.noreply.github.com> Date: Wed, 22 Jan 2020 19:03:26 +0100 Subject: [PATCH 27/39] changes as per comments in PR #283 --- borgmatic/borg/create.py | 9 ++------- borgmatic/borg/prune.py | 18 ++++-------------- tests/unit/borg/test_create.py | 2 +- tests/unit/borg/test_prune.py | 4 ++-- 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py index 09039bea..dd383ca2 100644 --- a/borgmatic/borg/create.py +++ b/borgmatic/borg/create.py @@ -178,16 +178,13 @@ def create_archive( + (('--lock-wait', str(lock_wait)) if lock_wait else ()) + ( ('--list', '--filter', 'AME-') - if logger.isEnabledFor(logging.INFO) - and not json - and not progress - and (files or logger.isEnabledFor(logging.DEBUG)) + if (files or logger.isEnabledFor(logging.DEBUG)) 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.DEBUG) or stats) and not json + if (stats or logger.isEnabledFor(logging.DEBUG)) and not json and not dry_run else () ) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) and not json else ()) @@ -211,8 +208,6 @@ def create_archive( if json: output_log_level = None - elif stats and logger.getEffectiveLevel() == logging.WARNING: - output_log_level = logging.WARNING else: output_log_level = logging.INFO diff --git a/borgmatic/borg/prune.py b/borgmatic/borg/prune.py index f3f17c0c..d68a12d3 100644 --- a/borgmatic/borg/prune.py +++ b/borgmatic/borg/prune.py @@ -58,23 +58,13 @@ def prune_archives( + (('--remote-path', remote_path) if remote_path else ()) + (('--umask', str(umask)) if umask else ()) + (('--lock-wait', str(lock_wait)) if lock_wait else ()) - + ( - ('--stats',) - if not dry_run and logger.getEffectiveLevel() == logging.DEBUG or stats - else () - ) + + (('--stats',) if (stats or logger.isEnabledFor(logging.DEBUG)) and not dry_run else ()) + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) - + (('--list',) if logger.getEffectiveLevel() == logging.INFO and files else ()) - + (('--debug', '--list', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + + (('--list',) if files or logger.isEnabledFor(logging.DEBUG) else ()) + + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (('--dry-run',) if dry_run else ()) + (tuple(extra_borg_options.split(' ')) if extra_borg_options else ()) + (repository,) ) - execute_command( - full_command, - output_log_level=logging.WARNING - if (stats and logger.getEffectiveLevel() == logging.WARNING) - else logging.INFO, - error_on_warnings=False, - ) + execute_command(full_command, output_log_level=logging.INFO, error_on_warnings=False) diff --git a/tests/unit/borg/test_create.py b/tests/unit/borg/test_create.py index 5cf8bb45..4b74df76 100644 --- a/tests/unit/borg/test_create.py +++ b/tests/unit/borg/test_create.py @@ -849,7 +849,7 @@ def test_create_archive_with_stats_calls_borg_with_stats_parameter(): flexmock(module).should_receive('_make_exclude_flags').and_return(()) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--stats') + ARCHIVE_WITH_PATHS, - output_log_level=logging.WARNING, + output_log_level=logging.INFO, error_on_warnings=False, ) diff --git a/tests/unit/borg/test_prune.py b/tests/unit/borg/test_prune.py index 07ef41ec..1694e4e8 100644 --- a/tests/unit/borg/test_prune.py +++ b/tests/unit/borg/test_prune.py @@ -89,7 +89,7 @@ def test_prune_archives_with_log_debug_calls_borg_with_debug_parameter(): BASE_PRUNE_FLAGS ) insert_execute_command_mock( - PRUNE_COMMAND + ('--stats', '--debug', '--list', '--show-rc', 'repo'), logging.INFO + PRUNE_COMMAND + ('--stats', '--list', '--debug', '--show-rc', 'repo'), logging.INFO ) insert_logging_mock(logging.DEBUG) @@ -147,7 +147,7 @@ def test_prune_archives_with_stats_calls_borg_with_stats_parameter(): flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return( BASE_PRUNE_FLAGS ) - insert_execute_command_mock(PRUNE_COMMAND + ('--stats', 'repo'), logging.WARNING) + insert_execute_command_mock(PRUNE_COMMAND + ('--stats', 'repo'), logging.INFO) module.prune_archives( dry_run=False, From d93fdbc5ad79eb9ebb6bcba38ca484603dcbe70f Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 22 Jan 2020 13:28:24 -0800 Subject: [PATCH 28/39] Support "--files" and "--stats" flags at verbosity level 0. --- NEWS | 5 +- borgmatic/borg/create.py | 2 + borgmatic/borg/prune.py | 7 ++- borgmatic/commands/arguments.py | 12 +---- tests/unit/borg/test_create.py | 84 ++++++++++++++++++++++++++++++++- tests/unit/borg/test_prune.py | 54 ++++++++++++++++++++- 6 files changed, 146 insertions(+), 18 deletions(-) diff --git a/NEWS b/NEWS index 25df1b71..dca9bd7a 100644 --- a/NEWS +++ b/NEWS @@ -1,8 +1,7 @@ 1.4.23.dev0 * #274: Add ~/.config/borgmatic.d as another configuration directory default. - * Removed `borg --list --stats` option from `create`and `prune` actions at verbosity 1 - * The `--stats` now always requires to use the `borgmatic --stats` option to be enabled. - * New option `--files` to (re-)add the `borg` `--list` at verbosity 1 + * For "create" and "prune" actions, no longer list files or show detailed stats at verbosity 1. You + can opt back in with "--files" or "--stats" flags. 1.4.22 * #276, #285: Disable colored output when "--json" flag is used, so as to produce valid JSON ouput. diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py index dd383ca2..5258e9d9 100644 --- a/borgmatic/borg/create.py +++ b/borgmatic/borg/create.py @@ -208,6 +208,8 @@ def create_archive( if json: output_log_level = None + elif (stats or files) and logger.getEffectiveLevel() == logging.WARNING: + output_log_level = logging.WARNING else: output_log_level = logging.INFO diff --git a/borgmatic/borg/prune.py b/borgmatic/borg/prune.py index d68a12d3..492ff9b6 100644 --- a/borgmatic/borg/prune.py +++ b/borgmatic/borg/prune.py @@ -67,4 +67,9 @@ def prune_archives( + (repository,) ) - execute_command(full_command, output_log_level=logging.INFO, error_on_warnings=False) + if (stats or files) and logger.getEffectiveLevel() == logging.WARNING: + output_log_level = logging.WARNING + else: + output_log_level = logging.INFO + + execute_command(full_command, output_log_level=output_log_level, error_on_warnings=False) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 76f25a64..61b9cfca 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -238,11 +238,7 @@ def parse_arguments(*unparsed_arguments): help='Display statistics of archive', ) prune_group.add_argument( - '--files', - dest='files', - default=False, - action='store_true', - help='Show file details and stats at verbosity 1', + '--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') @@ -269,11 +265,7 @@ def parse_arguments(*unparsed_arguments): help='Display statistics of archive', ) create_group.add_argument( - '--files', - dest='files', - default=False, - action='store_true', - help='Show file details and stats at verbosity 1', + '--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' diff --git a/tests/unit/borg/test_create.py b/tests/unit/borg/test_create.py index 4b74df76..79cdfcab 100644 --- a/tests/unit/borg/test_create.py +++ b/tests/unit/borg/test_create.py @@ -840,7 +840,7 @@ def test_create_archive_with_lock_wait_calls_borg_with_lock_wait_parameters(): ) -def test_create_archive_with_stats_calls_borg_with_stats_parameter(): +def test_create_archive_with_stats_calls_borg_with_stats_parameter_and_warning_output_log_level(): flexmock(module).should_receive('borgmatic_source_directories').and_return([]) flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar')) flexmock(module).should_receive('_expand_home_directories').and_return(()) @@ -849,7 +849,7 @@ def test_create_archive_with_stats_calls_borg_with_stats_parameter(): flexmock(module).should_receive('_make_exclude_flags').and_return(()) flexmock(module).should_receive('execute_command').with_args( ('borg', 'create', '--stats') + ARCHIVE_WITH_PATHS, - output_log_level=logging.INFO, + output_log_level=logging.WARNING, error_on_warnings=False, ) @@ -866,6 +866,86 @@ def test_create_archive_with_stats_calls_borg_with_stats_parameter(): ) +def test_create_archive_with_stats_and_log_info_calls_borg_with_stats_parameter_and_info_output_log_level(): + flexmock(module).should_receive('borgmatic_source_directories').and_return([]) + flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar')) + flexmock(module).should_receive('_expand_home_directories').and_return(()) + flexmock(module).should_receive('_write_pattern_file').and_return(None) + flexmock(module).should_receive('_make_pattern_flags').and_return(()) + flexmock(module).should_receive('_make_exclude_flags').and_return(()) + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'create', '--info', '--stats') + ARCHIVE_WITH_PATHS, + output_log_level=logging.INFO, + error_on_warnings=False, + ) + insert_logging_mock(logging.INFO) + + module.create_archive( + dry_run=False, + repository='repo', + location_config={ + 'source_directories': ['foo', 'bar'], + 'repositories': ['repo'], + 'exclude_patterns': None, + }, + storage_config={}, + stats=True, + ) + + +def test_create_archive_with_files_calls_borg_with_list_parameter_and_warning_output_log_level(): + flexmock(module).should_receive('borgmatic_source_directories').and_return([]) + flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar')) + flexmock(module).should_receive('_expand_home_directories').and_return(()) + flexmock(module).should_receive('_write_pattern_file').and_return(None) + flexmock(module).should_receive('_make_pattern_flags').and_return(()) + flexmock(module).should_receive('_make_exclude_flags').and_return(()) + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'create', '--list', '--filter', 'AME-') + ARCHIVE_WITH_PATHS, + output_log_level=logging.WARNING, + error_on_warnings=False, + ) + + module.create_archive( + dry_run=False, + repository='repo', + location_config={ + 'source_directories': ['foo', 'bar'], + 'repositories': ['repo'], + 'exclude_patterns': None, + }, + storage_config={}, + files=True, + ) + + +def test_create_archive_with_files_and_log_info_calls_borg_with_list_parameter_and_info_output_log_level(): + flexmock(module).should_receive('borgmatic_source_directories').and_return([]) + flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar')) + flexmock(module).should_receive('_expand_home_directories').and_return(()) + flexmock(module).should_receive('_write_pattern_file').and_return(None) + flexmock(module).should_receive('_make_pattern_flags').and_return(()) + flexmock(module).should_receive('_make_exclude_flags').and_return(()) + flexmock(module).should_receive('execute_command').with_args( + ('borg', 'create', '--list', '--filter', 'AME-', '--info') + ARCHIVE_WITH_PATHS, + output_log_level=logging.INFO, + error_on_warnings=False, + ) + insert_logging_mock(logging.INFO) + + module.create_archive( + dry_run=False, + repository='repo', + location_config={ + 'source_directories': ['foo', 'bar'], + 'repositories': ['repo'], + 'exclude_patterns': None, + }, + storage_config={}, + files=True, + ) + + def test_create_archive_with_progress_and_log_info_calls_borg_with_progress_parameter_and_no_list(): flexmock(module).should_receive('borgmatic_source_directories').and_return([]) flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar')) diff --git a/tests/unit/borg/test_prune.py b/tests/unit/borg/test_prune.py index 1694e4e8..a61c4908 100644 --- a/tests/unit/borg/test_prune.py +++ b/tests/unit/borg/test_prune.py @@ -142,12 +142,12 @@ def test_prune_archives_with_remote_path_calls_borg_with_remote_path_parameters( ) -def test_prune_archives_with_stats_calls_borg_with_stats_parameter(): +def test_prune_archives_with_stats_calls_borg_with_stats_parameter_and_warning_output_log_level(): retention_config = flexmock() flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return( BASE_PRUNE_FLAGS ) - insert_execute_command_mock(PRUNE_COMMAND + ('--stats', 'repo'), logging.INFO) + insert_execute_command_mock(PRUNE_COMMAND + ('--stats', 'repo'), logging.WARNING) module.prune_archives( dry_run=False, @@ -158,6 +158,56 @@ def test_prune_archives_with_stats_calls_borg_with_stats_parameter(): ) +def test_prune_archives_with_stats_and_log_info_calls_borg_with_stats_parameter_and_info_output_log_level(): + retention_config = flexmock() + flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return( + BASE_PRUNE_FLAGS + ) + insert_logging_mock(logging.INFO) + insert_execute_command_mock(PRUNE_COMMAND + ('--stats', '--info', 'repo'), logging.INFO) + + module.prune_archives( + dry_run=False, + repository='repo', + storage_config={}, + retention_config=retention_config, + stats=True, + ) + + +def test_prune_archives_with_files_calls_borg_with_list_parameter_and_warning_output_log_level(): + retention_config = flexmock() + flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return( + BASE_PRUNE_FLAGS + ) + insert_execute_command_mock(PRUNE_COMMAND + ('--list', 'repo'), logging.WARNING) + + module.prune_archives( + dry_run=False, + repository='repo', + storage_config={}, + retention_config=retention_config, + files=True, + ) + + +def test_prune_archives_with_files_and_log_info_calls_borg_with_list_parameter_and_info_output_log_level(): + retention_config = flexmock() + flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return( + BASE_PRUNE_FLAGS + ) + insert_logging_mock(logging.INFO) + insert_execute_command_mock(PRUNE_COMMAND + ('--info', '--list', 'repo'), logging.INFO) + + module.prune_archives( + dry_run=False, + repository='repo', + storage_config={}, + retention_config=retention_config, + files=True, + ) + + def test_prune_archives_with_umask_calls_borg_with_umask_parameters(): storage_config = {'umask': '077'} retention_config = flexmock() From f66fd1caaac68569b1aad7e32a5e97129d2682a5 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 22 Jan 2020 15:10:47 -0800 Subject: [PATCH 29/39] Customize Healthchecks log level via borgmatic "--monitoring-verbosity" flag (#277). --- NEWS | 1 + borgmatic/commands/arguments.py | 7 ++++ borgmatic/commands/borgmatic.py | 5 +++ borgmatic/hooks/cronhub.py | 2 +- borgmatic/hooks/cronitor.py | 2 +- borgmatic/hooks/healthchecks.py | 13 +++++--- borgmatic/logger.py | 11 ++++-- docs/how-to/monitor-your-backups.md | 6 ++-- tests/unit/commands/test_borgmatic.py | 24 +++++++------- tests/unit/hooks/test_cronhub.py | 20 ++++++++--- tests/unit/hooks/test_cronitor.py | 16 ++++++--- tests/unit/hooks/test_healthchecks.py | 48 ++++++++++++++++++++++----- 12 files changed, 113 insertions(+), 42 deletions(-) diff --git a/NEWS b/NEWS index dca9bd7a..6e022110 100644 --- a/NEWS +++ b/NEWS @@ -1,5 +1,6 @@ 1.4.23.dev0 * #274: Add ~/.config/borgmatic.d as another configuration directory default. + * #277: Customize Healthchecks log level via borgmatic "--monitoring-verbosity" flag. * For "create" and "prune" actions, no longer list files or show detailed stats at verbosity 1. You can opt back in with "--files" or "--stats" flags. diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 61b9cfca..61a754df 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -159,6 +159,13 @@ def parse_arguments(*unparsed_arguments): default=0, help='Log verbose progress to log file (from only errors to very verbose: -1, 0, 1, or 2). Only used when --log-file is given', ) + global_group.add_argument( + '--monitoring-verbosity', + type=int, + choices=range(-1, 3), + default=1, + help='Log verbose progress to monitoring integrations that support logging (from only errors to very verbose: -1, 0, 1, or 2)', + ) global_group.add_argument( '--log-file', type=str, diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index df50bb7a..4c9b0d86 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -53,6 +53,7 @@ def run_configuration(config_filename, config, arguments): encountered_error = None error_repository = '' prune_create_or_check = {'prune', 'create', 'check'}.intersection(arguments) + monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity) try: if prune_create_or_check: @@ -62,6 +63,7 @@ def run_configuration(config_filename, config, arguments): config_filename, monitor.MONITOR_HOOK_NAMES, monitor.State.START, + monitoring_log_level, global_arguments.dry_run, ) if 'create' in arguments: @@ -132,6 +134,7 @@ def run_configuration(config_filename, config, arguments): config_filename, monitor.MONITOR_HOOK_NAMES, monitor.State.FINISH, + monitoring_log_level, global_arguments.dry_run, ) except (OSError, CalledProcessError) as error: @@ -158,6 +161,7 @@ def run_configuration(config_filename, config, arguments): config_filename, monitor.MONITOR_HOOK_NAMES, monitor.State.FAIL, + monitoring_log_level, global_arguments.dry_run, ) except (OSError, CalledProcessError) as error: @@ -601,6 +605,7 @@ def main(): # pragma: no cover verbosity_to_log_level(global_arguments.verbosity), verbosity_to_log_level(global_arguments.syslog_verbosity), verbosity_to_log_level(global_arguments.log_file_verbosity), + verbosity_to_log_level(global_arguments.monitoring_verbosity), global_arguments.log_file, ) except (FileNotFoundError, PermissionError) as error: diff --git a/borgmatic/hooks/cronhub.py b/borgmatic/hooks/cronhub.py index a0d0ac96..f95abe62 100644 --- a/borgmatic/hooks/cronhub.py +++ b/borgmatic/hooks/cronhub.py @@ -13,7 +13,7 @@ MONITOR_STATE_TO_CRONHUB = { } -def ping_monitor(ping_url, config_filename, state, dry_run): +def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run): ''' Ping the given Cronhub URL, modified with the monitor.State. Use the given configuration filename in any log entries. If this is a dry run, then don't actually ping anything. diff --git a/borgmatic/hooks/cronitor.py b/borgmatic/hooks/cronitor.py index 65ad1095..e4bc7c4b 100644 --- a/borgmatic/hooks/cronitor.py +++ b/borgmatic/hooks/cronitor.py @@ -13,7 +13,7 @@ MONITOR_STATE_TO_CRONITOR = { } -def ping_monitor(ping_url, config_filename, state, dry_run): +def ping_monitor(ping_url, config_filename, state, monitoring_log_level, dry_run): ''' Ping the given Cronitor URL, modified with the monitor.State. Use the given configuration filename in any log entries. If this is a dry run, then don't actually ping anything. diff --git a/borgmatic/hooks/healthchecks.py b/borgmatic/hooks/healthchecks.py index a116205a..e16dfc7e 100644 --- a/borgmatic/hooks/healthchecks.py +++ b/borgmatic/hooks/healthchecks.py @@ -22,13 +22,14 @@ class Forgetful_buffering_handler(logging.Handler): first) once a particular capacity in bytes is reached. ''' - def __init__(self, byte_capacity): + def __init__(self, byte_capacity, log_level): super().__init__() self.byte_capacity = byte_capacity self.byte_count = 0 self.buffer = [] self.forgot = False + self.setLevel(log_level) def emit(self, record): message = record.getMessage() + '\n' @@ -64,16 +65,18 @@ def format_buffered_logs_for_payload(): return payload -def ping_monitor(ping_url_or_uuid, config_filename, state, dry_run): +def ping_monitor(ping_url_or_uuid, config_filename, state, monitoring_log_level, dry_run): ''' Ping the given Healthchecks URL or UUID, modified with the monitor.State. Use the given - configuration filename in any log entries. If this is a dry run, then don't actually ping - anything. + configuration filename in any log entries, and log to Healthchecks with the giving log level. + If this is a dry run, then don't actually ping anything. ''' if state is monitor.State.START: # Add a handler to the root logger that stores in memory the most recent logs emitted. That # way, we can send them all to Healthchecks upon a finish or failure state. - logging.getLogger().addHandler(Forgetful_buffering_handler(PAYLOAD_LIMIT_BYTES)) + logging.getLogger().addHandler( + Forgetful_buffering_handler(PAYLOAD_LIMIT_BYTES, monitoring_log_level) + ) payload = '' ping_url = ( diff --git a/borgmatic/logger.py b/borgmatic/logger.py index b20f89e3..70992305 100644 --- a/borgmatic/logger.py +++ b/borgmatic/logger.py @@ -110,7 +110,11 @@ def color_text(color, message): def configure_logging( - console_log_level, syslog_log_level=None, log_file_log_level=None, log_file=None + console_log_level, + syslog_log_level=None, + log_file_log_level=None, + monitoring_log_level=None, + log_file=None, ): ''' Configure logging to go to both the console and (syslog or log file). Use the given log levels, @@ -122,6 +126,8 @@ def configure_logging( syslog_log_level = console_log_level if log_file_log_level is None: log_file_log_level = console_log_level + if monitoring_log_level is None: + monitoring_log_level = console_log_level # Log certain log levels to console stderr and others to stdout. This supports use cases like # grepping (non-error) output. @@ -160,5 +166,6 @@ def configure_logging( handlers = (console_handler,) logging.basicConfig( - level=min(console_log_level, syslog_log_level, log_file_log_level), handlers=handlers + level=min(console_log_level, syslog_log_level, log_file_log_level, monitoring_log_level), + handlers=handlers, ) diff --git a/docs/how-to/monitor-your-backups.md b/docs/how-to/monitor-your-backups.md index 6f6f1505..c56a1513 100644 --- a/docs/how-to/monitor-your-backups.md +++ b/docs/how-to/monitor-your-backups.md @@ -131,9 +131,9 @@ the `on_error` hooks run, also tacking on logs including the error itself. But the logs are only included for errors that occur when a `prune`, `create`, or `check` action is run. -Note that borgmatic sends logs to Healthchecks by applying the maximum of any -other borgmatic verbosity levels (`--verbosity`, `--syslog-verbosity`, etc.), -as there is not currently a dedicated Healthchecks verbosity setting. +You can customize the verbosity of the logs that are sent to Healthchecks with +borgmatic's `--monitoring-verbosity` flag. The `--files` and `--stats` flags +may also be of use. See `borgmatic --help` for more information. You can configure Healthchecks to notify you by a [variety of mechanisms](https://healthchecks.io/#welcome-integrations) when backups fail diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index ccc63754..488872f1 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -13,7 +13,7 @@ def test_run_configuration_runs_actions_for_each_repository(): expected_results[1:] ) config = {'location': {'repositories': ['foo', 'bar']}} - arguments = {'global': flexmock()} + arguments = {'global': flexmock(monitoring_verbosity=1)} results = list(module.run_configuration('test.yaml', config, arguments)) @@ -26,7 +26,7 @@ def test_run_configuration_calls_hooks_for_prune_action(): flexmock(module.dispatch).should_receive('call_hooks').at_least().twice() flexmock(module).should_receive('run_actions').and_return([]) config = {'location': {'repositories': ['foo']}} - arguments = {'global': flexmock(dry_run=False), 'prune': flexmock()} + arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'prune': flexmock()} list(module.run_configuration('test.yaml', config, arguments)) @@ -37,7 +37,7 @@ def test_run_configuration_executes_and_calls_hooks_for_create_action(): flexmock(module.dispatch).should_receive('call_hooks').at_least().twice() flexmock(module).should_receive('run_actions').and_return([]) config = {'location': {'repositories': ['foo']}} - arguments = {'global': flexmock(dry_run=False), 'create': flexmock()} + arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} list(module.run_configuration('test.yaml', config, arguments)) @@ -48,7 +48,7 @@ def test_run_configuration_calls_hooks_for_check_action(): flexmock(module.dispatch).should_receive('call_hooks').at_least().twice() flexmock(module).should_receive('run_actions').and_return([]) config = {'location': {'repositories': ['foo']}} - arguments = {'global': flexmock(dry_run=False), 'check': flexmock()} + arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'check': flexmock()} list(module.run_configuration('test.yaml', config, arguments)) @@ -59,7 +59,7 @@ def test_run_configuration_does_not_trigger_hooks_for_list_action(): flexmock(module.dispatch).should_receive('call_hooks').never() flexmock(module).should_receive('run_actions').and_return([]) config = {'location': {'repositories': ['foo']}} - arguments = {'global': flexmock(dry_run=False), 'list': flexmock()} + arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'list': flexmock()} list(module.run_configuration('test.yaml', config, arguments)) @@ -72,7 +72,7 @@ def test_run_configuration_logs_actions_error(): flexmock(module).should_receive('make_error_log_records').and_return(expected_results) flexmock(module).should_receive('run_actions').and_raise(OSError) config = {'location': {'repositories': ['foo']}} - arguments = {'global': flexmock(dry_run=False)} + arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False)} results = list(module.run_configuration('test.yaml', config, arguments)) @@ -86,7 +86,7 @@ def test_run_configuration_logs_pre_hook_error(): flexmock(module).should_receive('make_error_log_records').and_return(expected_results) flexmock(module).should_receive('run_actions').never() config = {'location': {'repositories': ['foo']}} - arguments = {'global': flexmock(dry_run=False), 'create': flexmock()} + arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, arguments)) @@ -103,7 +103,7 @@ def test_run_configuration_logs_post_hook_error(): flexmock(module).should_receive('make_error_log_records').and_return(expected_results) flexmock(module).should_receive('run_actions').and_return([]) config = {'location': {'repositories': ['foo']}} - arguments = {'global': flexmock(dry_run=False), 'create': flexmock()} + arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, arguments)) @@ -119,7 +119,7 @@ def test_run_configuration_logs_on_error_hook_error(): ).and_return(expected_results[1:]) flexmock(module).should_receive('run_actions').and_raise(OSError) config = {'location': {'repositories': ['foo']}} - arguments = {'global': flexmock(dry_run=False), 'create': flexmock()} + arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()} results = list(module.run_configuration('test.yaml', config, arguments)) @@ -228,7 +228,7 @@ def test_collect_configuration_run_summary_logs_info_for_success(): def test_collect_configuration_run_summary_executes_hooks_for_create(): flexmock(module).should_receive('run_configuration').and_return([]) - arguments = {'create': flexmock(), 'global': flexmock(dry_run=False)} + arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)} logs = tuple( module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments) @@ -305,7 +305,7 @@ def test_collect_configuration_run_summary_logs_pre_hook_error(): flexmock(module.command).should_receive('execute_hook').and_raise(ValueError) expected_logs = (flexmock(),) flexmock(module).should_receive('make_error_log_records').and_return(expected_logs) - arguments = {'create': flexmock(), 'global': flexmock(dry_run=False)} + arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)} logs = tuple( module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments) @@ -319,7 +319,7 @@ def test_collect_configuration_run_summary_logs_post_hook_error(): flexmock(module).should_receive('run_configuration').and_return([]) expected_logs = (flexmock(),) flexmock(module).should_receive('make_error_log_records').and_return(expected_logs) - arguments = {'create': flexmock(), 'global': flexmock(dry_run=False)} + arguments = {'create': flexmock(), 'global': flexmock(monitoring_verbosity=1, dry_run=False)} logs = tuple( module.collect_configuration_run_summary_logs({'test.yaml': {}}, arguments=arguments) diff --git a/tests/unit/hooks/test_cronhub.py b/tests/unit/hooks/test_cronhub.py index 32018116..a2582833 100644 --- a/tests/unit/hooks/test_cronhub.py +++ b/tests/unit/hooks/test_cronhub.py @@ -7,32 +7,42 @@ def test_ping_monitor_rewrites_ping_url_for_start_state(): ping_url = 'https://example.com/start/abcdef' flexmock(module.requests).should_receive('get').with_args('https://example.com/start/abcdef') - module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=False) + module.ping_monitor( + ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False + ) def test_ping_monitor_rewrites_ping_url_and_state_for_start_state(): ping_url = 'https://example.com/ping/abcdef' flexmock(module.requests).should_receive('get').with_args('https://example.com/start/abcdef') - module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=False) + module.ping_monitor( + ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False + ) def test_ping_monitor_rewrites_ping_url_for_finish_state(): ping_url = 'https://example.com/start/abcdef' flexmock(module.requests).should_receive('get').with_args('https://example.com/finish/abcdef') - module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FINISH, dry_run=False) + module.ping_monitor( + ping_url, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False + ) def test_ping_monitor_rewrites_ping_url_for_fail_state(): ping_url = 'https://example.com/start/abcdef' flexmock(module.requests).should_receive('get').with_args('https://example.com/fail/abcdef') - module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FAIL, dry_run=False) + module.ping_monitor( + ping_url, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False + ) def test_ping_monitor_dry_run_does_not_hit_ping_url(): ping_url = 'https://example.com' flexmock(module.requests).should_receive('get').never() - module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=True) + module.ping_monitor( + ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True + ) diff --git a/tests/unit/hooks/test_cronitor.py b/tests/unit/hooks/test_cronitor.py index 73bcffa2..e0065081 100644 --- a/tests/unit/hooks/test_cronitor.py +++ b/tests/unit/hooks/test_cronitor.py @@ -7,25 +7,33 @@ def test_ping_monitor_hits_ping_url_for_start_state(): ping_url = 'https://example.com' flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'run')) - module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=False) + module.ping_monitor( + ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=False + ) def test_ping_monitor_hits_ping_url_for_finish_state(): ping_url = 'https://example.com' flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'complete')) - module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FINISH, dry_run=False) + module.ping_monitor( + ping_url, 'config.yaml', module.monitor.State.FINISH, monitoring_log_level=1, dry_run=False + ) def test_ping_monitor_hits_ping_url_for_fail_state(): ping_url = 'https://example.com' flexmock(module.requests).should_receive('get').with_args('{}/{}'.format(ping_url, 'fail')) - module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.FAIL, dry_run=False) + module.ping_monitor( + ping_url, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False + ) def test_ping_monitor_dry_run_does_not_hit_ping_url(): ping_url = 'https://example.com' flexmock(module.requests).should_receive('get').never() - module.ping_monitor(ping_url, 'config.yaml', module.monitor.State.START, dry_run=True) + module.ping_monitor( + ping_url, 'config.yaml', module.monitor.State.START, monitoring_log_level=1, dry_run=True + ) diff --git a/tests/unit/hooks/test_healthchecks.py b/tests/unit/hooks/test_healthchecks.py index 24e8fca0..7f904383 100644 --- a/tests/unit/hooks/test_healthchecks.py +++ b/tests/unit/hooks/test_healthchecks.py @@ -4,7 +4,7 @@ from borgmatic.hooks import healthchecks as module def test_forgetful_buffering_handler_emit_collects_log_records(): - handler = module.Forgetful_buffering_handler(byte_capacity=100) + handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1) handler.emit(flexmock(getMessage=lambda: 'foo')) handler.emit(flexmock(getMessage=lambda: 'bar')) @@ -13,7 +13,7 @@ def test_forgetful_buffering_handler_emit_collects_log_records(): def test_forgetful_buffering_handler_emit_forgets_log_records_when_capacity_reached(): - handler = module.Forgetful_buffering_handler(byte_capacity=len('foo\nbar\n')) + handler = module.Forgetful_buffering_handler(byte_capacity=len('foo\nbar\n'), log_level=1) handler.emit(flexmock(getMessage=lambda: 'foo')) assert handler.buffer == ['foo\n'] handler.emit(flexmock(getMessage=lambda: 'bar')) @@ -26,7 +26,7 @@ def test_forgetful_buffering_handler_emit_forgets_log_records_when_capacity_reac def test_format_buffered_logs_for_payload_flattens_log_buffer(): - handler = module.Forgetful_buffering_handler(byte_capacity=100) + handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1) handler.buffer = ['foo\n', 'bar\n'] flexmock(module.logging).should_receive('getLogger').and_return(flexmock(handlers=[handler])) @@ -36,7 +36,7 @@ def test_format_buffered_logs_for_payload_flattens_log_buffer(): def test_format_buffered_logs_for_payload_inserts_truncation_indicator_when_logs_forgotten(): - handler = module.Forgetful_buffering_handler(byte_capacity=100) + handler = module.Forgetful_buffering_handler(byte_capacity=100, log_level=1) handler.buffer = ['foo\n', 'bar\n'] handler.forgot = True flexmock(module.logging).should_receive('getLogger').and_return(flexmock(handlers=[handler])) @@ -63,7 +63,13 @@ def test_ping_monitor_hits_ping_url_for_start_state(): '{}/{}'.format(ping_url, 'start'), data=''.encode('utf-8') ) - module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.START, dry_run=False) + module.ping_monitor( + ping_url, + 'config.yaml', + state=module.monitor.State.START, + monitoring_log_level=1, + dry_run=False, + ) def test_ping_monitor_hits_ping_url_for_finish_state(): @@ -74,7 +80,13 @@ def test_ping_monitor_hits_ping_url_for_finish_state(): ping_url, data=payload.encode('utf-8') ) - module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.FINISH, dry_run=False) + module.ping_monitor( + ping_url, + 'config.yaml', + state=module.monitor.State.FINISH, + monitoring_log_level=1, + dry_run=False, + ) def test_ping_monitor_hits_ping_url_for_fail_state(): @@ -85,7 +97,13 @@ def test_ping_monitor_hits_ping_url_for_fail_state(): '{}/{}'.format(ping_url, 'fail'), data=payload.encode('utf') ) - module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.FAIL, dry_run=False) + module.ping_monitor( + ping_url, + 'config.yaml', + state=module.monitor.State.FAIL, + monitoring_log_level=1, + dry_run=False, + ) def test_ping_monitor_with_ping_uuid_hits_corresponding_url(): @@ -96,7 +114,13 @@ def test_ping_monitor_with_ping_uuid_hits_corresponding_url(): 'https://hc-ping.com/{}'.format(ping_uuid), data=payload.encode('utf-8') ) - module.ping_monitor(ping_uuid, 'config.yaml', state=module.monitor.State.FINISH, dry_run=False) + module.ping_monitor( + ping_uuid, + 'config.yaml', + state=module.monitor.State.FINISH, + monitoring_log_level=1, + dry_run=False, + ) def test_ping_monitor_dry_run_does_not_hit_ping_url(): @@ -104,4 +128,10 @@ def test_ping_monitor_dry_run_does_not_hit_ping_url(): ping_url = 'https://example.com' flexmock(module.requests).should_receive('post').never() - module.ping_monitor(ping_url, 'config.yaml', state=module.monitor.State.START, dry_run=True) + module.ping_monitor( + ping_url, + 'config.yaml', + state=module.monitor.State.START, + monitoring_log_level=1, + dry_run=True, + ) From 53e6ff95249a31adea02ac2b3b331cde60136288 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Wed, 22 Jan 2020 15:23:49 -0800 Subject: [PATCH 30/39] No longer list files or show stats by default at verbosity 2. --- NEWS | 4 ++-- borgmatic/borg/create.py | 12 ++---------- borgmatic/borg/prune.py | 4 ++-- tests/unit/borg/test_create.py | 36 +++------------------------------- tests/unit/borg/test_prune.py | 4 +--- 5 files changed, 10 insertions(+), 50 deletions(-) diff --git a/NEWS b/NEWS index 6e022110..0b75aa30 100644 --- a/NEWS +++ b/NEWS @@ -1,8 +1,8 @@ 1.4.23.dev0 * #274: Add ~/.config/borgmatic.d as another configuration directory default. * #277: Customize Healthchecks log level via borgmatic "--monitoring-verbosity" flag. - * For "create" and "prune" actions, no longer list files or show detailed stats at verbosity 1. You - can opt back in with "--files" or "--stats" flags. + * 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. 1.4.22 * #276, #285: Disable colored output when "--json" flag is used, so as to produce valid JSON ouput. diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py index 5258e9d9..cfbd7b6d 100644 --- a/borgmatic/borg/create.py +++ b/borgmatic/borg/create.py @@ -176,17 +176,9 @@ def create_archive( + (('--remote-path', remote_path) if remote_path else ()) + (('--umask', str(umask)) if umask else ()) + (('--lock-wait', str(lock_wait)) if lock_wait else ()) - + ( - ('--list', '--filter', 'AME-') - if (files or logger.isEnabledFor(logging.DEBUG)) 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 (stats or logger.isEnabledFor(logging.DEBUG)) and not json and not dry_run - 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 ()) diff --git a/borgmatic/borg/prune.py b/borgmatic/borg/prune.py index 492ff9b6..83e421d2 100644 --- a/borgmatic/borg/prune.py +++ b/borgmatic/borg/prune.py @@ -58,9 +58,9 @@ def prune_archives( + (('--remote-path', remote_path) if remote_path else ()) + (('--umask', str(umask)) if umask else ()) + (('--lock-wait', str(lock_wait)) if lock_wait else ()) - + (('--stats',) if (stats or logger.isEnabledFor(logging.DEBUG)) and not dry_run else ()) + + (('--stats',) if stats and not dry_run else ()) + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) - + (('--list',) if files or logger.isEnabledFor(logging.DEBUG) else ()) + + (('--list',) if files else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (('--dry-run',) if dry_run else ()) + (tuple(extra_borg_options.split(' ')) if extra_borg_options else ()) diff --git a/tests/unit/borg/test_create.py b/tests/unit/borg/test_create.py index 79cdfcab..5e51a51e 100644 --- a/tests/unit/borg/test_create.py +++ b/tests/unit/borg/test_create.py @@ -349,8 +349,7 @@ def test_create_archive_with_log_debug_calls_borg_with_debug_parameter(): flexmock(module).should_receive('_make_pattern_flags').and_return(()) flexmock(module).should_receive('_make_exclude_flags').and_return(()) flexmock(module).should_receive('execute_command').with_args( - ('borg', 'create', '--list', '--filter', 'AME-', '--stats', '--debug', '--show-rc') - + ARCHIVE_WITH_PATHS, + ('borg', 'create', '--debug', '--show-rc') + ARCHIVE_WITH_PATHS, output_log_level=logging.INFO, error_on_warnings=False, ) @@ -421,7 +420,7 @@ def test_create_archive_with_dry_run_calls_borg_with_dry_run_parameter(): ) -def test_create_archive_with_dry_run_and_log_info_calls_borg_without_stats_parameter(): +def test_create_archive_with_stats_and_dry_run_calls_borg_without_stats_parameter(): # --dry-run and --stats are mutually exclusive, see: # https://borgbackup.readthedocs.io/en/stable/usage/create.html#description flexmock(module).should_receive('borgmatic_source_directories').and_return([]) @@ -447,36 +446,7 @@ def test_create_archive_with_dry_run_and_log_info_calls_borg_without_stats_param 'exclude_patterns': None, }, storage_config={}, - ) - - -def test_create_archive_with_dry_run_and_log_debug_calls_borg_without_stats_parameter(): - # --dry-run and --stats are mutually exclusive, see: - # https://borgbackup.readthedocs.io/en/stable/usage/create.html#description - flexmock(module).should_receive('borgmatic_source_directories').and_return([]) - flexmock(module).should_receive('_expand_directories').and_return(('foo', 'bar')) - flexmock(module).should_receive('_expand_home_directories').and_return(()) - flexmock(module).should_receive('_write_pattern_file').and_return(None) - flexmock(module).should_receive('_make_pattern_flags').and_return(()) - flexmock(module).should_receive('_make_pattern_flags').and_return(()) - flexmock(module).should_receive('_make_exclude_flags').and_return(()) - flexmock(module).should_receive('execute_command').with_args( - ('borg', 'create', '--list', '--filter', 'AME-', '--debug', '--show-rc', '--dry-run') - + ARCHIVE_WITH_PATHS, - output_log_level=logging.INFO, - error_on_warnings=False, - ) - insert_logging_mock(logging.DEBUG) - - module.create_archive( - dry_run=True, - repository='repo', - location_config={ - 'source_directories': ['foo', 'bar'], - 'repositories': ['repo'], - 'exclude_patterns': None, - }, - storage_config={}, + stats=True, ) diff --git a/tests/unit/borg/test_prune.py b/tests/unit/borg/test_prune.py index a61c4908..b1ff0132 100644 --- a/tests/unit/borg/test_prune.py +++ b/tests/unit/borg/test_prune.py @@ -88,9 +88,7 @@ def test_prune_archives_with_log_debug_calls_borg_with_debug_parameter(): flexmock(module).should_receive('_make_prune_flags').with_args(retention_config).and_return( BASE_PRUNE_FLAGS ) - insert_execute_command_mock( - PRUNE_COMMAND + ('--stats', '--list', '--debug', '--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( From 5273037a948bc04f1a36096f61a87f6c6b8ad6d5 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 23 Jan 2020 11:17:39 -0800 Subject: [PATCH 31/39] For "list" and "info" actions, show repository names even at verbosity 0. --- NEWS | 1 + borgmatic/commands/borgmatic.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/NEWS b/NEWS index 0b75aa30..09525b14 100644 --- a/NEWS +++ b/NEWS @@ -3,6 +3,7 @@ * #277: Customize Healthchecks log level via borgmatic "--monitoring-verbosity" flag. * 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. diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 4c9b0d86..248bbb5f 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -353,7 +353,7 @@ def run_actions( if arguments['list'].repository is None or validate.repositories_match( repository, arguments['list'].repository ): - logger.info('{}: Listing archives'.format(repository)) + logger.warning('{}: Listing archives'.format(repository)) json_output = borg_list.list_archives( repository, storage, @@ -367,7 +367,7 @@ def run_actions( if arguments['info'].repository is None or validate.repositories_match( repository, arguments['info'].repository ): - logger.info('{}: Displaying summary info for archives'.format(repository)) + logger.warning('{}: Displaying summary info for archives'.format(repository)) json_output = borg_info.display_archives_info( repository, storage, From 952168ce2581b7f745ff451477c164f5cfc29bd1 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 23 Jan 2020 13:40:54 -0800 Subject: [PATCH 32/39] Fix unwanted console log messages with "list --json" and "info --json". --- borgmatic/commands/borgmatic.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 248bbb5f..daece3a5 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -353,7 +353,8 @@ def run_actions( if arguments['list'].repository is None or validate.repositories_match( repository, arguments['list'].repository ): - logger.warning('{}: Listing archives'.format(repository)) + if not arguments['list'].json: + logger.warning('{}: Listing archives'.format(repository)) json_output = borg_list.list_archives( repository, storage, @@ -367,7 +368,8 @@ def run_actions( if arguments['info'].repository is None or validate.repositories_match( repository, arguments['info'].repository ): - logger.warning('{}: Displaying summary info for archives'.format(repository)) + if not arguments['info'].json: + logger.warning('{}: Displaying summary info for archives'.format(repository)) json_output = borg_info.display_archives_info( repository, storage, From 94b9ef56be7fa4e0681e005f82bc1d29fafb5b35 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Thu, 23 Jan 2020 13:41:37 -0800 Subject: [PATCH 33/39] Change "exclude_if_present" option to support multiple filenames, rather than just a single filename (#280). --- NEWS | 2 ++ borgmatic/borg/create.py | 8 +++++-- borgmatic/config/normalize.py | 10 +++++++++ borgmatic/config/schema.yaml | 8 ++++--- borgmatic/config/validate.py | 3 ++- tests/integration/config/test_validate.py | 25 +++++++++++++++++++++ tests/unit/borg/test_create.py | 11 +++++++-- tests/unit/config/test_normalize.py | 27 +++++++++++++++++++++++ 8 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 borgmatic/config/normalize.py create mode 100644 tests/unit/config/test_normalize.py diff --git a/NEWS b/NEWS index 09525b14..53e53b82 100644 --- a/NEWS +++ b/NEWS @@ -1,6 +1,8 @@ 1.4.23.dev0 * #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. * 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. diff --git a/borgmatic/borg/create.py b/borgmatic/borg/create.py index cfbd7b6d..7a3d2749 100644 --- a/borgmatic/borg/create.py +++ b/borgmatic/borg/create.py @@ -88,8 +88,12 @@ def _make_exclude_flags(location_config, exclude_filename=None): ) ) caches_flag = ('--exclude-caches',) if location_config.get('exclude_caches') else () - if_present = location_config.get('exclude_if_present') - if_present_flags = ('--exclude-if-present', if_present) if if_present else () + if_present_flags = tuple( + itertools.chain.from_iterable( + ('--exclude-if-present', if_present) + for if_present in location_config.get('exclude_if_present', ()) + ) + ) keep_exclude_tags_flags = ( ('--keep-exclude-tags',) if location_config.get('keep_exclude_tags') else () ) diff --git a/borgmatic/config/normalize.py b/borgmatic/config/normalize.py new file mode 100644 index 00000000..05080bb2 --- /dev/null +++ b/borgmatic/config/normalize.py @@ -0,0 +1,10 @@ +def normalize(config): + ''' + Given a configuration dict, apply particular hard-coded rules to normalize its contents to + adhere to the configuration schema. + ''' + exclude_if_present = config.get('location', {}).get('exclude_if_present') + + # "Upgrade" exclude_if_present from a string to a list. + if isinstance(exclude_if_present, str): + config['location']['exclude_if_present'] = [exclude_if_present] diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index c58ebdec..46fa728a 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -121,11 +121,13 @@ map: http://www.brynosaurus.com/cachedir/spec.html for details. Defaults to false. example: true exclude_if_present: - type: str + seq: + - type: str desc: | - Exclude directories that contain a file with the given filename. Defaults to not + Exclude directories that contain a file with the given filenames. Defaults to not set. - example: .nobackup + example: + - .nobackup keep_exclude_tags: type: bool desc: | diff --git a/borgmatic/config/validate.py b/borgmatic/config/validate.py index b7d34a9f..504e1818 100644 --- a/borgmatic/config/validate.py +++ b/borgmatic/config/validate.py @@ -6,7 +6,7 @@ import pykwalify.core import pykwalify.errors import ruamel.yaml -from borgmatic.config import load, override +from borgmatic.config import load, normalize, override def schema_filename(): @@ -104,6 +104,7 @@ def parse_configuration(config_filename, schema_filename, overrides=None): raise Validation_error(config_filename, (str(error),)) override.apply_overrides(config, overrides) + normalize.normalize(config) validator = pykwalify.core.Core(source_data=config, schema_data=remove_examples(schema)) parsed_result = validator.validate(raise_exception=False) diff --git a/tests/integration/config/test_validate.py b/tests/integration/config/test_validate.py index cbd4f5ba..fbd2098c 100644 --- a/tests/integration/config/test_validate.py +++ b/tests/integration/config/test_validate.py @@ -239,3 +239,28 @@ def test_parse_configuration_applies_overrides(): 'local_path': 'borg2', } } + + +def test_parse_configuration_applies_normalization(): + mock_config_and_schema( + ''' + location: + source_directories: + - /home + + repositories: + - hostname.borg + + exclude_if_present: .nobackup + ''' + ) + + result = module.parse_configuration('config.yaml', 'schema.yaml') + + assert result == { + 'location': { + 'source_directories': ['/home'], + 'repositories': ['hostname.borg'], + 'exclude_if_present': ['.nobackup'], + } + } diff --git a/tests/unit/borg/test_create.py b/tests/unit/borg/test_create.py index 5e51a51e..68543718 100644 --- a/tests/unit/borg/test_create.py +++ b/tests/unit/borg/test_create.py @@ -145,9 +145,16 @@ def test_make_exclude_flags_does_not_include_exclude_caches_when_false_in_config def test_make_exclude_flags_includes_exclude_if_present_when_in_config(): - exclude_flags = module._make_exclude_flags(location_config={'exclude_if_present': 'exclude_me'}) + exclude_flags = module._make_exclude_flags( + location_config={'exclude_if_present': ['exclude_me', 'also_me']} + ) - assert exclude_flags == ('--exclude-if-present', 'exclude_me') + assert exclude_flags == ( + '--exclude-if-present', + 'exclude_me', + '--exclude-if-present', + 'also_me', + ) def test_make_exclude_flags_includes_keep_exclude_tags_when_true_in_config(): diff --git a/tests/unit/config/test_normalize.py b/tests/unit/config/test_normalize.py new file mode 100644 index 00000000..58e51059 --- /dev/null +++ b/tests/unit/config/test_normalize.py @@ -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 From fdbb2ee9058ea5e2b432ac8225248b205a7fa1d7 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 24 Jan 2020 11:27:16 -0800 Subject: [PATCH 34/39] View consistency check progress via "--progress" flag for "check" action (#287). --- NEWS | 1 + borgmatic/borg/check.py | 16 +++++++++------- borgmatic/commands/arguments.py | 11 +++++++++-- borgmatic/commands/borgmatic.py | 1 + tests/unit/borg/test_check.py | 15 +++++++++++++++ 5 files changed, 35 insertions(+), 9 deletions(-) diff --git a/NEWS b/NEWS index 53e53b82..820cef56 100644 --- a/NEWS +++ b/NEWS @@ -3,6 +3,7 @@ * #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. + * #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. diff --git a/borgmatic/borg/check.py b/borgmatic/borg/check.py index 45e59f28..55332157 100644 --- a/borgmatic/borg/check.py +++ b/borgmatic/borg/check.py @@ -91,13 +91,15 @@ def check_archives( consistency_config, local_path='borg', remote_path=None, + progress=None, repair=None, only_checks=None, ): ''' Given a local or remote repository path, a storage config dict, a consistency config dict, - local/remote commands to run, whether to attempt a repair, and an optional list of checks - to use instead of configured checks, check the contained Borg archives for consistency. + local/remote commands to run, whether to include progress information, whether to attempt a + repair, and an optional list of checks to use instead of configured checks, check the contained + Borg archives for consistency. If there are no consistency checks to run, skip running them. ''' @@ -124,17 +126,17 @@ def check_archives( + (('--remote-path', remote_path) if remote_path else ()) + (('--lock-wait', str(lock_wait)) if lock_wait else ()) + verbosity_flags + + (('--progress',) if progress else ()) + (tuple(extra_borg_options.split(' ')) if extra_borg_options else ()) + (repository,) ) # The Borg repair option trigger an interactive prompt, which won't work when output is - # captured. - if repair: + # captured. And progress messes with the terminal directly. + if repair or progress: execute_command_without_capture(full_command, error_on_warnings=True) - return - - execute_command(full_command, error_on_warnings=True) + else: + execute_command(full_command, error_on_warnings=True) if 'extract' in checks: extract.extract_last_archive_dry_run(repository, lock_wait, local_path, remote_path) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 61a754df..cde8539e 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -262,7 +262,7 @@ def parse_arguments(*unparsed_arguments): dest='progress', default=False, action='store_true', - help='Display progress for each file as it is processed', + help='Display progress for each file as it is backed up', ) create_group.add_argument( '--stats', @@ -287,6 +287,13 @@ def parse_arguments(*unparsed_arguments): add_help=False, ) check_group = check_parser.add_argument_group('check arguments') + check_group.add_argument( + '--progress', + dest='progress', + default=False, + action='store_true', + help='Display progress for each file as it is checked', + ) check_group.add_argument( '--repair', dest='repair', @@ -336,7 +343,7 @@ def parse_arguments(*unparsed_arguments): dest='progress', default=False, action='store_true', - help='Display progress for each file as it is processed', + help='Display progress for each file as it is extracted', ) extract_group.add_argument( '-h', '--help', action='help', help='Show this help message and exit' diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index daece3a5..9914668a 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -242,6 +242,7 @@ def run_actions( consistency, local_path=local_path, remote_path=remote_path, + progress=arguments['check'].progress, repair=arguments['check'].repair, only_checks=arguments['check'].only, ) diff --git a/tests/unit/borg/test_check.py b/tests/unit/borg/test_check.py index b2506aae..96b9979e 100644 --- a/tests/unit/borg/test_check.py +++ b/tests/unit/borg/test_check.py @@ -158,6 +158,21 @@ def test_make_check_flags_with_default_checks_and_prefix_includes_prefix_flag(): assert flags == ('--prefix', 'foo-') +def test_check_archives_with_progress_calls_borg_with_progress_parameter(): + checks = ('repository',) + consistency_config = {'check_last': None} + flexmock(module).should_receive('_parse_checks').and_return(checks) + flexmock(module).should_receive('_make_check_flags').and_return(()) + flexmock(module).should_receive('execute_command').never() + flexmock(module).should_receive('execute_command_without_capture').with_args( + ('borg', 'check', '--progress', 'repo'), error_on_warnings=True + ).once() + + module.check_archives( + repository='repo', storage_config={}, consistency_config=consistency_config, progress=True + ) + + def test_check_archives_with_repair_calls_borg_with_repair_parameter(): checks = ('repository',) consistency_config = {'check_last': None} From 2405e97c388aa4f6ceee28846aae8baef73fc657 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 24 Jan 2020 20:52:48 -0800 Subject: [PATCH 35/39] Backup to a removable drive or intermittent server via "soft failure" feature (#284). --- NEWS | 3 + README.md | 3 +- borgmatic/commands/borgmatic.py | 9 ++ borgmatic/config/schema.yaml | 6 +- borgmatic/hooks/command.py | 24 ++++ ...movable-drive-or-an-intermittent-server.md | 106 ++++++++++++++++++ tests/unit/commands/test_borgmatic.py | 47 ++++++++ tests/unit/hooks/test_command.py | 17 +++ 8 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md diff --git a/NEWS b/NEWS index 820cef56..bd15bb1b 100644 --- a/NEWS +++ b/NEWS @@ -3,6 +3,9 @@ * #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. diff --git a/README.md b/README.md index 9d088117..16dc0eaa 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ location: repositories: - 1234@usw-s001.rsync.net:backups.borg - k8pDxu32@k8pDxu32.repo.borgbase.com:repo - - /var/lib/backups/backups.borg + - /var/lib/backups/local.borg retention: # Retention policy for how many backups to keep. @@ -80,6 +80,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/). * [Extract a backup](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) * [Backup your databases](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/) * [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/) + * [Backup to a removable drive or an intermittent server](https://torsion.org/borgmatic/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server/) * [Upgrade borgmatic](https://torsion.org/borgmatic/docs/how-to/upgrade/) * [Develop on borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 9914668a..3e377767 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -83,6 +83,9 @@ def run_configuration(config_filename, config, arguments): 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 @@ -138,6 +141,9 @@ def run_configuration(config_filename, config, arguments): 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 @@ -165,6 +171,9 @@ def run_configuration(config_filename, config, arguments): 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 ) diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 46fa728a..5d00eeba 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -548,7 +548,8 @@ map: - type: str desc: | List of one or more shell commands or scripts to execute before running all - actions (if one of them is "create"), run once before all configuration files. + actions (if one of them is "create"). These are collected from all configuration + files and then run once before all of them (prior to all actions). example: - echo "Starting actions." after_everything: @@ -556,7 +557,8 @@ map: - type: str desc: | List of one or more shell commands or scripts to execute after running all - actions (if one of them is "create"), run once after all configuration files. + actions (if one of them is "create"). These are collected from all configuration + files and then run once before all of them (prior to all actions). example: - echo "Completed actions." umask: diff --git a/borgmatic/hooks/command.py b/borgmatic/hooks/command.py index 16dc3769..aaa777ac 100644 --- a/borgmatic/hooks/command.py +++ b/borgmatic/hooks/command.py @@ -6,6 +6,9 @@ from borgmatic import execute logger = logging.getLogger(__name__) +SOFT_FAIL_EXIT_CODE = 75 + + def interpolate_context(command, context): ''' Given a single hook command and a dict of context names/values, interpolate the values by @@ -69,3 +72,24 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte finally: if original_umask: os.umask(original_umask) + + +def considered_soft_failure(config_filename, error): + ''' + Given a configuration filename and an exception object, return whether the exception object + represents a subprocess.CalledProcessError with a return code of SOFT_FAIL_EXIT_CODE. If so, + that indicates that the error is a "soft failure", and should not result in an error. + ''' + exit_code = getattr(error, 'returncode', None) + if exit_code is None: + return False + + if exit_code == SOFT_FAIL_EXIT_CODE: + logger.info( + '{}: Command hook exited with soft failure exit code ({}); skipping remaining actions'.format( + config_filename, SOFT_FAIL_EXIT_CODE + ) + ) + return True + + return False diff --git a/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md b/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md new file mode 100644 index 00000000..8a86022f --- /dev/null +++ b/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md @@ -0,0 +1,106 @@ +--- +title: 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 complete just fine). + +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! + * This feature does not apply to `before_everything` or `after_everything` + hooks. + +## 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 --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index 488872f1..a3c020c9 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -3,6 +3,7 @@ import subprocess from flexmock import flexmock +import borgmatic.hooks.command from borgmatic.commands import borgmatic as module @@ -93,6 +94,20 @@ def test_run_configuration_logs_pre_hook_error(): 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( @@ -110,6 +125,23 @@ def test_run_configuration_logs_post_hook_error(): 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) @@ -126,6 +158,21 @@ def test_run_configuration_logs_on_error_hook_error(): 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)) + + assert results == expected_results + + def test_load_configurations_collects_parsed_configurations(): configuration = flexmock() other_configuration = flexmock() diff --git a/tests/unit/hooks/test_command.py b/tests/unit/hooks/test_command.py index 8289b027..54ed706e 100644 --- a/tests/unit/hooks/test_command.py +++ b/tests/unit/hooks/test_command.py @@ -1,4 +1,5 @@ import logging +import subprocess from flexmock import flexmock @@ -79,3 +80,19 @@ def test_execute_hook_on_error_logs_as_error(): ).once() module.execute_hook([':'], None, 'config.yaml', 'on-error', dry_run=False) + + +def test_considered_soft_failure_treats_soft_fail_exit_code_as_soft_fail(): + error = subprocess.CalledProcessError(module.SOFT_FAIL_EXIT_CODE, 'try again') + + assert module.considered_soft_failure('config.yaml', error) + + +def test_considered_soft_failure_does_not_treat_other_exit_code_as_soft_fail(): + error = subprocess.CalledProcessError(1, 'error') + + assert not module.considered_soft_failure('config.yaml', error) + + +def test_considered_soft_failure_does_not_treat_other_exception_type_as_soft_fail(): + assert not module.considered_soft_failure('config.yaml', Exception()) From b15c9b7dab1459c2acffb360599643e10615c43a Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Fri, 24 Jan 2020 21:02:56 -0800 Subject: [PATCH 36/39] Add missing "how to" text. --- .../backup-to-a-removable-drive-or-an-intermittent-server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md b/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md index 8a86022f..e9abfd86 100644 --- a/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md +++ b/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md @@ -1,5 +1,5 @@ --- -title: Backup to a removable drive or an intermittent server +title: How to backup to a removable drive or an intermittent server --- ## Occasional backups From 8ad8a9c422998eab005eaa3da092c5a9d5534d54 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 27 Jan 2020 11:07:07 -0800 Subject: [PATCH 37/39] Add per-action hooks: "before_prune", "after_prune", "before_check", and "after_check" (#255). --- NEWS | 3 +- borgmatic/commands/borgmatic.py | 36 ++++++++++++++++- borgmatic/config/schema.yaml | 39 +++++++++++++++++-- ...reparation-and-cleanup-steps-to-backups.md | 4 ++ ...movable-drive-or-an-intermittent-server.md | 5 ++- setup.py | 2 +- 6 files changed, 80 insertions(+), 9 deletions(-) diff --git a/NEWS b/NEWS index bd15bb1b..151e0d60 100644 --- a/NEWS +++ b/NEWS @@ -1,4 +1,5 @@ -1.4.23.dev0 +1.5.0 + * #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 diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 3e377767..8a2489cd 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -66,6 +66,14 @@ def run_configuration(config_filename, config, arguments): monitoring_log_level, global_arguments.dry_run, ) + if 'prune' in arguments: + command.execute_hook( + hooks.get('before_prune'), + hooks.get('umask'), + config_filename, + 'pre-prune', + global_arguments.dry_run, + ) if 'create' in arguments: command.execute_hook( hooks.get('before_backup'), @@ -82,13 +90,21 @@ def run_configuration(config_filename, config, arguments): location, global_arguments.dry_run, ) + if 'check' in arguments: + command.execute_hook( + hooks.get('before_check'), + hooks.get('umask'), + config_filename, + 'pre-check', + global_arguments.dry_run, + ) except (OSError, CalledProcessError) as error: if command.considered_soft_failure(config_filename, error): return encountered_error = error yield from make_error_log_records( - '{}: Error running pre-backup hook'.format(config_filename), error + '{}: Error running pre hook'.format(config_filename), error ) if not encountered_error: @@ -114,6 +130,14 @@ def run_configuration(config_filename, config, arguments): if not encountered_error: try: + if 'prune' in arguments: + command.execute_hook( + hooks.get('after_prune'), + hooks.get('umask'), + config_filename, + 'post-prune', + global_arguments.dry_run, + ) if 'create' in arguments: dispatch.call_hooks( 'remove_database_dumps', @@ -130,6 +154,14 @@ def run_configuration(config_filename, config, arguments): 'post-backup', global_arguments.dry_run, ) + if 'check' in arguments: + command.execute_hook( + hooks.get('after_check'), + hooks.get('umask'), + config_filename, + 'post-check', + global_arguments.dry_run, + ) if {'prune', 'create', 'check'}.intersection(arguments): dispatch.call_hooks( 'ping_monitor', @@ -146,7 +178,7 @@ def run_configuration(config_filename, config, arguments): 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: diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 5d00eeba..3a09fdf5 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -395,6 +395,22 @@ map: backup, run once per configuration file. example: - echo "Starting a backup." + before_prune: + seq: + - type: str + desc: | + List of one or more shell commands or scripts to execute before pruning, run + once per configuration file. + example: + - echo "Starting pruning." + before_check: + seq: + - type: str + desc: | + List of one or more shell commands or scripts to execute before consistency + checks, run once per configuration file. + example: + - echo "Starting checks." after_backup: seq: - type: str @@ -402,15 +418,32 @@ map: List of one or more shell commands or scripts to execute after creating a backup, run once per configuration file. example: - - echo "Created a backup." + - echo "Finished a backup." + after_prune: + seq: + - type: str + desc: | + List of one or more shell commands or scripts to execute after pruning, run once + per configuration file. + example: + - echo "Finished pruning." + after_check: + seq: + - type: str + desc: | + List of one or more shell commands or scripts to execute after consistency + checks, run once per configuration file. + example: + - echo "Finished checks." on_error: seq: - type: str desc: | List of one or more shell commands or scripts to execute when an exception - occurs during a backup or when running a before_backup or after_backup hook. + occurs during a "prune", "create", or "check" action or an associated + before/after hook. example: - - echo "Error while creating a backup or running a backup hook." + - echo "Error during prune/create/check." postgresql_databases: seq: - map: diff --git a/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md b/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md index 1f3b0c3e..41872ab1 100644 --- a/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md +++ b/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md @@ -29,6 +29,10 @@ configuration file, right before the `create` action. `after_backup` hooks run afterwards, but not if an error occurs in a previous hook or in the backups themselves. +There are additional hooks for the `prune` and `check` actions as well. +`before_prune` and `after_prune` run if there are any `prune` actions, while +`before_check` and `after_check` run if there are any `check` actions. + You can also use `before_everything` and `after_everything` hooks to perform global setup or cleanup: diff --git a/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md b/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md index e9abfd86..a38b3dc3 100644 --- a/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md +++ b/docs/how-to/backup-to-a-removable-drive-or-an-intermittent-server.md @@ -95,8 +95,9 @@ There are some caveats you should be aware of with this feature. * 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! - * This feature does not apply to `before_everything` or `after_everything` - hooks. + * The soft failure feature also works for `before_prune`, `after_prune`, + `before_check`, and `after_check` hooks. However it is not implemented for + `before_everything` or `after_everything`. ## Related documentation diff --git a/setup.py b/setup.py index c73c9ea7..2cce11ab 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ from setuptools import find_packages, setup -VERSION = '1.4.23.dev0' +VERSION = '1.5.0' setup( From e76d5ad9884ab519e8ce3b3206fa953992e38582 Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 27 Jan 2020 12:56:12 -0800 Subject: [PATCH 38/39] Fix tests. --- tests/unit/commands/test_borgmatic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index a3c020c9..3ecf8c24 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -23,7 +23,7 @@ def test_run_configuration_runs_actions_for_each_repository(): def test_run_configuration_calls_hooks_for_prune_action(): flexmock(module.borg_environment).should_receive('initialize') - flexmock(module.command).should_receive('execute_hook').never() + flexmock(module.command).should_receive('execute_hook').twice() flexmock(module.dispatch).should_receive('call_hooks').at_least().twice() flexmock(module).should_receive('run_actions').and_return([]) config = {'location': {'repositories': ['foo']}} @@ -45,7 +45,7 @@ def test_run_configuration_executes_and_calls_hooks_for_create_action(): 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']}} From bc02c123e63abca4c5006ab50444344f8067374b Mon Sep 17 00:00:00 2001 From: Dan Helfman Date: Mon, 27 Jan 2020 15:32:09 -0800 Subject: [PATCH 39/39] Monitor backups with PagerDuty hook integration (#245). --- NEWS | 2 + README.md | 1 + borgmatic/config/schema.yaml | 9 ++++ borgmatic/hooks/dispatch.py | 3 +- borgmatic/hooks/monitor.py | 2 +- borgmatic/hooks/pagerduty.py | 62 ++++++++++++++++++++++++++++ docs/how-to/monitor-your-backups.md | 41 ++++++++++++++---- docs/static/pagerduty.png | Bin 0 -> 20107 bytes tests/unit/hooks/test_pagerduty.py | 35 ++++++++++++++++ 9 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 borgmatic/hooks/pagerduty.py create mode 100644 docs/static/pagerduty.png create mode 100644 tests/unit/hooks/test_pagerduty.py diff --git a/NEWS b/NEWS index 151e0d60..1be3d09c 100644 --- a/NEWS +++ b/NEWS @@ -1,4 +1,6 @@ 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. diff --git a/README.md b/README.md index 16dc0eaa..b2ba6e0a 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/). Healthchecks      Cronitor      Cronhub      +PagerDuty      rsync.net      BorgBase      diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index 3a09fdf5..a228e7a3 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -567,6 +567,15 @@ map: for details. example: https://cronitor.link/d3x0c1 + pagerduty: + type: str + desc: | + PagerDuty integration key used to notify PagerDuty when a backup errors. Create + an account at https://www.pagerduty.com/ if you'd like to use this service. See + https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook + for details. + example: + a177cad45bd374409f78906a810a3074 cronhub: type: str desc: | diff --git a/borgmatic/hooks/dispatch.py b/borgmatic/hooks/dispatch.py index 206b0d1c..6c05cad9 100644 --- a/borgmatic/hooks/dispatch.py +++ b/borgmatic/hooks/dispatch.py @@ -1,6 +1,6 @@ import logging -from borgmatic.hooks import cronhub, cronitor, healthchecks, mysql, postgresql +from borgmatic.hooks import cronhub, cronitor, healthchecks, mysql, pagerduty, postgresql logger = logging.getLogger(__name__) @@ -8,6 +8,7 @@ HOOK_NAME_TO_MODULE = { 'healthchecks': healthchecks, 'cronitor': cronitor, 'cronhub': cronhub, + 'pagerduty': pagerduty, 'postgresql_databases': postgresql, 'mysql_databases': mysql, } diff --git a/borgmatic/hooks/monitor.py b/borgmatic/hooks/monitor.py index aee2b8f5..c4cf576e 100644 --- a/borgmatic/hooks/monitor.py +++ b/borgmatic/hooks/monitor.py @@ -1,6 +1,6 @@ from enum import Enum -MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub') +MONITOR_HOOK_NAMES = ('healthchecks', 'cronitor', 'cronhub', 'pagerduty') class State(Enum): diff --git a/borgmatic/hooks/pagerduty.py b/borgmatic/hooks/pagerduty.py new file mode 100644 index 00000000..0e613cc5 --- /dev/null +++ b/borgmatic/hooks/pagerduty.py @@ -0,0 +1,62 @@ +import datetime +import json +import logging +import platform + +import requests + +from borgmatic.hooks import monitor + +logger = logging.getLogger(__name__) + +EVENTS_API_URL = 'https://events.pagerduty.com/v2/enqueue' + + +def ping_monitor(integration_key, config_filename, state, monitoring_log_level, dry_run): + ''' + If this is an error state, create a PagerDuty event with the given integration key. Use the + given configuration filename in any log entries. If this is a dry run, then don't actually + create an event. + ''' + if state != monitor.State.FAIL: + logger.debug( + '{}: Ignoring unsupported monitoring {} in PagerDuty hook'.format( + config_filename, state.name.lower() + ) + ) + return + + dry_run_label = ' (dry run; not actually sending)' if dry_run else '' + logger.info('{}: Sending failure event to PagerDuty {}'.format(config_filename, dry_run_label)) + + if dry_run: + return + + hostname = platform.node() + local_timestamp = ( + datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).astimezone().isoformat() + ) + payload = json.dumps( + { + 'routing_key': integration_key, + 'event_action': 'trigger', + 'payload': { + 'summary': 'backup failed on {}'.format(hostname), + 'severity': 'error', + 'source': hostname, + 'timestamp': local_timestamp, + 'component': 'borgmatic', + 'group': 'backups', + 'class': 'backup failure', + 'custom_details': { + 'hostname': hostname, + 'configuration filename': config_filename, + 'server time': local_timestamp, + }, + }, + } + ) + logger.debug('{}: Using PagerDuty payload: {}'.format(config_filename, payload)) + + logging.getLogger('urllib3').setLevel(logging.ERROR) + requests.post(EVENTS_API_URL, data=payload.encode('utf-8')) diff --git a/docs/how-to/monitor-your-backups.md b/docs/how-to/monitor-your-backups.md index c56a1513..064c4080 100644 --- a/docs/how-to/monitor-your-backups.md +++ b/docs/how-to/monitor-your-backups.md @@ -28,14 +28,15 @@ hooks](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#error-hoo below for how to configure this. 4. **borgmatic monitoring hooks**: This feature integrates with monitoring services like [Healthchecks](https://healthchecks.io/), -[Cronitor](https://cronitor.io), and [Cronhub](https://cronhub.io), and pings -these services whenever borgmatic runs. That way, you'll receive an alert when -something goes wrong or the service doesn't hear from borgmatic for a -configured interval. See -[Healthchecks +[Cronitor](https://cronitor.io), [Cronhub](https://cronhub.io), and +[PagerDuty](https://www.pagerduty.com/) and pings these services whenever +borgmatic runs. That way, you'll receive an alert when something goes wrong or +(for certain hooks) the service doesn't hear from borgmatic for a configured +interval. See [Healthchecks hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#healthchecks-hook), [Cronitor -hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook), and [Cronhub -hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook) +hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronitor-hook), [Cronhub +hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#cronhub-hook), and +[PagerDuty hook](https://torsion.org/borgmatic/docs/how-to/monitor-your-backups/#pagerduty-hook) below for how to configure this. 3. **Third-party monitoring software**: You can use traditional monitoring software to consume borgmatic JSON output and track when the last @@ -200,6 +201,32 @@ mechanisms](https://docs.cronhub.io/integrations.html) when backups fail or it doesn't hear from borgmatic for a certain period of time. +## PagerDuty hook + +[PagerDuty](https://cronhub.io/) 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 diff --git a/docs/static/pagerduty.png b/docs/static/pagerduty.png new file mode 100644 index 0000000000000000000000000000000000000000..c60c63ece018c1c7164c5311be42dd3dd60f4b67 GIT binary patch literal 20107 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 LS3j3^P6*v literal 0 HcmV?d00001 diff --git a/tests/unit/hooks/test_pagerduty.py b/tests/unit/hooks/test_pagerduty.py new file mode 100644 index 00000000..76c5451a --- /dev/null +++ b/tests/unit/hooks/test_pagerduty.py @@ -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 + )