Compare commits

...
94 Commits
Author SHA1 Message Date
Dan Helfman 81cd03cbbf Bump version for release. 2024-12-06 20:29:16 -08:00
Dan Helfman f2455527fc Fix spelling in comment (#80). 2024-12-06 20:22:45 -08:00
Dan Helfman 62d67cde0a LVM snapshots + ZFS and Btrfs improvements (#80).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/949
2024-12-07 04:21:22 +00:00
Dan Helfman ae8a9db27d Fix flake issues (#80). 2024-12-06 16:12:01 -08:00
Dan Helfman 8979f8918d Organize imports (#80). 2024-12-06 16:05:46 -08:00
Dan Helfman eb97708092 Completed tests for LVM (#80). 2024-12-06 16:02:33 -08:00
Dan Helfman f2d93b85b4 Lots of LVM unit tests + code formatting (#80). 2024-12-06 13:59:38 -08:00
Dan Helfman b999d2dc4d Add some missing test coverage (#80). 2024-12-06 10:27:47 -08:00
Dan Helfman 7f2e38d061 Fix file permissions (#80). 2024-12-06 09:40:32 -08:00
Dan Helfman 140fc248b6 Fix LVM end-to-end tests (#80). 2024-12-06 09:39:24 -08:00
Dan Helfman ec9e1a8223 LVM hook end-to-end tests, not quite working yet (#80). 2024-12-05 22:46:50 -08:00
Dan Helfman 03bbe77dd9 Add an end-to-end test for the Btrfs hook using a fake Btrfs binary (#80). 2024-12-05 17:35:44 -08:00
Dan Helfman f1c5f11422 Add an end-to-end test for the ZFS hook using a fake ZFS binary (#80). 2024-12-05 11:18:53 -08:00
Dan Helfman f8df06fb92 Remove divison by zero (#80). 2024-12-04 20:33:59 -08:00
Dan Helfman d95707ff9b Get existing tests passing (#80). 2024-12-04 20:22:59 -08:00
Dan Helfman 51a7f50e3a Add ZFS snapshot unmount error fix to NEWS (#950). 2024-12-04 15:43:05 -08:00
Dan Helfman 49b8b693af Don't try to unmount a ZFS snapshot if it's already deleted (#80). 2024-12-04 15:39:04 -08:00
Dan Helfman d0e92493f6 Fix broken ZFS tests (#80). 2024-12-04 14:48:13 -08:00
Dan Helfman 9afdaca985 Before unmounting, remove the snapshot mount path instead of the parent snapshot directory (#80). 2024-12-03 19:19:22 -08:00
Dan Helfman cc11ed78e0 Put LVM snapshots into a data structure for convenience (#80). 2024-12-03 19:12:41 -08:00
Dan Helfman 87f3746881 Fix a ZFS edge case in which the hook tries to unmounted a non-mounted directory (#80). 2024-12-03 15:56:03 -08:00
Dan Helfman 347a4c3dd5 Fix breakage of ZFS user property auto-backup (#80). 2024-12-03 15:43:50 -08:00
Dan Helfman 399bb6ef68 Add recent LVM and ZFS work to NEWS (#80). 2024-12-03 12:22:43 -08:00
Dan Helfman 9b9ecad299 Port the parent directory discovery logic from LVM to Btrfs (#80). 2024-12-03 12:15:34 -08:00
Dan Helfman 8c4b899a13 Use a namedtuple for logical volume metadata (#80). 2024-12-03 11:12:27 -08:00
Dan Helfman 9b77de3d66 Port the parent directory discovery logic from LVM to ZFS (#80). 2024-12-03 11:05:45 -08:00
Dan Helfman bfeea5d394 Code formatting (#80). 2024-12-03 08:52:05 -08:00
Dan Helfman 8a6225b7c2 Factor out logic for finding contained source directories in a parent directory (#80). 2024-12-03 08:51:10 -08:00
Dan Helfman 9aaa3c925f Code formatting (#80). 2024-12-02 21:01:34 -08:00
Dan Helfman 88fd1ae454 Discover parent/grandparent/etc. logical volumes of source directories (#80). 2024-12-02 20:58:50 -08:00
Dan Helfman 27305ec2bf Clarify the path rewriting for LVM (but also ZFS + Btrfs) (#80). 2024-12-02 12:01:04 -08:00
Dan Helfman 4453c2d49c Add LVM logo to integrations docs. 2024-12-02 11:28:57 -08:00
Dan Helfman 6367a00013 Add snapshot_size option (#80). 2024-12-02 11:09:07 -08:00
Dan Helfman cd654cbb57 Fix a few docstring typos (#80). 2024-12-01 21:00:11 -08:00
Dan Helfman 1e8f73779f Fix typo in schema comment (#80). 2024-12-01 20:25:16 -08:00
Dan Helfman 27d167b071 LVM snapshots WIP (#80). 2024-12-01 20:13:02 -08:00
Dan Helfman cfff6c6855 Btrfs snapshotting (#251).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/946
2024-11-30 19:19:09 +00:00
Dan Helfman 37efaeae88 Warn if Btrfs is configured but there are no Btrfs subvolumes detected (#251). 2024-11-30 10:55:30 -08:00
Dan Helfman 0978c669ad A little more Btrfs error handling (#251). 2024-11-30 10:25:01 -08:00
Dan Helfman 1366269586 Add a couple of missing tests (#251). 2024-11-30 09:44:55 -08:00
Dan Helfman a9a0910817 Add Btrfs logo to integrations docs (#251). 2024-11-30 09:36:52 -08:00
Dan Helfman 5bcc7b60c8 Tests for Btrfs (#251). 2024-11-30 09:32:50 -08:00
Dan Helfman 84a0552277 Improve Btrfs hook factoring/organization (#251). 2024-11-29 09:36:46 -08:00
Dan Helfman d4a02f73b5 Create Btrfs snapshots as read-only (#251). 2024-11-28 22:18:44 -08:00
Dan Helfman 3f901c0a52 Btrfs hook documentation (#251). 2024-11-28 20:32:12 -08:00
Dan Helfman b5b5c1fafa Initial work on a Btrfs hook (#251). 2024-11-28 18:47:15 -08:00
Dan Helfman 86e5085acc Fix incorrect documentation links to source. 2024-11-27 08:54:19 -08:00
Dan Helfman 08a5e8717b Merge branch 'main' of ssh://projects.torsion.org:3022/borgmatic-collective/borgmatic 2024-11-27 08:51:00 -08:00
Dan Helfman 6b2f2b2ac4 Reorganize data source and monitoring hooks to make developing new hooks easier. 2024-11-27 08:50:34 -08:00
Dan Helfman a07cf9e699 Revert temporary reversion of 1.9.4.dev0.
revert Temporary revert of 1.9.4.dev0 changeset so we can re-build 1.9.3 (which never actually got built).

revert Fix library error when running within a PyInstaller bundle (#926).
2024-11-26 16:20:06 +00:00
Dan Helfman bf40b01077 Temporary revert of 1.9.4.dev0 changeset so we can re-build 1.9.3 (which never actually got built).
revert Fix library error when running within a PyInstaller bundle (#926).
2024-11-26 16:13:39 +00:00
Dan Helfman a5c6a2fe1c Fix library error when running within a PyInstaller bundle (#926). 2024-11-25 20:14:18 -08:00
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
111 changed files with 6464 additions and 1254 deletions
+25
View File
@@ -1,3 +1,28 @@
1.9.4
* #80 (beta): Add an LVM hook for snapshotting and backing up LVM logical volumes. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
* #251 (beta): Add a Btrfs hook for snapshotting and backing up Btrfs subvolumes. See the
documentation for more information:
https://torsion.org/borgmatic/docs/how-to/snapshot-your-filesystems/
* #926: Fix a library error when running within a PyInstaller bundle.
* #950: Fix a snapshot unmount error in the ZFS hook when using nested datasets.
* Update the ZFS hook to discover and snapshot ZFS datasets even if they are parent/grandparent
directories of your source directories.
* Reorganize data source and monitoring hooks to make developing new hooks easier.
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
+4
View File
@@ -61,6 +61,10 @@ 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://btrfs.readthedocs.io/"><img src="docs/static/btrfs.png" alt="Btrfs" height="60px" style="margin-bottom:20px; margin-right:20px;"></a>
<a href="https://sourceware.org/lvm2/"><img src="docs/static/lvm.png" alt="LVM" 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>
+14 -4
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
@@ -357,7 +364,7 @@ def collect_spot_check_source_paths(
'use_streaming',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
).values()
)
@@ -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')
+136 -33
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
@@ -9,35 +10,137 @@ import borgmatic.config.paths
import borgmatic.config.validate
import borgmatic.hooks.command
import borgmatic.hooks.dispatch
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(
@@ -82,33 +185,33 @@ def run_create(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
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,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
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,
@@ -128,7 +231,7 @@ def run_create(
'remove_data_source_dumps',
config,
config_filename,
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
+17 -10
View File
@@ -11,8 +11,8 @@ import borgmatic.borg.mount
import borgmatic.borg.repo_list
import borgmatic.config.paths
import borgmatic.config.validate
import borgmatic.hooks.data_source.dump
import borgmatic.hooks.dispatch
import borgmatic.hooks.dump
logger = logging.getLogger(__name__)
@@ -44,7 +44,8 @@ def get_configured_data_source(
hooks_to_search = {
hook_name: value
for (hook_name, value) in config.items()
if hook_name in borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES
if hook_name.split('_databases')[0]
in borgmatic.hooks.dispatch.get_submodule_names(borgmatic.hooks.data_source)
}
else:
try:
@@ -123,10 +124,10 @@ def restore_single_data_source(
'make_data_source_dump_patterns',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
data_source['name'],
)[hook_name]
)[hook_name.split('_databases')[0]]
destination_path = (
tempfile.mkdtemp(dir=borgmatic_runtime_directory)
@@ -141,7 +142,11 @@ def restore_single_data_source(
dry_run=global_arguments.dry_run,
repository=repository['path'],
archive=archive_name,
paths=[borgmatic.hooks.dump.convert_glob_patterns_to_borg_pattern(dump_patterns)],
paths=[
borgmatic.hooks.data_source.dump.convert_glob_patterns_to_borg_pattern(
dump_patterns
)
],
config=config,
local_borg_version=local_borg_version,
global_arguments=global_arguments,
@@ -162,11 +167,11 @@ def restore_single_data_source(
shutil.rmtree(destination_path, ignore_errors=True)
# Run a single data source restore, consuming the extract stdout (if any).
borgmatic.hooks.dispatch.call_hooks(
borgmatic.hooks.dispatch.call_hook(
function_name='restore_data_source_dump',
config=config,
log_prefix=repository['path'],
hook_names=[hook_name],
hook_name=hook_name,
data_source=data_source,
dry_run=global_arguments.dry_run,
extract_process=extract_process,
@@ -206,7 +211,9 @@ def collect_archive_data_source_names(
global_arguments,
list_paths=[
'sh:'
+ borgmatic.hooks.dump.make_data_source_dump_path(base_directory, '*_databases/*/*')
+ borgmatic.hooks.data_source.dump.make_data_source_dump_path(
base_directory, '*_databases/*/*'
)
for base_directory in (
'borgmatic',
borgmatic.config.paths.make_runtime_directory_glob(borgmatic_runtime_directory),
@@ -354,7 +361,7 @@ def run_restore(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
@@ -451,7 +458,7 @@ def run_restore(
'remove_data_source_dumps',
config,
repository['path'],
borgmatic.hooks.dump.DATA_SOURCE_HOOK_NAMES,
borgmatic.hooks.dispatch.Hook_type.DATA_SOURCE,
borgmatic_runtime_directory,
global_arguments.dry_run,
)
+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'
)
+9 -8
View File
@@ -39,7 +39,8 @@ from borgmatic.borg import umount as borg_umount
from borgmatic.borg import version as borg_version
from borgmatic.commands.arguments import parse_arguments
from borgmatic.config import checks, collect, validate
from borgmatic.hooks import command, dispatch, monitor
from borgmatic.hooks import command, dispatch
from borgmatic.hooks.monitoring import monitor
from borgmatic.logger import DISABLED, add_custom_log_levels, configure_logging, should_do_markup
from borgmatic.signals import configure_signals
from borgmatic.verbosity import verbosity_to_log_level
@@ -103,7 +104,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'initialize_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitoring_log_level,
global_arguments.dry_run,
)
@@ -112,7 +113,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'ping_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitor.State.START,
monitoring_log_level,
global_arguments.dry_run,
@@ -188,7 +189,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'ping_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitor.State.LOG,
monitoring_log_level,
global_arguments.dry_run,
@@ -205,7 +206,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'ping_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitor.State.FINISH,
monitoring_log_level,
global_arguments.dry_run,
@@ -214,7 +215,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'destroy_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitoring_log_level,
global_arguments.dry_run,
)
@@ -241,7 +242,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'ping_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitor.State.FAIL,
monitoring_log_level,
global_arguments.dry_run,
@@ -250,7 +251,7 @@ def run_configuration(config_filename, config, config_paths, arguments):
'destroy_monitor',
config,
config_filename,
monitor.MONITOR_HOOK_NAMES,
dispatch.Hook_type.MONITORING,
monitoring_log_level,
global_arguments.dry_run,
)
+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):
+46 -2
View File
@@ -33,6 +33,35 @@ def get_borgmatic_source_directory(config):
TEMPORARY_DIRECTORY_PREFIX = 'borgmatic-'
def replace_temporary_subdirectory_with_glob(
path, temporary_directory_prefix=TEMPORARY_DIRECTORY_PREFIX
):
'''
Given an absolute temporary directory path and an optional temporary directory prefix, look for
a subdirectory within it starting with the temporary directory prefix (or a default) 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 +99,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 +120,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 +140,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):
+100 -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,92 @@ 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.
btrfs:
type: ["object", "null"]
additionalProperties: false
properties:
btrfs_command:
type: string
description: |
Command to use instead of "btrfs".
example: /usr/local/bin/btrfs
findmnt_command:
type: string
description: |
Command to use instead of "findmnt".
example: /usr/local/bin/findmnt
description: |
Configuration for integration with the Btrfs filesystem.
lvm:
type: ["object", "null"]
additionalProperties: false
properties:
snapshot_size:
type: string
description: |
Size to allocate for each snapshot taken, including the
units to use for that size. Defaults to "10%ORIGIN" (10%
of the size of logical volume being snapshotted). See the
lvcreate "--size" and "--extents" documentation for more
information:
https://www.man7.org/linux/man-pages/man8/lvcreate.8.html
example: 5GB
lvcreate_command:
type: string
description: |
Command to use instead of "lvcreate".
example: /usr/local/bin/lvcreate
lvremove_command:
type: string
description: |
Command to use instead of "lvremove".
example: /usr/local/bin/lvremove
lvs_command:
type: string
description: |
Command to use instead of "lvs".
example: /usr/local/bin/lvs
lsblk_command:
type: string
description: |
Command to use instead of "lsblk".
example: /usr/local/bin/lsblk
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 Linux LVM (Logical Volume
Manager).
+25 -9
View File
@@ -2,8 +2,9 @@ import logging
import os
import re
import shlex
import sys
from borgmatic import execute
import borgmatic.execute
logger = logging.getLogger(__name__)
@@ -27,6 +28,20 @@ def interpolate_context(config_filename, hook_description, command, context):
return command
def make_environment(current_environment, sys_module=sys):
'''
Given the existing system environment as a map from environment variable name to value, return
(in the same form) any extra environment variables that should be used when running command
hooks.
'''
# Detect whether we're running within a PyInstaller bundle. If so, set or clear LD_LIBRARY_PATH
# based on the value of LD_LIBRARY_PATH_ORIG. This prevents library version information errors.
if getattr(sys_module, 'frozen', False) and hasattr(sys_module, '_MEIPASS'):
return {'LD_LIBRARY_PATH': current_environment.get('LD_LIBRARY_PATH_ORIG', '')}
return {}
def execute_hook(commands, umask, config_filename, description, dry_run, **context):
'''
Given a list of hook commands to execute, a umask to execute with (or None), a config filename,
@@ -65,14 +80,15 @@ def execute_hook(commands, umask, config_filename, description, dry_run, **conte
try:
for command in commands:
if not dry_run:
execute.execute_command(
[command],
output_log_level=(
logging.ERROR if description == 'on-error' else logging.WARNING
),
shell=True,
)
if dry_run:
continue
borgmatic.execute.execute_command(
[command],
output_log_level=(logging.ERROR if description == 'on-error' else logging.WARNING),
shell=True,
extra_environment=make_environment(os.environ),
)
finally:
if original_umask:
os.umask(original_umask)
+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()
+333
View File
@@ -0,0 +1,333 @@
import collections
import glob
import logging
import os
import shutil
import subprocess
import borgmatic.config.paths
import borgmatic.execute
import borgmatic.hooks.data_source.snapshot
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 get_filesystem_mount_points(findmnt_command):
'''
Given a findmnt command to run, get all top-level Btrfs filesystem mount points.
'''
findmnt_output = borgmatic.execute.execute_command_and_capture_output(
tuple(findmnt_command.split(' '))
+ (
'-n', # No headings.
'-t', # Filesystem type.
'btrfs',
)
)
return tuple(line.rstrip().split(' ')[0] for line in findmnt_output.splitlines())
def get_subvolumes_for_filesystem(btrfs_command, filesystem_mount_point):
'''
Given a Btrfs command to run and a Btrfs filesystem mount point, get the sorted subvolumes for
that filesystem. Include the filesystem itself.
'''
btrfs_output = borgmatic.execute.execute_command_and_capture_output(
tuple(btrfs_command.split(' '))
+ (
'subvolume',
'list',
filesystem_mount_point,
)
)
if not filesystem_mount_point.strip():
return ()
return (filesystem_mount_point,) + tuple(
sorted(
subvolume_path
for line in btrfs_output.splitlines()
for subvolume_subpath in (line.rstrip().split(' ')[-1],)
for subvolume_path in (os.path.join(filesystem_mount_point, subvolume_subpath),)
if subvolume_subpath.strip()
)
)
Subvolume = collections.namedtuple(
'Subvolume', ('path', 'contained_source_directories'), defaults=((),)
)
def get_subvolumes(btrfs_command, findmnt_command, source_directories=None):
'''
Given a Btrfs command to run and a sequence of configured source directories, find the
intersection between the current Btrfs filesystem and subvolume mount points and the configured
borgmatic source directories. The idea is that these are the requested subvolumes to snapshot.
If the source directories is None, then return all subvolumes, sorted by path.
Return the result as a sequence of matching subvolume mount points.
'''
candidate_source_directories = set(source_directories or ())
subvolumes = []
# For each filesystem mount point, find its subvolumes and match them again the given source
# directories to find the subvolumes to backup. And within this loop, sort the subvolumes from
# longest to shortest mount points, so longer mount points get a whack at the candidate source
# directory piñata before their parents do. (Source directories are consumed during this
# process, so no two datasets get the same contained source directories.)
for mount_point in get_filesystem_mount_points(findmnt_command):
subvolumes.extend(
Subvolume(subvolume_path, contained_source_directories)
for subvolume_path in reversed(
get_subvolumes_for_filesystem(btrfs_command, mount_point)
)
for contained_source_directories in (
borgmatic.hooks.data_source.snapshot.get_contained_directories(
subvolume_path, candidate_source_directories
),
)
if source_directories is None or contained_source_directories
)
return tuple(sorted(subvolumes, key=lambda subvolume: subvolume.path))
BORGMATIC_SNAPSHOT_PREFIX = '.borgmatic-snapshot-'
def make_snapshot_path(subvolume_path): # pragma: no cover
'''
Given the path to a subvolume, make a corresponding snapshot path for it.
'''
return os.path.join(
subvolume_path,
f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
# Included so that the snapshot ends up in the Borg archive at the "original" subvolume
# path.
subvolume_path.lstrip(os.path.sep),
)
def make_snapshot_exclude_path(subvolume_path): # pragma: no cover
'''
Given the path to a subvolume, make a corresponding exclude path for its embedded snapshot path.
This is to work around a quirk of Btrfs: If you make a snapshot path as a child directory of a
subvolume, then the snapshot's own initial directory component shows up as an empty directory
within the snapshot itself. For instance, if you have a Btrfs subvolume at /mnt and make a
snapshot of it at:
/mnt/.borgmatic-snapshot-1234/mnt
... then the snapshot itself will have an empty directory at:
/mnt/.borgmatic-snapshot-1234/mnt/.borgmatic-snapshot-1234
So to prevent that from ending up in the Borg archive, this function produces its path for
exclusion.
'''
snapshot_directory = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
return os.path.join(
subvolume_path,
snapshot_directory,
subvolume_path.lstrip(os.path.sep),
snapshot_directory,
)
def make_borg_source_directory_path(subvolume_path, source_directory): # pragma: no cover
'''
Given the path to a subvolume and a source directory inside it, make a corresponding path for
the source directory within a snapshot path intended for giving to Borg.
'''
return os.path.join(
subvolume_path,
f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}',
'.', # Borg 1.4+ "slashdot" hack.
# Included so that the source directory ends up in the Borg archive at its "original" path.
source_directory.lstrip(os.path.sep),
)
def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma: no cover
'''
Given a Btrfs command to run, the path to a subvolume, and the path for a snapshot, create a new
Btrfs snapshot of the subvolume.
'''
os.makedirs(os.path.dirname(snapshot_path), mode=0o700, exist_ok=True)
borgmatic.execute.execute_command(
tuple(btrfs_command.split(' '))
+ (
'subvolume',
'snapshot',
'-r', # Read-only,
subvolume_path,
snapshot_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 Btrfs 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 Btrfs subvolume mount points listed
in the given source directories. Also update those source directories, replacing subvolume mount
points with corresponding snapshot directories so they get stored in the Borg archive instead.
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 Btrfs subvolumes{dry_run_label}')
# Based on the configured source directories, determine Btrfs subvolumes to backup.
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
findmnt_command = hook_config.get('findmnt_command', 'findmnt')
subvolumes = get_subvolumes(btrfs_command, findmnt_command, source_directories)
if not subvolumes:
logger.warning(f'{log_prefix}: No Btrfs subvolumes found to snapshot{dry_run_label}')
# Snapshot each subvolume, rewriting source directories to use their snapshot paths.
for subvolume in subvolumes:
logger.debug(f'{log_prefix}: Creating Btrfs snapshot for {subvolume.path} subvolume')
snapshot_path = make_snapshot_path(subvolume.path)
if dry_run:
continue
snapshot_subvolume(btrfs_command, subvolume.path, snapshot_path)
for source_directory in subvolume.contained_source_directories:
try:
source_directories.remove(source_directory)
except ValueError:
pass
source_directories.append(
make_borg_source_directory_path(subvolume.path, source_directory)
)
config.setdefault('exclude_patterns', []).append(make_snapshot_exclude_path(subvolume.path))
return []
def delete_snapshot(btrfs_command, snapshot_path): # pragma: no cover
'''
Given a Btrfs command to run and the name of a snapshot path, delete it.
'''
borgmatic.execute.execute_command(
tuple(btrfs_command.split(' '))
+ (
'subvolume',
'delete',
snapshot_path,
),
output_log_level=logging.DEBUG,
)
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
'''
Given a Btrfs configuration dict, a configuration dict, a log prefix, the borgmatic runtime
directory, and whether this is a dry run, delete any Btrfs 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 ''
btrfs_command = hook_config.get('btrfs_command', 'btrfs')
findmnt_command = hook_config.get('findmnt_command', 'findmnt')
try:
all_subvolumes = get_subvolumes(btrfs_command, findmnt_command)
except FileNotFoundError as error:
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(f'{log_prefix}: {error}')
return
# Reversing the sorted subvolumes ensures that we remove longer mount point paths of child
# subvolumes before the shorter mount point paths of parent subvolumes.
for subvolume in reversed(all_subvolumes):
subvolume_snapshots_glob = borgmatic.config.paths.replace_temporary_subdirectory_with_glob(
os.path.normpath(make_snapshot_path(subvolume.path)),
temporary_directory_prefix=BORGMATIC_SNAPSHOT_PREFIX,
)
logger.debug(
f'{log_prefix}: Looking for snapshots to remove in {subvolume_snapshots_glob}{dry_run_label}'
)
for snapshot_path in glob.glob(subvolume_snapshots_glob):
if not os.path.isdir(snapshot_path):
continue
logger.debug(f'{log_prefix}: Deleting Btrfs snapshot {snapshot_path}{dry_run_label}')
if dry_run:
continue
try:
delete_snapshot(btrfs_command, snapshot_path)
except FileNotFoundError:
logger.debug(f'{log_prefix}: Could not find "{btrfs_command}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(f'{log_prefix}: {error}')
return
# Strip off the subvolume path from the end of the snapshot path and then delete the
# resulting directory.
shutil.rmtree(snapshot_path.rsplit(subvolume.path, 1)[0])
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()
@@ -5,13 +5,7 @@ import shutil
logger = logging.getLogger(__name__)
DATA_SOURCE_HOOK_NAMES = (
'mariadb_databases',
'mysql_databases',
'mongodb_databases',
'postgresql_databases',
'sqlite_databases',
)
IS_A_HOOK = False
def make_data_source_dump_path(borgmatic_runtime_directory, data_source_hook_name):
+400
View File
@@ -0,0 +1,400 @@
import collections
import glob
import json
import logging
import os
import shutil
import subprocess
import borgmatic.config.paths
import borgmatic.execute
import borgmatic.hooks.data_source.snapshot
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-'
Logical_volume = collections.namedtuple(
'Logical_volume', ('name', 'device_path', 'mount_point', 'contained_source_directories')
)
def get_logical_volumes(lsblk_command, source_directories=None):
'''
Given an lsblk command to run and a sequence of configured source directories, find the
intersection between the current LVM logical volume mount points and the configured borgmatic
source directories. The idea is that these are the requested logical volumes to snapshot.
If source directories is None, include all logical volume mounts points, not just those in
source directories.
Return the result as a sequence of Logical_volume instances.
'''
try:
devices_info = json.loads(
borgmatic.execute.execute_command_and_capture_output(
# Use lsblk instead of lvs here because lvs can't show active mounts.
tuple(lsblk_command.split(' '))
+ (
'--output',
'name,path,mountpoint,type',
'--json',
'--list',
)
)
)
except json.JSONDecodeError as error:
raise ValueError(f'Invalid {lsblk_command} JSON output: {error}')
candidate_source_directories = set(source_directories or ())
try:
return tuple(
Logical_volume(
device['name'], device['path'], device['mountpoint'], contained_source_directories
)
for device in devices_info['blockdevices']
if device['mountpoint'] and device['type'] == 'lvm'
for contained_source_directories in (
borgmatic.hooks.data_source.snapshot.get_contained_directories(
device['mountpoint'], candidate_source_directories
),
)
if not source_directories or contained_source_directories
)
except KeyError as error:
raise ValueError(f'Invalid {lsblk_command} output: Missing key "{error}"')
def snapshot_logical_volume(
lvcreate_command,
snapshot_name,
logical_volume_device,
snapshot_size,
):
'''
Given an lvcreate command to run, a snapshot name, the path to the logical volume device to
snapshot, and a snapshot size string, create a new LVM snapshot.
'''
borgmatic.execute.execute_command(
tuple(lvcreate_command.split(' '))
+ (
'--snapshot',
('--extents' if '%' in snapshot_size else '--size'),
snapshot_size,
'--name',
snapshot_name,
logical_volume_device,
),
output_log_level=logging.DEBUG,
)
def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path): # pragma: no cover
'''
Given a mount command to run, the device path for an existing snapshot, and the path where the
snapshot should be mounted, mount the snapshot as read-only (making any necessary directories
first).
'''
os.makedirs(snapshot_mount_path, mode=0o700, exist_ok=True)
borgmatic.execute.execute_command(
tuple(mount_command.split(' '))
+ (
'-o',
'ro',
snapshot_device,
snapshot_mount_path,
),
output_log_level=logging.DEBUG,
)
DEFAULT_SNAPSHOT_SIZE = '10%ORIGIN'
def dump_data_sources(
hook_config,
config,
log_prefix,
config_paths,
borgmatic_runtime_directory,
source_directories,
dry_run,
):
'''
Given an LVM 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 LVM logical volume mount points listed in the given
source directories. Also update those source directories, replacing logical volume mount points
with corresponding snapshot directories so they get stored in the Borg archive instead. 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 LVM logical volumes{dry_run_label}')
# List logical volumes to get their mount points.
lsblk_command = hook_config.get('lsblk_command', 'lsblk')
requested_logical_volumes = get_logical_volumes(lsblk_command, source_directories)
# Snapshot each logical volume, rewriting source directories to use the snapshot paths.
snapshot_suffix = f'{BORGMATIC_SNAPSHOT_PREFIX}{os.getpid()}'
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
if not requested_logical_volumes:
logger.warning(f'{log_prefix}: No LVM logical volumes found to snapshot{dry_run_label}')
for logical_volume in requested_logical_volumes:
snapshot_name = f'{logical_volume.name}_{snapshot_suffix}'
logger.debug(
f'{log_prefix}: Creating LVM snapshot {snapshot_name} of {logical_volume.mount_point}{dry_run_label}'
)
if not dry_run:
snapshot_logical_volume(
hook_config.get('lvcreate_command', 'lvcreate'),
snapshot_name,
logical_volume.device_path,
hook_config.get('snapshot_size', DEFAULT_SNAPSHOT_SIZE),
)
# Get the device path for the snapshot we just created.
try:
snapshot = get_snapshots(
hook_config.get('lvs_command', 'lvs'), snapshot_name=snapshot_name
)[0]
except IndexError:
raise ValueError(f'Cannot find LVM snapshot {snapshot_name}')
# Mount the snapshot into a particular named temporary directory so that the snapshot ends
# up in the Borg archive at the "original" logical volume mount point path.
snapshot_mount_path = os.path.join(
normalized_runtime_directory,
'lvm_snapshots',
logical_volume.mount_point.lstrip(os.path.sep),
)
logger.debug(
f'{log_prefix}: Mounting LVM snapshot {snapshot_name} at {snapshot_mount_path}{dry_run_label}'
)
if dry_run:
continue
mount_snapshot(
hook_config.get('mount_command', 'mount'), snapshot.device_path, snapshot_mount_path
)
# Update the path for each contained source directory, so Borg sees it within the
# mounted snapshot.
for source_directory in logical_volume.contained_source_directories:
try:
source_directories.remove(source_directory)
except ValueError:
pass
source_directories.append(
os.path.join(
normalized_runtime_directory,
'lvm_snapshots',
'.', # Borg 1.4+ "slashdot" hack.
source_directory.lstrip(os.path.sep),
)
)
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(
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
output_log_level=logging.DEBUG,
)
def remove_snapshot(lvremove_command, snapshot_device_path): # pragma: no cover
'''
Given an lvremove command to run and the device path of a snapshot, remove it it.
'''
borgmatic.execute.execute_command(
tuple(lvremove_command.split(' '))
+ (
'--force', # Suppress an interactive "are you sure?" type prompt.
snapshot_device_path,
),
output_log_level=logging.DEBUG,
)
Snapshot = collections.namedtuple(
'Snapshot',
('name', 'device_path'),
)
def get_snapshots(lvs_command, snapshot_name=None):
'''
Given an lvs command to run, return all LVM snapshots as a sequence of Snapshot instances.
If a snapshot name is given, filter the results to that snapshot.
'''
try:
snapshot_info = json.loads(
borgmatic.execute.execute_command_and_capture_output(
# Use lvs instead of lsblk here because lsblk can't filter to just snapshots.
tuple(lvs_command.split(' '))
+ (
'--report-format',
'json',
'--options',
'lv_name,lv_path',
'--select',
'lv_attr =~ ^s', # Filter to just snapshots.
)
)
)
except json.JSONDecodeError as error:
raise ValueError(f'Invalid {lvs_command} JSON output: {error}')
try:
return tuple(
Snapshot(snapshot['lv_name'], snapshot['lv_path'])
for snapshot in snapshot_info['report'][0]['lv']
if snapshot_name is None or snapshot['lv_name'] == snapshot_name
)
except IndexError:
raise ValueError(f'Invalid {lvs_command} output: Missing report data')
except KeyError as error:
raise ValueError(f'Invalid {lvs_command} output: Missing key "{error}"')
def remove_data_source_dumps(hook_config, config, log_prefix, borgmatic_runtime_directory, dry_run):
'''
Given an LVM configuration dict, a configuration dict, a log prefix, the borgmatic runtime
directory, and whether this is a dry run, unmount and delete any LVM 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.
try:
logical_volumes = get_logical_volumes(hook_config.get('lsblk_command', 'lsblk'))
except FileNotFoundError as error:
logger.debug(f'{log_prefix}: Could not find "{error.filename}" 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),
),
'lvm_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
for logical_volume in logical_volumes:
snapshot_mount_path = os.path.join(
snapshots_directory, logical_volume.mount_point.lstrip(os.path.sep)
)
if not os.path.isdir(snapshot_mount_path):
continue
# This might fail if the directory is already mounted, but we swallow errors here since
# we'll do another recursive delete below. The point of doing it here is that we don't
# want to try to unmount a non-mounted directory (which *will* fail).
if not dry_run:
shutil.rmtree(snapshot_mount_path, ignore_errors=True)
# If the delete was successful, that means there's nothing to unmount.
if not os.path.isdir(snapshot_mount_path):
continue
logger.debug(
f'{log_prefix}: Unmounting LVM snapshot at {snapshot_mount_path}{dry_run_label}'
)
if dry_run:
continue
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)
# Delete snapshots.
lvremove_command = hook_config.get('lvremove_command', 'lvremove')
try:
snapshots = get_snapshots(hook_config.get('lvs_command', 'lvs'))
except FileNotFoundError as error:
logger.debug(f'{log_prefix}: Could not find "{error.filename}" command')
return
except subprocess.CalledProcessError as error:
logger.debug(f'{log_prefix}: {error}')
return
for snapshot in snapshots:
# Only delete snapshots that borgmatic actually created!
if not snapshot.name.split('_')[-1].startswith(BORGMATIC_SNAPSHOT_PREFIX):
continue
logger.debug(f'{log_prefix}: Deleting LVM snapshot {snapshot.name}{dry_run_label}')
if not dry_run:
remove_snapshot(lvremove_command, snapshot.device_path)
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()
@@ -9,7 +9,7 @@ from borgmatic.execute import (
execute_command_and_capture_output,
execute_command_with_processes,
)
from borgmatic.hooks import dump
from borgmatic.hooks.data_source import dump
logger = logging.getLogger(__name__)
@@ -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]
@@ -1,9 +1,10 @@
import logging
import os
import shlex
import borgmatic.config.paths
from borgmatic.execute import execute_command, execute_command_with_processes
from borgmatic.hooks import dump
from borgmatic.hooks.data_source import dump
logger = logging.getLogger(__name__)
@@ -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
@@ -9,7 +9,7 @@ from borgmatic.execute import (
execute_command_and_capture_output,
execute_command_with_processes,
)
from borgmatic.hooks import dump
from borgmatic.hooks.data_source import dump
logger = logging.getLogger(__name__)
@@ -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]
@@ -11,7 +11,7 @@ from borgmatic.execute import (
execute_command_and_capture_output,
execute_command_with_processes,
)
from borgmatic.hooks import dump
from borgmatic.hooks.data_source import dump
logger = logging.getLogger(__name__)
@@ -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
+30
View File
@@ -0,0 +1,30 @@
import pathlib
IS_A_HOOK = False
def get_contained_directories(parent_directory, candidate_contained_directories):
'''
Given a parent directory and a set of candidate directories potentially inside it, get the
subset of contained directories for which the parent directory is actually the parent, a
grandparent, the very same directory, etc. The idea is if, say, /var/log and /var/lib are
candidate contained directories, but there's a parent directory (logical volume, dataset,
subvolume, etc.) at /var, then /var is what we want to snapshot.
Also mutate the given set of candidate contained directories to remove any actually contained
directories from it. That way, this function can be called multiple times, successively
processing candidate directories until none are left—and avoiding assigning any candidate
directory to more than one parent directory.
'''
if not candidate_contained_directories:
return ()
contained = tuple(
candidate
for candidate in candidate_contained_directories
if pathlib.PurePath(parent_directory) == pathlib.PurePath(candidate)
or pathlib.PurePath(parent_directory) in pathlib.PurePath(candidate).parents
)
candidate_contained_directories -= set(contained)
return contained
@@ -4,7 +4,7 @@ import shlex
import borgmatic.config.paths
from borgmatic.execute import execute_command, execute_command_with_processes
from borgmatic.hooks import dump
from borgmatic.hooks.data_source import dump
logger = logging.getLogger(__name__)
@@ -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
+389
View File
@@ -0,0 +1,389 @@
import collections
import glob
import logging
import os
import shutil
import subprocess
import borgmatic.config.paths
import borgmatic.execute
import borgmatic.hooks.data_source.snapshot
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'
Dataset = collections.namedtuple(
'Dataset',
('name', 'mount_point', 'auto_backup', 'contained_source_directories'),
defaults=(False, ()),
)
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 instances, sorted by mount point.
'''
list_output = borgmatic.execute.execute_command_and_capture_output(
tuple(zfs_command.split(' '))
+ (
'list',
'-H',
'-t',
'filesystem',
'-o',
f'name,mountpoint,{BORGMATIC_USER_PROPERTY}',
)
)
try:
# Sort from longest to shortest mount points, so longer mount points get a whack at the
# candidate source directory piñata before their parents do. (Source directories are
# consumed during the second loop below, so no two datasets get the same contained source
# directories.)
datasets = sorted(
(
Dataset(dataset_name, mount_point, (user_property_value == 'auto'), ())
for line in list_output.splitlines()
for (dataset_name, mount_point, user_property_value) in (line.rstrip().split('\t'),)
),
key=lambda dataset: dataset.mount_point,
reverse=True,
)
except ValueError:
raise ValueError(f'Invalid {zfs_command} list output')
candidate_source_directories = set(source_directories)
return tuple(
sorted(
(
Dataset(
dataset.name,
dataset.mount_point,
dataset.auto_backup,
contained_source_directories,
)
for dataset in datasets
for contained_source_directories in (
(
(dataset.mount_point,)
if dataset.auto_backup
else borgmatic.hooks.data_source.snapshot.get_contained_directories(
dataset.mount_point, candidate_source_directories
)
),
)
if contained_source_directories
),
key=lambda dataset: dataset.mount_point,
)
)
def get_all_dataset_mount_points(zfs_command):
'''
Given a ZFS command to run, return all ZFS datasets as a sequence of sorted mount points.
'''
list_output = borgmatic.execute.execute_command_and_capture_output(
tuple(zfs_command.split(' '))
+ (
'list',
'-H',
'-t',
'filesystem',
'-o',
'mountpoint',
)
)
return tuple(sorted(line.rstrip() for line in list_output.splitlines()))
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(
tuple(zfs_command.split(' '))
+ (
'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(
tuple(mount_command.split(' '))
+ (
'-t',
'zfs',
'-o',
'ro',
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. 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()}'
normalized_runtime_directory = os.path.normpath(borgmatic_runtime_directory)
if not requested_datasets:
logger.warning(f'{log_prefix}: No ZFS datasets found to snapshot{dry_run_label}')
for dataset in requested_datasets:
full_snapshot_name = f'{dataset.name}@{snapshot_name}'
logger.debug(
f'{log_prefix}: Creating ZFS snapshot {full_snapshot_name} of {dataset.mount_point}{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 = os.path.join(
normalized_runtime_directory,
'zfs_snapshots',
dataset.mount_point.lstrip(os.path.sep),
)
logger.debug(
f'{log_prefix}: Mounting ZFS snapshot {full_snapshot_name} at {snapshot_mount_path}{dry_run_label}'
)
if dry_run:
continue
mount_snapshot(
hook_config.get('mount_command', 'mount'), full_snapshot_name, snapshot_mount_path
)
for source_directory in dataset.contained_source_directories:
try:
source_directories.remove(source_directory)
except ValueError:
pass
source_directories.append(
os.path.join(
normalized_runtime_directory,
'zfs_snapshots',
'.', # Borg 1.4+ "slashdot" hack.
source_directory.lstrip(os.path.sep),
)
)
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(
tuple(umount_command.split(' ')) + (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(
tuple(zfs_command.split(' '))
+ (
'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(
tuple(zfs_command.split(' '))
+ (
'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:
dataset_mount_points = get_all_dataset_mount_points(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
# Reversing the sorted datasets ensures that we unmount the longer mount point paths of
# child datasets before the shorter mount point paths of parent datasets.
for mount_point in reversed(dataset_mount_points):
snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep))
if not os.path.isdir(snapshot_mount_path):
continue
# This might fail if the path is already mounted, but we swallow errors here since we'll
# do another recursive delete 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(snapshot_mount_path, ignore_errors=True)
# If the delete was successful, that means there's nothing to unmount.
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()
+53 -50
View File
@@ -1,71 +1,70 @@
import enum
import importlib
import logging
import pkgutil
from borgmatic.hooks import (
apprise,
cronhub,
cronitor,
healthchecks,
loki,
mariadb,
mongodb,
mysql,
ntfy,
pagerduty,
postgresql,
pushover,
sqlite,
uptimekuma,
zabbix,
)
import borgmatic.hooks.data_source
import borgmatic.hooks.monitoring
logger = logging.getLogger(__name__)
HOOK_NAME_TO_MODULE = {
'apprise': apprise,
'cronhub': cronhub,
'cronitor': cronitor,
'healthchecks': healthchecks,
'loki': loki,
'mariadb_databases': mariadb,
'mongodb_databases': mongodb,
'mysql_databases': mysql,
'ntfy': ntfy,
'pagerduty': pagerduty,
'postgresql_databases': postgresql,
'pushover': pushover,
'sqlite_databases': sqlite,
'uptime_kuma': uptimekuma,
'zabbix': zabbix,
}
class Hook_type(enum.Enum):
DATA_SOURCE = 'data_source'
MONITORING = 'monitoring'
def get_submodule_names(parent_module): # pragma: no cover
'''
Given a parent module, return the names of its direct submodules as a tuple of strings.
'''
return tuple(module_info.name for module_info in pkgutil.iter_modules(parent_module.__path__))
def call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs):
'''
Given a configuration dict and a prefix to use in log entries, call the requested function of
the Python module corresponding to the given hook name. Supply that call with the configuration
for this hook (if any), the log prefix, and any given args and kwargs. Return any return value.
for this hook (if any), the log prefix, and any given args and kwargs. Return the return value
of that call or None if the module in question is not a hook.
Raise ValueError if the hook name is unknown.
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 config.get(f'{hook_name}_databases') or {}
module_name = hook_name.split('_databases')[0]
try:
module = HOOK_NAME_TO_MODULE[hook_name]
except KeyError:
# Probe for a data source or monitoring hook module corresponding to the hook name.
for parent_module in (borgmatic.hooks.data_source, borgmatic.hooks.monitoring):
if module_name not in get_submodule_names(parent_module):
continue
module = importlib.import_module(f'{parent_module.__name__}.{module_name}')
# If this module is explicitly flagged as not a hook, bail.
if not getattr(module, 'IS_A_HOOK', True):
return None
break
else:
raise ValueError(f'Unknown hook name: {hook_name}')
logger.debug(f'{log_prefix}: Calling {hook_name} hook function {function_name}')
return getattr(module, function_name)(hook_config, config, log_prefix, *args, **kwargs)
def call_hooks(function_name, config, log_prefix, hook_names, *args, **kwargs):
def call_hooks(function_name, config, log_prefix, hook_type, *args, **kwargs):
'''
Given a configuration dict and a prefix to use in log entries, call the requested function of
the Python module corresponding to each given hook name. Supply each call with the configuration
for that hook, the log prefix, and any given args and kwargs. Collect any return values into a
dict from hook name to return value.
the Python module corresponding to each hook of the given hook type (either "data_source" or
"monitoring"). Supply each call with the configuration for that hook, the log prefix, and any
given args and kwargs.
Collect any return values into a dict from module name to return value. Note that the module
name is the name of the hook module itself, which might be different from the hook configuration
option (e.g. "postgresql" for the former vs. "postgresql_databases" for the latter).
If the hook name is not present in the hooks configuration, then don't call the function for it
and omit it from the return values.
@@ -76,22 +75,26 @@ 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)
for hook_name in get_submodule_names(
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
)
if hook_name in config or f'{hook_name}_databases' in config
}
def call_hooks_even_if_unconfigured(function_name, config, log_prefix, hook_names, *args, **kwargs):
def call_hooks_even_if_unconfigured(function_name, config, log_prefix, hook_type, *args, **kwargs):
'''
Given a configuration dict and a prefix to use in log entries, call the requested function of
the Python module corresponding to each given hook name. Supply each call with the configuration
for that hook, the log prefix, and any given args and kwargs. Collect any return values into a
dict from hook name to return value.
the Python module corresponding to each hook of the given hook type (either "data_source" or
"monitoring"). Supply each call with the configuration for that hook, the log prefix, and any
given args and kwargs. Collect any return values into a dict from hook name to return value.
Raise AttributeError if the function name is not found in the module.
Raise anything else that a called function raises. An error stops calls to subsequent functions.
'''
return {
hook_name: call_hook(function_name, config, log_prefix, hook_name, *args, **kwargs)
for hook_name in hook_names
for hook_name in get_submodule_names(
importlib.import_module(f'borgmatic.hooks.{hook_type.value}')
)
}
-21
View File
@@ -1,21 +0,0 @@
from enum import Enum
MONITOR_HOOK_NAMES = (
'apprise',
'cronhub',
'cronitor',
'healthchecks',
'loki',
'ntfy',
'pagerduty',
'pushover',
'uptime_kuma',
'zabbix',
)
class State(Enum):
START = 1
FINISH = 2
FAIL = 3
LOG = 4
@@ -1,8 +1,8 @@
import logging
import operator
import borgmatic.hooks.logs
import borgmatic.hooks.monitor
import borgmatic.hooks.monitoring.logs
import borgmatic.hooks.monitoring.monitor
logger = logging.getLogger(__name__)
@@ -22,12 +22,12 @@ def initialize_monitor(hook_config, config, config_filename, monitoring_log_leve
logs_size_limit = max(
hook_config.get('logs_size_limit', DEFAULT_LOGS_SIZE_LIMIT_BYTES)
- len(borgmatic.hooks.logs.PAYLOAD_TRUNCATION_INDICATOR),
- len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR),
0,
)
borgmatic.hooks.logs.add_handler(
borgmatic.hooks.logs.Forgetful_buffering_handler(
borgmatic.hooks.monitoring.logs.add_handler(
borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
HANDLER_IDENTIFIER, logs_size_limit, monitoring_log_level
)
)
@@ -82,11 +82,13 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
body = state_config.get('body')
if state in (
borgmatic.hooks.monitor.State.FINISH,
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitor.State.LOG,
borgmatic.hooks.monitoring.monitor.State.FINISH,
borgmatic.hooks.monitoring.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.LOG,
):
formatted_logs = borgmatic.hooks.logs.format_buffered_logs_for_payload(HANDLER_IDENTIFIER)
formatted_logs = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload(
HANDLER_IDENTIFIER
)
if formatted_logs:
body += f'\n\n{formatted_logs}'
@@ -106,4 +108,4 @@ def destroy_monitor(hook_config, config, config_filename, monitoring_log_level,
Remove the monitor handler that was added to the root logger. This prevents the handler from
getting reused by other instances of this monitor.
'''
borgmatic.hooks.logs.remove_handler(HANDLER_IDENTIFIER)
borgmatic.hooks.monitoring.logs.remove_handler(HANDLER_IDENTIFIER)
@@ -2,7 +2,7 @@ import logging
import requests
from borgmatic.hooks import monitor
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
@@ -2,7 +2,7 @@ import logging
import requests
from borgmatic.hooks import monitor
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
@@ -3,8 +3,8 @@ import re
import requests
import borgmatic.hooks.logs
from borgmatic.hooks import monitor
import borgmatic.hooks.monitoring.logs
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
@@ -30,12 +30,12 @@ def initialize_monitor(hook_config, config, config_filename, monitoring_log_leve
ping_body_limit = max(
hook_config.get('ping_body_limit', DEFAULT_PING_BODY_LIMIT_BYTES)
- len(borgmatic.hooks.logs.PAYLOAD_TRUNCATION_INDICATOR),
- len(borgmatic.hooks.monitoring.logs.PAYLOAD_TRUNCATION_INDICATOR),
0,
)
borgmatic.hooks.logs.add_handler(
borgmatic.hooks.logs.Forgetful_buffering_handler(
borgmatic.hooks.monitoring.logs.add_handler(
borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
HANDLER_IDENTIFIER, ping_body_limit, monitoring_log_level
)
)
@@ -78,7 +78,9 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
logger.debug(f'{config_filename}: Using Healthchecks ping URL {ping_url}')
if state in (monitor.State.FINISH, monitor.State.FAIL, monitor.State.LOG):
payload = borgmatic.hooks.logs.format_buffered_logs_for_payload(HANDLER_IDENTIFIER)
payload = borgmatic.hooks.monitoring.logs.format_buffered_logs_for_payload(
HANDLER_IDENTIFIER
)
else:
payload = ''
@@ -99,4 +101,4 @@ def destroy_monitor(hook_config, config, config_filename, monitoring_log_level,
Remove the monitor handler that was added to the root logger. This prevents the handler from
getting reused by other instances of this monitor.
'''
borgmatic.hooks.logs.remove_handler(HANDLER_IDENTIFIER)
borgmatic.hooks.monitoring.logs.remove_handler(HANDLER_IDENTIFIER)
@@ -1,5 +1,6 @@
import logging
IS_A_HOOK = False
PAYLOAD_TRUNCATION_INDICATOR = '...\n'
@@ -6,7 +6,7 @@ import time
import requests
from borgmatic.hooks import monitor
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
+10
View File
@@ -0,0 +1,10 @@
import enum
IS_A_HOOK = False
class State(enum.Enum):
START = 1
FINISH = 2
FAIL = 3
LOG = 4
@@ -5,7 +5,7 @@ import platform
import requests
from borgmatic.hooks import monitor
from borgmatic.hooks.monitoring import monitor
logger = logging.getLogger(__name__)
+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
+264
View File
@@ -0,0 +1,264 @@
---
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` option.
* 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
(non-recursively), 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 paths 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 `/var/dataset`, then the snapshotted files will appear
in an archive at `/var/dataset` as well—even if borgmatic has to mount the
snapshot somewhere in `/run/user/1000/borgmatic/zfs_snapshots/` to perform the
backup.
<span class="minilink minilink-addedin">New in version 1.9.4</span> borgmatic
is smart enough to look at the parent (and grandparent, etc.) directories of
each of your `source_directories` to discover any datasets. For instance,
let's say you add `/var/log` and `/var/lib` to your source directories, but
`/var` is a dataset. borgmatic will discover that and snapshot `/var`
accordingly. This also works even with nested datasets; borgmatic selects
the dataset that's the "closest" parent to your source directories.
<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.
### Btrfs
<span class="minilink minilink-addedin">New in version 1.9.4</span> <span
class="minilink minilink-addedin">Beta feature</span> borgmatic supports taking
snapshots with the [Btrfs filesystem](https://btrfs.readthedocs.io/) and sending
those snapshots to Borg for backup.
To use this feature, first you need one or more Btrfs subvolumes on mounted
filesystems. Then, enable Btrfs within borgmatic by adding the following line to
your configuration file:
```yaml
btrfs:
```
No other options are necessary to enable Btrfs support, but if desired you can
override some of the commands used by the Btrfs hook. For instance:
```yaml
btrfs:
btrfs_command: /usr/local/bin/btrfs
findmnt_command: /usr/local/bin/findmnt
```
As long as the Btrfs 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.
#### Subvolume discovery
For any subvolume you'd like backed up, add its path to borgmatic's
`source_directories` option. During a backup, borgmatic snapshots these
subvolumes (non-recursively) and includes the snapshotted files in the paths
sent to Borg. borgmatic is also responsible for cleaning up (deleting) these
snapshots after a backup completes.
borgmatic is smart enough to look at the parent (and grandparent, etc.)
directories of each of your `source_directories` to discover any subvolumes.
For instance, let's say you add `/var/log` and `/var/lib` to your source
directories, but `/var` is a subvolume. borgmatic will discover that and
snapshot `/var` accordingly. This also works even with nested subvolumes;
borgmatic selects the subvolume that's the "closest" parent to your source
directories.
Additionally, borgmatic rewrites the snapshot file paths so that they appear
at their original subvolume locations in a Borg archive. For instance, if your
subvolume exists at `/var/subvolume`, then the snapshotted files will appear
in an archive at `/var/subvolume` as well—even if borgmatic has to mount the
snapshot somewhere in `/var/subvolume/.borgmatic-snapshot-1234/` to perform
the backup.
<span class="minilink minilink-addedin">With Borg version 1.2 and
earlier</span>Snapshotted files are instead stored at a path dependent on the
temporary snapshot directory in use at the time the archive was created, as Borg
1.2 and earlier do not support path rewriting.
#### Extract a subvolume
Subvolume 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.
### LVM
<span class="minilink minilink-addedin">New in version 1.9.4</span> <span
class="minilink minilink-addedin">Beta feature</span> borgmatic supports
taking snapshots with [LVM](https://sourceware.org/lvm2/) (Linux Logical
Volume Manager) and sending those snapshots to Borg for backup. LVM isn't
itself a filesystem, but it can take snapshots at the layer right below your
filesystem.
To use this feature, first you need one or more mounted LVM logical volumes.
Then, enable LVM within borgmatic by adding the following line to your
configuration file:
```yaml
lvm:
```
No other options are necessary to enable LVM support, but if desired you can
override some of the options used by the LVM hook. For instance:
```yaml
lvm:
snapshot_size: 5GB # See below for details.
lvcreate_command: /usr/local/bin/lvcreate
lvremove_command: /usr/local/bin/lvremove
lvs_command: /usr/local/bin/lvs
lsbrk_command: /usr/local/bin/lsbrk
mount_command: /usr/local/bin/mount
umount_command: /usr/local/bin/umount
```
As long as the LVM 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.
#### Snapshot size
The `snapshot_size` option is the size to allocate for each snapshot taken,
including the units to use for that size. While borgmatic's snapshots
themselves are read-only and don't change during backups, the logical volume
being snapshotted *can* change—therefore requiring additional snapshot storage
since LVM snapshots are copy-on-write. And if the configured snapshot size is
too small (and LVM isn't configured to grow snapshots automatically), then the
snapshots will fail to allocate enough space, resulting in a broken backup.
If not specified, the `snapshot_size` option defaults to `10%ORIGIN`, which
means 10% of the size of logical volume being snapshotted. See the [`lvcreate
--size` and `--extents`
documentation](https://www.man7.org/linux/man-pages/man8/lvcreate.8.html) for
more information about possible values here. (Under the hood, borgmatic uses
`lvcreate --extents` if the `snapshot_size` is a percentage value, and
`lvcreate --size` otherwise.)
#### Logical volume discovery
For any logical volume you'd like backed up, add its mount point to
borgmatic's `source_directories` option.
During a backup, borgmatic automatically snapshots these discovered logical
volumes (non-recursively), 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 paths sent to Borg. borgmatic is
also responsible for cleaning up (deleting) these snapshots after a backup
completes.
borgmatic is smart enough to look at the parent (and grandparent, etc.)
directories of each of your `source_directories` to discover any logical
volumes. For instance, let's say you add `/var/log` and `/var/lib` to your
source directories, but `/var` is a logical volume. borgmatic will discover
that and snapshot `/var` accordingly.
Additionally, borgmatic rewrites the snapshot file paths so that they appear
at their original logical volume locations in a Borg archive. For instance, if
your logical volume is mounted at `/var/lvolume`, then the snapshotted files
will appear in an archive at `/var/lvolume` as well—even if borgmatic has to
mount the snapshot somewhere in `/run/user/1000/borgmatic/lvm_snapshots/` to
perform the backup.
<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 logical volume
Logical volume 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
+4 -2
View File
@@ -16,8 +16,10 @@ you get started. Starting at the top level, we have:
* [actions](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/actions): borgmatic-specific logic for running each action (create, list, check, etc.).
* [borg](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/borg): Lower-level code that's responsible for interacting with Borg to run each action.
* [commands](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/commands): Looking to add a new flag or action? Start here. This contains borgmatic's entry point, argument parsing, and shell completion.
* [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration.
* [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database or monitoring integration? Start here.
* [config](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/config): Code responsible for loading, normalizing, and validating borgmatic's configuration. Interested in adding a new configuration option? Check out `schema.yaml` here.
* [hooks](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks): Looking to add a new database, filesystem, or monitoring integration? Start here.
* [data_source](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/data_source): Database and filesystem hooks—anything that produces data or files to go into a backup archive.
* [monitoring](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/borgmatic/hooks/monitoring): Monitoring hooks—integrations with third-party or self-hosted monitoring services.
* [docs](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/docs): How-to and reference documentation, including the document you're reading now.
* [sample](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/sample): Example configurations for cron and systemd.
* [scripts](https://projects.torsion.org/borgmatic-collective/borgmatic/src/branch/main/scripts): Dev-facing scripts for things like building documentation and running end-to-end tests.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

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.4"
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"
+90
View File
@@ -0,0 +1,90 @@
import argparse
import json
import os
import shutil
import sys
def parse_arguments(*unparsed_arguments):
global_parser = argparse.ArgumentParser(add_help=False)
action_parsers = global_parser.add_subparsers(dest='action')
subvolume_parser = action_parsers.add_parser('subvolume')
subvolume_subparser = subvolume_parser.add_subparsers(dest='subaction')
list_parser = subvolume_subparser.add_parser('list')
list_parser.add_argument('-s', dest='snapshots_only', action='store_true')
list_parser.add_argument('subvolume_path')
snapshot_parser = subvolume_subparser.add_parser('snapshot')
snapshot_parser.add_argument('-r', dest='read_only', action='store_true')
snapshot_parser.add_argument('subvolume_path')
snapshot_parser.add_argument('snapshot_path')
delete_parser = subvolume_subparser.add_parser('delete')
delete_parser.add_argument('snapshot_path')
return global_parser.parse_args(unparsed_arguments)
BUILTIN_SUBVOLUME_LIST_LINES = (
'261 gen 29 top level 5 path sub',
'262 gen 29 top level 5 path other',
)
SUBVOLUME_LIST_LINE_PREFIX = '263 gen 29 top level 5 path '
def load_snapshots():
try:
return json.load(open('/tmp/fake_btrfs.json'))
except FileNotFoundError:
return []
def save_snapshots(snapshot_paths):
json.dump(snapshot_paths, open('/tmp/fake_btrfs.json', 'w'))
def print_subvolume_list(arguments, snapshot_paths):
assert arguments.subvolume_path == '/mnt/subvolume'
if not arguments.snapshots_only:
for line in BUILTIN_SUBVOLUME_LIST_LINES:
print(line)
for snapshot_path in snapshot_paths:
print(
SUBVOLUME_LIST_LINE_PREFIX
+ snapshot_path[snapshot_path.index('.borgmatic-snapshot-') :]
)
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshot_paths = load_snapshots()
if arguments.subaction == 'list':
print_subvolume_list(arguments, snapshot_paths)
elif arguments.subaction == 'snapshot':
snapshot_paths.append(arguments.snapshot_path)
save_snapshots(snapshot_paths)
subdirectory = os.path.join(arguments.snapshot_path, 'subdir')
os.makedirs(subdirectory, mode=0o700, exist_ok=True)
test_file = open(os.path.join(subdirectory, 'file.txt'), 'w')
test_file.write('contents')
test_file.close()
elif arguments.subaction == 'delete':
subdirectory = os.path.join(arguments.snapshot_path, 'subdir')
shutil.rmtree(subdirectory)
snapshot_paths = [
snapshot_path
for snapshot_path in snapshot_paths
if snapshot_path.endswith('/' + arguments.snapshot_path)
]
save_snapshots(snapshot_paths)
if __name__ == '__main__':
main()
+33
View File
@@ -0,0 +1,33 @@
import argparse
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('-n', dest='headings', action='store_false', default=True)
parser.add_argument('-t', dest='type')
return parser.parse_args(unparsed_arguments)
BUILTIN_FILESYSTEM_MOUNT_LINES = (
'/mnt/subvolume /dev/loop1 btrfs rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/',
)
def print_filesystem_mounts(arguments):
for line in BUILTIN_FILESYSTEM_MOUNT_LINES:
print(line)
def main():
arguments = parse_arguments(*sys.argv[1:])
assert not arguments.headings
assert arguments.type == 'btrfs'
print_filesystem_mounts(arguments)
if __name__ == '__main__':
main()
+70
View File
@@ -0,0 +1,70 @@
import argparse
import json
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--output', required=True)
parser.add_argument('--json', action='store_true', required=True)
parser.add_argument('--list', action='store_true', required=True)
return parser.parse_args(unparsed_arguments)
BUILTIN_BLOCK_DEVICES = {
'blockdevices': [
{'name': 'loop0', 'path': '/dev/loop0', 'mountpoint': None, 'type': 'loop'},
{'name': 'cryptroot', 'path': '/dev/mapper/cryptroot', 'mountpoint': '/', 'type': 'crypt'},
{
'name': 'vgroup-lvolume',
'path': '/dev/mapper/vgroup-lvolume',
'mountpoint': '/mnt/lvolume',
'type': 'lvm',
},
{
'name': 'vgroup-lvolume-real',
'path': '/dev/mapper/vgroup-lvolume-real',
'mountpoint': None,
'type': 'lvm',
},
]
}
def load_snapshots():
try:
return json.load(open('/tmp/fake_lvm.json'))
except FileNotFoundError:
return []
def print_logical_volumes_json(arguments, snapshots):
data = dict(BUILTIN_BLOCK_DEVICES)
for snapshot in snapshots:
data['blockdevices'].extend(
{
'name': snapshot['lv_name'],
'path': snapshot['lv_path'],
'mountpoint': None,
'type': 'lvm',
}
for snapshot in snapshots
)
print(json.dumps(data))
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshots = load_snapshots()
assert arguments.output == 'name,path,mountpoint,type'
print_logical_volumes_json(arguments, snapshots)
if __name__ == '__main__':
main()
@@ -0,0 +1,43 @@
import argparse
import json
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--snapshot', action='store_true', required=True)
parser.add_argument('--extents')
parser.add_argument('--size')
parser.add_argument('--name', dest='snapshot_name', required=True)
parser.add_argument('logical_volume_device')
return parser.parse_args(unparsed_arguments)
def load_snapshots():
try:
return json.load(open('/tmp/fake_lvm.json'))
except FileNotFoundError:
return []
def save_snapshots(snapshots):
json.dump(snapshots, open('/tmp/fake_lvm.json', 'w'))
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshots = load_snapshots()
assert arguments.extents or arguments.size
snapshots.append(
{'lv_name': arguments.snapshot_name, 'lv_path': f'/dev/vgroup/{arguments.snapshot_name}'},
)
save_snapshots(snapshots)
if __name__ == '__main__':
main()
@@ -0,0 +1,39 @@
import argparse
import json
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--force', action='store_true', required=True)
parser.add_argument('snapshot_device')
return parser.parse_args(unparsed_arguments)
def load_snapshots():
try:
return json.load(open('/tmp/fake_lvm.json'))
except FileNotFoundError:
return []
def save_snapshots(snapshots):
json.dump(snapshots, open('/tmp/fake_lvm.json', 'w'))
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshots = [
snapshot
for snapshot in load_snapshots()
if snapshot['lv_path'] != arguments.snapshot_device
]
save_snapshots(snapshots)
if __name__ == '__main__':
main()
+50
View File
@@ -0,0 +1,50 @@
import argparse
import json
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--report-format', required=True)
parser.add_argument('--options', required=True)
parser.add_argument('--select', required=True)
return parser.parse_args(unparsed_arguments)
def load_snapshots():
try:
return json.load(open('/tmp/fake_lvm.json'))
except FileNotFoundError:
return []
def print_snapshots_json(arguments, snapshots):
assert arguments.report_format == 'json'
assert arguments.options == 'lv_name,lv_path'
assert arguments.select == 'lv_attr =~ ^s'
print(
json.dumps(
{
'report': [
{
'lv': snapshots,
}
],
'log': [],
}
)
)
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshots = load_snapshots()
print_snapshots_json(arguments, snapshots)
if __name__ == '__main__':
main()
+29
View File
@@ -0,0 +1,29 @@
import argparse
import os
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('-t', dest='type')
parser.add_argument('-o', dest='options')
parser.add_argument('snapshot_name')
parser.add_argument('mount_point')
return parser.parse_args(unparsed_arguments)
def main():
arguments = parse_arguments(*sys.argv[1:])
assert arguments.options == 'ro'
subdirectory = os.path.join(arguments.mount_point, 'subdir')
os.mkdir(subdirectory)
test_file = open(os.path.join(subdirectory, 'file.txt'), 'w')
test_file.write('contents')
test_file.close()
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
import argparse
import os
import shutil
import sys
def parse_arguments(*unparsed_arguments):
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('mount_point')
return parser.parse_args(unparsed_arguments)
def main():
arguments = parse_arguments(*sys.argv[1:])
subdirectory = os.path.join(arguments.mount_point, 'subdir')
shutil.rmtree(subdirectory)
if __name__ == '__main__':
main()
+92
View File
@@ -0,0 +1,92 @@
import argparse
import json
import sys
def parse_arguments(*unparsed_arguments):
global_parser = argparse.ArgumentParser(add_help=False)
action_parsers = global_parser.add_subparsers(dest='action')
list_parser = action_parsers.add_parser('list')
list_parser.add_argument('-H', dest='header', action='store_false', default=True)
list_parser.add_argument('-t', dest='type', default='filesystem')
list_parser.add_argument('-o', dest='properties', default='name,used,avail,refer,mountpoint')
snapshot_parser = action_parsers.add_parser('snapshot')
snapshot_parser.add_argument('name')
destroy_parser = action_parsers.add_parser('destroy')
destroy_parser.add_argument('name')
return global_parser.parse_args(unparsed_arguments)
BUILTIN_DATASETS = (
{
'name': 'pool',
'used': '256K',
'avail': '23.7M',
'refer': '25K',
'mountpoint': '/pool',
},
{
'name': 'pool/dataset',
'used': '256K',
'avail': '23.7M',
'refer': '25K',
'mountpoint': '/pool/dataset',
},
)
def load_snapshots():
try:
return json.load(open('/tmp/fake_zfs.json'))
except FileNotFoundError:
return []
def save_snapshots(snapshots):
json.dump(snapshots, open('/tmp/fake_zfs.json', 'w'))
def print_dataset_list(arguments, datasets, snapshots):
properties = arguments.properties.split(',')
data = (
(tuple(property_name.upper() for property_name in properties),) if arguments.header else ()
) + tuple(
tuple(dataset.get(property_name, '-') for property_name in properties)
for dataset in (snapshots if arguments.type == 'snapshot' else datasets)
)
if not data:
return
for data_row in data:
print('\t'.join(data_row))
def main():
arguments = parse_arguments(*sys.argv[1:])
snapshots = load_snapshots()
if arguments.action == 'list':
print_dataset_list(arguments, BUILTIN_DATASETS, snapshots)
elif arguments.action == 'snapshot':
snapshots.append(
{
'name': arguments.name,
'used': '0B',
'avail': '-',
'refer': '25K',
'mountpoint': '-',
},
)
save_snapshots(snapshots)
elif arguments.action == 'destroy':
snapshots = [snapshot for snapshot in snapshots if snapshot['name'] != arguments.name]
save_snapshots(snapshots)
if __name__ == '__main__':
main()
+63
View File
@@ -0,0 +1,63 @@
import os
import shutil
import subprocess
import sys
import tempfile
def generate_configuration(config_path, repository_path):
'''
Generate borgmatic configuration into a file at the config path, and update the defaults so as
to work for testing (including injecting the given repository path and tacking on an encryption
passphrase).
'''
subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
config = (
open(config_path)
.read()
.replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
.replace('- path: /mnt/backup', '')
.replace('label: local', '')
.replace('- /home', f'- {config_path}')
.replace('- /etc', '- /mnt/subvolume/subdir')
.replace('- /var/log/syslog*', '')
+ 'encryption_passphrase: "test"\n'
+ 'btrfs:\n'
+ ' btrfs_command: python3 /app/tests/end-to-end/commands/fake_btrfs.py\n'
+ ' findmnt_command: python3 /app/tests/end-to-end/commands/fake_findmnt.py\n'
)
config_file = open(config_path, 'w')
config_file.write(config)
config_file.close()
def test_btrfs_create_and_list():
temporary_directory = tempfile.mkdtemp()
repository_path = os.path.join(temporary_directory, 'test.borg')
try:
config_path = os.path.join(temporary_directory, 'test.yaml')
generate_configuration(config_path, repository_path)
subprocess.check_call(
f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ')
)
# Run a create action to exercise Btrfs snapshotting and backup.
subprocess.check_call(f'borgmatic --config {config_path} create'.split(' '))
# List the resulting archive and assert that the snapshotted files are there.
output = subprocess.check_output(
f'borgmatic --config {config_path} list --archive latest'.split(' ')
).decode(sys.stdout.encoding)
assert 'mnt/subvolume/subdir/file.txt' in output
# Assert that the snapshot has been deleted.
assert not subprocess.check_output(
'python3 /app/tests/end-to-end/commands/fake_btrfs.py subvolume list -s /mnt/subvolume'.split(
' '
)
)
finally:
shutil.rmtree(temporary_directory)
+71
View File
@@ -0,0 +1,71 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
def generate_configuration(config_path, repository_path):
'''
Generate borgmatic configuration into a file at the config path, and update the defaults so as
to work for testing (including injecting the given repository path and tacking on an encryption
passphrase).
'''
subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
config = (
open(config_path)
.read()
.replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
.replace('- path: /mnt/backup', '')
.replace('label: local', '')
.replace('- /home', f'- {config_path}')
.replace('- /etc', '- /mnt/lvolume/subdir')
.replace('- /var/log/syslog*', '')
+ 'encryption_passphrase: "test"\n'
+ 'lvm:\n'
+ ' lsblk_command: python3 /app/tests/end-to-end/commands/fake_lsblk.py\n'
+ ' lvcreate_command: python3 /app/tests/end-to-end/commands/fake_lvcreate.py\n'
+ ' lvremove_command: python3 /app/tests/end-to-end/commands/fake_lvremove.py\n'
+ ' lvs_command: python3 /app/tests/end-to-end/commands/fake_lvs.py\n'
+ ' mount_command: python3 /app/tests/end-to-end/commands/fake_mount.py\n'
+ ' umount_command: python3 /app/tests/end-to-end/commands/fake_umount.py\n'
)
config_file = open(config_path, 'w')
config_file.write(config)
config_file.close()
def test_lvm_create_and_list():
temporary_directory = tempfile.mkdtemp()
repository_path = os.path.join(temporary_directory, 'test.borg')
try:
config_path = os.path.join(temporary_directory, 'test.yaml')
generate_configuration(config_path, repository_path)
subprocess.check_call(
f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ')
)
# Run a create action to exercise LVM snapshotting and backup.
subprocess.check_call(f'borgmatic --config {config_path} create'.split(' '))
# List the resulting archive and assert that the snapshotted files are there.
output = subprocess.check_output(
f'borgmatic --config {config_path} list --archive latest'.split(' ')
).decode(sys.stdout.encoding)
assert 'mnt/lvolume/subdir/file.txt' in output
# Assert that the snapshot has been deleted.
assert not json.loads(
subprocess.check_output(
'python3 /app/tests/end-to-end/commands/fake_lvs.py --report-format json --options lv_name,lv_path --select'.split(
' '
)
+ ['lv_attr =~ ^s']
)
)['report'][0]['lv']
finally:
shutil.rmtree(temporary_directory)
+62
View File
@@ -0,0 +1,62 @@
import os
import shutil
import subprocess
import sys
import tempfile
def generate_configuration(config_path, repository_path):
'''
Generate borgmatic configuration into a file at the config path, and update the defaults so as
to work for testing (including injecting the given repository path and tacking on an encryption
passphrase).
'''
subprocess.check_call(f'borgmatic config generate --destination {config_path}'.split(' '))
config = (
open(config_path)
.read()
.replace('ssh://user@backupserver/./sourcehostname.borg', repository_path)
.replace('- path: /mnt/backup', '')
.replace('label: local', '')
.replace('- /home', f'- {config_path}')
.replace('- /etc', '- /pool/dataset/subdir')
.replace('- /var/log/syslog*', '')
+ 'encryption_passphrase: "test"\n'
+ 'zfs:\n'
+ ' zfs_command: python3 /app/tests/end-to-end/commands/fake_zfs.py\n'
+ ' mount_command: python3 /app/tests/end-to-end/commands/fake_mount.py\n'
+ ' umount_command: python3 /app/tests/end-to-end/commands/fake_umount.py'
)
config_file = open(config_path, 'w')
config_file.write(config)
config_file.close()
def test_zfs_create_and_list():
temporary_directory = tempfile.mkdtemp()
repository_path = os.path.join(temporary_directory, 'test.borg')
try:
config_path = os.path.join(temporary_directory, 'test.yaml')
generate_configuration(config_path, repository_path)
subprocess.check_call(
f'borgmatic -v 2 --config {config_path} repo-create --encryption repokey'.split(' ')
)
# Run a create action to exercise ZFS snapshotting and backup.
subprocess.check_call(f'borgmatic --config {config_path} create'.split(' '))
# List the resulting archive and assert that the snapshotted files are there.
output = subprocess.check_output(
f'borgmatic --config {config_path} list --archive latest'.split(' ')
).decode(sys.stdout.encoding)
assert 'pool/dataset/subdir/file.txt' in output
# Assert that the snapshot has been deleted.
assert not subprocess.check_output(
'python3 /app/tests/end-to-end/commands/fake_zfs.py list -H -t snapshot'.split(' ')
)
finally:
shutil.rmtree(temporary_directory)
+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
View File
@@ -2,14 +2,14 @@ import logging
from flexmock import flexmock
from borgmatic.hooks import apprise as module
from borgmatic.hooks.monitoring import apprise as module
def test_destroy_monitor_removes_apprise_handler():
logger = logging.getLogger()
original_handlers = list(logger.handlers)
module.borgmatic.hooks.logs.add_handler(
module.borgmatic.hooks.logs.Forgetful_buffering_handler(
module.borgmatic.hooks.monitoring.logs.add_handler(
module.borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
identifier=module.HANDLER_IDENTIFIER, byte_capacity=100, log_level=1
)
)
@@ -2,14 +2,14 @@ import logging
from flexmock import flexmock
from borgmatic.hooks import healthchecks as module
from borgmatic.hooks.monitoring import healthchecks as module
def test_destroy_monitor_removes_healthchecks_handler():
logger = logging.getLogger()
original_handlers = list(logger.handlers)
module.borgmatic.hooks.logs.add_handler(
module.borgmatic.hooks.logs.Forgetful_buffering_handler(
module.borgmatic.hooks.monitoring.logs.add_handler(
module.borgmatic.hooks.monitoring.logs.Forgetful_buffering_handler(
identifier=module.HANDLER_IDENTIFIER, byte_capacity=100, log_level=1
)
)
@@ -3,7 +3,7 @@ import platform
from flexmock import flexmock
from borgmatic.hooks import loki as module
from borgmatic.hooks.monitoring import loki as module
def test_initialize_monitor_replaces_labels():
+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)
+24 -24
View File
@@ -90,7 +90,7 @@ def test_restore_single_data_source_extracts_and_restores_single_file_dump():
'make_data_source_dump_patterns', object, object, object, object, object
).and_return({'postgresql': flexmock()})
flexmock(module.tempfile).should_receive('mkdtemp').never()
flexmock(module.borgmatic.hooks.dump).should_receive(
flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
'convert_glob_patterns_to_borg_pattern'
).and_return(flexmock())
flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return(
@@ -98,11 +98,11 @@ def test_restore_single_data_source_extracts_and_restores_single_file_dump():
).once()
flexmock(module).should_receive('strip_path_prefix_from_extracted_dump_destination').never()
flexmock(module.shutil).should_receive('rmtree').never()
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args(
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args(
function_name='restore_data_source_dump',
config=object,
log_prefix=object,
hook_names=object,
hook_name=object,
data_source=object,
dry_run=object,
extract_process=object,
@@ -132,7 +132,7 @@ def test_restore_single_data_source_extracts_and_restores_directory_dump():
flexmock(module.tempfile).should_receive('mkdtemp').once().and_return(
'/run/user/0/borgmatic/tmp1234'
)
flexmock(module.borgmatic.hooks.dump).should_receive(
flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
'convert_glob_patterns_to_borg_pattern'
).and_return(flexmock())
flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return(
@@ -140,11 +140,11 @@ def test_restore_single_data_source_extracts_and_restores_directory_dump():
).once()
flexmock(module).should_receive('strip_path_prefix_from_extracted_dump_destination').once()
flexmock(module.shutil).should_receive('rmtree').once()
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args(
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args(
function_name='restore_data_source_dump',
config=object,
log_prefix=object,
hook_names=object,
hook_name=object,
data_source=object,
dry_run=object,
extract_process=object,
@@ -174,7 +174,7 @@ def test_restore_single_data_source_with_directory_dump_error_cleans_up_temporar
flexmock(module.tempfile).should_receive('mkdtemp').once().and_return(
'/run/user/0/borgmatic/tmp1234'
)
flexmock(module.borgmatic.hooks.dump).should_receive(
flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
'convert_glob_patterns_to_borg_pattern'
).and_return(flexmock())
flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_raise(
@@ -182,11 +182,11 @@ def test_restore_single_data_source_with_directory_dump_error_cleans_up_temporar
).once()
flexmock(module).should_receive('strip_path_prefix_from_extracted_dump_destination').never()
flexmock(module.shutil).should_receive('rmtree').once()
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args(
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args(
function_name='restore_data_source_dump',
config=object,
log_prefix=object,
hook_names=object,
hook_name=object,
data_source=object,
dry_run=object,
extract_process=object,
@@ -215,7 +215,7 @@ def test_restore_single_data_source_with_directory_dump_and_dry_run_skips_direct
'make_data_source_dump_patterns', object, object, object, object, object
).and_return({'postgresql': flexmock()})
flexmock(module.tempfile).should_receive('mkdtemp').once().and_return('/run/borgmatic/tmp1234')
flexmock(module.borgmatic.hooks.dump).should_receive(
flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
'convert_glob_patterns_to_borg_pattern'
).and_return(flexmock())
flexmock(module.borgmatic.borg.extract).should_receive('extract_archive').and_return(
@@ -223,11 +223,11 @@ def test_restore_single_data_source_with_directory_dump_and_dry_run_skips_direct
).once()
flexmock(module).should_receive('strip_path_prefix_from_extracted_dump_destination').never()
flexmock(module.shutil).should_receive('rmtree').never()
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').with_args(
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hook').with_args(
function_name='restore_data_source_dump',
config=object,
log_prefix=object,
hook_names=object,
hook_name=object,
data_source=object,
dry_run=object,
extract_process=object,
@@ -254,9 +254,9 @@ def test_collect_archive_data_source_names_parses_archive_paths():
flexmock(module.borgmatic.config.paths).should_receive(
'get_borgmatic_source_directory'
).and_return('/root/.borgmatic')
flexmock(module.borgmatic.hooks.dump).should_receive('make_data_source_dump_path').and_return(
''
)
flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
'make_data_source_dump_path'
).and_return('')
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
[
'borgmatic/postgresql_databases/localhost/foo',
@@ -286,9 +286,9 @@ def test_collect_archive_data_source_names_parses_archive_paths_with_different_b
flexmock(module.borgmatic.config.paths).should_receive(
'get_borgmatic_source_directory'
).and_return('/root/.borgmatic')
flexmock(module.borgmatic.hooks.dump).should_receive('make_data_source_dump_path').and_return(
''
)
flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
'make_data_source_dump_path'
).and_return('')
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
[
'borgmatic/postgresql_databases/localhost/foo',
@@ -319,9 +319,9 @@ def test_collect_archive_data_source_names_parses_directory_format_archive_paths
flexmock(module.borgmatic.config.paths).should_receive(
'get_borgmatic_source_directory'
).and_return('/root/.borgmatic')
flexmock(module.borgmatic.hooks.dump).should_receive('make_data_source_dump_path').and_return(
''
)
flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
'make_data_source_dump_path'
).and_return('')
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
[
'borgmatic/postgresql_databases/localhost/foo/table1',
@@ -349,9 +349,9 @@ def test_collect_archive_data_source_names_skips_bad_archive_paths():
flexmock(module.borgmatic.config.paths).should_receive(
'get_borgmatic_source_directory'
).and_return('/root/.borgmatic')
flexmock(module.borgmatic.hooks.dump).should_receive('make_data_source_dump_path').and_return(
''
)
flexmock(module.borgmatic.hooks.data_source.dump).should_receive(
'make_data_source_dump_path'
).and_return('')
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
[
'borgmatic/postgresql_databases/localhost/foo',
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
+107
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,29 @@ 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_replace_temporary_subdirectory_with_glob_passes_through_non_matching_path():
assert (
module.replace_temporary_subdirectory_with_glob('/tmp/foo-aet8kn93/borgmatic')
== '/tmp/foo-aet8kn93/borgmatic'
)
def test_replace_temporary_subdirectory_with_glob_uses_custom_temporary_directory_prefix():
assert (
module.replace_temporary_subdirectory_with_glob(
'/tmp/.borgmatic-aet8kn93/borgmatic', temporary_directory_prefix='.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 +79,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 +112,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 +145,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 +175,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 +208,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 +242,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',
(
@@ -0,0 +1,139 @@
import sys
from flexmock import flexmock
from borgmatic.hooks.data_source 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,
)
+748
View File
@@ -0,0 +1,748 @@
from flexmock import flexmock
from borgmatic.hooks.data_source import btrfs as module
def test_get_filesystem_mount_points_parses_findmnt_output():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'/mnt0 /dev/loop0 btrfs rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/\n'
'/mnt1 /dev/loop1 btrfs rw,relatime,ssd,space_cache=v2,subvolid=5,subvol=/\n'
)
assert module.get_filesystem_mount_points('findmnt') == ('/mnt0', '/mnt1')
def test_get_subvolumes_for_filesystem_parses_subvolume_list_output():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'ID 270 gen 107 top level 5 path subvol1\nID 272 gen 74 top level 5 path subvol2\n'
)
assert module.get_subvolumes_for_filesystem('btrfs', '/mnt') == (
'/mnt',
'/mnt/subvol1',
'/mnt/subvol2',
)
def test_get_subvolumes_for_filesystem_skips_empty_subvolume_paths():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return('\n \nID 272 gen 74 top level 5 path subvol2\n')
assert module.get_subvolumes_for_filesystem('btrfs', '/mnt') == ('/mnt', '/mnt/subvol2')
def test_get_subvolumes_for_filesystem_skips_empty_filesystem_mount_points():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'ID 270 gen 107 top level 5 path subvol1\nID 272 gen 74 top level 5 path subvol2\n'
)
assert module.get_subvolumes_for_filesystem('btrfs', ' ') == ()
def test_get_subvolumes_collects_subvolumes_matching_source_directories_from_all_filesystems():
flexmock(module).should_receive('get_filesystem_mount_points').and_return(('/mnt1', '/mnt2'))
flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
'btrfs', '/mnt1'
).and_return(('/one', '/two'))
flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
'btrfs', '/mnt2'
).and_return(('/three', '/four'))
for path in ('/one', '/four'):
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_directories'
).with_args(path, object).and_return((path,))
for path in ('/two', '/three'):
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_directories'
).with_args(path, object).and_return(())
assert module.get_subvolumes(
'btrfs', 'findmnt', source_directories=['/one', '/four', '/five', '/six', '/mnt2', '/mnt3']
) == (
module.Subvolume('/four', contained_source_directories=('/four',)),
module.Subvolume('/one', contained_source_directories=('/one',)),
)
def test_get_subvolumes_without_source_directories_collects_all_subvolumes_from_all_filesystems():
flexmock(module).should_receive('get_filesystem_mount_points').and_return(('/mnt1', '/mnt2'))
flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
'btrfs', '/mnt1'
).and_return(('/one', '/two'))
flexmock(module).should_receive('get_subvolumes_for_filesystem').with_args(
'btrfs', '/mnt2'
).and_return(('/three', '/four'))
for path in ('/one', '/two', '/three', '/four'):
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_directories'
).with_args(path, object).and_return((path,))
assert module.get_subvolumes('btrfs', 'findmnt') == (
module.Subvolume('/four', contained_source_directories=('/four',)),
module.Subvolume('/one', contained_source_directories=('/one',)),
module.Subvolume('/three', contained_source_directories=('/three',)),
module.Subvolume('/two', contained_source_directories=('/two',)),
)
def test_dump_data_sources_snapshots_each_subvolume_and_updates_source_directories():
source_directories = ['/foo', '/mnt/subvol1']
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
)
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
)
flexmock(module).should_receive('snapshot_subvolume').with_args(
'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).once()
flexmock(module).should_receive('snapshot_subvolume').with_args(
'btrfs', '/mnt/subvol2', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).once()
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
'/mnt/subvol1'
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
'/mnt/subvol2'
).and_return('/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234')
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
'/mnt/subvol1', object
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
'/mnt/subvol2', object
).and_return('/mnt/subvol2/.borgmatic-1234/mnt/subvol2')
assert (
module.dump_data_sources(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
dry_run=False,
)
== []
)
assert source_directories == [
'/foo',
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
]
assert config == {
'btrfs': {},
'exclude_patterns': [
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
],
}
def test_dump_data_sources_uses_custom_btrfs_command_in_commands():
source_directories = ['/foo', '/mnt/subvol1']
config = {'btrfs': {'btrfs_command': '/usr/local/bin/btrfs'}}
flexmock(module).should_receive('get_subvolumes').and_return(
(module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),)
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
)
flexmock(module).should_receive('snapshot_subvolume').with_args(
'/usr/local/bin/btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).once()
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
'/mnt/subvol1'
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
'/mnt/subvol1', object
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')
assert (
module.dump_data_sources(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
dry_run=False,
)
== []
)
assert source_directories == [
'/foo',
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
]
assert config == {
'btrfs': {
'btrfs_command': '/usr/local/bin/btrfs',
},
'exclude_patterns': [
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
],
}
def test_dump_data_sources_uses_custom_findmnt_command_in_commands():
source_directories = ['/foo', '/mnt/subvol1']
config = {'btrfs': {'findmnt_command': '/usr/local/bin/findmnt'}}
flexmock(module).should_receive('get_subvolumes').with_args(
'btrfs', '/usr/local/bin/findmnt', source_directories
).and_return(
(module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),)
).once()
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
)
flexmock(module).should_receive('snapshot_subvolume').with_args(
'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).once()
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
'/mnt/subvol1'
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
'/mnt/subvol1', object
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')
assert (
module.dump_data_sources(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
dry_run=False,
)
== []
)
assert source_directories == [
'/foo',
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
]
assert config == {
'btrfs': {
'findmnt_command': '/usr/local/bin/findmnt',
},
'exclude_patterns': [
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
],
}
def test_dump_data_sources_with_dry_run_skips_snapshot_and_source_directories_update():
source_directories = ['/foo', '/mnt/subvol1']
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),)
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
)
flexmock(module).should_receive('snapshot_subvolume').never()
flexmock(module).should_receive('make_snapshot_exclude_path').never()
assert (
module.dump_data_sources(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
dry_run=True,
)
== []
)
assert source_directories == ['/foo', '/mnt/subvol1']
assert config == {'btrfs': {}}
def test_dump_data_sources_without_matching_subvolumes_skips_snapshot_and_source_directories_update():
source_directories = ['/foo', '/mnt/subvol1']
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(())
flexmock(module).should_receive('make_snapshot_path').never()
flexmock(module).should_receive('snapshot_subvolume').never()
flexmock(module).should_receive('make_snapshot_exclude_path').never()
assert (
module.dump_data_sources(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
dry_run=False,
)
== []
)
assert source_directories == ['/foo', '/mnt/subvol1']
assert config == {'btrfs': {}}
def test_dump_data_sources_snapshots_adds_to_existing_exclude_patterns():
source_directories = ['/foo', '/mnt/subvol1']
config = {'btrfs': {}, 'exclude_patterns': ['/bar']}
flexmock(module).should_receive('get_subvolumes').and_return(
(
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
)
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
)
flexmock(module).should_receive('snapshot_subvolume').with_args(
'btrfs', '/mnt/subvol1', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).once()
flexmock(module).should_receive('snapshot_subvolume').with_args(
'btrfs', '/mnt/subvol2', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).once()
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
'/mnt/subvol1'
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234')
flexmock(module).should_receive('make_snapshot_exclude_path').with_args(
'/mnt/subvol2'
).and_return('/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234')
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
'/mnt/subvol1', object
).and_return('/mnt/subvol1/.borgmatic-1234/mnt/subvol1')
flexmock(module).should_receive('make_borg_source_directory_path').with_args(
'/mnt/subvol2', object
).and_return('/mnt/subvol2/.borgmatic-1234/mnt/subvol2')
assert (
module.dump_data_sources(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
source_directories=source_directories,
dry_run=False,
)
== []
)
assert source_directories == [
'/foo',
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
]
assert config == {
'btrfs': {},
'exclude_patterns': [
'/bar',
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1/.borgmatic-1234',
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2/.borgmatic-1234',
],
}
def test_remove_data_source_dumps_deletes_snapshots():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
)
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
'/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
).and_return(
('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
).and_return(
('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
).and_return(False)
flexmock(module).should_receive('delete_snapshot').with_args(
'btrfs', '/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).once()
flexmock(module).should_receive('delete_snapshot').with_args(
'btrfs', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
).once()
flexmock(module).should_receive('delete_snapshot').with_args(
'btrfs', '/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).once()
flexmock(module).should_receive('delete_snapshot').with_args(
'btrfs', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
).never()
flexmock(module.shutil).should_receive('rmtree').with_args(
'/mnt/subvol1/.borgmatic-1234'
).once()
flexmock(module.shutil).should_receive('rmtree').with_args(
'/mnt/subvol1/.borgmatic-5678'
).once()
flexmock(module.shutil).should_receive('rmtree').with_args(
'/mnt/subvol2/.borgmatic-1234'
).once()
flexmock(module.shutil).should_receive('rmtree').with_args(
'/mnt/subvol2/.borgmatic-5678'
).never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_get_subvolumes_file_not_found_error_bails():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_raise(FileNotFoundError)
flexmock(module).should_receive('make_snapshot_path').never()
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_get_subvolumes_called_process_error_bails():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_raise(
module.subprocess.CalledProcessError(1, 'command', 'error')
)
flexmock(module).should_receive('make_snapshot_path').never()
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_dry_run_skips_deletes():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
)
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
'/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
).and_return(
('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
).and_return(
('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
).and_return(False)
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=True,
)
def test_remove_data_source_dumps_without_subvolumes_skips_deletes():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(())
flexmock(module).should_receive('make_snapshot_path').never()
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).never()
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_without_snapshots_skips_deletes():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
)
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
'/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
)
flexmock(module.glob).should_receive('glob').and_return(())
flexmock(module.os.path).should_receive('isdir').never()
flexmock(module).should_receive('delete_snapshot').never()
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_delete_snapshot_file_not_found_error_bails():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
)
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
'/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
).and_return(
('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
).and_return(
('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
).and_return(False)
flexmock(module).should_receive('delete_snapshot').and_raise(FileNotFoundError)
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
def test_remove_data_source_dumps_with_delete_snapshot_called_process_error_bails():
config = {'btrfs': {}}
flexmock(module).should_receive('get_subvolumes').and_return(
(
module.Subvolume('/mnt/subvol1', contained_source_directories=('/mnt/subvol1',)),
module.Subvolume('/mnt/subvol2', contained_source_directories=('/mnt/subvol2',)),
)
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol1').and_return(
'/mnt/subvol1/.borgmatic-1234/./mnt/subvol1'
)
flexmock(module).should_receive('make_snapshot_path').with_args('/mnt/subvol2').and_return(
'/mnt/subvol2/.borgmatic-1234/./mnt/subvol2'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob'
).with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2',
temporary_directory_prefix=module.BORGMATIC_SNAPSHOT_PREFIX,
).and_return(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol1/.borgmatic-*/mnt/subvol1'
).and_return(
('/mnt/subvol1/.borgmatic-1234/mnt/subvol1', '/mnt/subvol1/.borgmatic-5678/mnt/subvol1')
)
flexmock(module.glob).should_receive('glob').with_args(
'/mnt/subvol2/.borgmatic-*/mnt/subvol2'
).and_return(
('/mnt/subvol2/.borgmatic-1234/mnt/subvol2', '/mnt/subvol2/.borgmatic-5678/mnt/subvol2')
)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-1234/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol1/.borgmatic-5678/mnt/subvol1'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-1234/mnt/subvol2'
).and_return(True)
flexmock(module.os.path).should_receive('isdir').with_args(
'/mnt/subvol2/.borgmatic-5678/mnt/subvol2'
).and_return(False)
flexmock(module).should_receive('delete_snapshot').and_raise(
module.subprocess.CalledProcessError(1, 'command', 'error')
)
flexmock(module.shutil).should_receive('rmtree').never()
module.remove_data_source_dumps(
hook_config=config['btrfs'],
config=config,
log_prefix='test',
borgmatic_runtime_directory='/run/borgmatic',
dry_run=False,
)
@@ -1,7 +1,7 @@
import pytest
from flexmock import flexmock
from borgmatic.hooks import dump as module
from borgmatic.hooks.data_source import dump as module
def test_make_data_source_dump_path_joins_arguments():
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@ import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import mariadb as module
from borgmatic.hooks.data_source import mariadb as module
def test_database_names_to_dump_passes_through_name():
@@ -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,
)
== []
)
@@ -2,7 +2,7 @@ import logging
from flexmock import flexmock
from borgmatic.hooks import mongodb as module
from borgmatic.hooks.data_source import mongodb as module
def test_use_streaming_true_for_any_non_directory_format_databases():
@@ -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]
@@ -3,7 +3,7 @@ import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import mysql as module
from borgmatic.hooks.data_source import mysql as module
def test_database_names_to_dump_passes_through_name():
@@ -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,
)
== []
)
@@ -3,7 +3,7 @@ import logging
import pytest
from flexmock import flexmock
from borgmatic.hooks import postgresql as module
from borgmatic.hooks.data_source import postgresql as module
def test_make_extra_environment_maps_options_to_environment():
@@ -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]
@@ -0,0 +1,26 @@
from borgmatic.hooks.data_source import snapshot as module
def test_get_contained_directories_without_candidates_returns_empty():
assert module.get_contained_directories('/mnt', {}) == ()
def test_get_contained_directories_with_self_candidate_returns_self():
candidates = {'/foo', '/mnt', '/bar'}
assert module.get_contained_directories('/mnt', candidates) == ('/mnt',)
assert candidates == {'/foo', '/bar'}
def test_get_contained_directories_with_child_candidate_returns_child():
candidates = {'/foo', '/mnt/subdir', '/bar'}
assert module.get_contained_directories('/mnt', candidates) == ('/mnt/subdir',)
assert candidates == {'/foo', '/bar'}
def test_get_contained_directories_with_grandchild_candidate_returns_child():
candidates = {'/foo', '/mnt/sub/dir', '/bar'}
assert module.get_contained_directories('/mnt', candidates) == ('/mnt/sub/dir',)
assert candidates == {'/foo', '/bar'}
@@ -2,7 +2,7 @@ import logging
from flexmock import flexmock
from borgmatic.hooks import sqlite as module
from borgmatic.hooks.data_source import sqlite as module
def test_use_streaming_true_for_any_databases():
@@ -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,
)
== []
)
+511
View File
@@ -0,0 +1,511 @@
import os
import pytest
from flexmock import flexmock
from borgmatic.hooks.data_source import zfs as module
def test_get_datasets_to_backup_filters_datasets_by_source_directories():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'dataset\t/dataset\t-\nother\t/other\t-',
)
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_directories'
).with_args('/dataset', object).and_return(('/dataset',))
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_directories'
).with_args('/other', object).and_return(())
assert module.get_datasets_to_backup(
'zfs', source_directories=('/foo', '/dataset', '/bar')
) == (
module.Dataset(
name='dataset', mount_point='/dataset', contained_source_directories=('/dataset',)
),
)
def test_get_datasets_to_backup_filters_datasets_by_user_property():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'dataset\t/dataset\tauto\nother\t/other\t-',
)
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_directories'
).with_args('/dataset', object).never()
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_directories'
).with_args('/other', object).and_return(())
assert module.get_datasets_to_backup('zfs', source_directories=('/foo', '/bar')) == (
module.Dataset(
name='dataset',
mount_point='/dataset',
auto_backup=True,
contained_source_directories=('/dataset',),
),
)
def test_get_datasets_to_backup_with_invalid_list_output_raises():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'dataset',
)
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_directories'
).never()
with pytest.raises(ValueError, match='zfs'):
module.get_datasets_to_backup('zfs', source_directories=('/foo', '/bar'))
def test_get_all_dataset_mount_points_does_not_filter_datasets():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'/dataset\n/other',
)
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_directories'
).and_return(('/dataset',))
assert module.get_all_dataset_mount_points('zfs') == (
('/dataset'),
('/other'),
)
def test_dump_data_sources_snapshots_and_mounts_and_updates_source_directories():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(
flexmock(
name='dataset',
mount_point='/mnt/dataset',
contained_source_directories=('/mnt/dataset/subdir',),
)
)
)
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/subdir']
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 == [os.path.join(snapshot_mount_path, 'subdir')]
def test_dump_data_sources_with_no_datasets_skips_snapshots():
flexmock(module).should_receive('get_datasets_to_backup').and_return(())
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=False,
)
== []
)
assert source_directories == ['/mnt/dataset']
def test_dump_data_sources_uses_custom_commands():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(
flexmock(
name='dataset',
mount_point='/mnt/dataset',
contained_source_directories=('/mnt/dataset/subdir',),
)
)
)
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/subdir']
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 == [os.path.join(snapshot_mount_path, 'subdir')]
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(
(flexmock(name='dataset', mount_point='/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_dump_data_sources_ignores_mismatch_between_source_directories_and_contained_source_directories():
flexmock(module).should_receive('get_datasets_to_backup').and_return(
(
flexmock(
name='dataset',
mount_point='/mnt/dataset',
contained_source_directories=('/mnt/dataset/subdir',),
)
)
)
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 = ['/hmm']
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 == ['/hmm', os.path.join(snapshot_mount_path, 'subdir')]
def test_get_all_snapshots_parses_list_output():
flexmock(module.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_dataset_mount_points').and_return(('/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_dataset_mount_points').and_return(('/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_dataset_mount_points').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_dataset_mount_points').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_dataset_mount_points').and_return(('/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_dataset_mount_points').and_return(('/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_dataset_mount_points').and_return(('/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_dataset_mount_points').and_return(('/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_skips_unmount_snapshot_mount_paths_after_rmtree_succeeds():
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/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(True).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_dataset_mount_points').and_return(('/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,
)
@@ -2,8 +2,8 @@ import apprise
from apprise import NotifyFormat, NotifyType
from flexmock import flexmock
import borgmatic.hooks.monitor
from borgmatic.hooks import apprise as module
import borgmatic.hooks.monitoring.monitor
from borgmatic.hooks.monitoring import apprise as module
TOPIC = 'borgmatic-unit-testing'
@@ -18,7 +18,7 @@ def mock_apprise():
def test_initialize_monitor_with_send_logs_false_does_not_add_handler():
flexmock(module.borgmatic.hooks.logs).should_receive('add_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('add_handler').never()
module.initialize_monitor(
hook_config={'send_logs': False},
@@ -31,12 +31,14 @@ def test_initialize_monitor_with_send_logs_false_does_not_add_handler():
def test_initialize_monitor_with_send_logs_true_adds_handler_with_default_log_size_limit():
truncation_indicator_length = 4
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').with_args(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).with_args(
module.HANDLER_IDENTIFIER,
module.DEFAULT_LOGS_SIZE_LIMIT_BYTES - truncation_indicator_length,
1,
).once()
flexmock(module.borgmatic.hooks.logs).should_receive('add_handler').once()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('add_handler').once()
module.initialize_monitor(
hook_config={'send_logs': True},
@@ -49,12 +51,14 @@ def test_initialize_monitor_with_send_logs_true_adds_handler_with_default_log_si
def test_initialize_monitor_without_send_logs_adds_handler_with_default_log_size_limit():
truncation_indicator_length = 4
flexmock(module.borgmatic.hooks.logs).should_receive('Forgetful_buffering_handler').with_args(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'Forgetful_buffering_handler'
).with_args(
module.HANDLER_IDENTIFIER,
module.DEFAULT_LOGS_SIZE_LIMIT_BYTES - truncation_indicator_length,
1,
).once()
flexmock(module.borgmatic.hooks.logs).should_receive('add_handler').once()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('add_handler').once()
module.initialize_monitor(
hook_config={},
@@ -66,8 +70,8 @@ def test_initialize_monitor_without_send_logs_adds_handler_with_default_log_size
def test_ping_monitor_respects_dry_run():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('loggy log')
mock_apprise().should_receive('notify').never()
@@ -76,30 +80,32 @@ def test_ping_monitor_respects_dry_run():
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_with_no_states_does_not_notify():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.logs).should_receive('format_buffered_logs_for_payload').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).never()
mock_apprise().should_receive('notify').never()
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': []},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=True,
)
def test_ping_monitor_notifies_fail_by_default():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -109,7 +115,7 @@ def test_ping_monitor_notifies_fail_by_default():
notify_type=NotifyType.FAILURE,
).once()
for state in borgmatic.hooks.monitor.State:
for state in borgmatic.hooks.monitoring.monitor.State:
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
@@ -121,8 +127,8 @@ def test_ping_monitor_notifies_fail_by_default():
def test_ping_monitor_with_logs_appends_logs_to_body():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('loggy log')
mock_apprise().should_receive('notify').with_args(
@@ -132,7 +138,7 @@ def test_ping_monitor_with_logs_appends_logs_to_body():
notify_type=NotifyType.FAILURE,
).once()
for state in borgmatic.hooks.monitor.State:
for state in borgmatic.hooks.monitoring.monitor.State:
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
@@ -144,8 +150,8 @@ def test_ping_monitor_with_logs_appends_logs_to_body():
def test_ping_monitor_with_finish_default_config_notifies():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -159,15 +165,17 @@ def test_ping_monitor_with_finish_default_config_notifies():
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['finish']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FINISH,
borgmatic.hooks.monitoring.monitor.State.FINISH,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_start_default_config_notifies():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.logs).should_receive('format_buffered_logs_for_payload').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).never()
mock_apprise().should_receive('notify').with_args(
title='A borgmatic START event happened',
body='A borgmatic START event happened',
@@ -179,15 +187,15 @@ def test_ping_monitor_with_start_default_config_notifies():
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['start']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.START,
borgmatic.hooks.monitoring.monitor.State.START,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_fail_default_config_notifies():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -201,15 +209,15 @@ def test_ping_monitor_with_fail_default_config_notifies():
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['fail']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_with_log_default_config_notifies():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -223,15 +231,15 @@ def test_ping_monitor_with_log_default_config_notifies():
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}], 'states': ['log']},
{},
'config.yaml',
borgmatic.hooks.monitor.State.LOG,
borgmatic.hooks.monitoring.monitor.State.LOG,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_passes_through_custom_message_title():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -249,15 +257,15 @@ def test_ping_monitor_passes_through_custom_message_title():
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_passes_through_custom_message_body():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').with_args(
@@ -275,15 +283,15 @@ def test_ping_monitor_passes_through_custom_message_body():
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_passes_through_custom_message_body_and_appends_logs():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('loggy log')
mock_apprise().should_receive('notify').with_args(
@@ -301,15 +309,15 @@ def test_ping_monitor_passes_through_custom_message_body_and_appends_logs():
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_pings_multiple_services():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('add').with_args([f'ntfys://{TOPIC}', f'ntfy://{TOPIC}']).once()
@@ -323,36 +331,38 @@ def test_ping_monitor_pings_multiple_services():
},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_logs_info_for_no_services():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.logs).should_receive('format_buffered_logs_for_payload').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler').never()
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).never()
flexmock(module.logger).should_receive('info').once()
module.ping_monitor(
{'services': []},
{},
'config.yaml',
borgmatic.hooks.monitor.State.FAIL,
borgmatic.hooks.monitoring.monitor.State.FAIL,
monitoring_log_level=1,
dry_run=False,
)
def test_ping_monitor_logs_warning_when_notify_fails():
flexmock(module.borgmatic.hooks.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.logs).should_receive(
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('get_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive(
'format_buffered_logs_for_payload'
).and_return('')
mock_apprise().should_receive('notify').and_return(False)
flexmock(module.logger).should_receive('warning').once()
for state in borgmatic.hooks.monitor.State:
for state in borgmatic.hooks.monitoring.monitor.State:
module.ping_monitor(
{'services': [{'url': f'ntfys://{TOPIC}', 'label': 'ntfys'}]},
{},
@@ -364,7 +374,7 @@ def test_ping_monitor_logs_warning_when_notify_fails():
def test_destroy_monitor_does_not_raise():
flexmock(module.borgmatic.hooks.logs).should_receive('remove_handler')
flexmock(module.borgmatic.hooks.monitoring.logs).should_receive('remove_handler')
module.destroy_monitor(
hook_config={},
@@ -1,6 +1,6 @@
from flexmock import flexmock
from borgmatic.hooks import cronhub as module
from borgmatic.hooks.monitoring import cronhub as module
def test_ping_monitor_rewrites_ping_url_for_start_state():
@@ -1,6 +1,6 @@
from flexmock import flexmock
from borgmatic.hooks import cronitor as module
from borgmatic.hooks.monitoring import cronitor as module
def test_ping_monitor_hits_ping_url_for_start_state():

Some files were not shown because too many files have changed in this diff Show More