diff --git a/AUTHORS b/AUTHORS index bd99af8ca..81ae45e4a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -5,3 +5,4 @@ Henning Schroeder: Copy editing Michele Lazzeri: Custom archive names Robin `ypid` Schneider: Support additional options of Borg Scott Squires: Custom archive names +Johannes Feichtner: Support for user hooks diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 2fdff34ad..e67fe88e7 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -5,6 +5,7 @@ from subprocess import CalledProcessError import sys from borgmatic.borg import check, create, prune +from borgmatic.commands import hook from borgmatic.config import collect, convert, validate @@ -84,26 +85,33 @@ def main(): # pragma: no cover for config_filename in config_filenames: config = validate.parse_configuration(config_filename, validate.schema_filename()) - (location, storage, retention, consistency) = ( + (location, storage, retention, consistency, hooks) = ( config.get(section_name, {}) - for section_name in ('location', 'storage', 'retention', 'consistency') + for section_name in ('location', 'storage', 'retention', 'consistency', 'hooks') ) remote_path = location.get('remote_path') - create.initialize(storage) + try: + create.initialize(storage) + hook.execute_hook(hooks.get('before_backup')) - for repository in location['repositories']: - if args.prune: - prune.prune_archives(args.verbosity, repository, retention, remote_path=remote_path) - if args.create: - create.create_archive( - args.verbosity, - repository, - location, - storage, - ) - if args.check: - check.check_archives(args.verbosity, repository, consistency, remote_path=remote_path) + for repository in location['repositories']: + if args.prune: + prune.prune_archives(args.verbosity, repository, retention, remote_path=remote_path) + if args.create: + create.create_archive( + args.verbosity, + repository, + location, + storage, + ) + if args.check: + check.check_archives(args.verbosity, repository, consistency, remote_path=remote_path) + + hook.execute_hook(hooks.get('after_backup')) + except (OSError, CalledProcessError): + hook.execute_hook(hooks.get('on_error')) + raise except (ValueError, OSError, CalledProcessError) as error: print(error, file=sys.stderr) sys.exit(1) diff --git a/borgmatic/commands/hook.py b/borgmatic/commands/hook.py new file mode 100644 index 000000000..4417dea05 --- /dev/null +++ b/borgmatic/commands/hook.py @@ -0,0 +1,7 @@ +import subprocess + + +def execute_hook(commands): + if commands: + for cmd in commands: + subprocess.check_call(cmd, shell=True) diff --git a/borgmatic/config/generate.py b/borgmatic/config/generate.py index 1a6d1a86e..2bfc3a259 100644 --- a/borgmatic/config/generate.py +++ b/borgmatic/config/generate.py @@ -24,7 +24,7 @@ def _schema_to_sample_configuration(schema, level=0): for each section based on the schema "desc" description. ''' example = schema.get('example') - if example: + if example is not None: return example config = yaml.comments.CommentedMap([ diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index d559ee3f3..60f6cdf5c 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -157,3 +157,28 @@ map: desc: Restrict the number of checked archives to the last n. Applies only to the "archives" check. example: 3 + hooks: + desc: | + Shell commands or scripts to execute before and after a backup or if an error has occurred. + IMPORTANT: All provided commands and scripts are executed with user permissions of borgmatic. + Do not forget to set secure permissions on this file as well as on any script listed (chmod 0700) to + prevent potential shell injection or privilege escalation. + map: + before_backup: + seq: + - type: scalar + desc: List of one or more shell commands or scripts to execute before creating a backup. + example: + - echo "`date` - Starting a backup job." + after_backup: + seq: + - type: scalar + desc: List of one or more shell commands or scripts to execute after creating a backup. + example: + - echo "`date` - Backup created." + on_error: + seq: + - type: scalar + desc: List of one or more shell commands or scripts to execute in case an exception has occurred. + example: + - echo "`date` - Error while creating a backup." diff --git a/borgmatic/config/validate.py b/borgmatic/config/validate.py index 312517626..ff616341a 100644 --- a/borgmatic/config/validate.py +++ b/borgmatic/config/validate.py @@ -48,7 +48,7 @@ def parse_configuration(config_filename, schema_filename): # simply remove all examples before passing the schema to pykwalify. for section_name, section_schema in schema['map'].items(): for field_name, field_schema in section_schema['map'].items(): - field_schema.pop('example') + field_schema.pop('example', None) validator = pykwalify.core.Core(source_data=config, schema_data=schema) parsed_result = validator.validate(raise_exception=False) diff --git a/borgmatic/tests/unit/borg/test_hook.py b/borgmatic/tests/unit/borg/test_hook.py new file mode 100644 index 000000000..6aabc57b3 --- /dev/null +++ b/borgmatic/tests/unit/borg/test_hook.py @@ -0,0 +1,10 @@ +from flexmock import flexmock + +from borgmatic.commands import hook as module + + +def test_execute_hook_invokes_each_command(): + subprocess = flexmock(module.subprocess) + subprocess.should_receive('check_call').with_args(':', shell=True).once() + + module.execute_hook([':'])