Compare commits

..
42 Commits
Author SHA1 Message Date
Dan Helfman 82141fe981 Bump version for release. 2024-11-25 07:49:11 -08:00
Dan Helfman 228a83978d Check docs clarifications. 2024-11-24 19:40:00 -08:00
Dan Helfman 638db3770b Clarify how frequent default checks run. 2024-11-24 19:38:20 -08:00
Dan Helfman 98df5c3af2 Fix docs about the relative speeds of different checks (#945).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/945
2024-11-25 03:23:39 +00:00
Dan Helfman b0e906c0e7 NEWS clarifications. 2024-11-24 19:05:11 -08:00
Dan Helfman e8dccbf1c1 Promote the "spot" check from a beta feature to stable. 2024-11-24 19:03:05 -08:00
Dan Helfman 4a997bc234 Add rclone to the integrations. 2024-11-24 18:59:21 -08:00
Dan Helfman 3197178b3d Fix flake errors. 2024-11-24 16:32:48 -08:00
Dan Helfman 5e618154d0 Clarify error message. 2024-11-24 16:18:29 -08:00
Dan Helfman 84f611ae4f Require the runtime directory to be an absolute path. 2024-11-24 16:15:19 -08:00
Dan Helfman 5dc8450c8e Adding missing bootstrap files. 2024-11-24 16:15:12 -08:00
Dan Helfman 689643e5fa Move bootstrap manifest file creation into a hook so it can actually clean up after itself. 2024-11-24 16:00:33 -08:00
Robin Schneider 0a3d87eaea docs: repository check means full CRC check -> slow
Ref: https://borgbackup.readthedocs.io/en/stable/usage/check.html#description
2024-11-24 18:04:58 +01:00
Dan Helfman b45b62cd38 Don't recursively snapshot ZFS datasets, since we're not mounting them recursively (#261). 2024-11-23 22:37:46 -08:00
Dan Helfman 8de7094691 ZFS snapshots (#261).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/944
2024-11-24 04:42:19 +00:00
Dan Helfman 8c7e68305e A few clarifications to the ZFS docs (#261). 2024-11-23 20:41:43 -08:00
Dan Helfman 65a323433c Add comment (#261). 2024-11-23 20:12:40 -08:00
Dan Helfman b5a3589471 A little more error handling (#261). 2024-11-23 18:09:59 -08:00
Dan Helfman f4a736bdfe Deduplicate directories again after hooks have their way with them (#261). 2024-11-23 14:33:41 -08:00
Dan Helfman eab0ec15ef Expand ZFS NEWS entry (#261). 2024-11-23 11:46:39 -08:00
Dan Helfman c65aa24001 Add test coverage for ZFS hook (#261). 2024-11-23 10:50:58 -08:00
Dan Helfman 5a24bf2037 Get tests passing (#261). 2024-11-22 20:16:18 -08:00
Dan Helfman 324dbc3a79 Swallow temporary directory removal errors (#261). 2024-11-22 10:56:07 -08:00
Dan Helfman 9fe7db320a Consider ZFS hook as a beta feature (#261). 2024-11-22 09:53:46 -08:00
Dan Helfman 4d19596616 Add ZFS documentation (#261). 2024-11-22 08:33:24 -08:00
Dan Helfman 5cec2bf3d9 Don't unmount directories that don't exist. 2024-11-21 22:16:05 -08:00
Dan Helfman 06e0f98fd8 More refactoring for better organization of ZFS hook (#261). 2024-11-21 22:09:18 -08:00
Dan Helfman 87f36caf8d Factoring out some utility functions (#261). 2024-11-21 20:17:57 -08:00
Dan Helfman ab7acceff6 Unmount and remove mounted snapshot directories, not just for the current process but for previous borgmatic runs as well (#261). 2024-11-21 19:09:30 -08:00
Dan Helfman 1b2b0c3020 Update out-of-date ZFS hook comments (#261). 2024-11-21 16:49:25 -08:00
Dan Helfman 289d178581 Also support discovery of ZFS datasets tagged with a borgmatic-specific user property (#261). 2024-11-21 16:45:44 -08:00
Dan Helfman 1e7f6d9f41 ZFS hook support for borgmatic's --dry-run (#261). 2024-11-21 11:55:45 -08:00
Dan Helfman d0c90389fb Remove ZFS "enabled" option and fix override command options. 2024-11-21 10:52:00 -08:00
Dan Helfman f9e920dce9 Prevent ZFS snapshots from ending up in the Borg archive twice (#261). 2024-11-21 10:23:27 -08:00
Dan Helfman 0ed52bbc4a Proceed gracefully in ZFS data source removal if ZFS isn't installed (#261). 2024-11-21 08:59:59 -08:00
Dan Helfman da8278b566 Use os.path.normpath() instead of custom list comprehension (#261). 2024-11-21 08:36:15 -08:00
Dan Helfman 2af3522902 Fix broken check action (#261). 2024-11-21 08:32:02 -08:00
Dan Helfman 5e4784991a Comment tweaks and additional TODOs (#261). 2024-11-20 22:33:23 -08:00
Dan Helfman ab43ef00ce ZFS snapshots WIP (#261). 2024-11-20 22:21:27 -08:00
Dan Helfman 47a8a95b29 Test path fix for finding schema file. 2024-11-20 08:18:06 -08:00
Dan Helfman 7c90c04ce0 Add a "--deleted" flag to the "repo-list" action (Borg 2 only). 2024-11-19 22:33:15 -08:00
Dan Helfman 97305cc3ce Fix broken tests when NO_COLOR=1 is set (#943). 2024-11-19 08:48:21 -08:00
50 changed files with 2144 additions and 895 deletions
+12
View File
@@ -1,3 +1,15 @@
1.9.3
* #261 (beta): Add a ZFS hook for snapshotting and backing up ZFS datasets. See the documentation
for more information: https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
* Remove any temporary copies of the manifest file created in support of the "bootstrap" action.
* Deprecate the "store_config_files" option at the global scope and move it under the "bootstrap"
hook. See the documentation for more information:
https://torsion.org/borgmatic/docs/how-to/extract-a-backup/#extract-the-configuration-files-used-to-create-an-archive
* Require the runtime directory to be an absolute path.
* Add a "--deleted" flag to the "repo-list" action for listing deleted archives that haven't
yet been compacted (Borg 2 only).
* Promote the "spot" check from a beta feature to stable.
1.9.2
* #441: Apply the "umask" option to all relevant actions, not just some of them.
* #722: Remove the restriction that the "extract" and "mount" actions must match a single
+2
View File
@@ -61,6 +61,8 @@ borgmatic is powered by [Borg Backup](https://www.borgbackup.org/).
<a href="https://mariadb.com/"><img src="docs/static/mariadb.png" alt="MariaDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://www.mongodb.com/"><img src="docs/static/mongodb.png" alt="MongoDB" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sqlite.org/"><img src="docs/static/sqlite.png" alt="SQLite" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://openzfs.org/"><img src="docs/static/openzfs.png" alt="OpenZFS" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://rclone.org"><img src="docs/static/rclone.png" alt="rclone" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://healthchecks.io/"><img src="docs/static/healthchecks.png" alt="Healthchecks" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://uptime.kuma.pet/"><img src="docs/static/uptimekuma.png" alt="Uptime Kuma" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://cronitor.io/"><img src="docs/static/cronitor.png" alt="Cronitor" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
+13 -3
View File
@@ -8,6 +8,7 @@ import pathlib
import random
import shutil
import borgmatic.actions.create
import borgmatic.borg.check
import borgmatic.borg.create
import borgmatic.borg.environment
@@ -345,7 +346,13 @@ def upgrade_check_times(config, borg_repository_id):
def collect_spot_check_source_paths(
repository, config, local_borg_version, global_arguments, local_path, remote_path
repository,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
):
'''
Given a repository configuration dict, a configuration dict, the local Borg version, global
@@ -366,10 +373,12 @@ def collect_spot_check_source_paths(
dry_run=True,
repository_path=repository['path'],
config=config,
config_paths=(),
source_directories=borgmatic.actions.create.process_source_directories(
config,
),
local_borg_version=local_borg_version,
global_arguments=global_arguments,
borgmatic_runtime_directories=(),
borgmatic_runtime_directory=borgmatic_runtime_directory,
local_path=local_path,
remote_path=remote_path,
list_files=True,
@@ -585,6 +594,7 @@ def spot_check(
global_arguments,
local_path,
remote_path,
borgmatic_runtime_directory,
)
logger.debug(f'{log_prefix}: {len(source_paths)} total source paths for spot check')
+133 -29
View File
@@ -1,7 +1,8 @@
import importlib.metadata
import json
import glob
import itertools
import logging
import os
import pathlib
import borgmatic.actions.json
import borgmatic.borg.create
@@ -14,30 +15,133 @@ import borgmatic.hooks.dump
logger = logging.getLogger(__name__)
def create_borgmatic_manifest(config, config_paths, borgmatic_runtime_directory, dry_run):
def expand_directory(directory, working_directory):
'''
Given a configuration dict, a sequence of config file paths, the borgmatic runtime directory,
and whether this is a dry run, create a borgmatic manifest file to store the paths to the
configuration files used to create the archive.
Given a directory path, expand any tilde (representing a user's home directory) and any globs
therein. Return a list of one or more resulting paths.
'''
if dry_run:
return
expanded_directory = os.path.join(working_directory or '', os.path.expanduser(directory))
borgmatic_manifest_path = os.path.join(
borgmatic_runtime_directory, 'bootstrap', 'manifest.json'
return glob.glob(expanded_directory) or [expanded_directory]
def expand_directories(directories, working_directory=None):
'''
Given a sequence of directory paths and an optional working directory, expand tildes and globs
in each one. Return all the resulting directories as a single flattened tuple.
'''
if directories is None:
return ()
return tuple(
itertools.chain.from_iterable(
expand_directory(directory, working_directory) for directory in directories
)
)
if not os.path.exists(borgmatic_manifest_path):
os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True)
with open(borgmatic_manifest_path, 'w') as config_list_file:
json.dump(
{
'borgmatic_version': importlib.metadata.version('borgmatic'),
'config_paths': config_paths,
},
config_list_file,
)
def map_directories_to_devices(directories, working_directory=None):
'''
Given a sequence of directories and an optional working directory, return a map from directory
to an identifier for the device on which that directory resides or None if the path doesn't
exist.
This is handy for determining whether two different directories are on the same filesystem (have
the same device identifier).
'''
return {
directory: os.stat(full_directory).st_dev if os.path.exists(full_directory) else None
for directory in directories
for full_directory in (os.path.join(working_directory or '', directory),)
}
def deduplicate_directories(directory_devices, additional_directory_devices):
'''
Given a map from directory to the identifier for the device on which that directory resides,
return the directories as a sorted sequence with all duplicate child directories removed. For
instance, if paths is ['/foo', '/foo/bar'], return just: ['/foo']
The one exception to this rule is if two paths are on different filesystems (devices). In that
case, they won't get de-duplicated in case they both need to be passed to Borg (e.g. the
location.one_file_system option is true).
The idea is that if Borg is given a parent directory, then it doesn't also need to be given
child directories, because it will naturally spider the contents of the parent directory. And
there are cases where Borg coming across the same file twice will result in duplicate reads and
even hangs, e.g. when a database hook is using a named pipe for streaming database dumps to
Borg.
If any additional directory devices are given, also deduplicate against them, but don't include
them in the returned directories.
'''
deduplicated = set()
directories = sorted(directory_devices.keys())
additional_directories = sorted(additional_directory_devices.keys())
all_devices = {**directory_devices, **additional_directory_devices}
for directory in directories:
deduplicated.add(directory)
parents = pathlib.PurePath(directory).parents
# If another directory in the given list (or the additional list) is a parent of current
# directory (even n levels up) and both are on the same filesystem, then the current
# directory is a duplicate.
for other_directory in directories + additional_directories:
for parent in parents:
if (
pathlib.PurePath(other_directory) == parent
and all_devices[directory] is not None
and all_devices[other_directory] == all_devices[directory]
):
if directory in deduplicated:
deduplicated.remove(directory)
break
return sorted(deduplicated)
ROOT_PATTERN_PREFIX = 'R '
def pattern_root_directories(patterns=None):
'''
Given a sequence of patterns, parse out and return just the root directories.
'''
if not patterns:
return []
return [
pattern.split(ROOT_PATTERN_PREFIX, maxsplit=1)[1]
for pattern in patterns
if pattern.startswith(ROOT_PATTERN_PREFIX)
]
def process_source_directories(config, source_directories=None):
'''
Given a sequence of source directories (either in the source_directories argument or, lacking
that, from config), expand and deduplicate the source directories, returning the result.
'''
working_directory = borgmatic.config.paths.get_working_directory(config)
if source_directories is None:
source_directories = tuple(config.get('source_directories', ()))
return deduplicate_directories(
map_directories_to_devices(
expand_directories(
tuple(source_directories),
working_directory=working_directory,
)
),
additional_directory_devices=map_directories_to_devices(
expand_directories(
pattern_root_directories(config.get('patterns')),
working_directory=working_directory,
)
),
)
def run_create(
@@ -86,29 +190,29 @@ def run_create(
borgmatic_runtime_directory,
global_arguments.dry_run,
)
source_directories = process_source_directories(config)
active_dumps = borgmatic.hooks.dispatch.call_hooks(
'dump_data_sources',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
config_paths,
borgmatic_runtime_directory,
source_directories,
global_arguments.dry_run,
)
stream_processes = [process for processes in active_dumps.values() for process in processes]
if config.get('store_config_files', True):
create_borgmatic_manifest(
config,
config_paths,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
# Process source directories again in case any data source hooks updated them. Without this
# step, we could end up with duplicate paths that cause Borg to hang when it tries to read
# from the same named pipe twice.
source_directories = process_source_directories(config, source_directories)
stream_processes = [process for processes in active_dumps.values() for process in processes]
json_output = borgmatic.borg.create.create_archive(
global_arguments.dry_run,
repository['path'],
config,
config_paths,
source_directories,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
+15 -156
View File
@@ -1,4 +1,3 @@
import glob
import itertools
import logging
import os
@@ -20,31 +19,6 @@ from borgmatic.execute import (
logger = logging.getLogger(__name__)
def expand_directory(directory, working_directory):
'''
Given a directory path, expand any tilde (representing a user's home directory) and any globs
therein. Return a list of one or more resulting paths.
'''
expanded_directory = os.path.join(working_directory or '', os.path.expanduser(directory))
return glob.glob(expanded_directory) or [expanded_directory]
def expand_directories(directories, working_directory=None):
'''
Given a sequence of directory paths and an optional working directory, expand tildes and globs
in each one. Return all the resulting directories as a single flattened tuple.
'''
if directories is None:
return ()
return tuple(
itertools.chain.from_iterable(
expand_directory(directory, working_directory) for directory in directories
)
)
def expand_home_directories(directories):
'''
Given a sequence of directory paths, expand tildes in each one. Do not perform any globbing.
@@ -56,67 +30,6 @@ def expand_home_directories(directories):
return tuple(os.path.expanduser(directory) for directory in directories)
def map_directories_to_devices(directories, working_directory=None):
'''
Given a sequence of directories and an optional working directory, return a map from directory
to an identifier for the device on which that directory resides or None if the path doesn't
exist.
This is handy for determining whether two different directories are on the same filesystem (have
the same device identifier).
'''
return {
directory: os.stat(full_directory).st_dev if os.path.exists(full_directory) else None
for directory in directories
for full_directory in (os.path.join(working_directory or '', directory),)
}
def deduplicate_directories(directory_devices, additional_directory_devices):
'''
Given a map from directory to the identifier for the device on which that directory resides,
return the directories as a sorted tuple with all duplicate child directories removed. For
instance, if paths is ('/foo', '/foo/bar'), return just: ('/foo',)
The one exception to this rule is if two paths are on different filesystems (devices). In that
case, they won't get de-duplicated in case they both need to be passed to Borg (e.g. the
location.one_file_system option is true).
The idea is that if Borg is given a parent directory, then it doesn't also need to be given
child directories, because it will naturally spider the contents of the parent directory. And
there are cases where Borg coming across the same file twice will result in duplicate reads and
even hangs, e.g. when a database hook is using a named pipe for streaming database dumps to
Borg.
If any additional directory devices are given, also deduplicate against them, but don't include
them in the returned directories.
'''
deduplicated = set()
directories = sorted(directory_devices.keys())
additional_directories = sorted(additional_directory_devices.keys())
all_devices = {**directory_devices, **additional_directory_devices}
for directory in directories:
deduplicated.add(directory)
parents = pathlib.PurePath(directory).parents
# If another directory in the given list (or the additional list) is a parent of current
# directory (even n levels up) and both are on the same filesystem, then the current
# directory is a duplicate.
for other_directory in directories + additional_directories:
for parent in parents:
if (
pathlib.PurePath(other_directory) == parent
and all_devices[directory] is not None
and all_devices[other_directory] == all_devices[directory]
):
if directory in deduplicated:
deduplicated.remove(directory)
break
return tuple(sorted(deduplicated))
def write_pattern_file(patterns=None, sources=None, pattern_file=None):
'''
Given a sequence of patterns and an optional sequence of source directories, write them to a
@@ -221,32 +134,6 @@ def make_list_filter_flags(local_borg_version, dry_run):
return f'{base_flags}-'
def collect_borgmatic_runtime_directories(borgmatic_runtime_directory):
'''
Return a list of borgmatic-specific runtime directories used for temporary runtime data like
streaming database dumps and bootstrap metadata. If no such directories exist, return an empty
list.
'''
return [borgmatic_runtime_directory] if os.path.exists(borgmatic_runtime_directory) else []
ROOT_PATTERN_PREFIX = 'R '
def pattern_root_directories(patterns=None):
'''
Given a sequence of patterns, parse out and return just the root directories.
'''
if not patterns:
return []
return [
pattern.split(ROOT_PATTERN_PREFIX, maxsplit=1)[1]
for pattern in patterns
if pattern.startswith(ROOT_PATTERN_PREFIX)
]
def special_file(path):
'''
Return whether the given path is a special file (character device, block device, or named pipe
@@ -307,21 +194,15 @@ def collect_special_file_paths(
)
def check_all_source_directories_exist(source_directories, working_directory=None):
def check_all_source_directories_exist(source_directories):
'''
Given a sequence of source directories and an optional working directory to serve as a prefix
for each (if it's a relative directory), check that the source directories all exist. If any do
Given a sequence of source directories, check that the source directories all exist. If any do
not, raise an exception.
'''
missing_directories = [
source_directory
for source_directory in source_directories
if not all(
[
os.path.exists(os.path.join(working_directory or '', directory))
for directory in expand_directory(source_directory, working_directory)
]
)
if not os.path.exists(source_directory)
]
if missing_directories:
raise ValueError(f"Source directories do not exist: {', '.join(missing_directories)}")
@@ -334,10 +215,10 @@ def make_base_create_command(
dry_run,
repository_path,
config,
config_paths,
source_directories,
local_borg_version,
global_arguments,
borgmatic_runtime_directories,
borgmatic_runtime_directory,
local_path='borg',
remote_path=None,
progress=False,
@@ -352,34 +233,13 @@ def make_base_create_command(
(base Borg create command flags, Borg create command positional arguments, open pattern file
handle, open exclude file handle).
'''
working_directory = borgmatic.config.paths.get_working_directory(config)
if config.get('source_directories_must_exist', False):
check_all_source_directories_exist(
config.get('source_directories'), working_directory=working_directory
)
sources = deduplicate_directories(
map_directories_to_devices(
expand_directories(
tuple(config.get('source_directories', ()))
+ borgmatic_runtime_directories
+ tuple(config_paths if config.get('store_config_files', True) else ()),
working_directory=working_directory,
)
),
additional_directory_devices=map_directories_to_devices(
expand_directories(
pattern_root_directories(config.get('patterns')),
working_directory=working_directory,
)
),
)
check_all_source_directories_exist(source_directories)
ensure_files_readable(config.get('patterns_from'), config.get('exclude_from'))
pattern_file = (
write_pattern_file(config.get('patterns'), sources)
write_pattern_file(config.get('patterns'), source_directories)
if config.get('patterns') or config.get('patterns_from')
else None
)
@@ -457,7 +317,7 @@ def make_base_create_command(
create_positional_arguments = flags.make_repository_archive_flags(
repository_path, archive_name_format, local_borg_version
) + (sources if not pattern_file else ())
) + (tuple(source_directories) if not pattern_file else ())
# If database hooks are enabled (as indicated by streaming processes), exclude files that might
# cause Borg to hang. But skip this if the user has explicitly set the "read_special" to True.
@@ -466,6 +326,7 @@ def make_base_create_command(
f'{repository_path}: Ignoring configured "read_special" value of false, as true is needed for database hooks.'
)
borg_environment = environment.make_environment(config)
working_directory = borgmatic.config.paths.get_working_directory(config)
logger.debug(f'{repository_path}: Collecting special file paths')
special_file_paths = collect_special_file_paths(
@@ -474,7 +335,9 @@ def make_base_create_command(
local_path,
working_directory,
borg_environment,
skip_directories=borgmatic_runtime_directories,
skip_directories=(
[borgmatic_runtime_directory] if os.path.exists(borgmatic_runtime_directory) else []
),
)
if special_file_paths:
@@ -501,7 +364,7 @@ def create_archive(
dry_run,
repository_path,
config,
config_paths,
source_directories,
local_borg_version,
global_arguments,
borgmatic_runtime_directory,
@@ -524,20 +387,16 @@ def create_archive(
borgmatic.logger.add_custom_log_levels()
working_directory = borgmatic.config.paths.get_working_directory(config)
borgmatic_runtime_directories = expand_directories(
collect_borgmatic_runtime_directories(borgmatic_runtime_directory),
working_directory=working_directory,
)
(create_flags, create_positional_arguments, pattern_file, exclude_file) = (
make_base_create_command(
dry_run,
repository_path,
config,
config_paths,
source_directories,
local_borg_version,
global_arguments,
borgmatic_runtime_directories,
borgmatic_runtime_directory,
local_path,
remote_path,
progress,
+6
View File
@@ -1244,6 +1244,12 @@ def make_parsers():
metavar='TIMESPAN',
help='List archives that are newer than the specified time range (e.g. 7d or 12m) from the current time [Borg 2.x+ only]',
)
repo_list_group.add_argument(
'--deleted',
default=False,
action='store_true',
help="List only deleted archives that haven't yet been compacted [Borg 2.x+ only]",
)
repo_list_group.add_argument(
'-h', '--help', action='help', help='Show this help message and exit'
)
+2 -2
View File
@@ -44,12 +44,12 @@ def schema_to_sample_configuration(schema, level=0, parent_is_sequence=False):
if example is not None:
return example
if schema_type == 'array':
if schema_type == 'array' or (isinstance(schema_type, list) and 'array' in schema_type):
config = ruamel.yaml.comments.CommentedSeq(
[schema_to_sample_configuration(schema['items'], level, parent_is_sequence=True)]
)
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
elif schema_type == 'object':
elif schema_type == 'object' or (isinstance(schema_type, list) and 'object' in schema_type):
config = ruamel.yaml.comments.CommentedMap(
[
(field_name, schema_to_sample_configuration(sub_schema, level + 1))
+19
View File
@@ -93,6 +93,25 @@ def normalize(config_filename, config):
)
config['exclude_if_present'] = [exclude_if_present]
# Unconditionally set the bootstrap hook so that it's enabled by default and config files get
# stored in each Borg archive.
config.setdefault('bootstrap', {})
# Move store_config_files from the global scope to the bootstrap hook.
store_config_files = config.get('store_config_files')
if store_config_files is not None:
logs.append(
logging.makeLogRecord(
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The store_config_files option has moved under the bootstrap hook. Specifying store_config_files at the global scope is deprecated and support will be removed from a future release.',
)
)
)
del config['store_config_files']
config['bootstrap']['store_config_files'] = store_config_files
# Upgrade various monitoring hooks from a string to a dict.
healthchecks = config.get('healthchecks')
if isinstance(healthchecks, str):
+43 -2
View File
@@ -33,6 +33,32 @@ def get_borgmatic_source_directory(config):
TEMPORARY_DIRECTORY_PREFIX = 'borgmatic-'
def replace_temporary_subdirectory_with_glob(path):
'''
Given an absolute temporary directory path, look for a subdirectory within it starting with the
temporary directory prefix and replace it with an appropriate glob. For instance, given:
/tmp/borgmatic-aet8kn93/borgmatic
... replace it with:
/tmp/borgmatic-*/borgmatic
This is useful for finding previous temporary directories from prior borgmatic runs.
'''
return os.path.join(
'/',
*(
(
f'{TEMPORARY_DIRECTORY_PREFIX}*'
if subdirectory.startswith(TEMPORARY_DIRECTORY_PREFIX)
else subdirectory
)
for subdirectory in path.split(os.path.sep)
),
)
class Runtime_directory:
'''
A Python context manager for creating and cleaning up the borgmatic runtime directory used for
@@ -70,9 +96,16 @@ class Runtime_directory:
)
if runtime_directory:
if not runtime_directory.startswith(os.path.sep):
raise ValueError('The runtime directory must be an absolute path')
self.temporary_directory = None
else:
base_directory = os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp'
if not base_directory.startswith(os.path.sep):
raise ValueError('The temporary directory must be an absolute path')
os.makedirs(base_directory, mode=0o700, exist_ok=True)
self.temporary_directory = tempfile.TemporaryDirectory(
prefix=TEMPORARY_DIRECTORY_PREFIX,
@@ -84,7 +117,9 @@ class Runtime_directory:
self.runtime_path = expand_user_in_path(
os.path.join(
base_path if final_directory == 'borgmatic' else runtime_directory, '.', 'borgmatic'
base_path if final_directory == 'borgmatic' else runtime_directory,
'.', # Borg 1.4+ "slashdot" hack.
'borgmatic',
)
)
os.makedirs(self.runtime_path, mode=0o700, exist_ok=True)
@@ -102,7 +137,13 @@ class Runtime_directory:
Delete any temporary directory that was created as part of initialization.
'''
if self.temporary_directory:
self.temporary_directory.cleanup()
try:
self.temporary_directory.cleanup()
# The cleanup() call errors if, for instance, there's still a
# mounted filesystem within the temporary directory. There's
# nothing we can do about that here, so swallow the error.
except OSError:
pass
def make_runtime_directory_glob(borgmatic_runtime_directory):
+37 -8
View File
@@ -229,13 +229,6 @@ properties:
create the check records again (and therefore re-run checks).
Defaults to $XDG_STATE_HOME or ~/.local/state.
example: /var/lib/borgmatic
store_config_files:
type: boolean
description: |
Store configuration files used to create a backup in the backup
itself. Defaults to true. Changing this to false prevents "borgmatic
bootstrap" from extracting configuration files from the backup.
example: false
source_directories_must_exist:
type: boolean
description: |
@@ -942,6 +935,20 @@ properties:
of them (after any action).
example:
- "echo Completed actions."
bootstrap:
type: object
properties:
store_config_files:
type: boolean
description: |
Store configuration files used to create a backup inside the
backup itself. Defaults to true. Changing this to false
prevents "borgmatic bootstrap" from extracting configuration
files from the backup.
example: false
description: |
Support for the "borgmatic bootstrap" action, used to extract
borgmatic configuration files from a backup archive.
postgresql_databases:
type: array
items:
@@ -2255,7 +2262,29 @@ properties:
config: "__config"
hostname: "__hostname"
description: |
Configuration for a monitoring integration with Grafana loki. You
Configuration for a monitoring integration with Grafana Loki. You
can send the logs to a self-hosted instance or create an account at
https://grafana.com/auth/sign-up/create-user. See borgmatic
monitoring documentation for details.
zfs:
type: ["object", "null"]
additionalProperties: false
properties:
zfs_command:
type: string
description: |
Command to use instead of "zfs".
example: /usr/local/bin/zfs
mount_command:
type: string
description: |
Command to use instead of "mount".
example: /usr/local/bin/mount
umount_command:
type: string
description: |
Command to use instead of "umount".
example: /usr/local/bin/umount
description: |
Configuration for integration with the ZFS filesystem.
+126
View File
@@ -0,0 +1,126 @@
import glob
import importlib
import json
import logging
import os
import borgmatic.config.paths
logger = logging.getLogger(__name__)
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
'''
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
'''
return False
def dump_data_sources(
hook_config,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
dry_run,
):
'''
Given a bootstrap configuration dict, a configuration dict, a log prefix, the borgmatic
configuration file paths, the borgmatic runtime directory, the configured source directories,
and whether this is a dry run, create a borgmatic manifest file to store the paths of the
configuration files used to create the archive. But skip this if the bootstrap
store_config_files option is False or if this is a dry run.
Return an empty sequence, since there are no ongoing dump processes from this hook.
'''
if hook_config.get('store_config_files') is False:
return []
borgmatic_manifest_path = os.path.join(
borgmatic_runtime_directory, 'bootstrap', 'manifest.json'
)
if dry_run:
return []
os.makedirs(os.path.dirname(borgmatic_manifest_path), exist_ok=True)
with open(borgmatic_manifest_path, 'w') as manifest_file:
json.dump(
{
'borgmatic_version': importlib.metadata.version('borgmatic'),
'config_paths': config_paths,
},
manifest_file,
)
source_directories.extend(config_paths)
source_directories.append(os.path.join(borgmatic_runtime_directory, 'bootstrap'))
return []
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
'''
Given a bootstrap configuration dict, a configuration dict, a log prefix, the borgmatic runtime
directory, and whether this is a dry run, then remove the manifest file created above. If this
is a dry run, then don't actually remove anything.
'''
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
manifest_glob = os.path.join(
borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
os.path.normpath(borgmatic_runtime_directory),
),
'bootstrap',
)
logger.debug(
f'{log_prefix}: Looking for bootstrap manifest files to remove in {manifest_glob}{dry_run_label}'
)
for manifest_directory in glob.glob(manifest_glob):
manifest_file_path = os.path.join(manifest_directory, 'manifest.json')
logger.debug(
f'{log_prefix}: Removing bootstrap manifest at {manifest_file_path}{dry_run_label}'
)
if dry_run:
continue
try:
os.remove(manifest_file_path)
except FileNotFoundError:
pass
try:
os.rmdir(manifest_directory)
except FileNotFoundError:
pass
def make_data_source_dump_patterns(
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Restores are implemented via the separate, purpose-specific "bootstrap" action rather than the
generic "restore".
'''
return ()
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
connection_params,
borgmatic_runtime_directory,
): # pragma: no cover
'''
Restores are implemented via the separate, purpose-specific "bootstrap" action rather than the
generic "restore".
'''
raise NotImplementedError()
+6 -2
View File
@@ -2,6 +2,7 @@ import logging
from borgmatic.hooks import (
apprise,
bootstrap,
cronhub,
cronitor,
healthchecks,
@@ -16,12 +17,14 @@ from borgmatic.hooks import (
sqlite,
uptimekuma,
zabbix,
zfs,
)
logger = logging.getLogger(__name__)
HOOK_NAME_TO_MODULE = {
'apprise': apprise,
'bootstrap': bootstrap,
'cronhub': cronhub,
'cronitor': cronitor,
'healthchecks': healthchecks,
@@ -36,6 +39,7 @@ HOOK_NAME_TO_MODULE = {
'sqlite_databases': sqlite,
'uptime_kuma': uptimekuma,
'zabbix': zabbix,
'zfs': zfs,
}
@@ -49,7 +53,7 @@ def call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs):
Raise AttributeError if the function name is not found in the module.
Raise anything else that the called function raises.
'''
hook_config = config.get(hook_name, {})
hook_config = config.get(hook_name) or {}
try:
module = HOOK_NAME_TO_MODULE[hook_name]
@@ -77,7 +81,7 @@ def call_hooks(function_name, config, log_prefix, hook_names, *args, **kwargs):
return {
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
for hook_name in hook_names
if config.get(hook_name)
if hook_name in config
}
+2
View File
@@ -6,11 +6,13 @@ import shutil
logger = logging.getLogger(__name__)
DATA_SOURCE_HOOK_NAMES = (
'bootstrap',
'mariadb_databases',
'mysql_databases',
'mongodb_databases',
'postgresql_databases',
'sqlite_databases',
'zfs',
)
+13 -1
View File
@@ -122,7 +122,15 @@ def use_streaming(databases, config, log_prefix):
return any(databases)
def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory, dry_run):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
dry_run,
):
'''
Dump the given MariaDB databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given
@@ -131,6 +139,7 @@ def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
Also append the given source directories with the parent directory of the database dumps.
'''
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
processes = []
@@ -178,6 +187,9 @@ def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory
)
)
if not dry_run:
source_directories.append(os.path.join(borgmatic_runtime_directory, 'mariadb_databases'))
return [process for process in processes if process]
+14 -1
View File
@@ -1,4 +1,5 @@
import logging
import os
import shlex
import borgmatic.config.paths
@@ -23,7 +24,15 @@ def use_streaming(databases, config, log_prefix):
return any(database.get('format') != 'directory' for database in databases)
def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory, dry_run):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
dry_run,
):
'''
Dump the given MongoDB databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the borgmatic
@@ -32,6 +41,7 @@ def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
Also append the given source directories with the parent directory of the database dumps.
'''
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
@@ -60,6 +70,9 @@ def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory
dump.create_named_pipe_for_dump(dump_filename)
processes.append(execute_command(command, shell=True, run_to_completion=False))
if not dry_run:
source_directories.append(os.path.join(borgmatic_runtime_directory, 'mongodb_databases'))
return processes
+13 -1
View File
@@ -121,7 +121,15 @@ def use_streaming(databases, config, log_prefix):
return any(databases)
def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory, dry_run):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
dry_run,
):
'''
Dump the given MySQL/MariaDB databases to a named pipe. The databases are supplied as a sequence
of dicts, one dict describing each database as per the configuration schema. Use the given
@@ -130,6 +138,7 @@ def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
Also append the given source directories with the parent directory of the database dumps.
'''
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
processes = []
@@ -177,6 +186,9 @@ def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory
)
)
if not dry_run:
source_directories.append(os.path.join(borgmatic_runtime_directory, 'mysql_databases'))
return [process for process in processes if process]
+13 -1
View File
@@ -104,7 +104,15 @@ def use_streaming(databases, config, log_prefix):
return any(database.get('format') != 'directory' for database in databases)
def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory, dry_run):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
dry_run,
):
'''
Dump the given PostgreSQL databases to a named pipe. The databases are supplied as a sequence of
dicts, one dict describing each database as per the configuration schema. Use the given
@@ -113,6 +121,7 @@ def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
Also append the given source directories with the parent directory of the database dumps.
Raise ValueError if the databases to dump cannot be determined.
'''
@@ -203,6 +212,9 @@ def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory
)
)
if not dry_run:
source_directories.append(os.path.join(borgmatic_runtime_directory, 'postgresql_databases'))
return processes
+13 -1
View File
@@ -24,7 +24,15 @@ def use_streaming(databases, config, log_prefix):
return any(databases)
def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory, dry_run):
def dump_data_sources(
databases,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
dry_run,
):
'''
Dump the given SQLite databases to a named pipe. The databases are supplied as a sequence of
configuration dicts, as per the configuration schema. Use the given borgmatic runtime directory
@@ -32,6 +40,7 @@ def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory
Return a sequence of subprocess.Popen instances for the dump processes ready to spew to a named
pipe. But if this is a dry run, then don't actually dump anything and return an empty sequence.
Also append the given source directories with the parent directory of the database dumps.
'''
dry_run_label = ' (dry run; not actually dumping anything)' if dry_run else ''
processes = []
@@ -73,6 +82,9 @@ def dump_data_sources(databases, config, log_prefix, borgmatic_runtime_directory
dump.create_named_pipe_for_dump(dump_filename)
processes.append(execute_command(command, shell=True, run_to_completion=False))
if not dry_run:
source_directories.append(os.path.join(borgmatic_runtime_directory, 'sqlite_databases'))
return processes
+333
View File
@@ -0,0 +1,333 @@
import glob
import logging
import os
import shutil
import subprocess
import borgmatic.config.paths
import borgmatic.execute
logger = logging.getLogger(__name__)
def use_streaming(hook_config, config, log_prefix): # pragma: no cover
'''
Return whether dump streaming is used for this hook. (Spoiler: It isn't.)
'''
return False
BORGMATIC_SNAPSHOT_PREFIX = 'borgmatic-'
BORGMATIC_USER_PROPERTY = 'org.torsion.borgmatic:backup'
def get_datasets_to_backup(zfs_command, source_directories):
'''
Given a ZFS command to run and a sequence of configured source directories, find the
intersection between the current ZFS dataset mount points and the configured borgmatic source
directories. The idea is that these are the requested datasets to snapshot. But also include any
datasets tagged with a borgmatic-specific user property, whether or not they appear in source
directories.
Return the result as a sequence of (dataset name, mount point) pairs.
'''
list_output = borgmatic.execute.execute_command_and_capture_output(
(
zfs_command,
'list',
'-H',
'-t',
'filesystem',
'-o',
f'name,mountpoint,{BORGMATIC_USER_PROPERTY}',
)
)
source_directories_set = set(source_directories)
try:
return tuple(
(dataset_name, mount_point)
for line in list_output.splitlines()
for (dataset_name, mount_point, user_property_value) in (line.rstrip().split('\t'),)
if mount_point in source_directories_set or user_property_value == 'auto'
)
except ValueError:
raise ValueError('Invalid {zfs_command} list output')
def get_all_datasets(zfs_command):
'''
Given a ZFS command to run, return all ZFS datasets as a sequence of (dataset name, mount point)
pairs.
'''
list_output = borgmatic.execute.execute_command_and_capture_output(
(
zfs_command,
'list',
'-H',
'-t',
'filesystem',
'-o',
'name,mountpoint',
)
)
try:
return tuple(
(dataset_name, mount_point)
for line in list_output.splitlines()
for (dataset_name, mount_point) in (line.rstrip().split('\t'),)
)
except ValueError:
raise ValueError('Invalid {zfs_command} list output')
def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover
'''
Given a ZFS command to run and a snapshot name of the form "dataset@snapshot", create a new ZFS
snapshot.
'''
borgmatic.execute.execute_command(
(
zfs_command,
'snapshot',
full_snapshot_name,
),
output_log_level=logging.DEBUG,
)
def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # pragma: no cover
'''
Given a mount command to run, an existing snapshot name of the form "dataset@snapshot", and the
path where the snapshot should be mounted, mount the snapshot (making any necessary directories
first).
'''
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
borgmatic.execute.execute_command(
(
mount_command,
'-t',
'zfs',
full_snapshot_name,
snapshot_mount_path,
),
output_log_level=logging.DEBUG,
)
def dump_data_sources(
hook_config,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
dry_run,
):
'''
Given a ZFS configuration dict, a configuration dict, a log prefix, the borgmatic configuration
file paths, the borgmatic runtime directory, the configured source directories, and whether this
is a dry run, auto-detect and snapshot any ZFS dataset mount points listed in the given source
directories and any dataset with a borgmatic-specific user property. Also update those source
directories, replacing dataset mount points with corresponding snapshot directories so they get
stored in the Borg archive instead of the dataset mount points. Use the log prefix in any log
entries.
Return an empty sequence, since there are no ongoing dump processes from this hook.
If this is a dry run, then don't actually snapshot anything.
'''
dry_run_label = ' (dry run; not actually snapshotting anything)' if dry_run else ''
logger.info(f'{log_prefix}: Snapshotting ZFS datasets{dry_run_label}')
# List ZFS datasets to get their mount points.
zfs_command = hook_config.get('zfs_command', 'zfs')
requested_datasets = get_datasets_to_backup(zfs_command, source_directories)
# Snapshot each dataset, rewriting source directories to use the snapshot paths.
snapshot_name = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
for dataset_name, mount_point in requested_datasets:
full_snapshot_name = f'{dataset_name}@{snapshot_name}'
logger.debug(f'{log_prefix}: Creating ZFS snapshot {full_snapshot_name}{dry_run_label}')
if not dry_run:
snapshot_dataset(zfs_command, full_snapshot_name)
# Mount the snapshot into a particular named temporary directory so that the snapshot ends
# up in the Borg archive at the "original" dataset mount point path.
snapshot_mount_path_for_borg = os.path.join(
os.path.normpath(borgmatic_runtime_directory),
'zfs_snapshots',
'.', # Borg 1.4+ "slashdot" hack.
mount_point.lstrip(os.path.sep),
)
snapshot_mount_path = os.path.normpath(snapshot_mount_path_for_borg)
logger.debug(
f'{log_prefix}: Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}'
)
if not dry_run:
mount_snapshot(
hook_config.get('mount_command', 'mount'), full_snapshot_name, snapshot_mount_path
)
if mount_point in source_directories:
source_directories.remove(mount_point)
source_directories.append(snapshot_mount_path_for_borg)
return []
def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
'''
Given a umount command to run and the mount path of a snapshot, unmount it.
'''
borgmatic.execute.execute_command(
(
umount_command,
snapshot_mount_path,
),
output_log_level=logging.DEBUG,
)
def destroy_snapshot(zfs_command, full_snapshot_name): # pragma: no cover
'''
Given a ZFS command to run and the name of a snapshot in the form "dataset@snapshot", destroy
it.
'''
borgmatic.execute.execute_command(
(
zfs_command,
'destroy',
full_snapshot_name,
),
output_log_level=logging.DEBUG,
)
def get_all_snapshots(zfs_command):
'''
Given a ZFS command to run, return all ZFS snapshots as a sequence of full snapshot names of the
form "dataset@snapshot".
'''
list_output = borgmatic.execute.execute_command_and_capture_output(
(
zfs_command,
'list',
'-H',
'-t',
'snapshot',
'-o',
'name',
)
)
return tuple(line.rstrip() for line in list_output.splitlines())
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
'''
Given a ZFS configuration dict, a configuration dict, a log prefix, the borgmatic runtime
directory, and whether this is a dry run, unmount and destroy any ZFS snapshots created by
borgmatic. Use the log prefix in any log entries. If this is a dry run, then don't actually
remove anything.
'''
dry_run_label = ' (dry run; not actually removing anything)' if dry_run else ''
# Unmount snapshots.
zfs_command = hook_config.get('zfs_command', 'zfs')
try:
datasets = get_all_datasets(zfs_command)
except FileNotFoundError:
logger.debug(f'{log_prefix}: Could not find "{zfs_command}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(f'{log_prefix}: {error}')
return
snapshots_glob = os.path.join(
borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
os.path.normpath(borgmatic_runtime_directory),
),
'zfs_snapshots',
)
logger.debug(
f'{log_prefix}: Looking for snapshots to remove in {snapshots_glob}{dry_run_label}'
)
umount_command = hook_config.get('umount_command', 'umount')
for snapshots_directory in glob.glob(snapshots_glob):
if not os.path.isdir(snapshots_directory):
continue
# This might fail if the directory is already mounted, but we swallow errors here since
# we'll try again below. The point of doing it here is that we don't want to try to unmount
# a non-mounted directory (which *will* fail), and probing for whether a directory is
# mounted is tough to do in a cross-platform way.
if not dry_run:
shutil.rmtree(snapshots_directory, ignore_errors=True)
for _, mount_point in datasets:
snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep))
if not os.path.isdir(snapshot_mount_path):
continue
logger.debug(
f'{log_prefix}: Unmounting ZFS snapshot at {snapshot_mount_path}{dry_run_label}'
)
if not dry_run:
try:
unmount_snapshot(umount_command, snapshot_mount_path)
except FileNotFoundError:
logger.debug(f'{log_prefix}: Could not find "{umount_command}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(f'{log_prefix}: {error}')
return
if not dry_run:
shutil.rmtree(snapshots_directory)
# Destroy snapshots.
full_snapshot_names = get_all_snapshots(zfs_command)
for full_snapshot_name in full_snapshot_names:
# Only destroy snapshots that borgmatic actually created!
if not full_snapshot_name.split('@')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
continue
logger.debug(f'{log_prefix}: Destroying ZFS snapshot {full_snapshot_name}{dry_run_label}')
if not dry_run:
destroy_snapshot(zfs_command, full_snapshot_name)
def make_data_source_dump_patterns(
hook_config, config, log_prefix, borgmatic_runtime_directory, name=None
): # pragma: no cover
'''
Restores aren't implemented, because stored files can be extracted directly with "extract".
'''
return ()
def restore_data_source_dump(
hook_config,
config,
log_prefix,
data_source,
dry_run,
extract_process,
connection_params,
borgmatic_runtime_directory,
): # pragma: no cover
'''
Restores aren't implemented, because stored files can be extracted directly with "extract".
'''
raise NotImplementedError()
+1 -1
View File
@@ -2,7 +2,7 @@ FROM docker.io/alpine:3.20.1 AS borgmatic
COPY . /app
RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib
RUN pip install --break-system-packages --no-cache /app && generate-borgmatic-config && chmod +r /etc/borgmatic/config.yaml
RUN pip install --break-system-packages --no-cache /app && borgmatic config generate && chmod +r /etc/borgmatic/config.yaml
RUN borgmatic --help > /command-line.txt \
&& for action in repo-create transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" export-tar mount umount repo-delete restore repo-list list repo-info info break-lock "key export" "key change-passphrase" borg; do \
echo -e "\n--------------------------------------------------------------------------------\n" >> /command-line.txt \
@@ -3,7 +3,7 @@ title: How to add preparation and cleanup steps to backups
eleventyNavigation:
key: 🧹 Add preparation and cleanup steps
parent: How-to guides
order: 9
order: 10
---
## Preparation and cleanup hooks
@@ -3,7 +3,7 @@ title: How to backup to a removable drive or an intermittent server
eleventyNavigation:
key: 💾 Backup to a removable drive/server
parent: How-to guides
order: 10
order: 11
---
## Occasional backups
+1 -1
View File
@@ -3,7 +3,7 @@ title: How to customize warnings and errors
eleventyNavigation:
key: 💥 Customize warnings/errors
parent: How-to guides
order: 12
order: 13
---
## When things go wrong
+15 -17
View File
@@ -62,8 +62,8 @@ actions](https://torsion.org/borgmatic/docs/how-to/set-up-backups/#skipping-acti
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. (Although see below about check frequency.) This is equivalent to
what `borg check` does if run without options.
repository, running the checks on a monthly basis. (See below about setting
your own check frequency.)
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
@@ -87,13 +87,16 @@ checks:
```
Here are the available checks from fastest to slowest:
Here are the available checks, roughly from fastest to slowest:
* `repository`: Checks the consistency of the repository itself.
* `archives`: Checks all of the archives in the repository.
* `archives`: Checks all of the archives' metadata in the repository.
* `repository`: Checks the consistency of the whole repository. The checks run
on the server and do not cause significant network traffic.
* `extract`: Performs an extraction dry-run of the latest archive.
* `data`: Verifies the data integrity of all archives contents, decrypting and decompressing all data.
* `spot`: Compares file counts and contents between your source files and the latest archive.
* `data`: Verifies the data integrity of all archives contents, decrypting
and decompressing all data.
* `spot`: Compares file counts and contents between your source files and the
latest archive.
Note that the `data` check is a more thorough version of the `archives` check,
so enabling the `data` check implicitly enables the `archives` check as well.
@@ -113,11 +116,11 @@ file). Because if something goes wrong with your source files, most
consistency checks will still pass with flying colors and you won't discover
there's a problem until you go to restore.
<span class="minilink minilink-addedin">New in version 1.8.10</span> <span
class="minilink minilink-addedin">Beta feature</span> That's where the spot
check comes in. This check actually compares your source file counts and data
against those in the latest archive, potentially catching problems like
incorrect excludes, inadvertent deletes, files changed by malware, etc.
<span class="minilink minilink-addedin">New in version 1.8.10</span> That's
where the spot check comes in. This check actually compares your source file
counts and data against those in the latest archive, potentially catching
problems like incorrect excludes, inadvertent deletes, files changed by
malware, etc.
But because an exhaustive comparison of all source files against the latest
archive might be too slow, the spot check supports *sampling* a percentage of
@@ -180,11 +183,6 @@ to run it immediately after a `create` action (borgmatic's default behavior).
Instead, it may make more sense to run the spot check on a separate schedule
from `create`.
As long as the spot check feature is in beta, it may be subject to breaking
changes. But feel free to use it in production if you're okay with that
caveat, and please [provide any
feedback](https://torsion.org/borgmatic/#issues) you have on this feature.
### Check frequency
+1 -1
View File
@@ -3,7 +3,7 @@ title: How to develop on borgmatic
eleventyNavigation:
key: 🏗️ Develop on borgmatic
parent: How-to guides
order: 14
order: 15
---
## Source code
+21 -9
View File
@@ -202,13 +202,25 @@ borgmatic config bootstrap --repository repo.borg --archive host-2023-01-02T04:0
See the output of `config bootstrap --help` for additional flags you may need
for bootstrapping.
<span class="minilink minilink-addedin">New in version 1.8.1</span> Set the
`store_config_files` option to `false` to disable the automatic backup of
borgmatic configuration files, for instance if they contain sensitive
information you don't want to store even inside your encrypted backups. If you
do this though, the `config bootstrap` action will no longer work.
<span class="minilink minilink-addedin">New in version 1.9.3</span>
If your borgmatic configuration files contain sensitive information you don't
want to store even inside your encrypted backups, you can disable the
automatic backup of the configuration files. To do this, set the
`store_config_files` option under the `bootstrap` hook to `false`. For
instance:
<span class="minilink minilink-addedin">New in version 1.8.7</span> Included
configuration files are stored in each backup archive. This means that the
`config bootstrap` action not only extracts the top-level configuration files
but also the includes they depend upon.
```yaml
bootstrap:
store_config_files: false
```
If you do this though, the `config bootstrap` action will no longer work.
<span class="minilink minilink-addedin">In version 1.8.1 through 1.9.2</span>
The `store_config_files` option was at the global scope instead of under the
`bootstrap` hook.
<span class="minilink minilink-addedin">New in version 1.8.7</span>
Configuration file includes are stored in each backup archive. This means that
the `config bootstrap` action not only extracts the top-level configuration
files but also the includes they depend upon.
+3 -3
View File
@@ -119,10 +119,10 @@ archive, regardless of the user who performs the backup. (Note that Borg
doesn't store the leading `/`.)
<span class="minilink minilink-addedin">With Borg version 1.2 and
earlier</span>Database dump files are stored at a path dependent on the
[runtime
earlier</span>Database dump files are stored at a path dependent on the [runtime
directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory)
in use at the time the archive was created.
in use at the time the archive was created, as Borg 1.2 and earlier do not
support path rewriting.
<span class="minilink minilink-addedin">Prior to borgmatic version
1.9.0</span>Database dump files were instead stored at `~/.borgmatic` within
+1 -1
View File
@@ -3,7 +3,7 @@ title: How to run arbitrary Borg commands
eleventyNavigation:
key: 🔧 Run arbitrary Borg commands
parent: How-to guides
order: 11
order: 12
---
## Running Borg with borgmatic
+90
View File
@@ -0,0 +1,90 @@
---
title: How to snapshot your filesystems
eleventyNavigation:
key: 📸 Snapshot your filesystems
parent: How-to guides
order: 9
---
## Filesystem hooks
Many filesystems support taking snapshots—point-in-time, read-only "copies" of
your data, ideal for backing up files that may change during the backup. These
snapshots initially don't use any additional storage space and can be made
almost instantly.
To help automate backup of these filesystems, borgmatic can use them to take
snapshots.
### ZFS
<span class="minilink minilink-addedin">New in version 1.9.3</span> <span
class="minilink minilink-addedin">Beta feature</span> borgmatic supports
taking snapshots with the [ZFS filesystem](https://openzfs.org/) and sending
those snapshots to Borg for backup.
To use this feature, first you need one or more mounted ZFS datasets. Then,
enable ZFS within borgmatic by adding the following line to your configuration
file:
```yaml
zfs:
```
No other options are necessary to enable ZFS support, but if desired you can
override some of the commands used by the ZFS hook. For instance:
```yaml
zfs:
zfs_command: /usr/local/bin/zfs
mount_command: /usr/local/bin/mount
umount_command: /usr/local/bin/umount
```
As long as the ZFS hook is in beta, it may be subject to breaking changes
and/or may not work well for your use cases. But feel free to use it in
production if you're okay with these caveats, and please [provide any
feedback](https://torsion.org/borgmatic/#issues) you have on this feature.
#### Dataset discovery
You have a couple of options for borgmatic to find and backup your ZFS datasets:
* For any dataset you'd like backed up, add its mount point to borgmatic's
`source_directories`.
* Or set the borgmatic-specific user property
`org.torsion.borgmatic:backup=auto` onto your dataset, e.g. by running `zfs
set org.torsion.borgmatic:backup=auto datasetname`. Then borgmatic can find
and backup these datasets.
If you have multiple borgmatic configuration files with ZFS enabled, and you'd
like particular datasets to be backed up only for particular configuration
files, use the `source_directories` option instead of the user property.
During a backup, borgmatic automatically snapshots these discovered datasets,
temporary mounts the snapshots within its [runtime
directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory),
and includes the snapshotted files in the files sent to Borg. borgmatic is
also responsible for cleaning up (destroying) these snapshots after a backup
completes.
Additionally, borgmatic rewrites the snapshot file paths so that they appear
at their original dataset locations in a Borg archive. For instance, if your
dataset is mounted at `/mnt/dataset`, then the snapshotted files will appear
in an archive at `/mnt/dataset` as well.
<span class="minilink minilink-addedin">With Borg version 1.2 and
earlier</span>Snapshotted files are instead stored at a path dependent on the
[runtime
directory](https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#runtime-directory)
in use at the time the archive was created, as Borg 1.2 and earlier do not
support path rewriting.
#### Extract a dataset
Filesystem snapshots are stored in a Borg archive as normal files, so
you can use the standard
[extract action](https://torsion.org/borgmatic/docs/how-to/extract-a-backup/) to
extract them.
+1 -1
View File
@@ -3,7 +3,7 @@ title: How to upgrade borgmatic and Borg
eleventyNavigation:
key: 📦 Upgrade borgmatic/Borg
parent: How-to guides
order: 13
order: 14
---
## Upgrading borgmatic
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "borgmatic"
version = "1.9.2"
version = "1.9.3"
authors = [
{ name="Dan Helfman", email="witten@torsion.org" },
]
@@ -49,7 +49,7 @@ skip-string-normalization = true
[tool.pytest.ini_options]
testpaths = "tests"
addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --ignore=tests/end-to-end"
addopts = "--cov-report term-missing:skip-covered --cov=borgmatic --no-cov-on-fail --cov-fail-under=100 --ignore=tests/end-to-end"
[tool.isort]
profile = "black"
+1 -1
View File
@@ -8,7 +8,7 @@ MAXIMUM_LINE_LENGTH = 80
def test_schema_line_length_stays_under_limit():
schema_file = open('borgmatic/config/schema.yaml')
schema_file = open(borgmatic.config.validate.schema_filename())
for line in schema_file.readlines():
assert len(line.rstrip('\n')) <= MAXIMUM_LINE_LENGTH
@@ -67,6 +67,7 @@ def test_parse_configuration_transforms_file_into_mapping():
'keep_hourly': 24,
'keep_minutely': 60,
'checks': [{'name': 'repository'}, {'name': 'archives'}],
'bootstrap': {},
}
assert config_paths == {'/tmp/config.yaml'}
assert logs == []
@@ -90,6 +91,7 @@ def test_parse_configuration_passes_through_quoted_punctuation():
assert config == {
'source_directories': [f'/home/{string.punctuation}'],
'repositories': [{'path': 'test.borg'}],
'bootstrap': {},
}
assert config_paths == {'/tmp/config.yaml'}
assert logs == []
@@ -150,6 +152,7 @@ def test_parse_configuration_inlines_include_inside_deprecated_section():
'repositories': [{'path': 'hostname.borg'}],
'keep_daily': 7,
'keep_hourly': 24,
'bootstrap': {},
}
assert config_paths == {'/tmp/include.yaml', '/tmp/config.yaml'}
assert len(logs) == 1
@@ -185,6 +188,7 @@ def test_parse_configuration_merges_include():
'repositories': [{'path': 'hostname.borg'}],
'keep_daily': 1,
'keep_hourly': 24,
'bootstrap': {},
}
assert config_paths == {'/tmp/include.yaml', '/tmp/config.yaml'}
assert logs == []
@@ -248,6 +252,7 @@ def test_parse_configuration_applies_overrides():
'source_directories': ['/home'],
'repositories': [{'path': 'hostname.borg'}],
'local_path': 'borg2',
'bootstrap': {},
}
assert config_paths == {'/tmp/config.yaml'}
assert logs == []
@@ -274,6 +279,7 @@ def test_parse_configuration_applies_normalization_after_environment_variable_in
'source_directories': ['/home'],
'repositories': [{'path': 'ssh://user@hostname/./repo'}],
'exclude_if_present': ['.nobackup'],
'bootstrap': {},
}
assert config_paths == {'/tmp/config.yaml'}
assert logs
+30 -10
View File
@@ -557,14 +557,17 @@ def test_collect_spot_check_source_paths_parses_borg_output():
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': True}
)
flexmock(module.borgmatic.actions.create).should_receive(
'process_source_directories'
).and_return(['foo', 'bar'])
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
dry_run=True,
repository_path='repo',
config=object,
config_paths=(),
source_directories=['foo', 'bar'],
local_borg_version=object,
global_arguments=object,
borgmatic_runtime_directories=(),
borgmatic_runtime_directory='/run/borgmatic',
local_path=object,
remote_path=object,
list_files=True,
@@ -588,6 +591,7 @@ def test_collect_spot_check_source_paths_parses_borg_output():
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
) == ('/etc/path', '/etc/other')
@@ -595,14 +599,17 @@ def test_collect_spot_check_source_paths_passes_through_stream_processes_false()
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': False}
)
flexmock(module.borgmatic.actions.create).should_receive(
'process_source_directories'
).and_return(['foo', 'bar'])
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
dry_run=True,
repository_path='repo',
config=object,
config_paths=(),
source_directories=['foo', 'bar'],
local_borg_version=object,
global_arguments=object,
borgmatic_runtime_directories=(),
borgmatic_runtime_directory='/run/borgmatic',
local_path=object,
remote_path=object,
list_files=True,
@@ -626,6 +633,7 @@ def test_collect_spot_check_source_paths_passes_through_stream_processes_false()
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
) == ('/etc/path', '/etc/other')
@@ -633,14 +641,17 @@ def test_collect_spot_check_source_paths_without_working_directory_parses_borg_o
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': True}
)
flexmock(module.borgmatic.actions.create).should_receive(
'process_source_directories'
).and_return(['foo', 'bar'])
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
dry_run=True,
repository_path='repo',
config=object,
config_paths=(),
source_directories=['foo', 'bar'],
local_borg_version=object,
global_arguments=object,
borgmatic_runtime_directories=(),
borgmatic_runtime_directory='/run/borgmatic',
local_path=object,
remote_path=object,
list_files=True,
@@ -664,6 +675,7 @@ def test_collect_spot_check_source_paths_without_working_directory_parses_borg_o
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
) == ('/etc/path', '/etc/other')
@@ -671,14 +683,17 @@ def test_collect_spot_check_source_paths_skips_directories():
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': True}
)
flexmock(module.borgmatic.actions.create).should_receive(
'process_source_directories'
).and_return(['foo', 'bar'])
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
dry_run=True,
repository_path='repo',
config=object,
config_paths=(),
source_directories=['foo', 'bar'],
local_borg_version=object,
global_arguments=object,
borgmatic_runtime_directories=(),
borgmatic_runtime_directory='/run/borgmatic',
local_path=object,
remote_path=object,
list_files=True,
@@ -704,6 +719,7 @@ def test_collect_spot_check_source_paths_skips_directories():
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
)
== ()
)
@@ -806,14 +822,17 @@ def test_collect_spot_check_source_paths_uses_working_directory():
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': True}
)
flexmock(module.borgmatic.actions.create).should_receive(
'process_source_directories'
).and_return(['foo', 'bar'])
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
dry_run=True,
repository_path='repo',
config=object,
config_paths=(),
source_directories=['foo', 'bar'],
local_borg_version=object,
global_arguments=object,
borgmatic_runtime_directories=(),
borgmatic_runtime_directory='/run/borgmatic',
local_path=object,
remote_path=object,
list_files=True,
@@ -840,6 +859,7 @@ def test_collect_spot_check_source_paths_uses_working_directory():
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
) == ('foo', 'bar')
+197 -95
View File
@@ -1,10 +1,199 @@
import sys
import pytest
from flexmock import flexmock
from borgmatic.actions import create as module
def test_expand_directory_with_basic_path_passes_it_through():
flexmock(module.os.path).should_receive('expanduser').and_return('foo')
flexmock(module.glob).should_receive('glob').and_return([])
paths = module.expand_directory('foo', None)
assert paths == ['foo']
def test_expand_directory_with_glob_expands():
flexmock(module.os.path).should_receive('expanduser').and_return('foo*')
flexmock(module.glob).should_receive('glob').and_return(['foo', 'food'])
paths = module.expand_directory('foo*', None)
assert paths == ['foo', 'food']
def test_expand_directory_with_working_directory_passes_it_through():
flexmock(module.os.path).should_receive('expanduser').and_return('foo')
flexmock(module.glob).should_receive('glob').with_args('/working/dir/foo').and_return([]).once()
paths = module.expand_directory('foo', working_directory='/working/dir')
assert paths == ['/working/dir/foo']
def test_expand_directory_with_glob_passes_through_working_directory():
flexmock(module.os.path).should_receive('expanduser').and_return('foo*')
flexmock(module.glob).should_receive('glob').with_args('/working/dir/foo*').and_return(
['/working/dir/foo', '/working/dir/food']
).once()
paths = module.expand_directory('foo*', working_directory='/working/dir')
assert paths == ['/working/dir/foo', '/working/dir/food']
def test_expand_directories_flattens_expanded_directories():
flexmock(module).should_receive('expand_directory').with_args('~/foo', None).and_return(
['/root/foo']
)
flexmock(module).should_receive('expand_directory').with_args('bar*', None).and_return(
['bar', 'barf']
)
paths = module.expand_directories(('~/foo', 'bar*'))
assert paths == ('/root/foo', 'bar', 'barf')
def test_expand_directories_with_working_directory_passes_it_through():
flexmock(module).should_receive('expand_directory').with_args('foo', '/working/dir').and_return(
['/working/dir/foo']
)
paths = module.expand_directories(('foo',), working_directory='/working/dir')
assert paths == ('/working/dir/foo',)
def test_expand_directories_considers_none_as_no_directories():
paths = module.expand_directories(None, None)
assert paths == ()
def test_map_directories_to_devices_gives_device_id_per_path():
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
flexmock(module.os).should_receive('stat').with_args('/bar').and_return(flexmock(st_dev=66))
device_map = module.map_directories_to_devices(('/foo', '/bar'))
assert device_map == {
'/foo': 55,
'/bar': 66,
}
def test_map_directories_to_devices_with_missing_path_does_not_error():
flexmock(module.os.path).should_receive('exists').and_return(True).and_return(False)
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
flexmock(module.os).should_receive('stat').with_args('/bar').never()
device_map = module.map_directories_to_devices(('/foo', '/bar'))
assert device_map == {
'/foo': 55,
'/bar': None,
}
def test_map_directories_to_devices_uses_working_directory_to_construct_path():
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os).should_receive('stat').with_args('/foo').and_return(flexmock(st_dev=55))
flexmock(module.os).should_receive('stat').with_args('/working/dir/bar').and_return(
flexmock(st_dev=66)
)
device_map = module.map_directories_to_devices(
('/foo', 'bar'), working_directory='/working/dir'
)
assert device_map == {
'/foo': 55,
'bar': 66,
}
@pytest.mark.parametrize(
'directories,additional_directories,expected_directories',
(
({'/': 1, '/root': 1}, {}, ['/']),
({'/': 1, '/root/': 1}, {}, ['/']),
({'/': 1, '/root': 2}, {}, ['/', '/root']),
({'/root': 1, '/': 1}, {}, ['/']),
({'/root': 1, '/root/foo': 1}, {}, ['/root']),
({'/root/': 1, '/root/foo': 1}, {}, ['/root/']),
({'/root': 1, '/root/foo/': 1}, {}, ['/root']),
({'/root': 1, '/root/foo': 2}, {}, ['/root', '/root/foo']),
({'/root/foo': 1, '/root': 1}, {}, ['/root']),
({'/root': None, '/root/foo': None}, {}, ['/root', '/root/foo']),
({'/root': 1, '/etc': 1, '/root/foo/bar': 1}, {}, ['/etc', '/root']),
({'/root': 1, '/root/foo': 1, '/root/foo/bar': 1}, {}, ['/root']),
({'/dup': 1, '/dup': 1}, {}, ['/dup']),
({'/foo': 1, '/bar': 1}, {}, ['/bar', '/foo']),
({'/foo': 1, '/bar': 2}, {}, ['/bar', '/foo']),
({'/root/foo': 1}, {'/root': 1}, []),
({'/root/foo': 1}, {'/root': 2}, ['/root/foo']),
({'/root/foo': 1}, {}, ['/root/foo']),
),
)
def test_deduplicate_directories_removes_child_paths_on_the_same_filesystem(
directories, additional_directories, expected_directories
):
assert (
module.deduplicate_directories(directories, additional_directories) == expected_directories
)
def test_pattern_root_directories_deals_with_none_patterns():
assert module.pattern_root_directories(patterns=None) == []
def test_pattern_root_directories_parses_roots_and_ignores_others():
assert module.pattern_root_directories(
['R /root', '+ /root/foo', '- /root/foo/bar', 'R /baz']
) == ['/root', '/baz']
def test_process_source_directories_includes_source_directories():
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
'/working'
)
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('expand_directories').with_args(
('foo', 'bar'), working_directory='/working'
).and_return(()).once()
flexmock(module).should_receive('pattern_root_directories').and_return(())
flexmock(module).should_receive('expand_directories').with_args(
(), working_directory='/working'
).and_return(())
assert module.process_source_directories(
config={'source_directories': ['foo', 'bar']},
) == ('foo', 'bar')
def test_process_source_directories_prefers_source_directory_argument_to_config():
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
'/working'
)
flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
flexmock(module).should_receive('map_directories_to_devices').and_return({})
flexmock(module).should_receive('expand_directories').with_args(
('foo', 'bar'), working_directory='/working'
).and_return(()).once()
flexmock(module).should_receive('pattern_root_directories').and_return(())
flexmock(module).should_receive('expand_directories').with_args(
(), working_directory='/working'
).and_return(())
assert module.process_source_directories(
config={'source_directories': ['nope']},
source_directories=['foo', 'bar'],
) == ('foo', 'bar')
def test_run_create_executes_and_calls_hooks_for_configured_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
@@ -12,12 +201,13 @@ def test_run_create_executes_and_calls_hooks_for_configured_repository():
flexmock()
)
flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
flexmock(module).should_receive('create_borgmatic_manifest').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({})
flexmock(module).should_receive('process_source_directories').and_return([])
flexmock(module.os.path).should_receive('join').and_return('/run/borgmatic/bootstrap')
create_arguments = flexmock(
repository=None,
progress=flexmock(),
@@ -44,45 +234,6 @@ def test_run_create_executes_and_calls_hooks_for_configured_repository():
)
def test_run_create_with_store_config_files_false_does_not_create_borgmatic_manifest():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never()
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
flexmock()
)
flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
flexmock(module).should_receive('create_borgmatic_manifest').never()
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(
repository=None,
progress=flexmock(),
stats=flexmock(),
json=False,
list_files=flexmock(),
)
global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
list(
module.run_create(
config_filename='test.yaml',
repository={'path': 'repo'},
config={'store_config_files': False},
config_paths=['/tmp/test.yaml'],
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(
@@ -92,12 +243,13 @@ def test_run_create_runs_with_selected_repository():
flexmock()
)
flexmock(module.borgmatic.borg.create).should_receive('create_archive').once()
flexmock(module).should_receive('create_borgmatic_manifest').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({})
flexmock(module).should_receive('process_source_directories').and_return([])
flexmock(module.os.path).should_receive('join').and_return('/run/borgmatic/bootstrap')
create_arguments = flexmock(
repository=flexmock(),
progress=flexmock(),
@@ -131,7 +283,6 @@ def test_run_create_bails_if_repository_does_not_match():
).once().and_return(False)
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').never()
flexmock(module.borgmatic.borg.create).should_receive('create_archive').never()
flexmock(module).should_receive('create_borgmatic_manifest').never()
create_arguments = flexmock(
repository=flexmock(),
progress=flexmock(),
@@ -171,12 +322,13 @@ def test_run_create_produces_json():
)
parsed_json = flexmock()
flexmock(module.borgmatic.actions.json).should_receive('parse_json').and_return(parsed_json)
flexmock(module).should_receive('create_borgmatic_manifest').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({})
flexmock(module).should_receive('process_source_directories').and_return([])
flexmock(module.os.path).should_receive('join').and_return('/run/borgmatic/bootstrap')
create_arguments = flexmock(
repository=flexmock(),
progress=flexmock(),
@@ -201,53 +353,3 @@ def test_run_create_produces_json():
remote_path=None,
)
) == [parsed_json]
def test_create_borgmatic_manifest_creates_manifest_file():
flexmock(module.os.path).should_receive('join').with_args(
'/run/borgmatic', 'bootstrap', 'manifest.json'
).and_return('/run/borgmatic/bootstrap/manifest.json')
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.os).should_receive('makedirs').and_return(True)
flexmock(module.importlib.metadata).should_receive('version').and_return('1.0.0')
flexmock(sys.modules['builtins']).should_receive('open').with_args(
'/run/borgmatic/bootstrap/manifest.json', 'w'
).and_return(
flexmock(
__enter__=lambda *args: flexmock(write=lambda *args: None, close=lambda *args: None),
__exit__=lambda *args: None,
)
)
flexmock(module.json).should_receive('dump').and_return(True).once()
module.create_borgmatic_manifest({}, 'test.yaml', '/run/borgmatic', False)
def test_create_borgmatic_manifest_creates_manifest_file_with_custom_borgmatic_runtime_directory():
flexmock(module.os.path).should_receive('join').with_args(
'/run/borgmatic', 'bootstrap', 'manifest.json'
).and_return('/run/borgmatic/bootstrap/manifest.json')
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.os).should_receive('makedirs').and_return(True)
flexmock(module.importlib.metadata).should_receive('version').and_return('1.0.0')
flexmock(sys.modules['builtins']).should_receive('open').with_args(
'/run/borgmatic/bootstrap/manifest.json', 'w'
).and_return(
flexmock(
__enter__=lambda *args: flexmock(write=lambda *args: None, close=lambda *args: None),
__exit__=lambda *args: None,
)
)
flexmock(module.json).should_receive('dump').and_return(True).once()
module.create_borgmatic_manifest(
{'borgmatic_runtime_directory': '/borgmatic'}, 'test.yaml', '/run/borgmatic', False
)
def test_create_borgmatic_manifest_does_not_create_manifest_file_on_dry_run():
flexmock(module.json).should_receive('dump').never()
module.create_borgmatic_manifest({}, 'test.yaml', '/run/borgmatic', True)
File diff suppressed because it is too large Load Diff
+20
View File
@@ -103,6 +103,26 @@ def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_e
assert config == [OrderedDict([('field1', 'Example 1'), ('field2', 'Example 2')])]
def test_schema_to_sample_configuration_generates_config_sequence_of_maps_with_multiple_types():
schema = {
'type': 'array',
'items': {
'type': ['object', 'null'],
'properties': OrderedDict(
[('field1', {'example': 'Example 1'}), ('field2', {'example': 'Example 2'})]
),
},
}
flexmock(module).should_receive('get_properties').and_return(schema['items']['properties'])
flexmock(module.ruamel.yaml.comments).should_receive('CommentedSeq').replace_with(list)
flexmock(module).should_receive('add_comments_to_configuration_sequence')
flexmock(module).should_receive('add_comments_to_configuration_object')
config = module.schema_to_sample_configuration(schema)
assert config == [OrderedDict([('field1', 'Example 1'), ('field2', 'Example 2')])]
def test_schema_to_sample_configuration_with_unsupported_schema_raises():
schema = {'gobbledygook': [{'type': 'not-your'}]}
+6
View File
@@ -136,6 +136,11 @@ def test_normalize_sections_with_only_scalar_raises():
{'exclude_if_present': ['.nobackup']},
False,
),
(
{'store_config_files': False},
{'bootstrap': {'store_config_files': False}},
True,
),
(
{'source_directories': ['foo', 'bar']},
{'source_directories': ['foo', 'bar']},
@@ -259,6 +264,7 @@ def test_normalize_applies_hard_coded_normalization_to_config(
flexmock(module).should_receive('normalize_sections').and_return([])
logs = module.normalize('test.yaml', config)
expected_config.setdefault('bootstrap', {})
assert config == expected_config
+91
View File
@@ -22,6 +22,10 @@ def test_expand_user_in_path_handles_none_directory():
assert module.expand_user_in_path(None) is None
def test_expand_user_in_path_handles_incorrectly_typed_directory():
assert module.expand_user_in_path(3) is None
def test_get_borgmatic_source_directory_uses_config_option():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
@@ -34,6 +38,13 @@ def test_get_borgmatic_source_directory_without_config_option_uses_default():
assert module.get_borgmatic_source_directory({}) == '~/.borgmatic'
def test_replace_temporary_subdirectory_with_glob_transforms_path():
assert (
module.replace_temporary_subdirectory_with_glob('/tmp/borgmatic-aet8kn93/borgmatic')
== '/tmp/borgmatic-*/borgmatic'
)
def test_runtime_directory_uses_config_option():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os).should_receive('makedirs')
@@ -52,6 +63,17 @@ def test_runtime_directory_uses_config_option_without_adding_duplicate_borgmatic
assert borgmatic_runtime_directory == '/run/./borgmatic'
def test_runtime_directory_with_relative_config_option_errors():
flexmock(module.os).should_receive('makedirs').never()
config = {'user_runtime_directory': 'run', 'borgmatic_source_directory': '/nope'}
with pytest.raises(ValueError):
with module.Runtime_directory(
config, 'prefix'
) as borgmatic_runtime_directory: # noqa: F841
pass
def test_runtime_directory_falls_back_to_xdg_runtime_dir():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(
@@ -74,6 +96,15 @@ def test_runtime_directory_falls_back_to_xdg_runtime_dir_without_adding_duplicat
assert borgmatic_runtime_directory == '/run/./borgmatic'
def test_runtime_directory_with_relative_xdg_runtime_dir_errors():
flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return('run')
flexmock(module.os).should_receive('makedirs').never()
with pytest.raises(ValueError):
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory: # noqa: F841
pass
def test_runtime_directory_falls_back_to_runtime_directory():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None)
@@ -98,6 +129,18 @@ def test_runtime_directory_falls_back_to_runtime_directory_without_adding_duplic
assert borgmatic_runtime_directory == '/run/./borgmatic'
def test_runtime_directory_with_relative_runtime_directory_errors():
flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None)
flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return(
'run'
)
flexmock(module.os).should_receive('makedirs').never()
with pytest.raises(ValueError):
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory: # noqa: F841
pass
def test_runtime_directory_falls_back_to_tmpdir_and_adds_temporary_subdirectory_that_get_cleaned_up():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None)
@@ -116,6 +159,20 @@ def test_runtime_directory_falls_back_to_tmpdir_and_adds_temporary_subdirectory_
assert borgmatic_runtime_directory == '/run/borgmatic-1234/./borgmatic'
def test_runtime_directory_with_relative_tmpdir_errors():
flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None)
flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return(
None
)
flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return('run')
flexmock(module.tempfile).should_receive('TemporaryDirectory').never()
flexmock(module.os).should_receive('makedirs').never()
with pytest.raises(ValueError):
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory: # noqa: F841
pass
def test_runtime_directory_falls_back_to_temp_and_adds_temporary_subdirectory_that_get_cleaned_up():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None)
@@ -135,6 +192,21 @@ def test_runtime_directory_falls_back_to_temp_and_adds_temporary_subdirectory_th
assert borgmatic_runtime_directory == '/run/borgmatic-1234/./borgmatic'
def test_runtime_directory_with_relative_temp_errors():
flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None)
flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return(
None
)
flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return(None)
flexmock(module.os.environ).should_receive('get').with_args('TEMP').and_return('run')
flexmock(module.tempfile).should_receive('TemporaryDirectory').never()
flexmock(module.os).should_receive('makedirs')
with pytest.raises(ValueError):
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory: # noqa: F841
pass
def test_runtime_directory_falls_back_to_hard_coded_tmp_path_and_adds_temporary_subdirectory_that_get_cleaned_up():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None)
@@ -154,6 +226,25 @@ def test_runtime_directory_falls_back_to_hard_coded_tmp_path_and_adds_temporary_
assert borgmatic_runtime_directory == '/tmp/borgmatic-1234/./borgmatic'
def test_runtime_directory_with_erroring_cleanup_does_not_raise():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os.environ).should_receive('get').with_args('XDG_RUNTIME_DIR').and_return(None)
flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return(
None
)
flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return(None)
flexmock(module.os.environ).should_receive('get').with_args('TEMP').and_return(None)
temporary_directory = flexmock(name='/tmp/borgmatic-1234')
temporary_directory.should_receive('cleanup').and_raise(OSError).once()
flexmock(module.tempfile).should_receive('TemporaryDirectory').with_args(
prefix='borgmatic-', dir='/tmp'
).and_return(temporary_directory)
flexmock(module.os).should_receive('makedirs')
with module.Runtime_directory({}, 'prefix') as borgmatic_runtime_directory:
assert borgmatic_runtime_directory == '/tmp/borgmatic-1234/./borgmatic'
@pytest.mark.parametrize(
'borgmatic_runtime_directory,expected_glob',
(
+139
View File
@@ -0,0 +1,139 @@
import sys
from flexmock import flexmock
from borgmatic.hooks import bootstrap as module
def test_dump_data_sources_creates_manifest_file():
flexmock(module.os).should_receive('makedirs')
flexmock(module.importlib.metadata).should_receive('version').and_return('1.0.0')
manifest_file = flexmock(
__enter__=lambda *args: flexmock(write=lambda *args: None, close=lambda *args: None),
__exit__=lambda *args: None,
)
flexmock(sys.modules['builtins']).should_receive('open').with_args(
'/run/borgmatic/bootstrap/manifest.json', 'w'
).and_return(manifest_file)
flexmock(module.json).should_receive('dump').with_args(
{'borgmatic_version': '1.0.0', 'config_paths': ('test.yaml',)},
manifest_file,
).once()
module.dump_data_sources(
hook_config={},
config={},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
def test_dump_data_sources_with_store_config_files_false_does_not_create_manifest_file():
flexmock(module.os).should_receive('makedirs').never()
flexmock(module.json).should_receive('dump').never()
hook_config = {'store_config_files': False}
module.dump_data_sources(
hook_config=hook_config,
config={'bootstrap': hook_config},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=True,
)
def test_dump_data_sources_with_dry_run_does_not_create_manifest_file():
flexmock(module.os).should_receive('makedirs').never()
flexmock(module.json).should_receive('dump').never()
module.dump_data_sources(
hook_config={},
config={},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=True,
)
def test_remove_data_source_dumps_deletes_manifest_and_parent_directory():
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os).should_receive('remove').with_args(
'/run/borgmatic/bootstrap/manifest.json'
).once()
flexmock(module.os).should_receive('rmdir').with_args('/run/borgmatic/bootstrap').once()
module.remove_data_source_dumps(
hook_config={},
config={},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_dry_run_bails():
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os).should_receive('remove').never()
flexmock(module.os).should_receive('rmdir').never()
module.remove_data_source_dumps(
hook_config={},
config={},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=True,
)
def test_remove_data_source_dumps_swallows_manifest_file_not_found_error():
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os).should_receive('remove').with_args(
'/run/borgmatic/bootstrap/manifest.json'
).and_raise(FileNotFoundError).once()
flexmock(module.os).should_receive('rmdir').with_args('/run/borgmatic/bootstrap').once()
module.remove_data_source_dumps(
hook_config={},
config={},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_swallows_manifest_parent_directory_not_found_error():
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os).should_receive('remove').with_args(
'/run/borgmatic/bootstrap/manifest.json'
).once()
flexmock(module.os).should_receive('rmdir').with_args('/run/borgmatic/bootstrap').and_raise(
FileNotFoundError
).once()
module.remove_data_source_dumps(
hook_config={},
config={},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
+5 -3
View File
@@ -77,10 +77,12 @@ def test_call_hooks_calls_skips_return_values_for_missing_hooks():
assert return_values == expected_return_values
def test_call_hooks_calls_skips_return_values_for_null_hooks():
def test_call_hooks_calls_treats_null_hook_as_optionless():
config = {'super_hook': flexmock(), 'other_hook': None}
expected_return_values = {'super_hook': flexmock()}
flexmock(module).should_receive('call_hook').and_return(expected_return_values['super_hook'])
expected_return_values = {'super_hook': flexmock(), 'other_hook': flexmock()}
flexmock(module).should_receive('call_hook').and_return(
expected_return_values['super_hook']
).and_return(expected_return_values['other_hook'])
return_values = module.call_hooks(
'do_stuff', config, 'prefix', ('super_hook', 'other_hook'), 55
+42 -6
View File
@@ -75,7 +75,13 @@ def test_dump_data_sources_dumps_each_database():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== processes
)
@@ -100,7 +106,13 @@ def test_dump_data_sources_dumps_with_password():
).and_return(process).once()
assert module.dump_data_sources(
[database], {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
[database],
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -120,7 +132,13 @@ def test_dump_data_sources_dumps_all_databases_at_once():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -143,7 +161,13 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== processes
)
@@ -449,7 +473,13 @@ def test_dump_data_sources_errors_for_missing_all_databases():
with pytest.raises(ValueError):
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
@@ -463,7 +493,13 @@ def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=True
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=True,
)
== []
)
+49 -7
View File
@@ -43,7 +43,13 @@ def test_dump_data_sources_runs_mongodump_for_each_database():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== processes
)
@@ -60,7 +66,13 @@ def test_dump_data_sources_with_dry_run_skips_mongodump():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=True
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=True,
)
== []
)
@@ -93,7 +105,13 @@ def test_dump_data_sources_runs_mongodump_with_hostname_and_port():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -133,7 +151,13 @@ def test_dump_data_sources_runs_mongodump_with_username_and_password():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -153,7 +177,13 @@ def test_dump_data_sources_runs_mongodump_with_directory_format():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== []
)
@@ -183,7 +213,13 @@ def test_dump_data_sources_runs_mongodump_with_options():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -203,7 +239,13 @@ def test_dump_data_sources_runs_mongodumpall_for_all_databases():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
+42 -6
View File
@@ -75,7 +75,13 @@ def test_dump_data_sources_dumps_each_database():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== processes
)
@@ -100,7 +106,13 @@ def test_dump_data_sources_dumps_with_password():
).and_return(process).once()
assert module.dump_data_sources(
[database], {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
[database],
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -120,7 +132,13 @@ def test_dump_data_sources_dumps_all_databases_at_once():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -143,7 +161,13 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== processes
)
@@ -447,7 +471,13 @@ def test_dump_data_sources_errors_for_missing_all_databases():
with pytest.raises(ValueError):
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
@@ -461,7 +491,13 @@ def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=True
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=True,
)
== []
)
+84 -12
View File
@@ -253,7 +253,13 @@ def test_dump_data_sources_runs_pg_dump_for_each_database():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== processes
)
@@ -267,7 +273,13 @@ def test_dump_data_sources_raises_when_no_database_names_to_dump():
with pytest.raises(ValueError):
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
@@ -278,7 +290,13 @@ def test_dump_data_sources_does_not_raise_when_no_database_names_to_dump():
flexmock(module).should_receive('database_names_to_dump').and_return(())
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=True
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=True,
) == []
@@ -298,7 +316,13 @@ def test_dump_data_sources_with_duplicate_dump_skips_pg_dump():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== []
)
@@ -320,7 +344,13 @@ def test_dump_data_sources_with_dry_run_skips_pg_dump():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=True
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=True,
)
== []
)
@@ -360,7 +390,13 @@ def test_dump_data_sources_runs_pg_dump_with_hostname_and_port():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -398,7 +434,13 @@ def test_dump_data_sources_runs_pg_dump_with_username_and_password():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -436,7 +478,13 @@ def test_dump_data_sources_with_username_injection_attack_gets_escaped():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -470,7 +518,13 @@ def test_dump_data_sources_runs_pg_dump_with_directory_format():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== []
)
@@ -507,7 +561,13 @@ def test_dump_data_sources_runs_pg_dump_with_options():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -531,7 +591,13 @@ def test_dump_data_sources_runs_pg_dumpall_for_all_databases():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
@@ -567,7 +633,13 @@ def test_dump_data_sources_runs_non_default_pg_dump():
).and_return(process).once()
assert module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
) == [process]
+42 -6
View File
@@ -28,7 +28,13 @@ def test_dump_data_sources_logs_and_skips_if_dump_already_exists():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== []
)
@@ -53,7 +59,13 @@ def test_dump_data_sources_dumps_each_database():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== processes
)
@@ -85,7 +97,13 @@ def test_dump_data_sources_with_path_injection_attack_gets_escaped():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== processes
)
@@ -108,7 +126,13 @@ def test_dump_data_sources_with_non_existent_path_warns_and_dumps_database():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== processes
)
@@ -133,7 +157,13 @@ def test_dump_data_sources_with_name_all_warns_and_dumps_all_databases():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=False
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=False,
)
== processes
)
@@ -152,7 +182,13 @@ def test_dump_data_sources_does_not_dump_if_dry_run():
assert (
module.dump_data_sources(
databases, {}, 'test.yaml', borgmatic_runtime_directory='/run/borgmatic', dry_run=True
databases,
{},
'test.yaml',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=[],
dry_run=True,
)
== []
)
+377
View File
@@ -0,0 +1,377 @@
import pytest
from flexmock import flexmock
import borgmatic.execute
from borgmatic.hooks import zfs as module
def test_get_datasets_to_backup_filters_datasets_by_source_directories():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
'dataset\t/dataset\t-\nother\t/other\t-',
)
assert module.get_datasets_to_backup(
'zfs', source_directories=('/foo', '/dataset', '/bar')
) == (('dataset', '/dataset'),)
def test_get_datasets_to_backup_filters_datasets_by_user_property():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
'dataset\t/dataset\tauto\nother\t/other\t-',
)
assert module.get_datasets_to_backup('zfs', source_directories=('/foo', '/bar')) == (
('dataset', '/dataset'),
)
def test_get_datasets_to_backup_with_invalid_list_output_raises():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
'dataset',
)
with pytest.raises(ValueError, match='zfs'):
module.get_datasets_to_backup('zfs', source_directories=('/foo', '/bar'))
def test_get_get_all_datasets_does_not_filter_datasets():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
'dataset\t/dataset\nother\t/other',
)
assert module.get_all_datasets('zfs') == (
('dataset', '/dataset'),
('other', '/other'),
)
def test_get_all_datasets_with_invalid_list_output_raises():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
'dataset',
)
with pytest.raises(ValueError, match='zfs'):
module.get_all_datasets('zfs')
def test_dump_data_sources_snapshots_and_mounts_and_updates_source_directories():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(('dataset', '/mnt/dataset'),)
)
flexmock(module.os).should_receive('getpid').and_return(1234)
full_snapshot_name = 'dataset@borgmatic-1234'
flexmock(module).should_receive('snapshot_dataset').with_args(
'zfs',
full_snapshot_name,
).once()
snapshot_mount_path = '/run/borgmatic/zfs_snapshots/./mnt/dataset'
flexmock(module).should_receive('mount_snapshot').with_args(
'mount',
full_snapshot_name,
module.os.path.normpath(snapshot_mount_path),
).once()
source_directories = ['/mnt/dataset']
assert (
module.dump_data_sources(
hook_config={},
config={'source_directories': '/mnt/dataset', 'zfs': {}},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
dry_run=False,
)
== []
)
assert source_directories == [snapshot_mount_path]
def test_dump_data_sources_uses_custom_commands():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(('dataset', '/mnt/dataset'),)
)
flexmock(module.os).should_receive('getpid').and_return(1234)
full_snapshot_name = 'dataset@borgmatic-1234'
flexmock(module).should_receive('snapshot_dataset').with_args(
'/usr/local/bin/zfs',
full_snapshot_name,
).once()
snapshot_mount_path = '/run/borgmatic/zfs_snapshots/./mnt/dataset'
flexmock(module).should_receive('mount_snapshot').with_args(
'/usr/local/bin/mount',
full_snapshot_name,
module.os.path.normpath(snapshot_mount_path),
).once()
source_directories = ['/mnt/dataset']
hook_config = {
'zfs_command': '/usr/local/bin/zfs',
'mount_command': '/usr/local/bin/mount',
}
assert (
module.dump_data_sources(
hook_config=hook_config,
config={
'source_directories': source_directories,
'zfs': hook_config,
},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
dry_run=False,
)
== []
)
assert source_directories == [snapshot_mount_path]
def test_dump_data_sources_with_dry_run_skips_commands_and_does_not_touch_source_directories():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(('dataset', '/mnt/dataset'),)
)
flexmock(module.os).should_receive('getpid').and_return(1234)
flexmock(module).should_receive('snapshot_dataset').never()
flexmock(module).should_receive('mount_snapshot').never()
source_directories = ['/mnt/dataset']
assert (
module.dump_data_sources(
hook_config={},
config={'source_directories': '/mnt/dataset', 'zfs': {}},
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
dry_run=True,
)
== []
)
assert source_directories == ['/mnt/dataset']
def test_get_all_snapshots_parses_list_output():
flexmock(borgmatic.execute).should_receive('execute_command_and_capture_output').and_return(
'dataset1@borgmatic-1234\ndataset2@borgmatic-4567',
)
assert module.get_all_snapshots('zfs') == ('dataset1@borgmatic-1234', 'dataset2@borgmatic-4567')
def test_remove_data_source_dumps_unmounts_and_destroys_snapshots():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os.path).should_receive('isdir').and_return(True)
flexmock(module.shutil).should_receive('rmtree')
flexmock(module).should_receive('unmount_snapshot').with_args(
'umount', '/run/borgmatic/zfs_snapshots/mnt/dataset'
).once()
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
)
flexmock(module).should_receive('destroy_snapshot').with_args(
'zfs', 'dataset@borgmatic-1234'
).once()
module.remove_data_source_dumps(
hook_config={},
config={'source_directories': '/mnt/dataset', 'zfs': {}},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_use_custom_commands():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os.path).should_receive('isdir').and_return(True)
flexmock(module.shutil).should_receive('rmtree')
flexmock(module).should_receive('unmount_snapshot').with_args(
'/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/mnt/dataset'
).once()
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
)
flexmock(module).should_receive('destroy_snapshot').with_args(
'/usr/local/bin/zfs', 'dataset@borgmatic-1234'
).once()
hook_config = {'zfs_command': '/usr/local/bin/zfs', 'umount_command': '/usr/local/bin/umount'}
module.remove_data_source_dumps(
hook_config=hook_config,
config={'source_directories': '/mnt/dataset', 'zfs': hook_config},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_bails_for_missing_zfs_command():
flexmock(module).should_receive('get_all_datasets').and_raise(FileNotFoundError)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
hook_config = {'zfs_command': 'wtf'}
module.remove_data_source_dumps(
hook_config=hook_config,
config={'source_directories': '/mnt/dataset', 'zfs': hook_config},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_bails_for_zfs_command_error():
flexmock(module).should_receive('get_all_datasets').and_raise(
module.subprocess.CalledProcessError(1, 'wtf')
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
hook_config = {'zfs_command': 'wtf'}
module.remove_data_source_dumps(
hook_config=hook_config,
config={'source_directories': '/mnt/dataset', 'zfs': hook_config},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_bails_for_missing_umount_command():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os.path).should_receive('isdir').and_return(True)
flexmock(module.shutil).should_receive('rmtree')
flexmock(module).should_receive('unmount_snapshot').with_args(
'/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/mnt/dataset'
).and_raise(FileNotFoundError)
flexmock(module).should_receive('get_all_snapshots').never()
flexmock(module).should_receive('destroy_snapshot').never()
hook_config = {'zfs_command': '/usr/local/bin/zfs', 'umount_command': '/usr/local/bin/umount'}
module.remove_data_source_dumps(
hook_config=hook_config,
config={'source_directories': '/mnt/dataset', 'zfs': hook_config},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_bails_for_umount_command_error():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os.path).should_receive('isdir').and_return(True)
flexmock(module.shutil).should_receive('rmtree')
flexmock(module).should_receive('unmount_snapshot').with_args(
'/usr/local/bin/umount', '/run/borgmatic/zfs_snapshots/mnt/dataset'
).and_raise(module.subprocess.CalledProcessError(1, 'wtf'))
flexmock(module).should_receive('get_all_snapshots').never()
flexmock(module).should_receive('destroy_snapshot').never()
hook_config = {'zfs_command': '/usr/local/bin/zfs', 'umount_command': '/usr/local/bin/umount'}
module.remove_data_source_dumps(
hook_config=hook_config,
config={'source_directories': '/mnt/dataset', 'zfs': hook_config},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_skips_unmount_snapshot_directories_that_are_not_actually_directories():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os.path).should_receive('isdir').and_return(False)
flexmock(module.shutil).should_receive('rmtree').never()
flexmock(module).should_receive('unmount_snapshot').never()
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
)
flexmock(module).should_receive('destroy_snapshot').with_args(
'zfs', 'dataset@borgmatic-1234'
).once()
module.remove_data_source_dumps(
hook_config={},
config={'source_directories': '/mnt/dataset', 'zfs': {}},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_not_actually_directories():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os.path).should_receive('isdir').with_args(
'/run/borgmatic/zfs_snapshots'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/run/borgmatic/zfs_snapshots/mnt/dataset'
).and_return(False)
flexmock(module.shutil).should_receive('rmtree')
flexmock(module).should_receive('unmount_snapshot').never()
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
)
flexmock(module).should_receive('destroy_snapshot').with_args(
'zfs', 'dataset@borgmatic-1234'
).once()
module.remove_data_source_dumps(
hook_config={},
config={'source_directories': '/mnt/dataset', 'zfs': {}},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_dry_run_skips_unmount_and_destroy():
flexmock(module).should_receive('get_all_datasets').and_return((('dataset', '/mnt/dataset'),))
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).and_return('/run/borgmatic')
flexmock(module.glob).should_receive('glob').replace_with(lambda path: [path])
flexmock(module.os.path).should_receive('isdir').and_return(True)
flexmock(module.shutil).should_receive('rmtree').never()
flexmock(module).should_receive('unmount_snapshot').never()
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid')
)
flexmock(module).should_receive('destroy_snapshot').never()
module.remove_data_source_dumps(
hook_config={},
config={'source_directories': '/mnt/dataset', 'zfs': {}},
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=True,
)
+4
View File
@@ -45,11 +45,13 @@ def test_interactive_console_true_when_isatty_and_TERM_is_not_dumb(capsys):
def test_should_do_markup_respects_no_color_value():
flexmock(module.os.environ).should_receive('get').and_return(None)
flexmock(module).should_receive('interactive_console').never()
assert module.should_do_markup(no_color=True, configs={}) is False
def test_should_do_markup_respects_config_value():
flexmock(module.os.environ).should_receive('get').and_return(None)
flexmock(module).should_receive('interactive_console').never()
assert module.should_do_markup(no_color=False, configs={'foo.yaml': {'color': False}}) is False
@@ -58,6 +60,7 @@ def test_should_do_markup_respects_config_value():
def test_should_do_markup_prefers_any_false_config_value():
flexmock(module.os.environ).should_receive('get').and_return(None)
flexmock(module).should_receive('interactive_console').never()
assert (
@@ -84,6 +87,7 @@ def test_should_do_markup_respects_PY_COLORS_environment_variable():
def test_should_do_markup_prefers_no_color_value_to_config_value():
flexmock(module.os.environ).should_receive('get').and_return(None)
flexmock(module).should_receive('interactive_console').never()
assert module.should_do_markup(no_color=True, configs={'foo.yaml': {'color': True}}) is False