Compare commits

...
20 Commits
Author SHA1 Message Date
Dan Helfman 8e9e06afe6 Bump version for release. 2022-05-31 09:41:20 -07:00
Dan Helfman 2bc91ac3d2 Add "generate-borgmatic-config --overwrite" flag to replace an existing destination file (#539). 2022-05-29 16:03:55 -07:00
Dan Helfman 5b615d51a4 Add support for "borgmatic borg debug" command (#538). 2022-05-29 15:43:03 -07:00
Dan Helfman c7f5d5fd0b Fix broken Bash completion of filenames, as in "-c config.yaml". 2022-05-29 10:49:33 -07:00
Dan Helfman 6ef7538eb0 Fix typo in Bash completions script. 2022-05-28 19:34:13 -07:00
Dan Helfman 8fa90053cf Add "borgmatic check --force" flag to ignore configured check frequencies (#523). 2022-05-28 19:29:33 -07:00
Dan Helfman b3682b61d1 Add another note about the consistency checks schema in old versions (#523). 2022-05-28 19:03:45 -07:00
Dan Helfman ad0e2e0d7c Tweak default check frequency to 1 month (#523). 2022-05-28 15:49:50 -07:00
Dan Helfman 6629f40cab In bash completion script, warn when script is out of date using script contents instead of version. (Fewer spurious warnings that way.) 2022-05-28 15:27:11 -07:00
Dan Helfman e76bfa555f Reduce the default consistency check frequency and support configuring the frequency independently for each check (#523). 2022-05-28 14:42:19 -07:00
Dan Helfman 8ddb7268eb Reuse "borg info" function. 2022-05-27 13:51:11 -07:00
Dan Helfman cb5fe02ebd Fix broken Bash completion end-to-end test. 2022-05-26 11:18:46 -07:00
Dan Helfman 77b84f8a48 Add Bash completion script so you can tab-complete the borgmatic command-line. 2022-05-26 10:27:53 -07:00
Dan Helfman 691ec96909 Fix python_requires to support all versions of 3.7 (#537).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/537
2022-05-26 15:51:46 +00:00
Steve Atwell 29b4666205 Fix python_requires to support all versions of 3.7
This is the standard way to support "Python 3.7 and newer" and it also
fixes use of borgmatic with some tools that do custom dependency
resolution.  E.g., using pex with --platform.
2022-05-26 07:05:04 -07:00
Dan Helfman 316a22701f Add documentation note about multiple merge limitation (#380). 2022-05-25 23:12:42 -07:00
Dan Helfman be59a3e574 Fix generate-borgmatic-config with "--source" flag to support more complex schema changes like the new Healthchecks configuration options (#536). 2022-05-25 10:26:26 -07:00
Dan Helfman 37327379bc Merge branch 'master' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2022-05-24 17:50:57 -07:00
Dan Helfman 22c2f13611 Remove trailing whitespace (#535).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/535
2022-05-25 00:50:12 +00:00
polyzen 8708ca07f4 Remove trailing whitespace 2022-05-25 00:43:40 +00:00
31 changed files with 907 additions and 168 deletions
+13
View File
@@ -1,3 +1,16 @@
1.6.2
* #523: Reduce the default consistency check frequency and support configuring the frequency
independently for each check. Also add "borgmatic check --force" flag to ignore configured
frequencies. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/deal-with-very-large-backups/#check-frequency
* #536: Fix generate-borgmatic-config to support more complex schema changes like the new
Healthchecks configuration options when the "--source" flag is used.
* #538: Add support for "borgmatic borg debug" command.
* #539: Add "generate-borgmatic-config --overwrite" flag to replace an existing destination file.
* Add Bash completion script so you can tab-complete the borgmatic command-line. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/set-up-backups/#shell-completion
1.6.1
* #294: Add Healthchecks monitoring hook "ping_body_limit" option to configure how many bytes of
logs to send to the Healthchecks server.
+3 -2
View File
@@ -37,8 +37,9 @@ retention:
consistency:
# List of checks to run to validate your backups.
checks:
- repository
- archives
- name: repository
- name: archives
frequency: 2 weeks
hooks:
# Custom preparation scripts to run.
+10 -3
View File
@@ -7,6 +7,8 @@ logger = logging.getLogger(__name__)
REPOSITORYLESS_BORG_COMMANDS = {'serve', None}
BORG_COMMANDS_WITH_SUBCOMMANDS = {'key', 'debug'}
BORG_SUBCOMMANDS_WITHOUT_REPOSITORY = (('debug', 'info'), ('debug', 'convert-profile'))
def run_arbitrary_borg(
@@ -22,15 +24,20 @@ def run_arbitrary_borg(
try:
options = options[1:] if options[0] == '--' else options
# Borg's "key" command has a sub-command ("export", etc.) that must follow it.
command_options_start_index = 2 if options[0] == 'key' else 1
# Borg commands like "key" have a sub-command ("export", etc.) that must follow it.
command_options_start_index = 2 if options[0] in BORG_COMMANDS_WITH_SUBCOMMANDS else 1
borg_command = tuple(options[:command_options_start_index])
command_options = tuple(options[command_options_start_index:])
except IndexError:
borg_command = ()
command_options = ()
repository_archive = '::'.join((repository, archive)) if repository and archive else repository
if borg_command in BORG_SUBCOMMANDS_WITHOUT_REPOSITORY:
repository_archive = None
else:
repository_archive = (
'::'.join((repository, archive)) if repository and archive else repository
)
full_command = (
(local_path,)
+204 -27
View File
@@ -1,46 +1,157 @@
import argparse
import datetime
import json
import logging
import os
import pathlib
from borgmatic.borg import extract
from borgmatic.borg import extract, info, state
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
DEFAULT_CHECKS = ('repository', 'archives')
DEFAULT_CHECKS = (
{'name': 'repository', 'frequency': '1 month'},
{'name': 'archives', 'frequency': '1 month'},
)
DEFAULT_PREFIX = '{hostname}-'
logger = logging.getLogger(__name__)
def _parse_checks(consistency_config, only_checks=None):
def parse_checks(consistency_config, only_checks=None):
'''
Given a consistency config with a "checks" list, and an optional list of override checks,
transform them a tuple of named checks to run.
Given a consistency config with a "checks" sequence of dicts and an optional list of override
checks, return a tuple of named checks to run.
For example, given a retention config of:
{'checks': ['repository', 'archives']}
{'checks': ({'name': 'repository'}, {'name': 'archives'})}
This will be returned as:
('repository', 'archives')
If no "checks" option is present in the config, return the DEFAULT_CHECKS. If the checks value
is the string "disabled", return an empty tuple, meaning that no checks should be run.
If no "checks" option is present in the config, return the DEFAULT_CHECKS. If a checks value
has a name of "disabled", return an empty tuple, meaning that no checks should be run.
If the "data" option is present, then make sure the "archives" option is included as well.
If the "data" check is present, then make sure the "archives" check is included as well.
'''
checks = [
check.lower() for check in (only_checks or consistency_config.get('checks', []) or [])
]
if checks == ['disabled']:
checks = only_checks or tuple(
check_config['name']
for check_config in (consistency_config.get('checks', None) or DEFAULT_CHECKS)
)
checks = tuple(check.lower() for check in checks)
if 'disabled' in checks:
if len(checks) > 1:
logger.warning(
'Multiple checks are configured, but one of them is "disabled"; not running any checks'
)
return ()
if 'data' in checks and 'archives' not in checks:
checks.append('archives')
return checks + ('archives',)
return tuple(check for check in checks if check not in ('disabled', '')) or DEFAULT_CHECKS
return checks
def _make_check_flags(checks, check_last=None, prefix=None):
def parse_frequency(frequency):
'''
Given a frequency string with a number and a unit of time, return a corresponding
datetime.timedelta instance or None if the frequency is None or "always".
For instance, given "3 weeks", return datetime.timedelta(weeks=3)
Raise ValueError if the given frequency cannot be parsed.
'''
if not frequency:
return None
frequency = frequency.strip().lower()
if frequency == 'always':
return None
try:
number, time_unit = frequency.split(' ')
number = int(number)
except ValueError:
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
if not time_unit.endswith('s'):
time_unit += 's'
if time_unit == 'months':
number *= 30
time_unit = 'days'
elif time_unit == 'years':
number *= 365
time_unit = 'days'
try:
return datetime.timedelta(**{time_unit: number})
except TypeError:
raise ValueError(f"Could not parse consistency check frequency '{frequency}'")
def filter_checks_on_frequency(
location_config, consistency_config, borg_repository_id, checks, force
):
'''
Given a location config, a consistency config with a "checks" sequence of dicts, a Borg
repository ID, a sequence of checks, and whether to force checks to run, filter down those
checks based on the configured "frequency" for each check as compared to its check time file.
In other words, a check whose check time file's timestamp is too new (based on the configured
frequency) will get cut from the returned sequence of checks. Example:
consistency_config = {
'checks': [
{
'name': 'archives',
'frequency': '2 weeks',
},
]
}
When this function is called with that consistency_config and "archives" in checks, "archives"
will get filtered out of the returned result if its check time file is newer than 2 weeks old,
indicating that it's not yet time to run that check again.
Raise ValueError if a frequency cannot be parsed.
'''
filtered_checks = list(checks)
if force:
return tuple(filtered_checks)
for check_config in consistency_config.get('checks', DEFAULT_CHECKS):
check = check_config['name']
if checks and check not in checks:
continue
frequency_delta = parse_frequency(check_config.get('frequency'))
if not frequency_delta:
continue
check_time = read_check_time(
make_check_time_path(location_config, borg_repository_id, check)
)
if not check_time:
continue
# If we've not yet reached the time when the frequency dictates we're ready for another
# check, skip this check.
if datetime.datetime.now() < check_time + frequency_delta:
remaining = check_time + frequency_delta - datetime.datetime.now()
logger.info(
f"Skipping {check} check due to configured frequency; {remaining} until next check"
)
filtered_checks.remove(check)
return tuple(filtered_checks)
def make_check_flags(checks, check_last=None, prefix=None):
'''
Given a parsed sequence of checks, transform it into tuple of command-line flags.
@@ -66,27 +177,67 @@ def _make_check_flags(checks, check_last=None, prefix=None):
last_flags = ()
prefix_flags = ()
if check_last:
logger.warning(
'Ignoring check_last option, as "archives" is not in consistency checks.'
)
logger.info('Ignoring check_last option, as "archives" is not in consistency checks')
if prefix:
logger.warning(
'Ignoring consistency prefix option, as "archives" is not in consistency checks.'
logger.info(
'Ignoring consistency prefix option, as "archives" is not in consistency checks'
)
common_flags = last_flags + prefix_flags + (('--verify-data',) if 'data' in checks else ())
if set(DEFAULT_CHECKS).issubset(set(checks)):
if {'repository', 'archives'}.issubset(set(checks)):
return common_flags
return (
tuple('--{}-only'.format(check) for check in checks if check in DEFAULT_CHECKS)
tuple('--{}-only'.format(check) for check in checks if check in ('repository', 'archives'))
+ common_flags
)
def make_check_time_path(location_config, borg_repository_id, check_type):
'''
Given a location configuration dict, a Borg repository ID, and the name of a check type
("repository", "archives", etc.), return a path for recording that check's time (the time of
that check last occurring).
'''
return os.path.join(
os.path.expanduser(
location_config.get(
'borgmatic_source_directory', state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
)
),
'checks',
borg_repository_id,
check_type,
)
def write_check_time(path): # pragma: no cover
'''
Record a check time of now as the modification time of the given path.
'''
logger.debug(f'Writing check time at {path}')
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
pathlib.Path(path, mode=0o600).touch()
def read_check_time(path):
'''
Return the check time based on the modification time of the given path. Return None if the path
doesn't exist.
'''
logger.debug(f'Reading check time from {path}')
try:
return datetime.datetime.fromtimestamp(os.stat(path).st_mtime)
except FileNotFoundError:
return None
def check_archives(
repository,
location_config,
storage_config,
consistency_config,
local_path='borg',
@@ -94,6 +245,7 @@ def check_archives(
progress=None,
repair=None,
only_checks=None,
force=None,
):
'''
Given a local or remote repository path, a storage config dict, a consistency config dict,
@@ -102,13 +254,34 @@ def check_archives(
Borg archives for consistency.
If there are no consistency checks to run, skip running them.
Raises ValueError if the Borg repository ID cannot be determined.
'''
checks = _parse_checks(consistency_config, only_checks)
try:
borg_repository_id = json.loads(
info.display_archives_info(
repository,
storage_config,
argparse.Namespace(json=True, archive=None),
local_path,
remote_path,
)
)['repository']['id']
except (json.JSONDecodeError, KeyError):
raise ValueError(f'Cannot determine Borg repository ID for {repository}')
checks = filter_checks_on_frequency(
location_config,
consistency_config,
borg_repository_id,
parse_checks(consistency_config, only_checks),
force,
)
check_last = consistency_config.get('check_last', None)
lock_wait = None
extra_borg_options = storage_config.get('extra_borg_options', {}).get('check', '')
if set(checks).intersection(set(DEFAULT_CHECKS + ('data',))):
if set(checks).intersection({'repository', 'archives', 'data'}):
lock_wait = storage_config.get('lock_wait', None)
verbosity_flags = ()
@@ -122,7 +295,7 @@ def check_archives(
full_command = (
(local_path, 'check')
+ (('--repair',) if repair else ())
+ _make_check_flags(checks, check_last, prefix)
+ make_check_flags(checks, check_last, prefix)
+ (('--remote-path', remote_path) if remote_path else ())
+ (('--lock-wait', str(lock_wait)) if lock_wait else ())
+ verbosity_flags
@@ -131,12 +304,16 @@ def check_archives(
+ (repository,)
)
# The Borg repair option trigger an interactive prompt, which won't work when output is
# The Borg repair option triggers an interactive prompt, which won't work when output is
# captured. And progress messes with the terminal directly.
if repair or progress:
execute_command(full_command, output_file=DO_NOT_CAPTURE)
else:
execute_command(full_command)
for check in checks:
write_check_time(make_check_time_path(location_config, borg_repository_id, check))
if 'extract' in checks:
extract.extract_last_archive_dry_run(repository, lock_wait, local_path, remote_path)
write_check_time(make_check_time_path(location_config, borg_repository_id, 'extract'))
+3 -6
View File
@@ -5,7 +5,7 @@ import os
import pathlib
import tempfile
from borgmatic.borg import feature
from borgmatic.borg import feature, state
from borgmatic.execute import DO_NOT_CAPTURE, execute_command, execute_command_with_processes
logger = logging.getLogger(__name__)
@@ -175,7 +175,7 @@ def make_exclude_flags(location_config, exclude_filename=None):
)
DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic'
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
def borgmatic_source_directories(borgmatic_source_directory):
@@ -183,7 +183,7 @@ def borgmatic_source_directories(borgmatic_source_directory):
Return a list of borgmatic-specific source directories used for state like database backups.
'''
if not borgmatic_source_directory:
borgmatic_source_directory = DEFAULT_BORGMATIC_SOURCE_DIRECTORY
borgmatic_source_directory = state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
return (
[borgmatic_source_directory]
@@ -192,9 +192,6 @@ def borgmatic_source_directories(borgmatic_source_directory):
)
DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
def create_archive(
dry_run,
repository,
+9 -10
View File
@@ -1,6 +1,8 @@
import argparse
import logging
import subprocess
from borgmatic.borg import info
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
@@ -23,17 +25,14 @@ 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')
+ (('--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:
execute_command(info_command, output_log_level=None)
info.display_archives_info(
repository,
storage_config,
argparse.Namespace(json=True, archive=None),
local_path,
remote_path,
)
logger.info('Repository already exists. Skipping initialization.')
return
except subprocess.CalledProcessError as error:
+1
View File
@@ -0,0 +1 @@
DEFAULT_BORGMATIC_SOURCE_DIRECTORY = '~/.borgmatic'
+26 -5
View File
@@ -109,10 +109,9 @@ class Extend_action(Action):
setattr(namespace, self.dest, list(values))
def parse_arguments(*unparsed_arguments):
def make_parsers():
'''
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.
Build a top-level parser and its subparsers and return them as a tuple.
'''
config_paths = collect.get_default_config_paths(expand_home=True)
unexpanded_config_paths = collect.get_default_config_paths(expand_home=False)
@@ -189,6 +188,12 @@ def parse_arguments(*unparsed_arguments):
action='extend',
help='One or more configuration file options to override with specified values',
)
global_group.add_argument(
'--bash-completion',
default=False,
action='store_true',
help='Show bash completion script and exit',
)
global_group.add_argument(
'--version',
dest='version',
@@ -341,7 +346,7 @@ def parse_arguments(*unparsed_arguments):
dest='repair',
default=False,
action='store_true',
help='Attempt to repair any inconsistencies found (experimental and only for interactive use)',
help='Attempt to repair any inconsistencies found (for interactive use)',
)
check_group.add_argument(
'--only',
@@ -349,7 +354,13 @@ def parse_arguments(*unparsed_arguments):
choices=('repository', 'archives', 'data', 'extract'),
dest='only',
action='append',
help='Run a particular consistency check (repository, archives, data, or extract) instead of configured checks; can specify flag multiple times',
help='Run a particular consistency check (repository, archives, data, or extract) instead of configured checks (subject to configured frequency, can specify flag multiple times)',
)
check_group.add_argument(
'--force',
default=False,
action='store_true',
help='Ignore configured check frequencies and run checks unconditionally',
)
check_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
@@ -647,6 +658,16 @@ def parse_arguments(*unparsed_arguments):
)
borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
return top_level_parser, subparsers
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.
'''
top_level_parser, subparsers = make_parsers()
arguments, remaining_arguments = parse_subparser_arguments(
unparsed_arguments, subparsers.choices
)
+6
View File
@@ -11,6 +11,7 @@ from subprocess import CalledProcessError
import colorama
import pkg_resources
import borgmatic.commands.completion
from borgmatic.borg import borg as borg_borg
from borgmatic.borg import check as borg_check
from borgmatic.borg import compact as borg_compact
@@ -394,6 +395,7 @@ def run_actions(
logger.info('{}: Running consistency checks'.format(repository))
borg_check.check_archives(
repository,
location,
storage,
consistency,
local_path=local_path,
@@ -401,6 +403,7 @@ def run_actions(
progress=arguments['check'].progress,
repair=arguments['check'].repair,
only_checks=arguments['check'].only,
force=arguments['check'].force,
)
command.execute_hook(
hooks.get('after_check'),
@@ -884,6 +887,9 @@ def main(): # pragma: no cover
if global_arguments.version:
print(pkg_resources.require('borgmatic')[0].version)
sys.exit(0)
if global_arguments.bash_completion:
print(borgmatic.commands.completion.bash_completion())
sys.exit(0)
config_filenames = tuple(collect.collect_config_filenames(global_arguments.config_paths))
configs, parse_logs = load_configurations(config_filenames, global_arguments.overrides)
+58
View File
@@ -0,0 +1,58 @@
from borgmatic.commands import arguments
UPGRADE_MESSAGE = '''
Your bash completions script is from a different version of borgmatic than is
currently installed. Please upgrade your script so your completions match the
command-line flags in your installed borgmatic! Try this to upgrade:
sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"
source $BASH_SOURCE
'''
def parser_flags(parser):
'''
Given an argparse.ArgumentParser instance, return its argument flags in a space-separated
string.
'''
return ' '.join(option for action in parser._actions for option in action.option_strings)
def bash_completion():
'''
Return a bash completion script for the borgmatic command. Produce this by introspecting
borgmatic's command-line argument parsers.
'''
top_level_parser, subparsers = arguments.make_parsers()
global_flags = parser_flags(top_level_parser)
actions = ' '.join(subparsers.choices.keys())
# Avert your eyes.
return '\n'.join(
(
'set -uo pipefail',
'check_version() {',
' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"',
' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"',
' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];'
' then cat << EOF\n%s\nEOF' % UPGRADE_MESSAGE,
' fi',
'}',
'complete_borgmatic() {',
)
+ tuple(
''' if [[ " ${COMP_WORDS[*]} " =~ " %s " ]]; then
COMPREPLY=($(compgen -W "%s %s %s" -- "${COMP_WORDS[COMP_CWORD]}"))
return 0
fi'''
% (action, parser_flags(subparser), actions, global_flags)
for action, subparser in subparsers.choices.items()
)
+ (
' COMPREPLY=($(compgen -W "%s %s" -- "${COMP_WORDS[COMP_CWORD]}"))'
% (actions, global_flags),
' (check_version &)',
'}',
'\ncomplete -o bashdefault -o default -F complete_borgmatic borgmatic',
)
)
+11 -2
View File
@@ -23,10 +23,16 @@ def parse_arguments(*arguments):
'--destination',
dest='destination_filename',
default=DEFAULT_DESTINATION_CONFIG_FILENAME,
help='Destination YAML configuration file. Default: {}'.format(
help='Destination YAML configuration file, default: {}'.format(
DEFAULT_DESTINATION_CONFIG_FILENAME
),
)
parser.add_argument(
'--overwrite',
default=False,
action='store_true',
help='Whether to overwrite any existing destination file, defaults to false',
)
return parser.parse_args(arguments)
@@ -36,7 +42,10 @@ def main(): # pragma: no cover
args = parse_arguments(*sys.argv[1:])
generate.generate_sample_configuration(
args.source_filename, args.destination_filename, validate.schema_filename()
args.source_filename,
args.destination_filename,
validate.schema_filename(),
overwrite=args.overwrite,
)
print('Generated a sample configuration file at {}.'.format(args.destination_filename))
+19 -9
View File
@@ -5,7 +5,7 @@ import re
from ruamel import yaml
from borgmatic.config import load
from borgmatic.config import load, normalize
INDENT = 4
SEQUENCE_INDENT = 2
@@ -109,13 +109,18 @@ def render_configuration(config):
return rendered.getvalue()
def write_configuration(config_filename, rendered_config, mode=0o600):
def write_configuration(config_filename, rendered_config, mode=0o600, overwrite=False):
'''
Given a target config filename and rendered config YAML, write it out to file. Create any
containing directories as needed.
containing directories as needed. But if the file already exists and overwrite is False,
abort before writing anything.
'''
if os.path.exists(config_filename):
raise FileExistsError('{} already exists. Aborting.'.format(config_filename))
if not overwrite and os.path.exists(config_filename):
raise FileExistsError(
'{} already exists. Aborting. Use --overwrite to replace the file.'.format(
config_filename
)
)
try:
os.makedirs(os.path.dirname(config_filename), mode=0o700)
@@ -263,18 +268,22 @@ def merge_source_configuration_into_destination(destination_config, source_confi
return destination_config
def generate_sample_configuration(source_filename, destination_filename, schema_filename):
def generate_sample_configuration(
source_filename, destination_filename, schema_filename, overwrite=False
):
'''
Given an optional source configuration filename, and a required destination configuration
filename, and the path to a schema filename in a YAML rendition of the JSON Schema format,
write out a sample configuration file based on that schema. If a source filename is provided,
merge the parsed contents of that configuration into the generated configuration.
filename, the path to a schema filename in a YAML rendition of the JSON Schema format, and
whether to overwrite a destination file, write out a sample configuration file based on that
schema. If a source filename is provided, merge the parsed contents of that configuration into
the generated configuration.
'''
schema = yaml.round_trip_load(open(schema_filename))
source_config = None
if source_filename:
source_config = load.load_configuration(source_filename)
normalize.normalize(source_config)
destination_config = merge_source_configuration_into_destination(
_schema_to_sample_configuration(schema), source_config
@@ -283,4 +292,5 @@ def generate_sample_configuration(source_filename, destination_filename, schema_
write_configuration(
destination_filename,
_comment_out_optional_configuration(render_configuration(destination_config)),
overwrite=overwrite,
)
+5
View File
@@ -24,3 +24,8 @@ def normalize(config):
cronhub = config.get('hooks', {}).get('cronhub')
if isinstance(cronhub, str):
config['hooks']['cronhub'] = {'ping_url': cronhub}
# Upgrade consistency checks from a list of strings to a list of dicts.
checks = config.get('consistency', {}).get('checks')
if isinstance(checks, list) and len(checks) and isinstance(checks[0], str):
config['consistency']['checks'] = [{'name': check_type} for check_type in checks]
+40 -21
View File
@@ -447,26 +447,45 @@ properties:
checks:
type: array
items:
type: string
enum:
- repository
- archives
- data
- extract
- disabled
uniqueItems: true
type: object
required: ['name']
additionalProperties: false
properties:
name:
type: string
enum:
- repository
- archives
- data
- extract
- disabled
description: |
Name of consistency check to run: "repository",
"archives", "data", and/or "extract". Set to
"disabled" to disable all consistency checks.
"repository" checks the consistency of the
repository, "archives" checks all of the
archives, "data" verifies the integrity of the
data within the archives, and "extract" does an
extraction dry-run of the most recent archive.
Note that "data" implies "archives".
example: repository
frequency:
type: string
description: |
How frequently to run this type of consistency
check (as a best effort). The value is a number
followed by a unit of time. E.g., "2 weeks" to
run this consistency check no more than every
two weeks for a given repository or "1 month" to
run it no more than monthly. Defaults to
"always": running this check every time checks
are run.
example: 2 weeks
description: |
List of one or more consistency checks to run: "repository",
"archives", "data", and/or "extract". Defaults to
"repository" and "archives". Set to "disabled" to disable
all consistency checks. "repository" checks the consistency
of the repository, "archives" checks all of the archives,
"data" verifies the integrity of the data within the
archives, and "extract" does an extraction dry-run of the
most recent archive. Note that "data" implies "archives".
example:
- repository
- archives
List of one or more consistency checks to run on a periodic
basis (if "frequency" is set) or every time borgmatic runs
checks (if "frequency" is omitted).
check_repositories:
type: array
items:
@@ -859,8 +878,8 @@ properties:
enum: ['archive', 'directory']
description: |
Database dump output format. One of "archive",
or "directory". Defaults to "archive". See
mongodump documentation for details. Note that
or "directory". Defaults to "archive". See
mongodump documentation for details. Note that
format is ignored when the database name is
"all".
example: directory
+1 -1
View File
@@ -2,7 +2,7 @@ import logging
import os
import shutil
from borgmatic.borg.create import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
from borgmatic.borg.state import DEFAULT_BORGMATIC_SOURCE_DIRECTORY
logger = logging.getLogger(__name__)
+37 -4
View File
@@ -49,7 +49,7 @@ consistency checks with `check` on a much less frequent basis (e.g. with
Another option is to customize your consistency checks. The default
consistency checks run both full-repository checks and per-archive checks
within each repository.
within each repository no more than once a month.
But if you find that archive checks are too slow, for example, you can
configure borgmatic to run repository checks only. Configure this in the
@@ -58,9 +58,11 @@ configure borgmatic to run repository checks only. Configure this in the
```yaml
consistency:
checks:
- repository
- name: repository
```
(Prior to borgmatic 1.6.2, `checks` was a plain list of strings without the `name:` part.)
Here are the available checks from fastest to slowest:
* `repository`: Checks the consistency of the repository itself.
@@ -70,6 +72,36 @@ Here are the available checks from fastest to slowest:
See [Borg's check documentation](https://borgbackup.readthedocs.io/en/stable/usage/check.html) for more information.
### Check frequency
As of borgmatic 1.6.2, you can optionally configure checks to run on a
periodic basis rather than every time borgmatic runs checks. For instance:
```yaml
consistency:
checks:
- name: repository
frequency: 2 weeks
```
This tells borgmatic to run this consistency check at most once every two
weeks for a given repository. The `frequency` value is a number followed by a
unit of time, e.g. "3 days", "1 week", "2 months", etc. The `frequency`
defaults to "always", which means run this check every time checks run.
Unlike a real scheduler like cron, borgmatic only makes a best effort to run
checks on the configured frequency. It compares that frequency with how long
it's been since the last check for a given repository (as recorded in a file
within `~/.borgmatic/checks`). If it hasn't been long enough, the check is
skipped. And you still have to run `borgmatic check` (or just `borgmatic`) in
order for checks to run, even when a `frequency` is configured!
If you want to temporarily ignore your configured frequencies, you can invoke
`borgmatic check --force` to run checks unconditionally.
### Disabling checks
If that's still too slow, you can disable consistency checks entirely,
either for a single repository or for all repositories.
@@ -78,7 +110,7 @@ Disabling all consistency checks looks like this:
```yaml
consistency:
checks:
- disabled
- name: disabled
```
Or, if you have multiple repositories in your borgmatic configuration file,
@@ -99,7 +131,8 @@ borgmatic check --only data --only extract
```
This is useful for running slow consistency checks on an infrequent basis,
separate from your regular checks.
separate from your regular checks. It is still subject to any configured
check frequencies unless the `--force` flag is used.
## Troubleshooting
@@ -133,6 +133,11 @@ Note that this `<<` include merging syntax is only for merging in mappings
(configuration options and their values). But if you'd like to include a
single value directly, please see the section above about standard includes.
Additionally, there is a limitation preventing multiple `<<` include merges
per section. So for instance, that means you can do one `<<` merge at the
global level, another `<<` within each configuration section, etc. (This is a
YAML limitation.)
## Configuration overrides
+28 -2
View File
@@ -111,6 +111,7 @@ Additionally, [rsync.net](https://www.rsync.net/products/borg.html) and
[Hetzner](https://www.hetzner.com/storage/storage-box) have compatible storage
offerings, but do not currently fund borgmatic development or hosting.
## Configuration
After you install borgmatic, generate a sample configuration file:
@@ -302,9 +303,34 @@ interested in an [unofficial work-around for Full Disk
Access](https://projects.torsion.org/borgmatic-collective/borgmatic/issues/293).
## Colored output
## Niceties
Borgmatic produces colored terminal output by default. It is disabled when a
### Shell completion
borgmatic includes a shell completion script (currently only for Bash) to
support tab-completing borgmatic command-line actions and flags. Depending on
how you installed borgmatic, this may be enabled by default. But if it's not,
you can install the shell completion script globally:
```bash
sudo su -c "borgmatic --bash-completion > /usr/share/bash-completion/completions/borgmatic"
```
Alternatively, if you'd like to install the script for just the current user:
```bash
mkdir --parents ~/.local/share/bash-completion/completions
borgmatic --bash-completion > ~/.local/share/bash-completion/completions/borgmatic
```
In either case, you may also need to install the `bash-completion` Linux
package and restart your shell (`exit` and open a new shell).
### Colored output
borgmatic produces colored terminal output by default. It is disabled when a
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`
+1 -1
View File
@@ -11,7 +11,7 @@
set -e
apk add --no-cache python3 py3-pip borgbackup postgresql-client mariadb-client mongodb-tools \
py3-ruamel.yaml py3-ruamel.yaml.clib
py3-ruamel.yaml py3-ruamel.yaml.clib bash
# If certain dependencies of black are available in this version of Alpine, install them.
apk add --no-cache py3-typed-ast py3-regex || true
python3 -m pip install --no-cache --upgrade pip==22.0.3 setuptools==60.8.1
+3 -3
View File
@@ -1,6 +1,6 @@
from setuptools import find_packages, setup
VERSION = '1.6.1'
VERSION = '1.6.2'
setup(
@@ -30,12 +30,12 @@ setup(
},
obsoletes=['atticmatic'],
install_requires=(
'colorama>=0.4.1,<0.5',
'jsonschema',
'requests',
'ruamel.yaml>0.15.0,<0.18.0',
'setuptools',
'colorama>=0.4.1,<0.5',
),
include_package_data=True,
python_requires='>3.7.0',
python_requires='>=3.7',
)
+5
View File
@@ -0,0 +1,5 @@
import subprocess
def test_bash_completion_runs_without_error():
subprocess.check_call('borgmatic --bash-completion | bash', shell=True)
@@ -0,0 +1,5 @@
from borgmatic.commands import completion as module
def test_bash_completion_does_not_raise():
assert module.bash_completion()
@@ -1,13 +1,25 @@
from borgmatic.commands import generate_config as module
def test_parse_arguments_with_no_arguments_uses_defaults():
def test_parse_arguments_with_no_arguments_uses_default_destination():
parser = module.parse_arguments()
assert parser.destination_filename == module.DEFAULT_DESTINATION_CONFIG_FILENAME
def test_parse_arguments_with_filename_argument_overrides_defaults():
def test_parse_arguments_with_destination_argument_overrides_default():
parser = module.parse_arguments('--destination', 'config.yaml')
assert parser.destination_filename == 'config.yaml'
def test_parse_arguments_parses_source():
parser = module.parse_arguments('--source', 'source.yaml', '--destination', 'config.yaml')
assert parser.source_filename == 'source.yaml'
def test_parse_arguments_parses_overwrite():
parser = module.parse_arguments('--destination', 'config.yaml', '--overwrite')
assert parser.overwrite
+8 -1
View File
@@ -87,7 +87,7 @@ location:
assert module._comment_out_optional_configuration(config.strip()) == expected_config.strip()
def testrender_configuration_converts_configuration_to_yaml_string():
def test_render_configuration_converts_configuration_to_yaml_string():
yaml_string = module.render_configuration({'foo': 'bar'})
assert yaml_string == 'foo: bar\n'
@@ -110,6 +110,12 @@ def test_write_configuration_with_already_existing_file_raises():
module.write_configuration('config.yaml', 'config: yaml')
def test_write_configuration_with_already_existing_file_and_overwrite_does_not_raise():
flexmock(os.path).should_receive('exists').and_return(True)
module.write_configuration('config.yaml', 'config: yaml', overwrite=True)
def test_write_configuration_with_already_existing_directory_does_not_raise():
flexmock(os.path).should_receive('exists').and_return(False)
flexmock(os).should_receive('makedirs').and_raise(FileExistsError)
@@ -212,6 +218,7 @@ def test_generate_sample_configuration_with_source_filename_does_not_raise():
builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.yaml).should_receive('round_trip_load')
flexmock(module.load).should_receive('load_configuration')
flexmock(module.normalize).should_receive('normalize')
flexmock(module).should_receive('_schema_to_sample_configuration')
flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('render_configuration')
+3 -3
View File
@@ -55,8 +55,8 @@ def test_parse_configuration_transforms_file_into_mapping():
consistency:
checks:
- repository
- archives
- name: repository
- name: archives
'''
)
@@ -65,7 +65,7 @@ def test_parse_configuration_transforms_file_into_mapping():
assert result == {
'location': {'source_directories': ['/home', '/etc'], 'repositories': ['hostname.borg']},
'retention': {'keep_daily': 7, 'keep_hourly': 24, 'keep_minutely': 60},
'consistency': {'checks': ['repository', 'archives']},
'consistency': {'checks': [{'name': 'repository'}, {'name': 'archives'}]},
}
+34
View File
@@ -131,3 +131,37 @@ def test_run_arbitrary_borg_passes_key_sub_command_to_borg_before_repository():
module.run_arbitrary_borg(
repository='repo', storage_config={}, options=['key', 'export'],
)
def test_run_arbitrary_borg_passes_debug_sub_command_to_borg_before_repository():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'debug', 'dump-manifest', 'repo', 'path'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
module.run_arbitrary_borg(
repository='repo', storage_config={}, options=['debug', 'dump-manifest', 'path'],
)
def test_run_arbitrary_borg_with_debug_info_command_does_not_pass_borg_repository():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'debug', 'info'), output_log_level=logging.WARNING, borg_local_path='borg',
)
module.run_arbitrary_borg(
repository='repo', storage_config={}, options=['debug', 'info'],
)
def test_run_arbitrary_borg_with_debug_convert_profile_command_does_not_pass_borg_repository():
flexmock(module).should_receive('execute_command').with_args(
('borg', 'debug', 'convert-profile', 'in', 'out'),
output_log_level=logging.WARNING,
borg_local_path='borg',
)
module.run_arbitrary_borg(
repository='repo', storage_config={}, options=['debug', 'convert-profile', 'in', 'out'],
)
+341 -60
View File
@@ -17,172 +17,336 @@ def insert_execute_command_never():
def test_parse_checks_returns_them_as_tuple():
checks = module._parse_checks({'checks': ['foo', 'disabled', 'bar']})
checks = module.parse_checks({'checks': [{'name': 'foo'}, {'name': 'bar'}]})
assert checks == ('foo', 'bar')
def test_parse_checks_with_missing_value_returns_defaults():
checks = module._parse_checks({})
checks = module.parse_checks({})
assert checks == module.DEFAULT_CHECKS
assert checks == ('repository', 'archives')
def test_parse_checks_with_blank_value_returns_defaults():
checks = module._parse_checks({'checks': []})
def test_parse_checks_with_empty_list_returns_defaults():
checks = module.parse_checks({'checks': []})
assert checks == module.DEFAULT_CHECKS
assert checks == ('repository', 'archives')
def test_parse_checks_with_none_value_returns_defaults():
checks = module._parse_checks({'checks': None})
checks = module.parse_checks({'checks': None})
assert checks == module.DEFAULT_CHECKS
assert checks == ('repository', 'archives')
def test_parse_checks_with_disabled_returns_no_checks():
checks = module._parse_checks({'checks': ['disabled']})
checks = module.parse_checks({'checks': [{'name': 'foo'}, {'name': 'disabled'}]})
assert checks == ()
def test_parse_checks_with_data_check_also_injects_archives():
checks = module._parse_checks({'checks': ['data']})
checks = module.parse_checks({'checks': [{'name': 'data'}]})
assert checks == ('data', 'archives')
def test_parse_checks_with_data_check_passes_through_archives():
checks = module._parse_checks({'checks': ['data', 'archives']})
checks = module.parse_checks({'checks': [{'name': 'data'}, {'name': 'archives'}]})
assert checks == ('data', 'archives')
def test_parse_checks_prefers_override_checks_to_configured_checks():
checks = module._parse_checks({'checks': ['archives']}, only_checks=['repository', 'extract'])
checks = module.parse_checks(
{'checks': [{'name': 'archives'}]}, only_checks=['repository', 'extract']
)
assert checks == ('repository', 'extract')
def test_parse_checks_with_override_data_check_also_injects_archives():
checks = module._parse_checks({'checks': ['extract']}, only_checks=['data'])
checks = module.parse_checks({'checks': [{'name': 'extract'}]}, only_checks=['data'])
assert checks == ('data', 'archives')
@pytest.mark.parametrize(
'frequency,expected_result',
(
(None, None),
('always', None),
('1 hour', module.datetime.timedelta(hours=1)),
('2 hours', module.datetime.timedelta(hours=2)),
('1 day', module.datetime.timedelta(days=1)),
('2 days', module.datetime.timedelta(days=2)),
('1 week', module.datetime.timedelta(weeks=1)),
('2 weeks', module.datetime.timedelta(weeks=2)),
('1 month', module.datetime.timedelta(days=30)),
('2 months', module.datetime.timedelta(days=60)),
('1 year', module.datetime.timedelta(days=365)),
('2 years', module.datetime.timedelta(days=365 * 2)),
),
)
def test_parse_frequency_parses_into_timedeltas(frequency, expected_result):
assert module.parse_frequency(frequency) == expected_result
@pytest.mark.parametrize(
'frequency', ('sometime', 'x days', '3 decades',),
)
def test_parse_frequency_raises_on_parse_error(frequency):
with pytest.raises(ValueError):
module.parse_frequency(frequency)
def test_filter_checks_on_frequency_without_config_uses_default_checks():
flexmock(module).should_receive('parse_frequency').and_return(
module.datetime.timedelta(weeks=4)
)
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('read_check_time').and_return(None)
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={},
borg_repository_id='repo',
checks=('repository', 'archives'),
force=False,
) == ('repository', 'archives')
def test_filter_checks_on_frequency_retains_unconfigured_check():
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={},
borg_repository_id='repo',
checks=('data',),
force=False,
) == ('data',)
def test_filter_checks_on_frequency_retains_check_without_frequency():
flexmock(module).should_receive('parse_frequency').and_return(None)
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={'checks': [{'name': 'archives'}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
) == ('archives',)
def test_filter_checks_on_frequency_retains_check_with_elapsed_frequency():
flexmock(module).should_receive('parse_frequency').and_return(
module.datetime.timedelta(hours=1)
)
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('read_check_time').and_return(
module.datetime.datetime(year=module.datetime.MINYEAR, month=1, day=1)
)
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
) == ('archives',)
def test_filter_checks_on_frequency_retains_check_with_missing_check_time_file():
flexmock(module).should_receive('parse_frequency').and_return(
module.datetime.timedelta(hours=1)
)
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('read_check_time').and_return(None)
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
) == ('archives',)
def test_filter_checks_on_frequency_skips_check_with_unelapsed_frequency():
flexmock(module).should_receive('parse_frequency').and_return(
module.datetime.timedelta(hours=1)
)
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('read_check_time').and_return(module.datetime.datetime.now())
assert (
module.filter_checks_on_frequency(
location_config={},
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
checks=('archives',),
force=False,
)
== ()
)
def test_filter_checks_on_frequency_restains_check_with_unelapsed_frequency_and_force():
assert module.filter_checks_on_frequency(
location_config={},
consistency_config={'checks': [{'name': 'archives', 'frequency': '1 hour'}]},
borg_repository_id='repo',
checks=('archives',),
force=True,
) == ('archives',)
def test_make_check_flags_with_repository_check_returns_flag():
flags = module._make_check_flags(('repository',))
flags = module.make_check_flags(('repository',))
assert flags == ('--repository-only',)
def test_make_check_flags_with_archives_check_returns_flag():
flags = module._make_check_flags(('archives',))
flags = module.make_check_flags(('archives',))
assert flags == ('--archives-only',)
def test_make_check_flags_with_data_check_returns_flag():
flags = module._make_check_flags(('data',))
flags = module.make_check_flags(('data',))
assert flags == ('--verify-data',)
def test_make_check_flags_with_extract_omits_extract_flag():
flags = module._make_check_flags(('extract',))
flags = module.make_check_flags(('extract',))
assert flags == ()
def test_make_check_flags_with_default_checks_and_default_prefix_returns_default_flags():
flags = module._make_check_flags(module.DEFAULT_CHECKS, prefix=module.DEFAULT_PREFIX)
flags = module.make_check_flags(('repository', 'archives'), prefix=module.DEFAULT_PREFIX)
assert flags == ('--prefix', module.DEFAULT_PREFIX)
def test_make_check_flags_with_all_checks_and_default_prefix_returns_default_flags():
flags = module._make_check_flags(
module.DEFAULT_CHECKS + ('extract',), prefix=module.DEFAULT_PREFIX
flags = module.make_check_flags(
('repository', 'archives', 'extract'), prefix=module.DEFAULT_PREFIX
)
assert flags == ('--prefix', module.DEFAULT_PREFIX)
def test_make_check_flags_with_archives_check_and_last_includes_last_flag():
flags = module._make_check_flags(('archives',), check_last=3)
flags = module.make_check_flags(('archives',), check_last=3)
assert flags == ('--archives-only', '--last', '3')
def test_make_check_flags_with_repository_check_and_last_omits_last_flag():
flags = module._make_check_flags(('repository',), check_last=3)
flags = module.make_check_flags(('repository',), check_last=3)
assert flags == ('--repository-only',)
def test_make_check_flags_with_default_checks_and_last_includes_last_flag():
flags = module._make_check_flags(module.DEFAULT_CHECKS, check_last=3)
flags = module.make_check_flags(('repository', 'archives'), check_last=3)
assert flags == ('--last', '3')
def test_make_check_flags_with_archives_check_and_prefix_includes_prefix_flag():
flags = module._make_check_flags(('archives',), prefix='foo-')
flags = module.make_check_flags(('archives',), prefix='foo-')
assert flags == ('--archives-only', '--prefix', 'foo-')
def test_make_check_flags_with_archives_check_and_empty_prefix_omits_prefix_flag():
flags = module._make_check_flags(('archives',), prefix='')
flags = module.make_check_flags(('archives',), prefix='')
assert flags == ('--archives-only',)
def test_make_check_flags_with_archives_check_and_none_prefix_omits_prefix_flag():
flags = module._make_check_flags(('archives',), prefix=None)
flags = module.make_check_flags(('archives',), prefix=None)
assert flags == ('--archives-only',)
def test_make_check_flags_with_repository_check_and_prefix_omits_prefix_flag():
flags = module._make_check_flags(('repository',), prefix='foo-')
flags = module.make_check_flags(('repository',), prefix='foo-')
assert flags == ('--repository-only',)
def test_make_check_flags_with_default_checks_and_prefix_includes_prefix_flag():
flags = module._make_check_flags(module.DEFAULT_CHECKS, prefix='foo-')
flags = module.make_check_flags(('repository', 'archives'), prefix='foo-')
assert flags == ('--prefix', 'foo-')
def test_read_check_time_does_not_raise():
flexmock(module.os).should_receive('stat').and_return(flexmock(st_mtime=123))
assert module.read_check_time('/path')
def test_read_check_time_on_missing_file_does_not_raise():
flexmock(module.os).should_receive('stat').and_raise(FileNotFoundError)
assert module.read_check_time('/path') is None
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('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').and_return(())
flexmock(module).should_receive('execute_command').never()
flexmock(module).should_receive('execute_command').with_args(
('borg', 'check', '--progress', 'repo'), output_file=module.DO_NOT_CAPTURE
).once()
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config, progress=True
repository='repo',
location_config={},
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}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').and_return(())
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').and_return(())
flexmock(module).should_receive('execute_command').never()
flexmock(module).should_receive('execute_command').with_args(
('borg', 'check', '--repair', 'repo'), output_file=module.DO_NOT_CAPTURE
).once()
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config, repair=True
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
repair=True,
)
@@ -198,64 +362,142 @@ def test_check_archives_with_repair_calls_borg_with_repair_parameter():
def test_check_archives_calls_borg_with_parameters(checks):
check_last = flexmock()
consistency_config = {'check_last': check_last}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').with_args(
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').with_args(
checks, check_last, module.DEFAULT_PREFIX
).and_return(())
insert_execute_command_mock(('borg', 'check', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
)
def test_check_archives_with_json_error_raises():
checks = ('archives',)
check_last = flexmock()
consistency_config = {'check_last': check_last}
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"unexpected": {"id": "repo"}}'
)
with pytest.raises(ValueError):
module.check_archives(
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
)
def test_check_archives_with_missing_json_keys_raises():
checks = ('archives',)
check_last = flexmock()
consistency_config = {'check_last': check_last}
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return('{invalid JSON')
with pytest.raises(ValueError):
module.check_archives(
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
)
def test_check_archives_with_extract_check_calls_extract_only():
checks = ('extract',)
check_last = flexmock()
consistency_config = {'check_last': check_last}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').never()
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').never()
flexmock(module.extract).should_receive('extract_last_archive_dry_run').once()
flexmock(module).should_receive('write_check_time')
insert_execute_command_never()
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
)
def test_check_archives_with_log_info_calls_borg_with_info_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('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').and_return(())
insert_logging_mock(logging.INFO)
insert_execute_command_mock(('borg', 'check', '--info', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
)
def test_check_archives_with_log_debug_calls_borg_with_debug_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('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').and_return(())
insert_logging_mock(logging.DEBUG)
insert_execute_command_mock(('borg', 'check', '--debug', '--show-rc', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
)
def test_check_archives_without_any_checks_bails():
consistency_config = {'check_last': None}
flexmock(module).should_receive('_parse_checks').and_return(())
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(())
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
insert_execute_command_never()
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
)
@@ -263,14 +505,21 @@ def test_check_archives_with_local_path_calls_borg_via_local_path():
checks = ('repository',)
check_last = flexmock()
consistency_config = {'check_last': check_last}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').with_args(
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').with_args(
checks, check_last, module.DEFAULT_PREFIX
).and_return(())
insert_execute_command_mock(('borg1', 'check', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
local_path='borg1',
@@ -281,14 +530,21 @@ def test_check_archives_with_remote_path_calls_borg_with_remote_path_parameters(
checks = ('repository',)
check_last = flexmock()
consistency_config = {'check_last': check_last}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').with_args(
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').with_args(
checks, check_last, module.DEFAULT_PREFIX
).and_return(())
insert_execute_command_mock(('borg', 'check', '--remote-path', 'borg1', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
remote_path='borg1',
@@ -299,14 +555,23 @@ def test_check_archives_with_lock_wait_calls_borg_with_lock_wait_parameters():
checks = ('repository',)
check_last = flexmock()
consistency_config = {'check_last': check_last}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').with_args(
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').with_args(
checks, check_last, module.DEFAULT_PREFIX
).and_return(())
insert_execute_command_mock(('borg', 'check', '--lock-wait', '5', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository='repo', storage_config={'lock_wait': 5}, consistency_config=consistency_config
repository='repo',
location_config={},
storage_config={'lock_wait': 5},
consistency_config=consistency_config,
)
@@ -315,26 +580,42 @@ def test_check_archives_with_retention_prefix():
check_last = flexmock()
prefix = 'foo-'
consistency_config = {'check_last': check_last, 'prefix': prefix}
flexmock(module).should_receive('_parse_checks').and_return(checks)
flexmock(module).should_receive('_make_check_flags').with_args(
flexmock(module).should_receive('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').with_args(
checks, check_last, prefix
).and_return(())
insert_execute_command_mock(('borg', 'check', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository='repo', storage_config={}, consistency_config=consistency_config
repository='repo',
location_config={},
storage_config={},
consistency_config=consistency_config,
)
def test_check_archives_with_extra_borg_options_calls_borg_with_extra_options():
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('parse_checks')
flexmock(module).should_receive('filter_checks_on_frequency').and_return(checks)
flexmock(module.info).should_receive('display_archives_info').and_return(
'{"repository": {"id": "repo"}}'
)
flexmock(module).should_receive('make_check_flags').and_return(())
insert_execute_command_mock(('borg', 'check', '--extra', '--options', 'repo'))
flexmock(module).should_receive('make_check_time_path')
flexmock(module).should_receive('write_check_time')
module.check_archives(
repository='repo',
location_config={},
storage_config={'extra_borg_options': {'check': '--extra --options'}},
consistency_config=consistency_config,
)
+3 -1
View File
@@ -271,7 +271,9 @@ def test_borgmatic_source_directories_defaults_when_directory_not_given():
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os.path).should_receive('expanduser')
assert module.borgmatic_source_directories(None) == [module.DEFAULT_BORGMATIC_SOURCE_DIRECTORY]
assert module.borgmatic_source_directories(None) == [
module.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
]
DEFAULT_ARCHIVE_NAME = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'
+4 -4
View File
@@ -13,11 +13,11 @@ INIT_COMMAND = ('borg', 'init', '--encryption', 'repokey')
def insert_info_command_found_mock():
flexmock(module).should_receive('execute_command')
flexmock(module.info).should_receive('display_archives_info')
def insert_info_command_not_found_mock():
flexmock(module).should_receive('execute_command').and_raise(
flexmock(module.info).should_receive('display_archives_info').and_raise(
subprocess.CalledProcessError(module.INFO_REPOSITORY_NOT_FOUND_EXIT_CODE, [])
)
@@ -48,13 +48,13 @@ def test_initialize_repository_raises_for_borg_init_error():
def test_initialize_repository_skips_initialization_when_repository_already_exists():
flexmock(module).should_receive('execute_command').once()
insert_info_command_found_mock()
module.initialize_repository(repository='repo', storage_config={}, encryption_mode='repokey')
def test_initialize_repository_raises_for_unknown_info_command_error():
flexmock(module).should_receive('execute_command').and_raise(
flexmock(module.info).should_receive('display_archives_info').and_raise(
subprocess.CalledProcessError(INFO_SOME_UNKNOWN_EXIT_CODE, [])
)
+3 -1
View File
@@ -468,7 +468,9 @@ def test_run_actions_calls_hooks_for_check_action():
flexmock(module.command).should_receive('execute_hook').twice()
arguments = {
'global': flexmock(monitoring_verbosity=1, dry_run=False),
'check': flexmock(progress=flexmock(), repair=flexmock(), only=flexmock()),
'check': flexmock(
progress=flexmock(), repair=flexmock(), only=flexmock(), force=flexmock()
),
}
list(
+4
View File
@@ -35,6 +35,10 @@ from borgmatic.config import normalize as module
{'hooks': {'cronhub': 'https://example.com'}},
{'hooks': {'cronhub': {'ping_url': 'https://example.com'}}},
),
(
{'consistency': {'checks': ['archives']}},
{'consistency': {'checks': [{'name': 'archives'}]}},
),
),
)
def test_normalize_applies_hard_coded_normalization_to_config(config, expected_config):