Compare commits

...
8 Commits
17 changed files with 256 additions and 37 deletions
+9
View File
@@ -1,3 +1,12 @@
1.3.24
* #86: Add "borgmatic list --successful" flag to only list successful (non-checkpoint) archives.
* Add a suggestion form to all documentation pages, so users can submit ideas for improving the
documentation.
* Update documentation link to community Arch Linux borgmatic package.
1.3.23
* #174: More detailed error alerting via runtime context available in "on_error" hook.
1.3.22
* #144: When backups to one of several repositories fails, keep backing up to the other
repositories and report errors afterwards.
+9 -1
View File
@@ -6,6 +6,10 @@ from borgmatic.execute import execute_command
logger = logging.getLogger(__name__)
# A hack to convince Borg to exclude archives ending in ".checkpoint".
BORG_EXCLUDE_CHECKPOINTS_GLOB = '*[!.][!c][!h][!e][!c][!k][!p][!o][!i][!n][!t]'
def list_archives(repository, storage_config, list_arguments, local_path='borg', remote_path=None):
'''
Given a local or remote repository path, a storage config dict, and the arguments to the list
@@ -13,6 +17,8 @@ def list_archives(repository, storage_config, list_arguments, local_path='borg',
if an archive name is given, listing the files in that archive.
'''
lock_wait = storage_config.get('lock_wait', None)
if list_arguments.successful:
list_arguments.glob_archives = BORG_EXCLUDE_CHECKPOINTS_GLOB
full_command = (
(local_path, 'list')
@@ -28,7 +34,9 @@ def list_archives(repository, storage_config, list_arguments, local_path='borg',
)
+ make_flags('remote-path', remote_path)
+ make_flags('lock-wait', lock_wait)
+ make_flags_from_arguments(list_arguments, excludes=('repository', 'archive'))
+ make_flags_from_arguments(
list_arguments, excludes=('repository', 'archive', 'successful')
)
+ (
'::'.join((repository, list_arguments.archive))
if list_arguments.archive
+10 -1
View File
@@ -316,6 +316,12 @@ def parse_arguments(*unparsed_arguments):
list_group.add_argument(
'-a', '--glob-archives', metavar='GLOB', help='Only list archive names matching this glob'
)
list_group.add_argument(
'--successful',
default=False,
action='store_true',
help='Only list archive names of successful (non-checkpoint) backups',
)
list_group.add_argument(
'--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
)
@@ -323,7 +329,7 @@ def parse_arguments(*unparsed_arguments):
'--first', metavar='N', help='List first N archives after other filters are applied'
)
list_group.add_argument(
'--last', metavar='N', help='List first N archives after other filters are applied'
'--last', metavar='N', help='List last N archives after other filters are applied'
)
list_group.add_argument(
'-e', '--exclude', metavar='PATTERN', help='Exclude paths matching the pattern'
@@ -388,6 +394,9 @@ def parse_arguments(*unparsed_arguments):
if 'init' in arguments and arguments['global'].dry_run:
raise ValueError('The init action cannot be used with the --dry-run option')
if 'list' in arguments and arguments['list'].glob_archives and arguments['list'].successful:
raise ValueError('The --glob-archives and --successful options cannot be used together')
if (
'list' in arguments
and 'info' in arguments
+9 -4
View File
@@ -48,7 +48,8 @@ def run_configuration(config_filename, config, arguments):
local_path = location.get('local_path', 'borg')
remote_path = location.get('remote_path')
borg_environment.initialize(storage)
encountered_error = False
encountered_error = None
error_repository = ''
if 'create' in arguments:
try:
@@ -60,7 +61,7 @@ def run_configuration(config_filename, config, arguments):
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
encountered_error = True
encountered_error = error
yield from make_error_log_records(
'{}: Error running pre-backup hook'.format(config_filename), error
)
@@ -79,7 +80,8 @@ def run_configuration(config_filename, config, arguments):
repository_path=repository_path,
)
except (OSError, CalledProcessError) as error:
encountered_error = True
encountered_error = error
error_repository = repository_path
yield from make_error_log_records(
'{}: Error running actions for repository'.format(repository_path), error
)
@@ -94,7 +96,7 @@ def run_configuration(config_filename, config, arguments):
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
encountered_error = True
encountered_error = error
yield from make_error_log_records(
'{}: Error running post-backup hook'.format(config_filename), error
)
@@ -107,6 +109,9 @@ def run_configuration(config_filename, config, arguments):
config_filename,
'on-error',
global_arguments.dry_run,
repository=error_repository,
error=encountered_error,
output=getattr(encountered_error, 'output', ''),
)
except (OSError, CalledProcessError) as error:
yield from make_error_log_records(
+18 -1
View File
@@ -6,12 +6,26 @@ from borgmatic import execute
logger = logging.getLogger(__name__)
def execute_hook(commands, umask, config_filename, description, dry_run):
def interpolate_context(command, context):
'''
Given a single hook command and a dict of context names/values, interpolate the values by
"{name}" into the command and return the result.
'''
for name, value in context.items():
command = command.replace('{%s}' % name, str(value))
return command
def execute_hook(commands, umask, config_filename, description, dry_run, **context):
'''
Given a list of hook commands to execute, a umask to execute with (or None), a config filename,
a hook description, and whether this is a dry run, run the given commands. Or, don't run them
if this is a dry run.
The context contains optional values interpolated by name into the hook commands. Currently,
this only applies to the on_error hook.
Raise ValueError if the umask cannot be parsed.
Raise subprocesses.CalledProcessError if an error occurs in a hook.
'''
@@ -21,6 +35,9 @@ def execute_hook(commands, umask, config_filename, description, dry_run):
dry_run_label = ' (dry run; not actually running hooks)' if dry_run else ''
context['configuration_filename'] = config_filename
commands = [interpolate_context(command, context) for command in commands]
if len(commands) == 1:
logger.info(
'{}: Running command for {} hook{}'.format(config_filename, description, dry_run_label)
@@ -0,0 +1,18 @@
#suggestion-form textarea {
font-family: sans-serif;
width: 100%;
}
#suggestion-form label {
font-weight: bold;
}
#suggestion-form input[type=email] {
font-size: 16px;
width: 100%;
}
#suggestion-form .form-error {
color: red;
}
@@ -0,0 +1,33 @@
<h2>Improve this documentation</h2>
<p>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 <a href="https://torsion.org/borgmatic/#issues">issue tracker</a>
instead.)</p>
<form id="suggestion-form">
<div><label for="suggestion">Suggestion</label></div>
<textarea id="suggestion" rows="8" cols="60" name="suggestion"></textarea>
<div data-sk-error="suggestion" class="form-error"></div>
<input id="_page" type="hidden" name="_page">
<input id="_subject" type="hidden" name="_subject" value="borgmatic documentation suggestion">
<br />
<label for="email">Email address</label>
<div><input id="email" type="email" name="email" placeholder="Only required if you want a response!"></div>
<div data-sk-error="email" class="form-error"></div>
<br />
<div><button type="submit">Send</button></div>
<br />
</form>
<script>
document.getElementById('_page').value = window.location.href;
window.sk=window.sk||function(){(sk.q=sk.q||[]).push(arguments)};
sk('form', 'init', {
id: '1d536680ab96',
element: '#suggestion-form'
});
</script>
<script defer src="https://js.statickit.com/statickit.js"></script>
+1
View File
@@ -11,6 +11,7 @@
{% include 'components/minilink.css' %}
{% include 'components/toc.css' %}
{% include 'components/info-blocks.css' %}
{% include 'components/suggestion-form.css' %}
{% include 'prism-theme.css' %}
{% include 'asciinema.css' %}
{% endset %}
+2
View File
@@ -8,5 +8,7 @@ headerClass: elv-header-default
<main class="elv-layout{% if layoutClass %} {{ layoutClass }}{% endif %}">
<article>
{{ content | safe }}
{% include 'components/suggestion-form.html' %}
</article>
</main>
@@ -47,19 +47,10 @@ 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 a backup hook. Here's an example configuration:
```yaml
hooks:
on_error:
- 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.
a backup or running a backup hook. See the [error alerting
documentation](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups.md)
for more information.
## Hook output
+2 -2
View File
@@ -14,7 +14,7 @@ repositories.
If you find yourself in this situation, you have some options. First, you can
run borgmatic's pruning, creating, or checking actions separately. For
instance, the the following optional flags are available:
instance, the the following optional actions are available:
```bash
borgmatic prune
@@ -25,7 +25,7 @@ borgmatic check
(No borgmatic `prune`, `create`, or `check` actions? Try the old-style
`--prune`, `--create`, or `--check`. Or upgrade borgmatic!)
You can run with only one of these flags provided, or you can mix and match
You can run with only one of these actions provided, or you can mix and match
any number of them in a single borgmatic run. This supports approaches like
making backups with `create` on a frequent schedule, while only running
expensive consistency checks with `check` on a much less frequent basis from
+63 -2
View File
@@ -24,7 +24,7 @@ borgmatic --verbosity 2
If you're less concerned with progress during a backup, and you just want to
see the summary of archive statistics at the end, you can use the stats
option:
option when performing a backup:
```bash
borgmatic --stats
@@ -32,7 +32,7 @@ borgmatic --stats
## Existing backups
Borgmatic provides convenient flags for Borg's
borgmatic provides convenient actions for Borg's
[list](https://borgbackup.readthedocs.io/en/stable/usage/list.html) and
[info](https://borgbackup.readthedocs.io/en/stable/usage/info.html)
functionality:
@@ -46,6 +46,7 @@ borgmatic info
(No borgmatic `list` or `info` actions? Try the old-style `--list` or
`--info`. Or upgrade borgmatic!)
## Logging
By default, borgmatic logs to a local syslog-compatible daemon if one is
@@ -82,6 +83,49 @@ Note that the [sample borgmatic systemd service
file](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#systemd)
already has this rate limit disabled.
## Error alerting
When an error occurs during a backup, 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:
on_error:
- echo "Error while creating a backup or running a backup hook."
```
The `on_error` hook supports interpolating particular runtime variables into
the hook command. Here's an example that assumes you provide a separate shell
script to handle the alerting:
```yaml
hooks:
on_error:
- send-text-message.sh "{configuration_filename}" "{repository}"
```
In this example, when the error occurs, borgmatic interpolates a few runtime
values into the hook command: the borgmatic configuration filename, and the
path of the repository. Here's the full set of supported variables you can use
here:
* `configuration_filename`: borgmatic configuration filename in which the
error occurred
* `repository`: path of the repository in which the error occurred (may be
blank if the error occurs in a hook)
* `error`: the error message itself
* `output`: output of the command that failed (may be blank if an error
occurred without running a command)
Note that 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.md),
especially the security information.
## Scripting borgmatic
To consume the output of borgmatic in other software, you can include an
@@ -92,8 +136,25 @@ Note that when you specify the `--json` flag, Borg's other non-JSON output is
suppressed so as not to interfere with the captured JSON. Also note that JSON
output only shows up at the console, and not in syslog.
### Successful backups
`borgmatic list` includes support for a `--successful` flag that only lists
successful (non-checkpoint) backups. Combined with a built-in Borg flag like
`--last`, you can list the last successful backup for use in your monitoring
scripts. Here's an example combined with `--json`:
```bash
borgmatic list --successful --last 1 --json
```
Note that this particular combination will only work if you've got a single
backup "series" in your repository. If you're instead backing up, say, from
multiple different hosts into a single repository, then you'll need to get
fancier with your archive listing. See `borg list --help` for more flags.
## Related documentation
* [Set up backups with borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups.md)
* [Add preparation and cleanup steps to backups](https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups.md)
* [Develop on borgmatic](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic.md)
+1 -1
View File
@@ -35,7 +35,7 @@ borgmatic:
* [Debian](https://tracker.debian.org/pkg/borgmatic)
* [Ubuntu](https://launchpad.net/ubuntu/+source/borgmatic)
* [Fedora](https://bodhi.fedoraproject.org/updates/?search=borgmatic)
* [Arch Linux](https://aur.archlinux.org/packages/borgmatic/)
* [Arch Linux](https://www.archlinux.org/packages/community/any/borgmatic/)
* [OpenBSD](http://ports.su/sysutils/borgmatic)
* [openSUSE](https://software.opensuse.org/package/borgmatic)
* [stand-alone binary](https://github.com/cmarquardt/borgmatic-binary)
+1 -1
View File
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup
VERSION = '1.3.22'
VERSION = '1.3.24'
setup(
@@ -230,6 +230,15 @@ def test_parse_arguments_disallows_init_and_dry_run():
)
def test_parse_arguments_disallows_glob_archives_with_successful():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(ValueError):
module.parse_arguments(
'--config', 'myconfig', 'list', '--glob-archives', '*glob*', '--successful'
)
def test_parse_arguments_disallows_repository_without_extract_or_list():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
+39 -12
View File
@@ -14,7 +14,9 @@ def test_list_archives_calls_borg_with_parameters():
)
module.list_archives(
repository='repo', storage_config={}, list_arguments=flexmock(archive=None, json=False)
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=False),
)
@@ -25,7 +27,9 @@ def test_list_archives_with_log_info_calls_borg_with_info_parameter():
insert_logging_mock(logging.INFO)
module.list_archives(
repository='repo', storage_config={}, list_arguments=flexmock(archive=None, json=False)
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=False),
)
@@ -36,7 +40,9 @@ def test_list_archives_with_log_info_and_json_suppresses_most_borg_output():
insert_logging_mock(logging.INFO)
module.list_archives(
repository='repo', storage_config={}, list_arguments=flexmock(archive=None, json=True)
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=True, successful=False),
)
@@ -47,7 +53,9 @@ def test_list_archives_with_log_debug_calls_borg_with_debug_parameter():
insert_logging_mock(logging.DEBUG)
module.list_archives(
repository='repo', storage_config={}, list_arguments=flexmock(archive=None, json=False)
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=False),
)
@@ -58,7 +66,9 @@ def test_list_archives_with_log_debug_and_json_suppresses_most_borg_output():
insert_logging_mock(logging.DEBUG)
module.list_archives(
repository='repo', storage_config={}, list_arguments=flexmock(archive=None, json=True)
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=True, successful=False),
)
@@ -71,7 +81,7 @@ def test_list_archives_with_lock_wait_calls_borg_with_lock_wait_parameters():
module.list_archives(
repository='repo',
storage_config=storage_config,
list_arguments=flexmock(archive=None, json=False),
list_arguments=flexmock(archive=None, json=False, successful=False),
)
@@ -84,7 +94,7 @@ def test_list_archives_with_archive_calls_borg_with_archive_parameter():
module.list_archives(
repository='repo',
storage_config=storage_config,
list_arguments=flexmock(archive='archive', json=False),
list_arguments=flexmock(archive='archive', json=False, successful=False),
)
@@ -96,7 +106,7 @@ def test_list_archives_with_local_path_calls_borg_via_local_path():
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False),
list_arguments=flexmock(archive=None, json=False, successful=False),
local_path='borg1',
)
@@ -109,7 +119,7 @@ def test_list_archives_with_remote_path_calls_borg_with_remote_path_parameters()
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False),
list_arguments=flexmock(archive=None, json=False, successful=False),
remote_path='borg1',
)
@@ -122,7 +132,7 @@ def test_list_archives_with_short_calls_borg_with_short_parameter():
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, short=True),
list_arguments=flexmock(archive=None, json=False, successful=False, short=True),
)
@@ -149,7 +159,22 @@ def test_list_archives_passes_through_arguments_to_borg(argument_name):
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, **{argument_name: 'value'}),
list_arguments=flexmock(
archive=None, json=False, successful=False, **{argument_name: 'value'}
),
)
def test_list_archives_with_successful_calls_borg_to_exclude_checkpoints():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'list', '--glob-archives', module.BORG_EXCLUDE_CHECKPOINTS_GLOB, 'repo'),
output_log_level=logging.WARNING,
).and_return('[]')
module.list_archives(
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=False, successful=True),
)
@@ -159,7 +184,9 @@ def test_list_archives_with_json_calls_borg_with_json_parameter():
).and_return('[]')
json_output = module.list_archives(
repository='repo', storage_config={}, list_arguments=flexmock(archive=None, json=True)
repository='repo',
storage_config={},
list_arguments=flexmock(archive=None, json=True, successful=False),
)
assert json_output == '[]'
+29
View File
@@ -5,7 +5,24 @@ from flexmock import flexmock
from borgmatic import hook as module
def test_interpolate_context_passes_through_command_without_variable():
assert module.interpolate_context('ls', {'foo': 'bar'}) == 'ls'
def test_interpolate_context_passes_through_command_with_unknown_variable():
assert module.interpolate_context('ls {baz}', {'foo': 'bar'}) == 'ls {baz}'
def test_interpolate_context_interpolates_variables():
context = {'foo': 'bar', 'baz': 'quux'}
assert module.interpolate_context('ls {foo}{baz} {baz}', context) == 'ls barquux quux'
def test_execute_hook_invokes_each_command():
flexmock(module).should_receive('interpolate_context').replace_with(
lambda command, context: command
)
flexmock(module.execute).should_receive('execute_command').with_args(
[':'], output_log_level=logging.WARNING, shell=True
).once()
@@ -14,6 +31,9 @@ def test_execute_hook_invokes_each_command():
def test_execute_hook_with_multiple_commands_invokes_each_command():
flexmock(module).should_receive('interpolate_context').replace_with(
lambda command, context: command
)
flexmock(module.execute).should_receive('execute_command').with_args(
[':'], output_log_level=logging.WARNING, shell=True
).once()
@@ -25,6 +45,9 @@ def test_execute_hook_with_multiple_commands_invokes_each_command():
def test_execute_hook_with_umask_sets_that_umask():
flexmock(module).should_receive('interpolate_context').replace_with(
lambda command, context: command
)
flexmock(module.os).should_receive('umask').with_args(0o77).and_return(0o22).once()
flexmock(module.os).should_receive('umask').with_args(0o22).once()
flexmock(module.execute).should_receive('execute_command').with_args(
@@ -35,6 +58,9 @@ def test_execute_hook_with_umask_sets_that_umask():
def test_execute_hook_with_dry_run_skips_commands():
flexmock(module).should_receive('interpolate_context').replace_with(
lambda command, context: command
)
flexmock(module.execute).should_receive('execute_command').never()
module.execute_hook([':', 'true'], None, 'config.yaml', 'pre-backup', dry_run=True)
@@ -45,6 +71,9 @@ def test_execute_hook_with_empty_commands_does_not_raise():
def test_execute_hook_on_error_logs_as_error():
flexmock(module).should_receive('interpolate_context').replace_with(
lambda command, context: command
)
flexmock(module.execute).should_receive('execute_command').with_args(
[':'], output_log_level=logging.ERROR, shell=True
).once()