Merge branch 'master' of https://github.com/diivi/borgmatic into feat/file-urls-support

This commit is contained in:
Divyansh Singh 2023-03-21 16:55:05 +05:30
commit e86d223bbf
48 changed files with 933 additions and 302 deletions

1
.flake8 Normal file
View file

@ -0,0 +1 @@
select = Q0

19
NEWS
View file

@ -1,5 +1,22 @@
1.7.9.dev0
1.7.10.dev0
* #501: Optionally error if a source directory does not exist via "source_directories_must_exist"
option in borgmatic's location configuration.
* #618: Support for BORG_FILES_CACHE_TTL environment variable via "borg_files_cache_ttl" option in
borgmatic's storage configuration.
1.7.9
* #295: Add a SQLite database dump/restore hook.
* #304: Change the default action order when no actions are specified on the command-line to:
"create", "prune", "compact", "check". If you'd like to retain the old ordering ("prune" and
"compact" first), then specify actions explicitly on the command-line.
* #304: Run any command-line actions in the order specified instead of using a fixed ordering.
* #564: Add "--repository" flag to all actions where it makes sense, so you can run borgmatic on
a single configured repository instead of all of them.
* #628: Add a Healthchecks "log" state to send borgmatic logs to Healthchecks without signalling
success or failure.
* #647: Add "--strip-components all" feature on the "extract" action to remove leading path
components of files you extract. Must be used with the "--path" flag.
* Add support for Python 3.11.
1.7.8
* #620: With the "create" action and the "--list" ("--files") flag, only show excluded files at

View file

@ -81,8 +81,8 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
Your first step is to [install and configure
borgmatic](https://torsion.org/borgmatic/docs/how-to/set-up-backups/).
For additional documentation, check out the links above for <a
href="https://torsion.org/borgmatic/#documentation">borgmatic how-to and
For additional documentation, check out the links above (left panel on wide screens)
for <a href="https://torsion.org/borgmatic/#documentation">borgmatic how-to and
reference guides</a>.

View file

@ -1,6 +1,7 @@
import logging
import borgmatic.borg.check
import borgmatic.config.validate
import borgmatic.hooks.command
logger = logging.getLogger(__name__)
@ -23,6 +24,11 @@ def run_check(
'''
Run the "check" action for the given repository.
'''
if check_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, check_arguments.repository
):
return
borgmatic.hooks.command.execute_hook(
hooks.get('before_check'),
hooks.get('umask'),

View file

@ -2,6 +2,7 @@ import logging
import borgmatic.borg.compact
import borgmatic.borg.feature
import borgmatic.config.validate
import borgmatic.hooks.command
logger = logging.getLogger(__name__)
@ -24,6 +25,11 @@ def run_compact(
'''
Run the "compact" action for the given repository.
'''
if compact_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, compact_arguments.repository
):
return
borgmatic.hooks.command.execute_hook(
hooks.get('before_compact'),
hooks.get('umask'),

View file

@ -2,6 +2,7 @@ import json
import logging
import borgmatic.borg.create
import borgmatic.config.validate
import borgmatic.hooks.command
import borgmatic.hooks.dispatch
import borgmatic.hooks.dump
@ -28,6 +29,11 @@ def run_create(
If create_arguments.json is True, yield the JSON output from creating the archive.
'''
if create_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, create_arguments.repository
):
return
borgmatic.hooks.command.execute_hook(
hooks.get('before_backup'),
hooks.get('umask'),

View file

@ -1,6 +1,7 @@
import logging
import borgmatic.borg.prune
import borgmatic.config.validate
import borgmatic.hooks.command
logger = logging.getLogger(__name__)
@ -23,6 +24,11 @@ def run_prune(
'''
Run the "prune" action for the given repository.
'''
if prune_arguments.repository and not borgmatic.config.validate.repositories_match(
repository, prune_arguments.repository
):
return
borgmatic.hooks.command.execute_hook(
hooks.get('before_prune'),
hooks.get('umask'),

View file

@ -139,7 +139,7 @@ def filter_checks_on_frequency(
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"
f'Skipping {check} check due to configured frequency; {remaining} until next check'
)
filtered_checks.remove(check)

View file

@ -306,6 +306,20 @@ def collect_special_file_paths(
)
def check_all_source_directories_exist(source_directories):
'''
Given a sequence of source directories, check that they all exist. If any do not, raise an
exception.
'''
missing_directories = [
source_directory
for source_directory in source_directories
if not os.path.exists(source_directory)
]
if missing_directories:
raise ValueError(f"Source directories do not exist: {', '.join(missing_directories)}")
def create_archive(
dry_run,
repository,
@ -331,6 +345,8 @@ def create_archive(
borgmatic_source_directories = expand_directories(
collect_borgmatic_source_directories(location_config.get('borgmatic_source_directory'))
)
if location_config.get('source_directories_must_exist', False):
check_all_source_directories_exist(location_config.get('source_directories'))
sources = deduplicate_directories(
map_directories_to_devices(
expand_directories(

View file

@ -2,6 +2,7 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
'borg_base_directory': 'BORG_BASE_DIR',
'borg_config_directory': 'BORG_CONFIG_DIR',
'borg_cache_directory': 'BORG_CACHE_DIR',
'borg_files_cache_ttl': 'BORG_FILES_CACHE_TTL',
'borg_security_directory': 'BORG_SECURITY_DIR',
'borg_keys_directory': 'BORG_KEYS_DIR',
'encryption_passcommand': 'BORG_PASSCOMMAND',
@ -27,7 +28,7 @@ def make_environment(storage_config):
value = storage_config.get(option_name)
if value:
environment[environment_variable_name] = value
environment[environment_variable_name] = str(value)
for (
option_name,

View file

@ -87,6 +87,13 @@ def extract_archive(
else:
numeric_ids_flags = ('--numeric-owner',) if location_config.get('numeric_ids') else ()
if strip_components == 'all':
if not paths:
raise ValueError('The --strip-components flag with "all" requires at least one --path')
# Calculate the maximum number of leading path components of the given paths.
strip_components = max(0, *(len(path.split(os.path.sep)) - 1 for path in paths))
full_command = (
(local_path, 'extract')
+ (('--remote-path', remote_path) if remote_path else ())

View file

@ -17,7 +17,7 @@ def resolve_archive_name(
Raise ValueError if "latest" is given but there are no archives in the repository.
'''
if archive != "latest":
if archive != 'latest':
return archive
lock_wait = storage_config.get('lock_wait', None)

View file

@ -46,11 +46,12 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
if 'borg' in unparsed_arguments:
subparsers = {'borg': subparsers['borg']}
for subparser_name, subparser in subparsers.items():
if subparser_name not in remaining_arguments:
continue
for argument in remaining_arguments:
canonical_name = alias_to_subparser_name.get(argument, argument)
subparser = subparsers.get(canonical_name)
canonical_name = alias_to_subparser_name.get(subparser_name, subparser_name)
if not subparser:
continue
# If a parsed value happens to be the same as the name of a subparser, remove it from the
# remaining arguments. This prevents, for instance, "check --only extract" from triggering
@ -67,9 +68,9 @@ def parse_subparser_arguments(unparsed_arguments, subparsers):
arguments[canonical_name] = parsed
# If no actions are explicitly requested, assume defaults: prune, compact, create, and check.
# If no actions are explicitly requested, assume defaults.
if not arguments and '--help' not in unparsed_arguments and '-h' not in unparsed_arguments:
for subparser_name in ('prune', 'compact', 'create', 'check'):
for subparser_name in ('create', 'prune', 'compact', 'check'):
subparser = subparsers[subparser_name]
parsed, unused_remaining = subparser.parse_known_args(unparsed_arguments)
arguments[subparser_name] = parsed
@ -215,7 +216,7 @@ def make_parsers():
top_level_parser = ArgumentParser(
description='''
Simple, configuration-driven backup software for servers and workstations. If none of
the action options are given, then borgmatic defaults to: prune, compact, create, and
the action options are given, then borgmatic defaults to: create, prune, compact, and
check.
''',
parents=[global_parser],
@ -224,7 +225,7 @@ def make_parsers():
subparsers = top_level_parser.add_subparsers(
title='actions',
metavar='',
help='Specify zero or more actions. Defaults to prune, compact, create, and check. Use --help with action for details:',
help='Specify zero or more actions. Defaults to creat, prune, compact, and check. Use --help with action for details:',
)
rcreate_parser = subparsers.add_parser(
'rcreate',
@ -332,6 +333,10 @@ def make_parsers():
add_help=False,
)
prune_group = prune_parser.add_argument_group('prune arguments')
prune_group.add_argument(
'--repository',
help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file)',
)
prune_group.add_argument(
'--stats',
dest='stats',
@ -352,6 +357,10 @@ def make_parsers():
add_help=False,
)
compact_group = compact_parser.add_argument_group('compact arguments')
compact_group.add_argument(
'--repository',
help='Path of specific existing repository to compact (must be already specified in a borgmatic configuration file)',
)
compact_group.add_argument(
'--progress',
dest='progress',
@ -384,6 +393,10 @@ def make_parsers():
add_help=False,
)
create_group = create_parser.add_argument_group('create arguments')
create_group.add_argument(
'--repository',
help='Path of specific existing repository to backup to (must be already specified in a borgmatic configuration file)',
)
create_group.add_argument(
'--progress',
dest='progress',
@ -414,6 +427,10 @@ def make_parsers():
add_help=False,
)
check_group = check_parser.add_argument_group('check arguments')
check_group.add_argument(
'--repository',
help='Path of specific existing repository to check (must be already specified in a borgmatic configuration file)',
)
check_group.add_argument(
'--progress',
dest='progress',
@ -475,10 +492,9 @@ def make_parsers():
)
extract_group.add_argument(
'--strip-components',
type=int,
type=lambda number: number if number == 'all' else int(number),
metavar='NUMBER',
dest='strip_components',
help='Number of leading path components to remove from each extracted path. Skip paths with fewer elements',
help='Number of leading path components to remove from each extracted path or "all" to strip all leading path components. Skip paths with fewer elements',
)
extract_group.add_argument(
'--progress',
@ -611,7 +627,7 @@ def make_parsers():
metavar='NAME',
nargs='+',
dest='databases',
help='Names of databases to restore from archive, defaults to all databases. Note that any databases to restore must be defined in borgmatic\'s configuration',
help="Names of databases to restore from archive, defaults to all databases. Note that any databases to restore must be defined in borgmatic's configuration",
)
restore_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
@ -805,7 +821,7 @@ def make_parsers():
'borg',
aliases=SUBPARSER_ALIASES['borg'],
help='Run an arbitrary Borg command',
description='Run an arbitrary Borg command based on borgmatic\'s configuration',
description="Run an arbitrary Borg command based on borgmatic's configuration",
add_help=False,
)
borg_group = borg_parser.add_argument_group('borg arguments')

View file

@ -44,8 +44,8 @@ LEGACY_CONFIG_PATH = '/etc/borgmatic/config'
def run_configuration(config_filename, config, arguments):
'''
Given a config filename, the corresponding parsed config dict, and command-line arguments as a
dict from subparser name to a namespace of parsed arguments, execute the defined prune, compact,
create, check, and/or other actions.
dict from subparser name to a namespace of parsed arguments, execute the defined create, prune,
compact, check, and/or other actions.
Yield a combination of:
@ -64,7 +64,7 @@ def run_configuration(config_filename, config, arguments):
retry_wait = storage.get('retry_wait', 0)
encountered_error = None
error_repository = ''
using_primary_action = {'prune', 'compact', 'create', 'check'}.intersection(arguments)
using_primary_action = {'create', 'prune', 'compact', 'check'}.intersection(arguments)
monitoring_log_level = verbosity_to_log_level(global_arguments.monitoring_verbosity)
try:
@ -152,6 +152,25 @@ def run_configuration(config_filename, config, arguments):
encountered_error = error
error_repository = repository_path
try:
if using_primary_action:
# send logs irrespective of error
dispatch.call_hooks(
'ping_monitor',
hooks,
config_filename,
monitor.MONITOR_HOOK_NAMES,
monitor.State.LOG,
monitoring_log_level,
global_arguments.dry_run,
)
except (OSError, CalledProcessError) as error:
if command.considered_soft_failure(config_filename, error):
return
encountered_error = error
yield from log_error_records('{}: Error pinging monitor'.format(config_filename), error)
if not encountered_error:
try:
if using_primary_action:
@ -262,57 +281,28 @@ def run_actions(
**hook_context,
)
if 'rcreate' in arguments:
for (action_name, action_arguments) in arguments.items():
if action_name == 'rcreate':
borgmatic.actions.rcreate.run_rcreate(
repository,
storage,
local_borg_version,
arguments['rcreate'],
action_arguments,
global_arguments,
local_path,
remote_path,
)
if 'transfer' in arguments:
elif action_name == 'transfer':
borgmatic.actions.transfer.run_transfer(
repository,
storage,
local_borg_version,
arguments['transfer'],
action_arguments,
global_arguments,
local_path,
remote_path,
)
if 'prune' in arguments:
borgmatic.actions.prune.run_prune(
config_filename,
repository,
storage,
retention,
hooks,
hook_context,
local_borg_version,
arguments['prune'],
global_arguments,
dry_run_label,
local_path,
remote_path,
)
if 'compact' in arguments:
borgmatic.actions.compact.run_compact(
config_filename,
repository,
storage,
retention,
hooks,
hook_context,
local_borg_version,
arguments['compact'],
global_arguments,
dry_run_label,
local_path,
remote_path,
)
if 'create' in arguments:
elif action_name == 'create':
yield from borgmatic.actions.create.run_create(
config_filename,
repository,
@ -321,13 +311,44 @@ def run_actions(
hooks,
hook_context,
local_borg_version,
arguments['create'],
action_arguments,
global_arguments,
dry_run_label,
local_path,
remote_path,
)
if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency):
elif action_name == 'prune':
borgmatic.actions.prune.run_prune(
config_filename,
repository,
storage,
retention,
hooks,
hook_context,
local_borg_version,
action_arguments,
global_arguments,
dry_run_label,
local_path,
remote_path,
)
elif action_name == 'compact':
borgmatic.actions.compact.run_compact(
config_filename,
repository,
storage,
retention,
hooks,
hook_context,
local_borg_version,
action_arguments,
global_arguments,
dry_run_label,
local_path,
remote_path,
)
elif action_name == 'check':
if checks.repository_enabled_for_checks(repository, consistency):
borgmatic.actions.check.run_check(
config_filename,
repository,
@ -337,12 +358,12 @@ def run_actions(
hooks,
hook_context,
local_borg_version,
arguments['check'],
action_arguments,
global_arguments,
local_path,
remote_path,
)
if 'extract' in arguments:
elif action_name == 'extract':
borgmatic.actions.extract.run_extract(
config_filename,
repository,
@ -351,54 +372,59 @@ def run_actions(
hooks,
hook_context,
local_borg_version,
arguments['extract'],
action_arguments,
global_arguments,
local_path,
remote_path,
)
if 'export-tar' in arguments:
elif action_name == 'export-tar':
borgmatic.actions.export_tar.run_export_tar(
repository,
storage,
local_borg_version,
arguments['export-tar'],
action_arguments,
global_arguments,
local_path,
remote_path,
)
if 'mount' in arguments:
elif action_name == 'mount':
borgmatic.actions.mount.run_mount(
repository, storage, local_borg_version, arguments['mount'], local_path, remote_path,
repository,
storage,
local_borg_version,
arguments['mount'],
local_path,
remote_path,
)
if 'restore' in arguments:
elif action_name == 'restore':
borgmatic.actions.restore.run_restore(
repository,
location,
storage,
hooks,
local_borg_version,
arguments['restore'],
action_arguments,
global_arguments,
local_path,
remote_path,
)
if 'rlist' in arguments:
elif action_name == 'rlist':
yield from borgmatic.actions.rlist.run_rlist(
repository, storage, local_borg_version, arguments['rlist'], local_path, remote_path,
repository, storage, local_borg_version, action_arguments, local_path, remote_path,
)
if 'list' in arguments:
elif action_name == 'list':
yield from borgmatic.actions.list.run_list(
repository, storage, local_borg_version, arguments['list'], local_path, remote_path,
repository, storage, local_borg_version, action_arguments, local_path, remote_path,
)
if 'rinfo' in arguments:
elif action_name == 'rinfo':
yield from borgmatic.actions.rinfo.run_rinfo(
repository, storage, local_borg_version, arguments['rinfo'], local_path, remote_path,
repository, storage, local_borg_version, action_arguments, local_path, remote_path,
)
if 'info' in arguments:
elif action_name == 'info':
yield from borgmatic.actions.info.run_info(
repository, storage, local_borg_version, arguments['info'], local_path, remote_path,
repository, storage, local_borg_version, action_arguments, local_path, remote_path,
)
if 'break-lock' in arguments:
elif action_name == 'break-lock':
borgmatic.actions.break_lock.run_break_lock(
repository,
storage,
@ -407,9 +433,9 @@ def run_actions(
local_path,
remote_path,
)
if 'borg' in arguments:
elif action_name == 'borg':
borgmatic.actions.borg.run_borg(
repository, storage, local_borg_version, arguments['borg'], local_path, remote_path,
repository, storage, local_borg_version, action_arguments, local_path, remote_path,
)
command.execute_hook(

View file

@ -202,6 +202,12 @@ properties:
path prevents "borgmatic restore" from finding any database
dumps created before the change. Defaults to ~/.borgmatic
example: /tmp/borgmatic
source_directories_must_exist:
type: boolean
description: |
If true, then source directories must exist, otherwise an
error is raised. Defaults to false.
example: true
storage:
type: object
description: |
@ -315,6 +321,12 @@ properties:
Path for Borg cache files. Defaults to
$borg_base_directory/.cache/borg
example: /path/to/base/cache
borg_files_cache_ttl:
type: integer
description: |
Maximum time to live (ttl) for entries in the Borg files
cache.
example: 20
borg_security_directory:
type: string
description: |
@ -369,6 +381,11 @@ properties:
description: |
Extra command-line options to pass to "borg init".
example: "--extra-option"
create:
type: string
description: |
Extra command-line options to pass to "borg create".
example: "--extra-option"
prune:
type: string
description: |
@ -379,11 +396,6 @@ properties:
description: |
Extra command-line options to pass to "borg compact".
example: "--extra-option"
create:
type: string
description: |
Extra command-line options to pass to "borg create".
example: "--extra-option"
check:
type: string
description: |
@ -663,11 +675,11 @@ properties:
type: string
description: |
List of one or more shell commands or scripts to execute
when an exception occurs during a "prune", "compact",
"create", or "check" action or an associated before/after
when an exception occurs during a "create", "prune",
"compact", or "check" action or an associated before/after
hook.
example:
- echo "Error during prune/compact/create/check."
- echo "Error during create/prune/compact/check."
before_everything:
type: array
items:
@ -1168,7 +1180,7 @@ properties:
type: string
description: |
Healthchecks ping URL or UUID to notify when a
backup begins, ends, or errors.
backup begins, ends, errors or just to send logs.
example: https://hc-ping.com/your-uuid-here
verify_tls:
type: boolean
@ -1180,7 +1192,8 @@ properties:
type: boolean
description: |
Send borgmatic logs to Healthchecks as part the
"finish" state. Defaults to true.
"finish", "fail", and "log" states. Defaults to
true.
example: false
ping_body_limit:
type: integer
@ -1199,10 +1212,11 @@ properties:
- start
- finish
- fail
- log
uniqueItems: true
description: |
List of one or more monitoring states to ping for:
"start", "finish", and/or "fail". Defaults to
"start", "finish", "fail", and/or "log". Defaults to
pinging for all states.
example:
- finish

View file

@ -189,5 +189,5 @@ def guard_single_repository_selected(repository, configurations):
if count != 1:
raise ValueError(
'Can\'t determine which repository to use. Use --repository to disambiguate'
"Can't determine which repository to use. Use --repository to disambiguate"
)

View file

@ -27,6 +27,12 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
Ping the configured Cronhub URL, modified with the monitor.State. Use the given configuration
filename in any log entries. If this is a dry run, then don't actually ping anything.
'''
if state not in MONITOR_STATE_TO_CRONHUB:
logger.debug(
f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronhub hook'
)
return
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
formatted_state = '/{}/'.format(MONITOR_STATE_TO_CRONHUB[state])
ping_url = (

View file

@ -27,6 +27,12 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
Ping the configured Cronitor URL, modified with the monitor.State. Use the given configuration
filename in any log entries. If this is a dry run, then don't actually ping anything.
'''
if state not in MONITOR_STATE_TO_CRONITOR:
logger.debug(
f'{config_filename}: Ignoring unsupported monitoring {state.name.lower()} in Cronitor hook'
)
return
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
ping_url = '{}/{}'.format(hook_config['ping_url'], MONITOR_STATE_TO_CRONITOR[state])

View file

@ -10,6 +10,7 @@ MONITOR_STATE_TO_HEALTHCHECKS = {
monitor.State.START: 'start',
monitor.State.FINISH: None, # Healthchecks doesn't append to the URL for the finished state.
monitor.State.FAIL: 'fail',
monitor.State.LOG: 'log',
}
PAYLOAD_TRUNCATION_INDICATOR = '...\n'
@ -117,7 +118,7 @@ def ping_monitor(hook_config, config_filename, state, monitoring_log_level, dry_
)
logger.debug('{}: Using Healthchecks ping URL {}'.format(config_filename, ping_url))
if state in (monitor.State.FINISH, monitor.State.FAIL):
if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
payload = format_buffered_logs_for_payload()
else:
payload = ''

View file

@ -7,3 +7,4 @@ class State(Enum):
START = 1
FINISH = 2
FAIL = 3
LOG = 4

View file

@ -2,16 +2,8 @@ import logging
import requests
from borgmatic.hooks import monitor
logger = logging.getLogger(__name__)
MONITOR_STATE_TO_NTFY = {
monitor.State.START: None,
monitor.State.FINISH: None,
monitor.State.FAIL: None,
}
def initialize_monitor(
ping_url, config_filename, monitoring_log_level, dry_run

View file

@ -4,7 +4,7 @@ COPY . /app
RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib
RUN pip install --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
RUN borgmatic --help > /command-line.txt \
&& for action in rcreate transfer prune compact create check extract export-tar mount umount restore rlist list rinfo info break-lock borg; do \
&& for action in rcreate transfer create prune compact check extract export-tar mount umount restore rlist list rinfo info break-lock borg; do \
echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
&& borgmatic "$action" --help >> /command-line.txt; done

View file

@ -316,7 +316,10 @@ user and you're extracting to `/tmp`, then the dump will be in
`/tmp/root/.borgmatic`.
After extraction, you can manually restore the dump file using native database
commands like `pg_restore`, `mysql`, `mongorestore` or similar.
commands like `pg_restore`, `mysql`, `mongorestore`, `sqlite`, or similar.
Also see the documentation on [listing database
dumps](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#listing-database-dumps).
## Preparation and cleanup hooks

View file

@ -9,37 +9,47 @@ eleventyNavigation:
Borg itself is great for efficiently de-duplicating data across successive
backup archives, even when dealing with very large repositories. But you may
find that while borgmatic's default mode of `prune`, `compact`, `create`, and
`check` works well on small repositories, it's not so great on larger ones.
That's because running the default pruning, compact, and consistency checks
take a long time on large repositories.
find that while borgmatic's default actions of `create`, `prune`, `compact`,
and `check` works well on small repositories, it's not so great on larger
ones. That's because running the default pruning, compact, and consistency
checks take a long time on large repositories.
<span class="minilink minilink-addedin">Prior to version 1.7.9</span> The
default action ordering was `prune`, `compact`, `create`, and `check`.
### A la carte actions
If you find yourself in this situation, you have some options. First, you can
run borgmatic's `prune`, `compact`, `create`, or `check` actions separately.
For instance, the following optional actions are available:
If you find yourself wanting to customize the actions, you have some options.
First, you can run borgmatic's `prune`, `compact`, `create`, or `check`
actions separately. For instance, the following optional actions are
available (among others):
```bash
borgmatic create
borgmatic prune
borgmatic compact
borgmatic create
borgmatic check
```
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
skipping certain actions while running others. For instance, this skips
`prune` and `compact` and only runs `create` and `check`:
You can run borgmatic 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 skipping certain actions while running others. For instance,
this skips `prune` and `compact` and only runs `create` and `check`:
```bash
borgmatic create check
```
Or, you can make backups with `create` on a frequent schedule (e.g. with
`borgmatic create` called from one cron job), while only running expensive
consistency checks with `check` on a much less frequent basis (e.g. with
`borgmatic check` called from a separate cron job).
<span class="minilink minilink-addedin">New in version 1.7.9</span> borgmatic
now respects your specified command-line action order, running actions in the
order you specify. In previous versions, borgmatic ran your specified actions
in a fixed ordering regardless of the order they appeared on the command-line.
But instead of running actions together, another option is to run backups with
`create` on a frequent schedule (e.g. with `borgmatic create` called from one
cron job), while only running expensive consistency checks with `check` on a
much less frequent basis (e.g. with `borgmatic check` called from a separate
cron job).
### Consistency check configuration
@ -47,8 +57,8 @@ consistency checks with `check` on a much less frequent basis (e.g. with
Another option is to customize your consistency checks. By default, if you
omit consistency checks from configuration, borgmatic runs full-repository
checks (`repository`) and per-archive checks (`archives`) within each
repository, no more than once a month. This is equivalent to what `borg check`
does if run without options.
repository. (Although see below about check frequency.) This is equivalent to
what `borg check` does if run without options.
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
@ -60,8 +70,9 @@ consistency:
- name: repository
```
<span class="minilink minilink-addedin">Prior to version 1.6.2</span> `checks`
was a plain list of strings without the `name:` part. For example:
<span class="minilink minilink-addedin">Prior to version 1.6.2</span> The
`checks` option was a plain list of strings without the `name:` part, and
borgmatic ran each configured check every time checks were run. For example:
```yaml
consistency:
@ -102,8 +113,13 @@ consistency:
This tells borgmatic to run the `repository` consistency check at most once
every two weeks for a given repository and the `archives` check at most once a
month. 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.
days", "1 week", "2 months", etc.
The `frequency` defaults to `always` for a check configured without a
`frequency`, which means run this check every time checks run. But if you omit
consistency checks from configuration entirely, borgmatic runs full-repository
checks (`repository`) and per-archive checks (`archives`) within each
repository, at most once a month.
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

View file

@ -26,7 +26,7 @@ make sure your changes work.
```bash
cd borgmatic/
pip3 install --editable --user .
pip3 install --user --editable .
```
Note that this will typically install the borgmatic commands into

View file

@ -20,15 +20,15 @@ borgmatic rlist
That should yield output looking something like:
```text
host-2019-01-01T04:05:06.070809 Tue, 2019-01-01 04:05:06 [...]
host-2019-01-02T04:06:07.080910 Wed, 2019-01-02 04:06:07 [...]
host-2023-01-01T04:05:06.070809 Tue, 2023-01-01 04:05:06 [...]
host-2023-01-02T04:06:07.080910 Wed, 2023-01-02 04:06:07 [...]
```
Assuming that you want to extract the archive with the most up-to-date files
and therefore the latest timestamp, run a command like:
```bash
borgmatic extract --archive host-2019-01-02T04:06:07.080910
borgmatic extract --archive host-2023-01-02T04:06:07.080910
```
(No borgmatic `extract` action? Upgrade borgmatic!)
@ -54,7 +54,7 @@ But if you have multiple repositories configured, then you'll need to specify
the repository path containing the archive to extract. Here's an example:
```bash
borgmatic extract --repository repo.borg --archive host-2019-...
borgmatic extract --repository repo.borg --archive host-2023-...
```
## Extract particular files
@ -74,6 +74,13 @@ run the `extract` command above, borgmatic will extract `/var/path/1` and
`/var/path/2`.
### Searching for files
If you're not sure which archive contains the files you're looking for, you
can [search across
archives](https://torsion.org/borgmatic/docs/how-to/inspect-your-backups/#searching-for-a-file).
## Extract to a particular destination
By default, borgmatic extracts files into the current directory. To instead

View file

@ -91,6 +91,19 @@ example, to search only the last five archives:
borgmatic list --find foo.txt --last 5
```
## Listing database dumps
If you have enabled borgmatic's [database
hooks](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/), you
can list backed up database dumps via borgmatic. For example:
```bash
borgmatic list --archive latest --find .borgmatic/*_databases
```
This gives you a listing of all database dump files contained in the latest
archive, complete with file sizes.
## Logging

View file

@ -83,7 +83,7 @@ tests](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/).
## Error hooks
When an error occurs during a `prune`, `compact`, `create`, or `check` action,
When an error occurs during a `create`, `prune`, `compact`, or `check` action,
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:
@ -116,8 +116,8 @@ the repository. Here's the full set of supported variables you can use here:
* `output`: output of the command that failed (may be blank if an error
occurred without running a command)
Note that borgmatic runs the `on_error` hooks only for `prune`, `compact`,
`create`, or `check` actions or hooks in which an error occurs, and not other
Note that borgmatic runs the `on_error` hooks only for `create`, `prune`,
`compact`, or `check` actions or hooks in which an error occurs, and not other
actions. 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
@ -144,7 +144,7 @@ With this hook in place, borgmatic pings your Healthchecks project when a
backup begins, ends, or errors. Specifically, after the <a
href="https://torsion.org/borgmatic/docs/how-to/add-preparation-and-cleanup-steps-to-backups/">`before_backup`
hooks</a> run, borgmatic lets Healthchecks know that it has started if any of
the `prune`, `compact`, `create`, or `check` actions are run.
the `create`, `prune`, `compact`, or `check` actions are run.
Then, if the actions complete successfully, borgmatic notifies Healthchecks of
the success after the `after_backup` hooks run, and includes borgmatic logs in
@ -154,8 +154,8 @@ in the Healthchecks UI, although be aware that Healthchecks currently has a
If an error occurs during any action or hook, borgmatic notifies Healthchecks
after the `on_error` hooks run, also tacking on logs including the error
itself. But the logs are only included for errors that occur when a `prune`,
`compact`, `create`, or `check` action is run.
itself. But the logs are only included for errors that occur when a `create`,
`prune`, `compact`, or `check` action is run.
You can customize the verbosity of the logs that are sent to Healthchecks with
borgmatic's `--monitoring-verbosity` flag. The `--list` and `--stats` flags

View file

@ -94,6 +94,7 @@ installing borgmatic:
* [openSUSE](https://software.opensuse.org/package/borgmatic)
* [macOS (via Homebrew)](https://formulae.brew.sh/formula/borgmatic)
* [macOS (via MacPorts)](https://ports.macports.org/port/borgmatic/)
* [NixOS](https://search.nixos.org/packages?show=borgmatic&sort=relevance&type=packages&query=borgmatic)
* [Ansible role](https://github.com/borgbase/ansible-role-borgbackup)
* [virtualenv](https://virtualenv.pypa.io/en/stable/)

View file

@ -10,6 +10,8 @@ filterwarnings =
[flake8]
ignore = E501,W503
exclude = *.*/*
multiline-quotes = '''
docstring-quotes = '''
[tool:isort]
force_single_line = False

View file

@ -1,6 +1,6 @@
from setuptools import find_packages, setup
VERSION = '1.7.9.dev0'
VERSION = '1.7.10.dev0'
setup(

View file

@ -5,6 +5,7 @@ click==7.1.2; python_version >= '3.8'
colorama==0.4.4
coverage==5.3
flake8==4.0.1
flake8-quotes==3.3.2
flexmock==0.10.4
isort==5.9.1
mccabe==0.6.1

View file

@ -254,13 +254,6 @@ def test_parse_arguments_allows_init_and_create():
module.parse_arguments('--config', 'myconfig', 'init', '--encryption', 'repokey', 'create')
def test_parse_arguments_disallows_repository_unless_action_consumes_it():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])
with pytest.raises(SystemExit):
module.parse_arguments('--config', 'myconfig', '--repository', 'test.borg')
def test_parse_arguments_allows_repository_with_extract():
flexmock(module.collect).should_receive('get_default_config_paths').and_return(['default'])

View file

@ -3,15 +3,78 @@ from flexmock import flexmock
from borgmatic.actions import check as module
def test_run_check_calls_hooks():
def test_run_check_calls_hooks_for_configured_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.checks).should_receive(
'repository_enabled_for_checks'
).and_return(True)
flexmock(module.borgmatic.borg.check).should_receive('check_archives')
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
flexmock(module.borgmatic.borg.check).should_receive('check_archives').once()
flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
check_arguments = flexmock(
progress=flexmock(), repair=flexmock(), only=flexmock(), force=flexmock()
repository=None, progress=flexmock(), repair=flexmock(), only=flexmock(), force=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
module.run_check(
config_filename='test.yaml',
repository='repo',
location={'repositories': ['repo']},
storage={},
consistency={},
hooks={},
hook_context={},
local_borg_version=None,
check_arguments=check_arguments,
global_arguments=global_arguments,
local_path=None,
remote_path=None,
)
def test_run_check_runs_with_selected_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive(
'repositories_match'
).once().and_return(True)
flexmock(module.borgmatic.borg.check).should_receive('check_archives').once()
check_arguments = flexmock(
repository=flexmock(),
progress=flexmock(),
repair=flexmock(),
only=flexmock(),
force=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
module.run_check(
config_filename='test.yaml',
repository=flexmock(),
location={'repositories': ['repo']},
storage={},
consistency={},
hooks={},
hook_context={},
local_borg_version=None,
check_arguments=check_arguments,
global_arguments=global_arguments,
local_path=None,
remote_path=None,
)
def test_run_check_bails_if_repository_does_not_match():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive(
'repositories_match'
).once().and_return(False)
flexmock(module.borgmatic.borg.check).should_receive('check_archives').never()
check_arguments = flexmock(
repository=flexmock(),
progress=flexmock(),
repair=flexmock(),
only=flexmock(),
force=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)

View file

@ -3,13 +3,70 @@ from flexmock import flexmock
from borgmatic.actions import compact as module
def test_compact_actions_calls_hooks():
def test_compact_actions_calls_hooks_for_configured_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.compact).should_receive('compact_segments')
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').once()
flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
compact_arguments = flexmock(
progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
repository=None, progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
module.run_compact(
config_filename='test.yaml',
repository='repo',
storage={},
retention={},
hooks={},
hook_context={},
local_borg_version=None,
compact_arguments=compact_arguments,
global_arguments=global_arguments,
dry_run_label='',
local_path=None,
remote_path=None,
)
def test_compact_runs_with_selected_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive(
'repositories_match'
).once().and_return(True)
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').once()
compact_arguments = flexmock(
repository=flexmock(), progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
module.run_compact(
config_filename='test.yaml',
repository='repo',
storage={},
retention={},
hooks={},
hook_context={},
local_borg_version=None,
compact_arguments=compact_arguments,
global_arguments=global_arguments,
dry_run_label='',
local_path=None,
remote_path=None,
)
def test_compact_bails_if_repository_does_not_match():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.config.validate).should_receive(
'repositories_match'
).once().and_return(False)
flexmock(module.borgmatic.borg.compact).should_receive('compact_segments').never()
compact_arguments = flexmock(
repository=flexmock(), progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)

View file

@ -3,16 +3,87 @@ from flexmock import flexmock
from borgmatic.actions import create as module
def test_run_create_executes_and_calls_hooks():
def test_run_create_executes_and_calls_hooks_for_configured_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.create).should_receive('create_archive')
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return({})
flexmock(module.borgmatic.hooks.dispatch).should_receive(
'call_hooks_even_if_unconfigured'
).and_return({})
create_arguments = flexmock(
progress=flexmock(), stats=flexmock(), json=flexmock(), list_files=flexmock()
repository=None,
progress=flexmock(),
stats=flexmock(),
json=flexmock(),
list_files=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
list(
module.run_create(
config_filename='test.yaml',
repository='repo',
location={},
storage={},
hooks={},
hook_context={},
local_borg_version=None,
create_arguments=create_arguments,
global_arguments=global_arguments,
dry_run_label='',
local_path=None,
remote_path=None,
)
)
def test_run_create_runs_with_selected_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive(
'repositories_match'
).once().and_return(True)
flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
create_arguments = flexmock(
repository=flexmock(),
progress=flexmock(),
stats=flexmock(),
json=flexmock(),
list_files=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
list(
module.run_create(
config_filename='test.yaml',
repository='repo',
location={},
storage={},
hooks={},
hook_context={},
local_borg_version=None,
create_arguments=create_arguments,
global_arguments=global_arguments,
dry_run_label='',
local_path=None,
remote_path=None,
)
)
def test_run_create_bails_if_repository_does_not_match():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive(
'repositories_match'
).once().and_return(False)
flexmock(module.borgmatic.borg.create).should_receive('create_archive').never()
create_arguments = flexmock(
repository=flexmock(),
progress=flexmock(),
stats=flexmock(),
json=flexmock(),
list_files=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)

View file

@ -3,11 +3,62 @@ from flexmock import flexmock
from borgmatic.actions import prune as module
def test_run_prune_calls_hooks():
def test_run_prune_calls_hooks_for_configured_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.prune).should_receive('prune_archives')
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').once()
flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
prune_arguments = flexmock(stats=flexmock(), list_archives=flexmock())
prune_arguments = flexmock(repository=None, stats=flexmock(), list_archives=flexmock())
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
module.run_prune(
config_filename='test.yaml',
repository='repo',
storage={},
retention={},
hooks={},
hook_context={},
local_borg_version=None,
prune_arguments=prune_arguments,
global_arguments=global_arguments,
dry_run_label='',
local_path=None,
remote_path=None,
)
def test_run_prune_runs_with_selected_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive(
'repositories_match'
).once().and_return(True)
flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').once()
prune_arguments = flexmock(repository=flexmock(), stats=flexmock(), list_archives=flexmock())
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
module.run_prune(
config_filename='test.yaml',
repository='repo',
storage={},
retention={},
hooks={},
hook_context={},
local_borg_version=None,
prune_arguments=prune_arguments,
global_arguments=global_arguments,
dry_run_label='',
local_path=None,
remote_path=None,
)
def test_run_prune_bails_if_repository_does_not_match():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive(
'repositories_match'
).once().and_return(False)
flexmock(module.borgmatic.borg.prune).should_receive('prune_archives').never()
prune_arguments = flexmock(repository=flexmock(), stats=flexmock(), list_archives=flexmock())
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
module.run_prune(

View file

@ -207,7 +207,6 @@ def test_make_exclude_flags_includes_exclude_patterns_filename_when_given():
def test_make_exclude_flags_includes_exclude_from_filenames_when_in_config():
exclude_flags = module.make_exclude_flags(
location_config={'exclude_from': ['excludes', 'other']}
)
@ -1916,7 +1915,7 @@ def test_create_archive_with_stream_processes_ignores_read_special_false_and_log
(f'repo::{DEFAULT_ARCHIVE_NAME}',)
)
flexmock(module.environment).should_receive('make_environment')
flexmock(module).should_receive('collect_special_file_paths').and_return(("/dev/null",))
flexmock(module).should_receive('collect_special_file_paths').and_return(('/dev/null',))
create_command = (
'borg',
'create',
@ -2530,3 +2529,27 @@ def test_create_archive_with_stream_processes_calls_borg_with_processes_and_read
local_borg_version='1.2.3',
stream_processes=processes,
)
def test_create_archive_with_non_existent_directory_and_source_directories_must_exist_raises_error():
'''
If a source directory doesn't exist and source_directories_must_exist is True, raise an error.
'''
flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([])
flexmock(module.os.path).should_receive('exists').and_return(False)
with pytest.raises(ValueError):
module.create_archive(
dry_run=False,
repository='repo',
location_config={
'source_directories': ['foo', 'bar'],
'repositories': ['repo'],
'exclude_patterns': None,
'source_directories_must_exist': True,
},
storage_config={},
local_borg_version='1.2.3',
)

View file

@ -32,3 +32,8 @@ def test_make_environment_with_relocated_repo_access_should_override_default():
environment = module.make_environment({'relocated_repo_access_is_ok': True})
assert environment.get('BORG_RELOCATED_REPO_ACCESS_IS_OK') == 'yes'
def test_make_environment_with_integer_variable_value():
environment = module.make_environment({'borg_files_cache_ttl': 40})
assert environment.get('BORG_FILES_CACHE_TTL') == '40'

View file

@ -312,6 +312,57 @@ def test_extract_archive_calls_borg_with_strip_components():
)
def test_extract_archive_calls_borg_with_strip_components_calculated_from_all():
flexmock(module.os.path).should_receive('abspath').and_return('repo')
insert_execute_command_mock(
(
'borg',
'extract',
'--strip-components',
'2',
'repo::archive',
'foo/bar/baz.txt',
'foo/bar.txt',
)
)
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(
('repo::archive',)
)
module.extract_archive(
dry_run=False,
repository='repo',
archive='archive',
paths=['foo/bar/baz.txt', 'foo/bar.txt'],
location_config={},
storage_config={},
local_borg_version='1.2.3',
strip_components='all',
)
def test_extract_archive_with_strip_components_all_and_no_paths_raises():
flexmock(module.os.path).should_receive('abspath').and_return('repo')
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(
('repo::archive',)
)
flexmock(module).should_receive('execute_command').never()
with pytest.raises(ValueError):
module.extract_archive(
dry_run=False,
repository='repo',
archive='archive',
paths=None,
location_config={},
storage_config={},
local_borg_version='1.2.3',
strip_components='all',
)
def test_extract_archive_calls_borg_with_progress_parameter():
flexmock(module.os.path).should_receive('abspath').and_return('repo')
flexmock(module.environment).should_receive('make_environment')

View file

@ -1,3 +1,5 @@
import collections
from flexmock import flexmock
from borgmatic.commands import arguments as module
@ -70,6 +72,26 @@ def test_parse_subparser_arguments_consumes_multiple_subparser_arguments():
assert remaining_arguments == []
def test_parse_subparser_arguments_respects_command_line_action_ordering():
other_namespace = flexmock()
action_namespace = flexmock(foo=True)
subparsers = {
'action': flexmock(
parse_known_args=lambda arguments: (action_namespace, ['action', '--foo', 'true'])
),
'other': flexmock(parse_known_args=lambda arguments: (other_namespace, ['other'])),
}
arguments, remaining_arguments = module.parse_subparser_arguments(
('other', '--foo', 'true', 'action'), subparsers
)
assert arguments == collections.OrderedDict(
[('other', other_namespace), ('action', action_namespace)]
)
assert remaining_arguments == []
def test_parse_subparser_arguments_applies_default_subparsers():
prune_namespace = flexmock()
compact_namespace = flexmock()

View file

@ -40,7 +40,7 @@ def test_run_configuration_logs_monitor_start_error():
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').and_raise(OSError).and_return(
None
).and_return(None)
).and_return(None).and_return(None)
expected_results = [flexmock()]
flexmock(module).should_receive('log_error_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').never()
@ -99,7 +99,7 @@ def test_run_configuration_bails_for_actions_soft_failure():
assert results == []
def test_run_configuration_logs_monitor_finish_error():
def test_run_configuration_logs_monitor_log_error():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
@ -116,13 +116,48 @@ def test_run_configuration_logs_monitor_finish_error():
assert results == expected_results
def test_run_configuration_bails_for_monitor_log_soft_failure():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
None
).and_raise(error)
flexmock(module).should_receive('log_error_records').never()
flexmock(module).should_receive('run_actions').and_return([])
flexmock(module.command).should_receive('considered_soft_failure').and_return(True)
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == []
def test_run_configuration_logs_monitor_finish_error():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
None
).and_return(None).and_raise(OSError)
expected_results = [flexmock()]
flexmock(module).should_receive('log_error_records').and_return(expected_results)
flexmock(module).should_receive('run_actions').and_return([])
config = {'location': {'repositories': ['foo']}}
arguments = {'global': flexmock(monitoring_verbosity=1, dry_run=False), 'create': flexmock()}
results = list(module.run_configuration('test.yaml', config, arguments))
assert results == expected_results
def test_run_configuration_bails_for_monitor_finish_soft_failure():
flexmock(module).should_receive('verbosity_to_log_level').and_return(logging.INFO)
flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock())
error = subprocess.CalledProcessError(borgmatic.hooks.command.SOFT_FAIL_EXIT_CODE, 'try again')
flexmock(module.dispatch).should_receive('call_hooks').and_return(None).and_return(
None
).and_raise(error)
).and_raise(None).and_raise(error)
flexmock(module).should_receive('log_error_records').never()
flexmock(module).should_receive('run_actions').and_return([])
flexmock(module.command).should_receive('considered_soft_failure').and_return(True)
@ -401,6 +436,30 @@ def test_run_actions_runs_transfer():
)
def test_run_actions_runs_create():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
expected = flexmock()
flexmock(borgmatic.actions.create).should_receive('run_create').and_yield(expected).once()
result = tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False), 'create': flexmock()},
config_filename=flexmock(),
location={'repositories': []},
storage=flexmock(),
retention=flexmock(),
consistency=flexmock(),
hooks={},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository_path='repo',
)
)
assert result == (expected,)
def test_run_actions_runs_prune():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
@ -445,30 +504,6 @@ def test_run_actions_runs_compact():
)
def test_run_actions_runs_create():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
expected = flexmock()
flexmock(borgmatic.actions.create).should_receive('run_create').and_yield(expected).once()
result = tuple(
module.run_actions(
arguments={'global': flexmock(dry_run=False), 'create': flexmock()},
config_filename=flexmock(),
location={'repositories': []},
storage=flexmock(),
retention=flexmock(),
consistency=flexmock(),
hooks={},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository_path='repo',
)
)
assert result == (expected,)
def test_run_actions_runs_check_when_repository_enabled_for_checks():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
@ -743,6 +778,33 @@ def test_run_actions_runs_borg():
)
def test_run_actions_runs_multiple_actions_in_argument_order():
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.command).should_receive('execute_hook')
flexmock(borgmatic.actions.borg).should_receive('run_borg').once().ordered()
flexmock(borgmatic.actions.restore).should_receive('run_restore').once().ordered()
tuple(
module.run_actions(
arguments={
'global': flexmock(dry_run=False),
'borg': flexmock(),
'restore': flexmock(),
},
config_filename=flexmock(),
location={'repositories': []},
storage=flexmock(),
retention=flexmock(),
consistency=flexmock(),
hooks={},
local_path=flexmock(),
remote_path=flexmock(),
local_borg_version=flexmock(),
repository_path='repo',
)
)
def test_load_configurations_collects_parsed_configurations_and_logs():
configuration = flexmock()
other_configuration = flexmock()

View file

@ -102,3 +102,11 @@ def test_ping_monitor_with_other_error_logs_warning():
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_unsupported_monitoring_state():
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').never()
module.ping_monitor(
hook_config, 'config.yaml', module.monitor.State.LOG, monitoring_log_level=1, dry_run=False,
)

View file

@ -87,3 +87,11 @@ def test_ping_monitor_with_other_error_logs_warning():
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_unsupported_monitoring_state():
hook_config = {'ping_url': 'https://example.com'}
flexmock(module.requests).should_receive('get').never()
module.ping_monitor(
hook_config, 'config.yaml', module.monitor.State.LOG, monitoring_log_level=1, dry_run=False,
)

View file

@ -184,6 +184,23 @@ def test_ping_monitor_hits_ping_url_for_fail_state():
)
def test_ping_monitor_hits_ping_url_for_log_state():
hook_config = {'ping_url': 'https://example.com'}
payload = 'data'
flexmock(module).should_receive('format_buffered_logs_for_payload').and_return(payload)
flexmock(module.requests).should_receive('post').with_args(
'https://example.com/log', data=payload.encode('utf'), verify=True
).and_return(flexmock(ok=True))
module.ping_monitor(
hook_config,
'config.yaml',
state=module.monitor.State.LOG,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_ping_uuid_hits_corresponding_url():
hook_config = {'ping_url': 'abcd-efgh-ijkl-mnop'}
payload = 'data'

View file

@ -72,7 +72,7 @@ def test_dump_databases_runs_mongodump_with_username_and_password():
'name': 'foo',
'username': 'mongo',
'password': 'trustsome1',
'authentication_database': "admin",
'authentication_database': 'admin',
}
]
process = flexmock()

View file

@ -2,6 +2,7 @@ from enum import Enum
from flexmock import flexmock
import borgmatic.hooks.monitor
from borgmatic.hooks import ntfy as module
default_base_url = 'https://ntfy.sh'
@ -37,12 +38,16 @@ def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
hook_config = {'topic': topic}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(module.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
auth=None,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
hook_config,
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@ -54,12 +59,16 @@ def test_ping_monitor_with_auth_hits_hosted_ntfy_on_fail():
}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(module.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
auth=module.requests.auth.HTTPBasicAuth('testuser', 'fakepassword'),
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
hook_config,
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@ -67,13 +76,17 @@ def test_ping_monitor_auth_with_no_username_warning():
hook_config = {'topic': topic, 'password': 'fakepassword'}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(module.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
auth=None,
).and_return(flexmock(ok=True)).once()
flexmock(module.logger).should_receive('warning').once()
module.ping_monitor(
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
hook_config,
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@ -81,13 +94,17 @@ def test_ping_monitor_auth_with_no_password_warning():
hook_config = {'topic': topic, 'username': 'testuser'}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(module.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
auth=None,
).and_return(flexmock(ok=True)).once()
flexmock(module.logger).should_receive('warning').once()
module.ping_monitor(
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
hook_config,
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@ -98,7 +115,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
module.ping_monitor(
hook_config,
'config.yaml',
module.monitor.State.START,
borgmatic.hooks.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
@ -111,7 +128,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
module.ping_monitor(
hook_config,
'config.yaml',
module.monitor.State.FINISH,
borgmatic.hooks.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
)
@ -121,12 +138,16 @@ def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
hook_config = {'topic': topic, 'server': custom_base_url}
flexmock(module.requests).should_receive('post').with_args(
f'{custom_base_url}/{topic}',
headers=return_default_message_headers(module.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
auth=None,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
hook_config,
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@ -135,7 +156,11 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
flexmock(module.requests).should_receive('post').never()
module.ping_monitor(
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=True
hook_config,
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=True,
)
@ -146,7 +171,11 @@ def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
hook_config, 'config.yaml', module.monitor.State.FAIL, monitoring_log_level=1, dry_run=False
hook_config,
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@ -154,14 +183,14 @@ def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
hook_config = {'topic': topic, 'states': ['start', 'fail']}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(module.monitor.State.START),
headers=return_default_message_headers(borgmatic.hooks.monitor.State.START),
auth=None,
).and_return(flexmock(ok=True)).once()
module.ping_monitor(
hook_config,
'config.yaml',
module.monitor.State.START,
borgmatic.hooks.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
@ -171,7 +200,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
hook_config = {'topic': topic}
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(module.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
auth=None,
).and_raise(module.requests.exceptions.ConnectionError)
flexmock(module.logger).should_receive('warning').once()
@ -179,7 +208,7 @@ def test_ping_monitor_with_connection_error_logs_warning():
module.ping_monitor(
hook_config,
'config.yaml',
module.monitor.State.FAIL,
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
@ -193,7 +222,7 @@ def test_ping_monitor_with_other_error_logs_warning():
)
flexmock(module.requests).should_receive('post').with_args(
f'{default_base_url}/{topic}',
headers=return_default_message_headers(module.monitor.State.FAIL),
headers=return_default_message_headers(borgmatic.hooks.monitor.State.FAIL),
auth=None,
).and_return(response)
flexmock(module.logger).should_receive('warning').once()
@ -201,7 +230,7 @@ def test_ping_monitor_with_other_error_logs_warning():
module.ping_monitor(
hook_config,
'config.yaml',
module.monitor.State.FAIL,
borgmatic.hooks.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)

View file

@ -1,5 +1,5 @@
[tox]
envlist = py37,py38,py39,py310
envlist = py37,py38,py39,py310,py311
skip_missing_interpreters = True
skipsdist = True
minversion = 3.14.1
@ -13,7 +13,7 @@ whitelist_externals =
passenv = COVERAGE_FILE
commands =
pytest {posargs}
py38,py39,py310: black --check .
py38,py39,py310,py311: black --check .
isort --check-only --settings-path setup.cfg .
flake8 borgmatic tests