Compare commits

...
6 Commits
9 changed files with 312 additions and 49 deletions
+2
View File
@@ -33,3 +33,5 @@ de2d7721cdec93a52d20222a9ddd579ed93c1017 1.0.1
5a003056a8ff4709c5bd4d6d33354199423f8a1d 1.1.0
7d3d11eff6c0773883c48f221431f157bc7995eb 1.1.1
f052a77a8ad5a0fea7fa86a902e0e401252f7d80 1.1.2
3f838f661546e04529b453aa443529b432afc243 1.1.3
3d605962d891731a0f372b903b556ac7a8c8359f 1.1.4
+11
View File
@@ -1,3 +1,14 @@
1.1.5
* #34: New "extract" consistency check that performs a dry-run extraction of the most recent
archive.
1.1.4
* #17: Added command-line flags for performing a borgmatic run with only pruning, creating, or
checking enabled. This supports use cases like running consistency checks from a different cron
job with a different frequency, or running pruning with a different verbosity level.
1.1.3
* #14: Support for running multiple config files in /etc/borgmatic.d/ from a single borgmatic run.
+26 -6
View File
@@ -1,6 +1,4 @@
title: borgmatic
<img src="static/borgmatic.svg" alt="borgmatic logo" style="width: 8em; float: right; padding-left: 1em;" />
<img src="https://cdn.rawgit.com/witten/borgmatic/master/static/borgmatic.svg" width="150px" style="float: right; padding-left: 1em;">
## Overview
@@ -79,11 +77,17 @@ default). You should edit the file to suit your needs, as the values are just
representative. All fields are optional except where indicated, so feel free
to remove anything you don't need.
You can also have a look at the [full configuration
schema](https://torsion.org/hg/borgmatic/file/tip/borgmatic/config/schema.yaml)
for the authoritative set of all configuration options. This is handy if
borgmatic has added new options since you originally created your
configuration file.
### Multiple configuration files
A more advanced usage is to create multiple separate configuration files and
place each one in a /etc/borgmatic.d directory. For instance:
place each one in an /etc/borgmatic.d directory. For instance:
sudo mkdir /etc/borgmatic.d
sudo generate-borgmatic-config --destination /etc/borgmatic.d/app1.yaml
@@ -175,6 +179,12 @@ arguments:
This will also prune any old backups as per the configured retention policy,
and check backups for consistency problems due to things like file damage.
If you'd like to see the available command-line arguments, view the help:
borgmatic --help
### Verbosity
By default, the backup will proceed silently except in the case of errors. But
if you'd like to to get additional information about the progress of the
backup as it proceeds, use the verbosity option:
@@ -185,9 +195,19 @@ Or, for even more progress spew:
borgmatic --verbosity 2
If you'd like to see the available command-line arguments, view the help:
### À la carte
borgmatic --help
If you want to run borgmatic with only pruning, creating, or checking enabled,
the following optional flags are available:
borgmatic --prune
borgmatic --create
borgmatic --check
You can run with only one of these flags provided, or you can mix and match
any number of them. This supports use cases like running consistency checks
from a different cron job with a different frequency, or running pruning with
a different verbosity level.
## Autopilot
+53 -13
View File
@@ -3,6 +3,7 @@ import glob
import itertools
import os
import platform
import sys
import re
import subprocess
import tempfile
@@ -43,7 +44,7 @@ def create_archive(
):
'''
Given a vebosity flag, a storage config dict, a list of source directories, a local or remote
repository path, a list of exclude patterns, and a command to run, create an attic archive.
repository path, a list of exclude patterns, and a command to run, create a Borg archive.
'''
sources = tuple(
itertools.chain.from_iterable(
@@ -68,8 +69,8 @@ def create_archive(
full_command = (
command, 'create',
'{repo}::{hostname}-{timestamp}'.format(
repo=repository,
'{repository}::{hostname}-{timestamp}'.format(
repository=repository,
hostname=platform.node(),
timestamp=datetime.now().isoformat(),
),
@@ -104,7 +105,7 @@ def _make_prune_flags(retention_config):
def prune_archives(verbosity, repository, retention_config, command=COMMAND, remote_path=None):
'''
Given a verbosity flag, a local or remote repository path, a retention config dict, and a
command to run, prune attic archives according the the retention policy specified in that
command to run, prune Borg archives according the the retention policy specified in that
configuration.
'''
remote_path_flags = ('--remote-path', remote_path) if remote_path else ()
@@ -170,33 +171,72 @@ def _make_check_flags(checks, check_last=None):
return tuple(
'--{}-only'.format(check) for check in checks
if check in DEFAULT_CHECKS
) + last_flag
def check_archives(verbosity, repository, consistency_config, command=COMMAND, remote_path=None):
'''
Given a verbosity flag, a local or remote repository path, a consistency config dict, and a
command to run, check the contained attic archives for consistency.
command to run, check the contained Borg archives for consistency.
If there are no consistency checks to run, skip running them.
'''
checks = _parse_checks(consistency_config)
check_last = consistency_config.get('check_last', None)
if not checks:
return
if set(checks).intersection(set(DEFAULT_CHECKS)):
remote_path_flags = ('--remote-path', remote_path) if remote_path else ()
verbosity_flags = {
VERBOSITY_SOME: ('--info',),
VERBOSITY_LOTS: ('--debug',),
}.get(verbosity, ())
full_command = (
command, 'check',
repository,
) + _make_check_flags(checks, check_last) + remote_path_flags + verbosity_flags
# The check command spews to stdout/stderr even without the verbose flag. Suppress it.
stdout = None if verbosity_flags else open(os.devnull, 'w')
subprocess.check_call(full_command, stdout=stdout, stderr=subprocess.STDOUT)
if 'extract' in checks:
extract_last_archive_dry_run(verbosity, repository, command, remote_path)
def extract_last_archive_dry_run(verbosity, repository, command=COMMAND, remote_path=None):
'''
Perform an extraction dry-run of just the most recent archive. If there are no archives, skip
the dry-run.
'''
remote_path_flags = ('--remote-path', remote_path) if remote_path else ()
verbosity_flags = {
VERBOSITY_SOME: ('--info',),
VERBOSITY_LOTS: ('--debug',),
}.get(verbosity, ())
full_command = (
command, 'check',
full_list_command = (
command, 'list',
'--short',
repository,
) + _make_check_flags(checks, check_last) + remote_path_flags + verbosity_flags
) + remote_path_flags + verbosity_flags
# The check command spews to stdout/stderr even without the verbose flag. Suppress it.
stdout = None if verbosity_flags else open(os.devnull, 'w')
list_output = subprocess.check_output(full_list_command).decode(sys.stdout.encoding)
subprocess.check_call(full_command, stdout=stdout, stderr=subprocess.STDOUT)
last_archive_name = list_output.strip().split('\n')[-1]
if not last_archive_name:
return
list_flag = ('--list',) if verbosity == VERBOSITY_LOTS else ()
full_extract_command = (
command, 'extract',
'--dry-run',
'{repository}::{last_archive_name}'.format(
repository=repository,
last_archive_name=last_archive_name,
),
) + remote_path_flags + verbosity_flags + list_flag
subprocess.check_call(full_extract_command)
+49 -11
View File
@@ -18,7 +18,14 @@ def parse_arguments(*arguments):
Given command-line arguments with which this script was invoked, parse the arguments and return
them as an ArgumentParser instance.
'''
parser = ArgumentParser()
parser = ArgumentParser(
description=
'''
A simple wrapper script for the Borg backup software that creates and prunes backups.
If none of the --prune, --create, or --check options are given, then borgmatic defaults
to all three: prune, create, and check archives.
'''
)
parser.add_argument(
'-c', '--config',
nargs='+',
@@ -29,7 +36,25 @@ def parse_arguments(*arguments):
parser.add_argument(
'--excludes',
dest='excludes_filename',
help='Excludes filename, deprecated in favor of exclude_patterns within configuration',
help='Deprecated in favor of exclude_patterns within configuration',
)
parser.add_argument(
'-p', '--prune',
dest='prune',
action='store_true',
help='Prune archives according to the retention policy',
)
parser.add_argument(
'-C', '--create',
dest='create',
action='store_true',
help='Create archives (actually perform backups)',
)
parser.add_argument(
'-k', '--check',
dest='check',
action='store_true',
help='Check archives for consistency',
)
parser.add_argument(
'-v', '--verbosity',
@@ -37,7 +62,17 @@ def parse_arguments(*arguments):
help='Display verbose progress (1 for some, 2 for lots)',
)
return parser.parse_args(arguments)
args = parser.parse_args(arguments)
# If any of the three action flags in the given parse arguments have been explicitly requested,
# leave them as-is. Otherwise, assume defaults: Mutate the given arguments to enable all the
# actions.
if not args.prune and not args.create and not args.check:
args.prune = True
args.create = True
args.check = True
return args
def main(): # pragma: no cover
@@ -60,14 +95,17 @@ def main(): # pragma: no cover
borg.initialize(storage)
for repository in location['repositories']:
borg.prune_archives(args.verbosity, repository, retention, remote_path=remote_path)
borg.create_archive(
args.verbosity,
repository,
location,
storage,
)
borg.check_archives(args.verbosity, repository, consistency, remote_path=remote_path)
if args.prune:
borg.prune_archives(args.verbosity, repository, retention, remote_path=remote_path)
if args.create:
borg.create_archive(
args.verbosity,
repository,
location,
storage,
)
if args.check:
borg.check_archives(args.verbosity, repository, consistency, remote_path=remote_path)
except (ValueError, OSError, CalledProcessError) as error:
print(error, file=sys.stderr)
sys.exit(1)
+10 -6
View File
@@ -106,21 +106,25 @@ map:
consistency:
desc: |
Consistency checks to run after backups. See
https://borgbackup.readthedocs.org/en/stable/usage.html#borg-check for details.
https://borgbackup.readthedocs.org/en/stable/usage.html#borg-check and
https://borgbackup.readthedocs.org/en/stable/usage.html#borg-extract for details.
map:
checks:
seq:
- type: str
enum: ['repository', 'archives', 'disabled']
enum: ['repository', 'archives', 'extract', 'disabled']
unique: true
desc: |
List of consistency checks to run: "repository", "archives", or both. Defaults
to both. Set to "disabled" to disable all consistency checks. See
https://borgbackup.readthedocs.org/en/stable/usage.html#borg-check for details.
List of one or more consistency checks to run: "repository", "archives", and/or
"extract". Defaults to "repository" and "archives". Set to "disabled" to disable
all consistency checks. "repository" checks the consistency of the repository,
"archive" checks all of the archives, and "extract" does an extraction dry-run
of just the most recent archive.
example:
- repository
- archives
check_last:
type: int
desc: Restrict the number of checked archives to the last n.
desc: Restrict the number of checked archives to the last n. Applies only to the
"archives" check.
example: 3
@@ -37,6 +37,30 @@ def test_parse_arguments_with_verbosity_flag_overrides_default():
assert parser.verbosity == 1
def test_parse_arguments_with_no_actions_defaults_to_all_actions_enabled():
parser = module.parse_arguments()
assert parser.prune is True
assert parser.create is True
assert parser.check is True
def test_parse_arguments_with_prune_action_leaves_other_actions_disabled():
parser = module.parse_arguments('--prune')
assert parser.prune is True
assert parser.create is False
assert parser.check is False
def test_parse_arguments_with_multiple_actions_leaves_other_action_disabled():
parser = module.parse_arguments('--create', '--check')
assert parser.prune is False
assert parser.create is True
assert parser.check is True
def test_parse_arguments_with_invalid_arguments_exits():
with pytest.raises(SystemExit):
module.parse_arguments('--posix-me-harder')
+136 -12
View File
@@ -4,6 +4,7 @@ import sys
import os
from flexmock import flexmock
import pytest
from borgmatic import borg as module
from borgmatic.verbosity import VERBOSITY_SOME, VERBOSITY_LOTS
@@ -46,17 +47,23 @@ def test_write_exclude_file_with_empty_exclude_patterns_does_not_raise():
def insert_subprocess_mock(check_call_command, **kwargs):
subprocess = flexmock(STDOUT=STDOUT)
subprocess = flexmock(module.subprocess)
subprocess.should_receive('check_call').with_args(check_call_command, **kwargs).once()
flexmock(module).subprocess = subprocess
def insert_subprocess_never():
subprocess = flexmock()
subprocess = flexmock(module.subprocess)
subprocess.should_receive('check_call').never()
flexmock(module).subprocess = subprocess
def insert_subprocess_check_output_mock(check_output_command, result, **kwargs):
subprocess = flexmock(module.subprocess)
subprocess.should_receive('check_output').with_args(check_output_command, **kwargs).and_return(result).once()
flexmock(module).subprocess = subprocess
def insert_platform_mock():
flexmock(module.platform).should_receive('node').and_return('host')
@@ -395,9 +402,15 @@ def test_parse_checks_with_disabled_returns_no_checks():
def test_make_check_flags_with_checks_returns_flags():
flags = module._make_check_flags(('foo', 'bar'))
flags = module._make_check_flags(('repository',))
assert flags == ('--foo-only', '--bar-only')
assert flags == ('--repository-only',)
def test_make_check_flags_with_extract_check_does_not_make_extract_flag():
flags = module._make_check_flags(('extract',))
assert flags == ()
def test_make_check_flags_with_default_checks_returns_no_flags():
@@ -407,19 +420,27 @@ def test_make_check_flags_with_default_checks_returns_no_flags():
def test_make_check_flags_with_checks_and_last_returns_flags_including_last():
flags = module._make_check_flags(('foo', 'bar'), check_last=3)
flags = module._make_check_flags(('repository',), check_last=3)
assert flags == ('--foo-only', '--bar-only', '--last', '3')
assert flags == ('--repository-only', '--last', '3')
def test_make_check_flags_with_last_returns_last_flag():
def test_make_check_flags_with_default_checks_and_last_returns_last_flag():
flags = module._make_check_flags(module.DEFAULT_CHECKS, check_last=3)
assert flags == ('--last', '3')
def test_check_archives_should_call_borg_with_parameters():
checks = flexmock()
@pytest.mark.parametrize(
'checks',
(
('repository',),
('archives',),
('repository', 'archives'),
('repository', 'archives', 'other'),
),
)
def test_check_archives_should_call_borg_with_parameters(checks):
check_last = flexmock()
consistency_config = flexmock().should_receive('get').and_return(check_last).mock
flexmock(module).should_receive('_parse_checks').and_return(checks)
@@ -442,9 +463,27 @@ def test_check_archives_should_call_borg_with_parameters():
)
def test_check_archives_with_extract_check_should_call_extract_only():
checks = ('extract',)
check_last = flexmock()
consistency_config = flexmock().should_receive('get').and_return(check_last).mock
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').never()
flexmock(module).should_receive('extract_last_archive_dry_run').once()
insert_subprocess_never()
module.check_archives(
verbosity=None,
repository='repo',
consistency_config=consistency_config,
command='borg',
)
def test_check_archives_with_verbosity_some_should_call_borg_with_info_parameter():
checks = ('repository',)
consistency_config = flexmock().should_receive('get').and_return(None).mock
flexmock(module).should_receive('_parse_checks').and_return(flexmock())
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').and_return(())
insert_subprocess_mock(
('borg', 'check', 'repo', '--info'),
@@ -462,8 +501,9 @@ def test_check_archives_with_verbosity_some_should_call_borg_with_info_parameter
def test_check_archives_with_verbosity_lots_should_call_borg_with_debug_parameter():
checks = ('repository',)
consistency_config = flexmock().should_receive('get').and_return(None).mock
flexmock(module).should_receive('_parse_checks').and_return(flexmock())
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').and_return(())
insert_subprocess_mock(
('borg', 'check', 'repo', '--debug'),
@@ -494,7 +534,7 @@ def test_check_archives_without_any_checks_should_bail():
def test_check_archives_with_remote_path_should_call_borg_with_remote_path_parameters():
checks = flexmock()
checks = ('repository',)
check_last = flexmock()
consistency_config = flexmock().should_receive('get').and_return(check_last).mock
flexmock(module).should_receive('_parse_checks').and_return(checks)
@@ -516,3 +556,87 @@ def test_check_archives_with_remote_path_should_call_borg_with_remote_path_param
command='borg',
remote_path='borg1',
)
def test_extract_last_archive_dry_run_should_call_borg_with_last_archive():
flexmock(sys.stdout).encoding = 'utf-8'
insert_subprocess_check_output_mock(
('borg', 'list', '--short', 'repo'),
result='archive1\narchive2\n'.encode('utf-8'),
)
insert_subprocess_mock(
('borg', 'extract', '--dry-run', 'repo::archive2'),
)
module.extract_last_archive_dry_run(
verbosity=None,
repository='repo',
command='borg',
)
def test_extract_last_archive_dry_run_without_any_archives_should_bail():
flexmock(sys.stdout).encoding = 'utf-8'
insert_subprocess_check_output_mock(
('borg', 'list', '--short', 'repo'),
result='\n'.encode('utf-8'),
)
insert_subprocess_never()
module.extract_last_archive_dry_run(
verbosity=None,
repository='repo',
command='borg',
)
def test_extract_last_archive_dry_run_with_verbosity_some_should_call_borg_with_info_parameter():
flexmock(sys.stdout).encoding = 'utf-8'
insert_subprocess_check_output_mock(
('borg', 'list', '--short', 'repo', '--info'),
result='archive1\narchive2\n'.encode('utf-8'),
)
insert_subprocess_mock(
('borg', 'extract', '--dry-run', 'repo::archive2', '--info'),
)
module.extract_last_archive_dry_run(
verbosity=VERBOSITY_SOME,
repository='repo',
command='borg',
)
def test_extract_last_archive_dry_run_with_verbosity_lots_should_call_borg_with_debug_parameter():
flexmock(sys.stdout).encoding = 'utf-8'
insert_subprocess_check_output_mock(
('borg', 'list', '--short', 'repo', '--debug'),
result='archive1\narchive2\n'.encode('utf-8'),
)
insert_subprocess_mock(
('borg', 'extract', '--dry-run', 'repo::archive2', '--debug', '--list'),
)
module.extract_last_archive_dry_run(
verbosity=VERBOSITY_LOTS,
repository='repo',
command='borg',
)
def test_extract_last_archive_dry_run_should_call_borg_with_remote_path_parameters():
flexmock(sys.stdout).encoding = 'utf-8'
insert_subprocess_check_output_mock(
('borg', 'list', '--short', 'repo', '--remote-path', 'borg1'),
result='archive1\narchive2\n'.encode('utf-8'),
)
insert_subprocess_mock(
('borg', 'extract', '--dry-run', 'repo::archive2', '--remote-path', 'borg1'),
)
module.extract_last_archive_dry_run(
verbosity=None,
repository='repo',
command='borg',
remote_path='borg1',
)
+1 -1
View File
@@ -1,7 +1,7 @@
from setuptools import setup, find_packages
VERSION = '1.1.3'
VERSION = '1.1.5'
setup(