Skip to content

Commit

Permalink
User-defined hooks for global setup or cleanup that run before/after …
Browse files Browse the repository at this point in the history
…all actions. (#192).
  • Loading branch information
witten committed Sep 28, 2019
1 parent a897ffd commit e14ebee
Show file tree
Hide file tree
Showing 9 changed files with 219 additions and 64 deletions.
5 changes: 5 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
1.3.21
* #192: User-defined hooks for global setup or cleanup that run before/after all actions. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/

1.3.20
* #205: More robust sample systemd service: boot delay, network dependency, lowered CPU/IO
priority, etc.
Expand Down
103 changes: 69 additions & 34 deletions borgmatic/commands/borgmatic.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,32 @@ def load_configurations(config_filenames):
return (configs, logs)


def make_error_log_records(error, message):
'''
Given an exception object and error message text, yield a series of logging.LogRecord instances
with error summary information.
'''
try:
raise error
except CalledProcessError as error:
yield logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
)
yield logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error.output)
)
yield logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error))
except (ValueError, OSError) as error:
yield logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=message)
)
yield logging.makeLogRecord(dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error))
except: # noqa: E722
# Raising above only as a means of determining the error type. Swallow the exception here
# because we don't want the exception to propagate out of this function.
pass


def collect_configuration_run_summary_logs(configs, arguments):
'''
Given a dict of configuration filename to corresponding parsed configuration, and parsed
Expand Down Expand Up @@ -258,6 +284,33 @@ def collect_configuration_run_summary_logs(configs, arguments):
)
return

if not configs:
yield logging.makeLogRecord(
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg='{}: No configuration files found'.format(
' '.join(arguments['global'].config_paths)
),
)
)
return

try:
if 'create' in arguments:
for config_filename, config in configs.items():
hooks = config.get('hooks', {})
hook.execute_hook(
hooks.get('before_everything'),
hooks.get('umask'),
config_filename,
'pre-everything',
arguments['global'].dry_run,
)
except (CalledProcessError, ValueError, OSError) as error:
yield from make_error_log_records(error, 'Error running pre-everything hook')
return

# Execute the actions corresponding to each configuration file.
json_results = []
for config_filename, config in configs.items():
Expand All @@ -270,45 +323,27 @@ def collect_configuration_run_summary_logs(configs, arguments):
msg='{}: Successfully ran configuration file'.format(config_filename),
)
)
except CalledProcessError as error:
yield logging.makeLogRecord(
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg='{}: Error running configuration file'.format(config_filename),
)
)
yield logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error.output)
)
yield logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
)
except (ValueError, OSError) as error:
yield logging.makeLogRecord(
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg='{}: Error running configuration file'.format(config_filename),
)
)
yield logging.makeLogRecord(
dict(levelno=logging.CRITICAL, levelname='CRITICAL', msg=error)
except (CalledProcessError, ValueError, OSError) as error:
yield from make_error_log_records(
error, '{}: Error running configuration file'.format(config_filename)
)

if json_results:
sys.stdout.write(json.dumps(json_results))

if not configs:
yield logging.makeLogRecord(
dict(
levelno=logging.CRITICAL,
levelname='CRITICAL',
msg='{}: No configuration files found'.format(
' '.join(arguments['global'].config_paths)
),
)
)
try:
if 'create' in arguments:
for config_filename, config in configs.items():
hooks = config.get('hooks', {})
hook.execute_hook(
hooks.get('after_everything'),
hooks.get('umask'),
config_filename,
'post-everything',
arguments['global'].dry_run,
)
except (CalledProcessError, ValueError, OSError) as error:
yield from make_error_log_records(error, 'Error running post-everything hook')


def exit_with_help_link(): # pragma: no cover
Expand Down
41 changes: 31 additions & 10 deletions borgmatic/config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -337,31 +337,52 @@ map:
example: false
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.
Shell commands or scripts to execute at various points during a borgmatic run.
IMPORTANT: All provided commands and scripts are executed with user permissions of
borgmatic. Do not forget to set secure permissions on this configuration file (chmod
0600) as well as on any script called from a hook (chmod 0700) to prevent potential
shell injection or privilege escalation.
map:
before_backup:
seq:
- type: str
desc: List of one or more shell commands or scripts to execute before creating a backup.
desc: |
List of one or more shell commands or scripts to execute before creating a
backup, run once per configuration file.
example:
- echo "Starting a backup job."
- echo "Starting a backup."
after_backup:
seq:
- type: str
desc: List of one or more shell commands or scripts to execute after creating a backup.
desc: |
List of one or more shell commands or scripts to execute after creating a
backup, run once per configuration file.
example:
- echo "Backup created."
- echo "Created a backup."
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 hook.
during a backup or when running a before_backup or after_backup hook.
example:
- echo "Error while creating a backup or running a backup hook."
before_everything:
seq:
- 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.
example:
- echo "Starting actions."
after_everything:
seq:
- 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.
example:
- echo "Error while creating a backup or running a hook."
- echo "Completed actions."
umask:
type: scalar
desc: Umask used when executing hooks. Defaults to the umask that borgmatic is run with.
Expand Down
2 changes: 2 additions & 0 deletions borgmatic/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ def execute_command(full_command, output_log_level=logging.INFO, shell=False):
Execute the given command (a sequence of command/argument strings) and log its output at the
given log level. If output log level is None, instead capture and return the output. If
shell is True, execute the command within a shell.
Raise subprocesses.CalledProcessError if an error occurs while running the command.
'''
logger.debug(' '.join(full_command))

Expand Down
1 change: 1 addition & 0 deletions borgmatic/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def execute_hook(commands, umask, config_filename, description, dry_run):
if this is a dry run.
Raise ValueError if the umask cannot be parsed.
Raise subprocesses.CalledProcessError if an error occurs in a hook.
'''
if not commands:
logger.debug('{}: No commands to run for {} hook'.format(config_filename, description))
Expand Down
38 changes: 32 additions & 6 deletions docs/how-to/add-preparation-and-cleanup-steps-to-backups.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,47 @@ hooks:
- rm /to/file.sql
```
borgmatic hooks run once per configuration file. `before_backup` hooks run
prior to backups of all repositories. `after_backup` hooks run afterwards, but
not if an error occurs in a previous hook or in the backups themselves.
The `before_backup` and `after_backup` hooks each run once per configuration
file. `before_backup` hooks run prior to backups of all repositories in a
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.

You can also use `before_everything` and `after_everything` hooks to perform
global setup or cleanup:

```yaml
hooks:
before_everything:
- set-up-stuff-globally
after_everything:
- clean-up-stuff-globally
```

`before_everything` hooks collected from all borgmatic configuration files run
once before all configuration files (prior to all actions), but only if there
is a `create` action. An error encountered during a `before_everything` hook
causes borgmatic to exit without creating backups.

`after_everything` hooks run once after all configuration files and actions,
but only if there is a `create` action. It runs even if an error occurs during
a backup or a backup hook, but not if an error occurs during a
`before_everything` hook.

## Error hooks

borgmatic also runs `on_error` hooks if an error occurs, either when creating
a backup or running another hook. Here's an example configuration:
a backup or running a backup hook. Here's an example configuration:

```yaml
hooks:
on_error:
- echo "Error while creating a backup or running a hook."
- echo "Error while creating a backup or running a backup hook."
```

Note however that borgmatic does not run `on_error` hooks if an error occurs
within a `before_everything` or `after_everything` hook.

## Hook output

Any output produced by your hooks shows up both at the console and in syslog
Expand All @@ -48,7 +73,8 @@ your backups</a>.
An important security note about hooks: borgmatic executes all hook commands
with the user permissions of borgmatic itself. So to prevent potential shell
injection or privilege escalation, do not forget to set secure permissions
(`chmod 0700`) on borgmatic configuration files and scripts invoked by hooks.
on borgmatic configuration files (`chmod 0600`) and scripts (`chmod 0700`)
invoked by hooks.


## Related documentation
Expand Down
9 changes: 5 additions & 4 deletions docs/how-to/set-up-backups.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ To get up and running, first [install
Borg](https://borgbackup.readthedocs.io/en/stable/installation.html), at
least version 1.1.

Borgmatic consumes configurations in `/etc/borgmatic/` and `/etc/borgmatic.d/`
by default. Therefore, we show how to install borgmatic for the root user which
will have access permissions for these locations by default.
By default, borgmatic looks for its configuration files in `/etc/borgmatic/`
and `/etc/borgmatic.d/`, where the root user typically has read access.

Run the following commands to download and install borgmatic:
So, to download and install borgmatic as the root user, run the following
commands:

```bash
sudo pip3 install --user --upgrade borgmatic
Expand Down Expand Up @@ -39,6 +39,7 @@ borgmatic:
* [OpenBSD](http://ports.su/sysutils/borgmatic)
* [openSUSE](https://software.opensuse.org/package/borgmatic)
* [stand-alone binary](https://github.com/cmarquardt/borgmatic-binary)
* [virtualenv](https://virtualenv.pypa.io/en/stable/)


## Hosting providers
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup

VERSION = '1.3.20'
VERSION = '1.3.21'


setup(
Expand Down
Loading

0 comments on commit e14ebee

Please sign in to comment.