Compare commits

...
Author SHA1 Message Date
Dan Helfman 6ac2c86d2d Merge branch 'main' into parallel-borgmatic 2026-05-09 11:06:36 -07:00
Dan Helfman 9839b3dada Fix end-to-end database tests (#1303). 2026-05-09 11:05:28 -07:00
Dan Helfman b7ecc9c6c3 Fix repo-create action (#1267). 2026-05-09 10:30:48 -07:00
Dan Helfman 4f728b1cd4 Get rest of tests passing (#1267). 2026-05-08 22:16:41 -07:00
Dan Helfman 78db924b70 Get tests passing (#1267). 2026-05-08 18:14:15 -07:00
Dan Helfman b55834c1ca Initial support for multiple borgmatic instances run in parallel—as long as they're operating on different Borg repositories (#1267). 2026-05-08 12:44:16 -07:00
Dan Helfman 411685280c For MariaDB/MySQL, dump events, routines, and tablespaces. For MariaDB, dump a subset of system data when dumping "mysql"/"all" (#1303). 2026-05-08 09:38:13 -07:00
Dan Helfman cce679248f Clarify "umask" option semantics in regards to integer and octal (#1305). 2026-05-06 14:57:43 -07:00
Dan Helfman 68c9516424 Upgrade Apprise dependency. 2026-05-02 19:08:24 -07:00
Dan Helfman a508cabe3f Update the KeePassXC credential hook to support KeePassXC's secret service integration. 2026-04-29 12:52:27 -07:00
Dan Helfman 0e1659bd73 Expand the "patterns_from" and "exclude_from" options to support paths containing tildes and globs (#1301). 2026-04-27 10:13:21 -07:00
Dan Helfman ad61ad356e Update the documentation to use Pagefind's component-based search UI. 2026-04-26 09:10:07 -07:00
Dan Helfman 5c7d03910b Fix the "source_directories_must_exist" option to support source directories relative to a "working_directory" (#1300). 2026-04-25 13:49:55 -07:00
Dan Helfman 468af1de0b Fix the PostgreSQL database hook to properly parse "*options" values containing quoted spaces. 2026-04-24 19:24:44 -07:00
Dan Helfman 524d8263a7 Upgrade dependencies. 2026-04-24 17:04:29 -07:00
Dan Helfman 1d2aea0951 Add a development script for upgrading pinned dependencies. 2026-04-24 11:54:37 -07:00
Dan Helfman 961ff7c724 For the MariaDB and MySQL database hooks, escape quotes in passwords when the "password_transport" option is "pipe". 2026-04-24 10:24:45 -07:00
Dan Helfman 3e80056956 Add projects.torsion.org reply by email to NEWS. 2026-04-20 21:52:22 -07:00
Dan Helfman d8f558ce0d Add the NEWS changelog file to release tarball (#1298). 2026-04-20 15:42:17 -07:00
Dan Helfman 9da75fdc58 Use pytest tmp_path fixture instead of hardcoded /tmp paths (#1296).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1296
2026-04-20 18:10:02 +00:00
Dan Helfman ac9c8bb644 Fix build of standalone binary. 2026-04-20 10:28:57 -07:00
Dan Helfman d085fc2398 Bump version for release. 2026-04-20 09:46:54 -07:00
Dan Helfman 40d2d521a7 Fix the ZFS hook to properly unmount snapshots for empty datasets (#1295). 2026-04-18 11:43:58 -07:00
Sirio Balmelli 0b34ef0e1a tests: use pytest tmp_path fixture instead of hardcoded /tmp paths
tmp_path is a built-in pytest fixture providing a unique per-test tempdir.

Hardcoded /tmp paths break when multiple builders run tests concurrently
(e.g. Nix parallel builds): leftover or colliding files cause spurious failures.

Signed-off-by: Sirio Balmelli <sirio@b-ad.ch>
2026-04-15 16:35:41 +02:00
Dan Helfman 3f34d0848e Modify NEWS entry. 2026-04-13 15:09:04 -07:00
Dan Helfman 54289e3ee0 Fix documentation typo (#1229). 2026-04-13 10:51:32 -07:00
Dan Helfman 6eea2d5323 Document the permissions needed for the PostgreSQL database hook (#1229). 2026-04-13 10:40:10 -07:00
Dan Helfman 0ca5333fd4 Add Loki documentation link to NEWS (#1289). 2026-04-13 09:22:22 -07:00
Dan Helfman 462e1392da Add Loki mTLS to NEWS and remove unnecesary log (#1289). 2026-04-13 09:16:15 -07:00
Dan Helfman 71e2762aa7 Add mTLS support for Loki monitoring hook (#1289).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1293
Reviewed-by: Dan Helfman <witten@torsion.org>
2026-04-13 16:07:56 +00:00
Maxime Hamon edfa708fa3 Add mTLS support for Loki monitoring hook 2026-04-12 17:57:01 +02:00
Dan Helfman aee16e32e2 Fix a regression in which SSH warnings from remote repositories broke the "spot" check (#1294). 2026-04-10 20:28:14 -07:00
Dan Helfman f3ae04225d Fix a "source directories do not exist" regression when configuration paths are relative symlinks and the bootstrap data source hook is enabled (#1292). 2026-04-06 16:03:39 -07:00
Dan Helfman 3f70cf0b29 Add Borgmatic Director UI link. 2026-04-06 09:10:36 -07:00
65 changed files with 1255 additions and 486 deletions
+1
View File
@@ -1,4 +1,5 @@
# This file only applies to the source dist tarball, not the built wheel.
include NEWS
include borgmatic/config/schema.yaml
graft docs
graft sample
+39 -1
View File
@@ -1,3 +1,41 @@
2.1.6.dev0
* #1267: Support multiple borgmatic instances run in parallel—as long as they're operating on
different Borg repositories. This required an internal change to the runtime directory path,
which may cause a one-time performance hit (Borg cache misses) for Borg 1.x users of database and
filesystem hooks. See the documentation for more information:
https://torsion.org/borgmatic/how-to/make-per-application-backups/#parallelism
* #1300: Fix the "source_directories_must_exist" option to support source directories relative to a
"working_directory".
* #1301: Expand the "patterns_from" and "exclude_from" options to support paths containing tildes
and globs.
* #1303: For the MariaDB and MySQL database hooks, include events, routines, and tablespaces when
dumping a database.
* #1303: For the MariaDB hook, include only a subset of system data when dumping the "mysql" system
database (or "all" databases), so the dump is actually restorable. See the documentation for more
information: https://torsion.org/borgmatic/reference/configuration/data-sources/mariadb/
* Update the KeePassXC credential hook to support KeePassXC's secret service integration. See the
documentation for more information:
https://torsion.org/borgmatic/reference/configuration/credentials/keepassxc/
* Add the NEWS changelog file to release tarball (#1298).
* Enable reply by email on projects.torsion.org, so replies to notification emails get posted as
comments on tickets.
* For the MariaDB and MySQL database hooks, escape quotes in passwords when the
"password_transport" option is "pipe".
* Add a development script for upgrading pinned dependencies.
* Fix the PostgreSQL database hook to properly parse "*options" values containing quoted spaces.
* Update the documentation to use Pagefind's component-based search UI.
2.1.5
* #1229: Document the permissions needed for the PostgreSQL database hook:
https://torsion.org/borgmatic/reference/configuration/data-sources/postgresql/
* #1289: Add mutual TLS support for the Loki monitoring hook. See the documentation for more
information: https://torsion.org/borgmatic/reference/configuration/monitoring/loki/
* #1292: Fix a "source directories do not exist" regression when configuration paths are relative
symlinks and the bootstrap data source hook is enabled.
* #1294: Fix a regression in which SSH warnings from remote repositories broke the "spot" check
and other actions as well.
* #1295: Fix the ZFS hook to properly unmount snapshots for empty datasets.
2.1.4
* #1266: Add a stand-alone borgmatic Linux binary to the release downloads to serve as another way
to install borgmatic. Consider this binary a beta feature.
@@ -10,7 +48,7 @@
* #1268: Fix the "spot" check, "extract" check, and all uses of the archive name "latest" to
respect the "match_archives" and "archives_name_format" options. This means that borgmatic now
uses the "latest" archive that also matches those options instead of the latest archive overall.
* When Borg exits with a warning exit code, show a description of it, so you don't have to lookup
* When Borg exits with a warning exit code, show a description of it, so you don't have to look up
the code.
* Split out borgmatic installation documentation to its own page, so it's easier to find.
* Switch the default borgmatic installation method from pipx to uv, as uv is faster and used for
+19
View File
@@ -0,0 +1,19 @@
.
apprise
attrs
certifi
charset-normalizer
click
idna
jsonschema
jsonschema-specifications
markdown
oauthlib
packaging
pyyaml
referencing
requests
requests-oauthlib
rpds-py
ruamel-yaml
urllib3
+21 -19
View File
@@ -1,19 +1,21 @@
.
apprise==1.9.9
attrs==26.1.0
certifi==2026.2.25
charset-normalizer==3.4.6
click==8.3.1
idna==3.11
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
markdown==3.10.2
oauthlib==3.3.1
packaging==26.0
pyyaml==6.0.3
referencing==0.37.0
requests==2.33.1
requests-oauthlib==2.0.0
rpds-py==0.30.0
ruamel-yaml==0.19.1
urllib3==2.6.3
# This file was autogenerated by uv via the following command:
# uv pip compile --annotation-style line binary_requirements.in -o binary_requirements.txt
apprise==1.10.0 # via -r binary_requirements.in
attrs==26.1.0 # via jsonschema, referencing, -r binary_requirements.in
. # via -r binary_requirements.in
certifi==2026.4.22 # via apprise, requests, -r binary_requirements.in
charset-normalizer==3.4.7 # via requests, -r binary_requirements.in
click==8.3.3 # via apprise, -r binary_requirements.in
idna==3.13 # via requests, -r binary_requirements.in
jsonschema==4.26.0 # via borgmatic, -r binary_requirements.in
jsonschema-specifications==2025.9.1 # via jsonschema, -r binary_requirements.in
markdown==3.10.2 # via apprise, -r binary_requirements.in
oauthlib==3.3.1 # via requests-oauthlib, -r binary_requirements.in
packaging==26.2 # via borgmatic, -r binary_requirements.in
pyyaml==6.0.3 # via apprise, -r binary_requirements.in
referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r binary_requirements.in
requests==2.33.1 # via apprise, borgmatic, requests-oauthlib, -r binary_requirements.in
requests-oauthlib==2.0.0 # via apprise, -r binary_requirements.in
rpds-py==0.30.0 # via jsonschema, referencing, -r binary_requirements.in
ruamel-yaml==0.19.1 # via borgmatic, -r binary_requirements.in
urllib3==2.6.3 # via requests, -r binary_requirements.in
+5 -3
View File
@@ -383,7 +383,7 @@ def collect_spot_check_source_paths(
# Omit "progress" because it interferes with "list_details".
config=dict(config, progress=False, list_details=True),
patterns=borgmatic.actions.pattern.process_patterns(
borgmatic.actions.pattern.collect_patterns(config)
borgmatic.actions.pattern.collect_patterns(config, working_directory)
+ tuple(
borgmatic.borg.pattern.Pattern(
config_path,
@@ -788,7 +788,7 @@ def run_check(
'''
logger.info('Running consistency checks')
repository_id = borgmatic.borg.check.get_repository_id(
repository_id = borgmatic.borg.repo_info.get_repository_id(
repository['path'],
config,
local_borg_version,
@@ -844,7 +844,9 @@ def run_check(
if 'spot' in checks:
logger.info('Running spot check')
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
with borgmatic.config.paths.Runtime_directory(
config, repository_id
) as borgmatic_runtime_directory:
spot_check(
repository,
config,
+12 -1
View File
@@ -3,6 +3,7 @@ import logging
import os
import borgmatic.borg.extract
import borgmatic.borg.repo_info
import borgmatic.borg.repo_list
import borgmatic.config.paths
@@ -109,8 +110,18 @@ def run_bootstrap(bootstrap_arguments, global_arguments, local_borg_version):
local_path=bootstrap_arguments.local_path,
remote_path=bootstrap_arguments.remote_path,
)
repository_id = borgmatic.borg.repo_info.get_repository_id(
bootstrap_arguments.repository,
config,
local_borg_version,
global_arguments,
local_path=bootstrap_arguments.local_path,
remote_path=bootstrap_arguments.remote_path,
)
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
with borgmatic.config.paths.Runtime_directory(
config, repository_id
) as borgmatic_runtime_directory:
manifest_config_paths = load_config_paths_from_archive(
bootstrap_arguments.repository,
archive_name,
+12 -2
View File
@@ -42,10 +42,20 @@ def run_create(
logger.info(f'Creating archive{dry_run_label}')
working_directory = borgmatic.config.paths.get_working_directory(config)
repository_id = borgmatic.borg.repo_info.get_repository_id(
repository['path'],
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
with borgmatic.config.paths.Runtime_directory(
config, repository_id
) as borgmatic_runtime_directory:
patterns = pattern.process_patterns(
pattern.collect_patterns(config),
pattern.collect_patterns(config, working_directory),
config,
working_directory,
borgmatic_runtime_directory,
+3 -2
View File
@@ -21,10 +21,11 @@ def run_diff(
# Only process patterns if only_patterns flag is set
if diff_arguments.only_patterns:
working_directory = borgmatic.config.paths.get_working_directory(config)
processed_patterns = borgmatic.actions.pattern.process_patterns(
(*borgmatic.actions.pattern.collect_patterns(config),),
(*borgmatic.actions.pattern.collect_patterns(config, working_directory),),
config,
borgmatic.config.paths.get_working_directory(config),
working_directory,
)
else:
processed_patterns = None
+8 -5
View File
@@ -34,10 +34,11 @@ def parse_pattern(pattern_line, default_style=borgmatic.borg.pattern.Pattern_sty
)
def collect_patterns(config):
def collect_patterns(config, working_directory):
'''
Given a configuration dict, produce a single sequence of patterns comprised of the configured
source directories, patterns, excludes, pattern files, and exclude files.
Given a configuration dict and the working directory, produce a single sequence of patterns
comprised of the configured source directories, patterns, excludes, pattern files, and exclude
files.
The idea is that Borg has all these different ways of specifying includes, excludes, source
directories, etc., but we'd like to collapse them all down to one common format (patterns) for
@@ -68,7 +69,8 @@ def collect_patterns(config):
+ tuple(
parse_pattern(pattern_line.strip())
for filename in config.get('patterns_from', ())
for pattern_line in open(filename, encoding='utf-8')
for expanded_path in expand_directory(filename, working_directory)
for pattern_line in open(expanded_path, encoding='utf-8')
if not pattern_line.lstrip().startswith('#')
if pattern_line.strip()
)
@@ -78,7 +80,8 @@ def collect_patterns(config):
borgmatic.borg.pattern.Pattern_style.FNMATCH,
)
for filename in config.get('exclude_from', ())
for exclude_line in open(filename, encoding='utf-8')
for expanded_path in expand_directory(filename, working_directory)
for exclude_line in open(expanded_path, encoding='utf-8')
if not exclude_line.lstrip().startswith('#')
if exclude_line.strip()
)
+4 -2
View File
@@ -30,10 +30,12 @@ def run_recreate(
else:
logger.answer(f'Recreating repository{dry_run_label}')
working_directory = borgmatic.config.paths.get_working_directory(config)
# Collect and process patterns.
processed_patterns = borgmatic.actions.pattern.process_patterns(
(
*borgmatic.actions.pattern.collect_patterns(config),
*borgmatic.actions.pattern.collect_patterns(config, working_directory),
# Also add borgmatic-specific paths, so they don't get excluded from the recreated
# archive. Note that this doesn't currently work for archives created with Borg 1.2 or
# below.
@@ -42,7 +44,7 @@ def run_recreate(
),
),
config,
borgmatic.config.paths.get_working_directory(config),
working_directory,
)
archive = borgmatic.borg.repo_list.resolve_archive_name(
+12 -2
View File
@@ -536,10 +536,20 @@ def run_restore(
'''
logger.info(f'Restoring data sources from archive {restore_arguments.archive}')
working_directory = borgmatic.config.paths.get_working_directory(config)
repository_id = borgmatic.borg.repo_info.get_repository_id(
repository['path'],
config,
local_borg_version,
global_arguments,
local_path=local_path,
remote_path=remote_path,
)
with borgmatic.config.paths.Runtime_directory(config) as borgmatic_runtime_directory:
with borgmatic.config.paths.Runtime_directory(
config, repository_id
) as borgmatic_runtime_directory:
patterns = borgmatic.actions.pattern.process_patterns(
borgmatic.actions.pattern.collect_patterns(config),
borgmatic.actions.pattern.collect_patterns(config, working_directory),
config,
working_directory,
)
+1 -33
View File
@@ -1,10 +1,8 @@
import argparse
import json
import logging
import shlex
import borgmatic.config.paths
from borgmatic.borg import environment, feature, flags, repo_info
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import DO_NOT_CAPTURE, execute_command
logger = logging.getLogger(__name__)
@@ -82,36 +80,6 @@ def make_check_name_flags(checks, archive_filter_flags):
)
def get_repository_id(
repository_path,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, global
arguments, and local/remote commands to run, return the corresponding Borg repository ID.
Raise ValueError if the Borg repository ID cannot be determined.
'''
try:
return json.loads(
repo_info.display_repository_info(
repository_path,
config,
local_borg_version,
argparse.Namespace(json=True),
global_arguments,
local_path,
remote_path,
),
)['repository']['id']
except (json.JSONDecodeError, KeyError):
raise ValueError(f'Cannot determine Borg repository ID for {repository_path}')
def check_archives(
repository_path,
config,
+3 -1
View File
@@ -180,7 +180,9 @@ def make_base_create_command( # noqa: PLR0912
open pattern file handle).
'''
if config.get('source_directories_must_exist', True):
borgmatic.borg.pattern.check_all_root_patterns_exist(patterns)
borgmatic.borg.pattern.check_all_root_patterns_exist(
patterns, borgmatic.config.paths.get_working_directory(config)
)
patterns_file = borgmatic.borg.pattern.write_patterns_file(
patterns,
+4 -4
View File
@@ -88,16 +88,16 @@ def write_patterns_file(patterns, borgmatic_runtime_directory, patterns_file=Non
return patterns_file
def check_all_root_patterns_exist(patterns):
def check_all_root_patterns_exist(patterns, working_directory):
'''
Given a sequence of Pattern instances, check that all root pattern paths exist. If any don't,
raise an exception.
Given a sequence of Pattern instances and the current working directory, check that all root
pattern paths exist. If any don't, raise an exception.
'''
missing_paths = [
pattern.path
for pattern in patterns
if pattern.type == Pattern_type.ROOT
if not os.path.exists(pattern.path)
if not os.path.exists(os.path.join(working_directory or '', pattern.path))
]
if missing_paths:
+32
View File
@@ -1,3 +1,5 @@
import argparse
import json
import logging
import shlex
@@ -81,3 +83,33 @@ def display_repository_info(
)
return None
def get_repository_id(
repository_path,
config,
local_borg_version,
global_arguments,
local_path,
remote_path,
):
'''
Given a local or remote repository path, a configuration dict, the local Borg version, global
arguments, and local/remote commands to run, return the corresponding Borg repository ID.
Raise ValueError if the Borg repository ID cannot be determined.
'''
try:
return json.loads(
display_repository_info(
repository_path,
config,
local_borg_version,
argparse.Namespace(json=True),
global_arguments,
local_path,
remote_path,
),
)['repository']['id']
except (json.JSONDecodeError, KeyError):
raise ValueError(f'Cannot determine Borg repository ID for {repository_path}')
+39 -20
View File
@@ -1,6 +1,7 @@
import contextlib
import logging
import os
import shutil
import tempfile
from enum import Enum
@@ -84,6 +85,25 @@ def replace_temporary_subdirectory_with_glob(
)
class Fixed_name_temporary_directory:
'''
A class whose instances can stand-in for tempfile.TemporaryDirectory's, except the temporary
filename path is fixed rather than randomly generated.
'''
def __init__(self, path):
'''
Given a temporary directory path, save it off for later.
'''
self.name = path
def cleanup(self):
'''
Remove the temporary directory path.
'''
shutil.rmtree(self.name, ignore_errors=True)
class Runtime_directory:
'''
A Python context manager for creating and cleaning up the borgmatic runtime directory used for
@@ -98,16 +118,17 @@ class Runtime_directory:
automatically gets cleaned up as necessary.
'''
def __init__(self, config):
def __init__(self, config, repository_id):
'''
Given a configuration dict determine the borgmatic runtime directory, creating a secure,
temporary directory within it if necessary. Defaults to $XDG_RUNTIME_DIR/./borgmatic or
$RUNTIME_DIRECTORY/./borgmatic or $TMPDIR/borgmatic-[random]/./borgmatic or
$TEMP/borgmatic-[random]/./borgmatic or /tmp/borgmatic-[random]/./borgmatic where "[random]"
is a randomly generated string intended to avoid path collisions.
If XDG_RUNTIME_DIR or RUNTIME_DIRECTORY is set and already ends in "/borgmatic", then don't
tack on a second "/borgmatic" path component.
Given a configuration dict and the Borg ID for a repository, determine the borgmatic runtime
directory, creating a secure, temporary directory within it if necessary. Defaults to
$XDG_RUNTIME_DIR/borgmatic-[repository_id]/./borgmatic or
$RUNTIME_DIRECTORY/borgmatic-[repository_id]/./borgmatic or
$TMPDIR/borgmatic-[random]/./borgmatic or $TEMP/borgmatic-[random]/./borgmatic or
/tmp/borgmatic-[random]/./borgmatic where "[random]" is a randomly generated string and
"[repository_id]" is the Borg repository ID being operated on. Both strings are intended to
avoid path collisions, and the random string helps avoid predictable temporary path attacks
in shared temporary directories.
The "/./" is taking advantage of a Borg feature such that the part of the path before the "/./"
does not get stored in the file path within an archive. That way, the path of the runtime
@@ -125,7 +146,8 @@ class Runtime_directory:
if not runtime_directory.startswith(os.path.sep):
raise ValueError('The runtime directory must be an absolute path')
self.temporary_directory = None
runtime_directory = os.path.join(runtime_directory, f'borgmatic-{repository_id}')
self.temporary_directory = Fixed_name_temporary_directory(runtime_directory)
else:
base_directory = (
os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp' # noqa: S108
@@ -141,11 +163,9 @@ class Runtime_directory:
)
runtime_directory = self.temporary_directory.name
(base_path, final_directory) = os.path.split(runtime_directory.rstrip(os.path.sep))
self.runtime_path = expand_user_in_path(
os.path.join(
base_path if final_directory == 'borgmatic' else runtime_directory,
runtime_directory,
'.', # Borg 1.4+ "slashdot" hack.
'borgmatic',
),
@@ -162,14 +182,13 @@ class Runtime_directory:
def __exit__(self, exception_type, exception, traceback):
'''
Delete any temporary directory that was created as part of initialization.
Delete the temporary directory that was created as part of initialization.
'''
if self.temporary_directory:
# 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.
with contextlib.suppress(OSError):
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.
with contextlib.suppress(OSError):
self.temporary_directory.cleanup()
def make_runtime_directory_glob(borgmatic_runtime_directory):
+31 -2
View File
@@ -489,6 +489,9 @@ properties:
description: |
Umask used for when executing Borg or calling hooks. Defaults to
0077 for Borg or the umask that borgmatic is run with for hooks.
Even though this value is a YAML integer, borgmatic interprets it as
octal. YAML's "0o"-prefixed octal notation is not currently
supported.
example: 0077
lock_wait:
type: integer
@@ -3098,6 +3101,24 @@ properties:
Grafana Loki log URL to notify when a backup begins,
ends, or fails.
example: "http://localhost:3100/loki/api/v1/push"
tls:
type: object
additionalProperties: false
properties:
cert_path:
type: string
description: |
Path to a PEM client certificate file for mutual
TLS authentication.
example: /etc/borgmatic/loki-client.crt
key_path:
type: string
description: |
Path to a PEM private key file for the client
certificate.
example: /etc/borgmatic/loki-client.key
description: |
TLS options for mutual TLS (mTLS) authentication with Loki.
labels:
type: object
additionalProperties:
@@ -3291,24 +3312,32 @@ properties:
description: |
Command to use instead of "keepassxc-cli".
example: /usr/local/bin/keepassxc-cli
secret_tool_command:
type: string
description: |
Command to use instead of "secret-tool".
example: /usr/local/bin/secret-tool
ask_for_password:
type: boolean
description: |
Whether keepassxc-cli should prompt the user for a password.
Disabling this is only really useful if you're unlocking
your KeePassXC database with a key file instead of a
password. Defaults to true.
password. Ignored when using KeePassXC's secret service
integration. Defaults to true.
example: false
key_file:
type: string
description: |
Path to a key file for unlocking the KeePassXC database.
Ignored when using KeePassXC's secret service integration.
example: /path/to/keyfile
yubikey:
type: string
description: |
YubiKey slot and optional serial number used to access the
KeePassXC database. The format is "<slot[:serial]>", where:
KeePassXC database. Ignored when using KeePassXC's secret
service integration. The format is "<slot[:serial]>", where:
* <slot> is the YubiKey slot number (e.g., `1` or `2`).
* <serial> (optional) is the YubiKey's serial number (e.g.,
`7370001`).
+6 -10
View File
@@ -315,8 +315,8 @@ def log_buffer_lines(
Given a dict from buffer object to Buffer_reader, a dict from subprocess.Popen() instance to
Process_metadata instance, a requested output log level for stdout, Borg's local path, and
whether to capture stderr, read and log any ready output lines from the buffers. Additionally,
for any log records with a log level that meets or exceeds the output log level, yield those log
messages for capture.
for any log records with a log level the same as the output log level, yield those log messages
for capture.
This function just does one "turn of the crank" of logging buffer output. It is intended to be
called repeatedly to continue to process buffers.
@@ -367,9 +367,7 @@ def log_buffer_lines(
)
if (
log_record.levelno is None
or output_log_level is None
or log_record.levelno >= output_log_level
log_record.levelno is None or log_record.levelno == output_log_level
) and process_metadatas[reader.process].capture:
yield log_record.getMessage()
@@ -429,8 +427,8 @@ def log_remaining_buffer_lines(
Given a dict from buffer object to Buffer_reader, a dict from subprocess.Popen() instance to
Process_metadata instance, a requested output log level for stdout, Borg's local path, and
whether to capture stderr, drain and log any remaining output lines from the buffers until
they're empty. Additionally, for any log records with a log level that meets or exceeds the
output log level, yield those log messages for capture.
they're empty. Additionally, for any log records with a log level the same as the output log
level, yield those log messages for capture.
'''
for output_buffer, reader in buffer_readers.items():
if not reader.process:
@@ -451,9 +449,7 @@ def log_remaining_buffer_lines(
)
if (
log_record.levelno is None
or output_log_level is None
or log_record.levelno >= output_log_level
log_record.levelno is None or log_record.levelno == output_log_level
) and process_metadatas[reader.process].capture:
yield log_record.getMessage()
+17 -2
View File
@@ -7,19 +7,34 @@ import borgmatic.execute
logger = logging.getLogger(__name__)
SECRET_SERVICE_DATABASE_PATH = 'secret-service'
def load_credential(hook_config, config, credential_parameters):
'''
Given the hook configuration dict, the configuration dict, and a credential parameters tuple
containing a KeePassXC database path and an attribute name to load, run keepassxc-cli to fetch
the corresponding KeePassXC credential and return it.
the corresponding KeePassXC credential and return it. Or use secret-tool if the database path
is "secret-service", indicating that KeePassXC's secret service integration should be used
instead.
Raise ValueError if keepassxc-cli can't retrieve the credential.
Raise ValueError if keepassxc-cli or secret-tool can't retrieve the credential.
'''
try:
(database_path, attribute_name) = credential_parameters
except ValueError:
raise ValueError(f'Invalid KeePassXC credential parameters: {credential_parameters}')
if database_path == SECRET_SERVICE_DATABASE_PATH:
command = (
*shlex.split((hook_config or {}).get('secret_tool_command', 'secret-tool')),
*('lookup', 'Path', attribute_name),
)
return '\n'.join(borgmatic.execute.execute_command_and_capture_output(command)).rstrip(
os.linesep
)
expanded_database_path = os.path.expanduser(database_path)
if not os.path.exists(expanded_database_path):
+6 -3
View File
@@ -28,17 +28,20 @@ def resolve_config_path_symlinks(path):
Given a path, resolve and yield each successive symlink until the final non-symlink target. If
the given path isn't a symlink, then just yield it.
The purpose of this is to ensure that configuration files that are behind a symbolic link (or
several) actually get backed up.
Raise ValueError if we have to follow too many symlinks without getting to the final target.
'''
original_path = path
original_path = os.path.normpath(path)
for _ in range(MAXIMUM_CONFIG_SYMLINKS_TO_FOLLOW):
yield os.path.abspath(path)
yield path
if not os.path.islink(path):
return
path = os.readlink(path)
path = os.path.normpath(os.path.join(os.path.dirname(path), os.readlink(path)))
raise ValueError(f'Too many symlinks to follow for configuration path: {original_path}')
+18 -10
View File
@@ -1,4 +1,3 @@
import copy
import logging
import os
import re
@@ -67,7 +66,9 @@ def make_defaults_file_options(username=None, password=None, defaults_extra_file
Do not use the returned value for multiple different command invocations. That will not work
because each pipe is "used up" once read.
'''
escaped_password = None if password is None else password.replace('\\', '\\\\')
escaped_password = (
None if password is None else password.replace('\\', '\\\\').replace('"', '\\"')
)
values = '\n'.join(
(
@@ -106,6 +107,10 @@ def make_defaults_file_options(username=None, password=None, defaults_extra_file
return (f'--defaults-extra-file=/dev/fd/{read_file_descriptor}',)
EXCLUDED_SYSTEM_DATABASE_NAMES = ('information_schema', 'performance_schema', 'sys')
SYSTEM_DATABASE_NAME = 'mysql'
def database_names_to_dump(database, config, username, password, environment, dry_run):
'''
Given a requested database config, a configuration dict, a database username and password, an
@@ -164,17 +169,18 @@ def database_names_to_dump(database, config, username, password, environment, dr
working_directory=borgmatic.config.paths.get_working_directory(config),
)
# Dumping system databases directly doesn't work; too much gets dumped (including virtual tables
# that MariaDB recreates on startup) and so the dump isn't restorable. Therefore several system
# databases are excluded here. But also see below where select system tables from the "mysql"
# system table are dumped via the "--system" flag.
return tuple(
show_name.strip()
for show_name in show_lines
if show_name not in SYSTEM_DATABASE_NAMES
if show_name not in EXCLUDED_SYSTEM_DATABASE_NAMES
if not skip_names or show_name not in skip_names
)
SYSTEM_DATABASE_NAMES = ('information_schema', 'mysql', 'performance_schema', 'sys')
def execute_dump_command(
database,
config,
@@ -235,8 +241,9 @@ def execute_dump_command(
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if database.get('tls') is True else ())
+ (('--skip-ssl',) if database.get('tls') is False else ())
+ ('--databases',)
+ database_names
+ ('--databases', '--events', '--routines', '--all-tablespaces')
+ (('--system=users,udfs,servers',) if SYSTEM_DATABASE_NAME in database_names else ())
+ tuple(name for name in database_names if name != SYSTEM_DATABASE_NAME)
+ ('--result-file', dump_filename)
)
@@ -323,6 +330,7 @@ def dump_data_sources(
raise ValueError('Cannot find any MariaDB databases to dump.')
# Database dumps to individual files.
if database['name'] == 'all' and database.get('format'):
for database_name in dump_database_names:
dumps_metadata.append(
@@ -335,8 +343,7 @@ def dump_data_sources(
database.get('container'),
)
)
renamed_database = copy.copy(database)
renamed_database['name'] = database_name
renamed_database = dict(database, name=database_name)
processes.append(
execute_dump_command(
renamed_database,
@@ -350,6 +357,7 @@ def dump_data_sources(
dry_run_label,
),
)
# Database dumps all to one file.
else:
dumps_metadata.append(
borgmatic.actions.restore.Dump(
+2 -4
View File
@@ -1,4 +1,3 @@
import copy
import logging
import os
import shlex
@@ -166,7 +165,7 @@ def execute_dump_command(
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if database.get('tls') is True else ())
+ (('--skip-ssl',) if database.get('tls') is False else ())
+ ('--databases',)
+ ('--databases', '--events', '--routines', '--all-tablespaces')
+ database_names
+ ('--result-file', dump_filename)
)
@@ -266,8 +265,7 @@ def dump_data_sources(
database.get('container'),
)
)
renamed_database = copy.copy(database)
renamed_database['name'] = database_name
renamed_database = dict(database, name=database_name)
processes.append(
execute_dump_command(
renamed_database,
+8 -4
View File
@@ -100,7 +100,11 @@ def database_names_to_dump(database, config, environment, dry_run):
if 'username' in database
else ()
)
+ (tuple(database['list_options'].split(' ')) if 'list_options' in database else ())
+ (
tuple(shlex.quote(part) for part in shlex.split(database['list_options']))
if 'list_options' in database
else ()
)
)
logger.debug('Querying for "all" PostgreSQL databases to dump')
list_lines = execute_command_and_capture_output(
@@ -226,7 +230,7 @@ def dump_data_sources(
+ (('--compress', shlex.quote(str(compression))) if compression is not None else ())
+ (('--file', shlex.quote(dump_filename)) if dump_format == 'directory' else ())
+ (
tuple(shlex.quote(option) for option in database['options'].split(' '))
tuple(shlex.quote(part) for part in shlex.split(database['options']))
if 'options' in database
else ()
)
@@ -393,7 +397,7 @@ def restore_data_source_dump(
+ (('--username', username) if username else ())
+ (('--dbname', data_source['name']) if not all_databases else ())
+ (
tuple(data_source['analyze_options'].split(' '))
tuple(shlex.quote(part) for part in shlex.split(data_source['analyze_options']))
if 'analyze_options' in data_source
else ()
)
@@ -414,7 +418,7 @@ def restore_data_source_dump(
+ (('--username', username) if username else ())
+ (('--no-owner',) if data_source.get('no_owner', False) else ())
+ (
tuple(data_source['restore_options'].split(' '))
tuple(shlex.quote(part) for part in shlex.split(data_source['restore_options']))
if 'restore_options' in data_source
else ()
)
+22 -16
View File
@@ -2,6 +2,7 @@ import collections
import glob
import hashlib
import logging
import operator
import os
import shutil
import subprocess
@@ -123,7 +124,8 @@ def get_datasets_to_backup(zfs_command, patterns):
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.
Given a ZFS command to run, return a dict from ZFS dataset name to mount point (reverse sorted
by mount point).
'''
list_lines = borgmatic.execute.execute_command_and_capture_output(
(
@@ -133,19 +135,23 @@ def get_all_dataset_mount_points(zfs_command):
'-t',
'filesystem',
'-o',
'mountpoint',
'name,mountpoint',
),
close_fds=True,
)
return tuple(
return dict(
sorted(
{
mount_point
(
(dataset_name, mount_point)
for line in list_lines
for mount_point in (line.rstrip(),)
for (dataset_name, mount_point) in (line.rstrip().split('\t'),)
if mount_point != 'none'
},
),
key=operator.itemgetter(1),
# 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.
reverse=True,
),
)
@@ -376,7 +382,8 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, p
zfs_command = hook_config.get('zfs_command', 'zfs')
try:
dataset_mount_points = get_all_dataset_mount_points(zfs_command)
dataset_name_to_mount_point = get_all_dataset_mount_points(zfs_command)
full_snapshot_names = get_all_snapshots(zfs_command)
except FileNotFoundError:
logger.debug(f'Could not find "{zfs_command}" command')
return
@@ -393,19 +400,20 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, p
)
logger.debug(f'Looking for snapshots to remove in {snapshots_glob}{dry_run_label}')
umount_command = hook_config.get('umount_command', 'umount')
snapshot_dataset_names = {
full_snapshot_name.split('@')[0] for full_snapshot_name in full_snapshot_names
}
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):
for dataset_name, mount_point in dataset_name_to_mount_point.items():
snapshot_mount_path = os.path.join(snapshots_directory, mount_point.lstrip(os.path.sep))
# If the snapshot mount path is empty, this is probably just a "shadow" of a nested
# dataset and therefore there's nothing to unmount.
if not os.path.isdir(snapshot_mount_path) or not os.listdir(snapshot_mount_path):
# If this dataset name does not correspond to a known snapshot, then this is probably
# just a "shadow" of a nested dataset and therefore there's nothing to unmount.
if not os.path.isdir(snapshot_mount_path) or dataset_name not in snapshot_dataset_names:
continue
# This might fail if the path is already mounted, but we swallow errors here since we'll
@@ -435,8 +443,6 @@ def remove_data_source_dumps(hook_config, config, borgmatic_runtime_directory, p
shutil.rmtree(snapshot_mount_path, ignore_errors=True)
# 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):
+28 -4
View File
@@ -27,9 +27,16 @@ class Loki_log_buffer:
adding labels to the log stream and takes care of communication with Loki.
'''
def __init__(self, url, dry_run):
def __init__(self, url, dry_run, tls_cert_path=None, tls_key_path=None):
'''
Given a Loki URL, a dry run flag, and optional TLS certificate and key paths for mTLS authentication,
create an instance of Loki_log_buffer.
'''
self.url = url
self.dry_run = dry_run
self.tls_cert_path = tls_cert_path
self.tls_key_path = tls_key_path
self.root = {'streams': [{'stream': {}, 'values': []}]}
def add_value(self, value):
@@ -77,6 +84,7 @@ class Loki_log_buffer:
'Content-Type': 'application/json',
'User-Agent': 'borgmatic',
},
cert=(self.tls_cert_path, self.tls_key_path) if self.tls_cert_path else None,
)
result.raise_for_status()
except requests.RequestException:
@@ -88,7 +96,7 @@ class Loki_log_handler(logging.Handler):
A log handler that sends logs to Loki.
'''
def __init__(self, url, send_logs, log_level, dry_run):
def __init__(self, url, send_logs, log_level, dry_run, tls_cert_path=None, tls_key_path=None):
'''
Given a URL to send logs to, whether all borgmatic logs should be sent (or just explicitly
added messages from this hook), the log level to use (influencing which logs get sent), and
@@ -96,7 +104,9 @@ class Loki_log_handler(logging.Handler):
'''
super().__init__()
self.buffer = Loki_log_buffer(url, dry_run)
self.buffer = Loki_log_buffer(
url, dry_run, tls_cert_path=tls_cert_path, tls_key_path=tls_key_path
)
self.send_logs = send_logs
self.setLevel(log_level)
@@ -139,7 +149,21 @@ def initialize_monitor(hook_config, config, config_filename, monitoring_log_leve
Add a handler to the root logger to regularly send the logs to Loki.
'''
url = hook_config.get('url')
loki = Loki_log_handler(url, hook_config.get('send_logs', False), monitoring_log_level, dry_run)
tls = hook_config.get('tls', {})
if bool(tls.get('cert_path')) != bool(tls.get('key_path')):
raise ValueError(
'Invalid Loki TLS configuration: cert_path and key_path must both be set or both be unset'
)
loki = Loki_log_handler(
url,
hook_config.get('send_logs', False),
monitoring_log_level,
dry_run,
tls_cert_path=tls.get('cert_path'),
tls_key_path=tls.get('key_path'),
)
for key, value in hook_config.get('labels').items():
if value == '__hostname':
+1
View File
@@ -23,6 +23,7 @@
{% if feedTitle and feedUrl %}
<link rel="alternate" href="{{ feedUrl }}" title="{{ feedTitle }}" type="application/atom+xml">
{% endif %}
{{ head_additions | safe }}
</head>
<body>
+1
View File
@@ -80,6 +80,7 @@ installing borgmatic:
* [container image with scheduled backups](https://github.com/borgmatic-collective/docker-borgmatic) (+ Docker Compose files)
* [container image with multi-arch and Docker CLI support](https://github.com/modem7/docker-borgmatic)
* [Borgmatic Director UI](https://github.com/SpeedbitsInfinityTools/borgmatic-ui-community)
#### Operating system packages
+16 -10
View File
@@ -58,19 +58,25 @@ choice](https://torsion.org/borgmatic/how-to/set-up-backups/#autopilot), each
entry using borgmatic's `--config` flag instead of relying on
`/etc/borgmatic.d`.
<a id="limitations"></a>
## Limitations
## Parallelism
borgmatic does not currently support its own parallelism—being run multiple
times on the same machine simultaneously. In particular, many of the [data
source
hooks](https://torsion.org/borgmatic/reference/configuration/data-sources/) rely
on global borgmatic runtime files which can't be shared across processes, and
therefore multiple borgmatic instances on the same machine would interfere with
each other.
<span class="minilink minilink-addedin">New in version 2.1.6</span> borgmatic
supports multiple borgmatic instances run on the same machine in parallel—as
long as they're operating on different Borg repositories.
A single borgmatic instance also doesn't currently support running multiple Borg
instances in parallel on the same machine.
However, note that a single borgmatic instance doesn't currently support running
multiple Borg instances in parallel on the same machine.
<span class="minilink minilink-addedin">Prior to version 2.1.6</span> borgmatic
did not support parallel borgmatic runs on the same machine simultaneously. In
particular, many of the [data source
hooks](https://torsion.org/borgmatic/reference/configuration/data-sources/)
relied on global borgmatic runtime files which couldn't be shared across
processes, and therefore multiple borgmatic instances on the same machine
interfered with each other.
<a id="archive-naming"></a>
@@ -43,14 +43,15 @@ postgresql_databases:
```
### Custom command
### Custom commands
You can also optionally override the `keepassxc-cli` command that borgmatic calls to load
passwords:
You can also optionally override the `keepassxc-cli` or `secret-tool` commands
that borgmatic call to load passwords:
```yaml
keepassxc:
keepassxc_cli_command: /usr/local/bin/keepassxc-cli
secret_tool_command: /usr/local/bin/secret-tool
```
Another example:
@@ -98,3 +99,30 @@ keepassxc:
The value here is the YubiKey slot number (e.g., `1` or `2`) and optional serial
number (e.g., `7370001`) used to access the KeePassXC database. Join the two
values with a colon, but omit the colon if you're leaving out the serial number.
### Secret service integration
<span class="minilink minilink-addedin">New in version 2.1.6</span> borgmatic
supports [KeePassXC's secret service
integration](https://keepassxc.org/docs/KeePassXC_UserGuide#_secret_service_integration)
that integrates with the [freedesktop secret service
API](https://specifications.freedesktop.org/secret-service/latest/) and allows
clients like borgmatic to access your passwords.
To use this feature from borgmatic, specify `secret-service` instead of a
KeePassXC database path when calling this credential hook. For instance:
```yaml
encryption_passphrase: "{credential keepassxc secret-service borgmatic}"
```
With this in place, borgmatic runs libsecret's `secret-tool` command to retrieve
the password titled "borgmatic" on demand. KeePassXC may then prompt you to
approve the password access, depending on how you've configured it.
One benefit of using the KeePassXC's secret service integration like this is
that you don't have to type a KeePassXC database passhprase (or use a keyfile)
whenever borgmatic accesses your passwords. Instead, you can configure
KeePassXC to prompt you to approve or deny each access. Or you can even
pre-approve password access to support automated borgmatic runs.
@@ -16,10 +16,46 @@ mariadb_databases:
```
### Full configuration
## System databases
<span class="minilink minilink-addedin">New in version 2.1.6</span> When dumping
["all"
databases](https://torsion.org/borgmatic/how-to/backup-your-databases/#all-databases),
borgmatic excludes most data coming from [MariaDB system
databases](https://mariadb.com/docs/server/reference/system-tables),
because much of it is populated on MariaDB startup and thus not restorable (or
just unnecessary to backup). The system data that borgmatic does include in
these dumps are: users, roles, grants, user-defined functions, and remote
servers.
Within a Borg archive, you can find this data stored in a dump named `mysql`—the
name of the system table this data comes from. And if you'd like to dump this
data without having to dump "all" databases, then you can configure a database
named `mysql` in your borgmatic configuration. For example:
```yaml
mariadb_databases:
- name: mysql
```
Even in this case though, only the subset of system data described above is
included in the dump.
<span class="minilink minilink-addedin">Prior to version 2.1.6</span> Dumps of
"all" databases excluded system databases and all of their data. Additionally,
explicitly dumping `mysql` was treated like any other database—and thus wasn't
easily restorable.
## Full configuration
{% include snippet/configuration/sample.md %}
```yaml
{% include borgmatic/mariadb_databases.yaml %}
```
## Related documentation
* [How to backup your databases](https://torsion.org/borgmatic/how-to/backup-your-databases/)
@@ -14,10 +14,15 @@ mongodb_databases:
```
### Full configuration
## Full configuration
{% include snippet/configuration/sample.md %}
```yaml
{% include borgmatic/mongodb_databases.yaml %}
```
## Related documentation
* [How to backup your databases](https://torsion.org/borgmatic/how-to/backup-your-databases/)
@@ -21,3 +21,8 @@ mysql_databases:
```yaml
{% include borgmatic/mysql_databases.yaml %}
```
## Related documentation
* [How to backup your databases](https://torsion.org/borgmatic/how-to/backup-your-databases/)
@@ -13,6 +13,61 @@ postgresql_databases:
- name: users
```
See below for the full set of configuration options available, including
hostname, PostgreSQL username, password, etc.
## Permissions
### Dumping
In order to dump your database as part of creating a backup, the PostgreSQL user
performing the dump needs relevant permissions. A common way to accomplish this
is to connect as the PostgreSQL superuser, usually `postgres`. However, if you'd
like to connect as a non-superuser, that user will need permissions to:
* connect to the database
* read tables and sequences
Here is one way to do that with PostgreSQL 14+:
```sql
GRANT CONNECT ON DATABASE example_database TO database_user;
GRANT pg_read_all_data TO database_user;
```
And here is an alternate way to accomplish something similar that limits read access
to a particular schema instead of the whole cluster. Replace "public" with the
name of the schema you're using:
```sql
GRANT CONNECT ON DATABASE example_database TO database_user;
GRANT USAGE ON SCHEMA public TO database_user;
-- Grant read privileges on all current and future tables in the schema.
GRANT SELECT ON ALL TABLES IN SCHEMA public TO database_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO database_user;
-- Grant read privileges on all current and future indexes in the schema.
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO database_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO database_user;
```
### Restoring
If you also want this user to be able to restore your database, and you're not
restoring as the PostgreSQL superuser, then you'll need to grant write and
`ANALYZE` permissions as well. For instance:
```sql
GRANT pg_write_all_data TO database_user;
GRANT pg_maintain TO database_user;
```
Or you can perform schema-level grants if you prefer.
For more information, see the PostgreSQL documentation on [PostgreSQL predefined
roles](https://www.postgresql.org/docs/current/predefined-roles.html) and
[privileges](https://www.postgresql.org/docs/current/ddl-priv.html).
## Full configuration
@@ -21,3 +76,8 @@ postgresql_databases:
```yaml
{% include borgmatic/postgresql_databases.yaml %}
```
## Related documentation
* [How to backup your databases](https://torsion.org/borgmatic/how-to/backup-your-databases/)
@@ -22,3 +22,8 @@ sqlite_databases:
```yaml
{% include borgmatic/sqlite_databases.yaml %}
```
## Related documentation
* [How to backup your databases](https://torsion.org/borgmatic/how-to/backup-your-databases/)
@@ -89,3 +89,34 @@ for more information.
<span class="minilink minilink-addedin">New in version 2.0.0</span>Set the
defaults for these flags in your borgmatic configuration via the
`monitoring_verbosity`, `list`, and `statistics` options.
### Mutual TLS authentication
<span class="minilink minilink-addedin">New in version 2.1.5</span> Since Loki
does not come with a built-in authentication layer
[(doc)](https://grafana.com/docs/loki/latest/operations/authentication/), this
feature is typically used alongside a reverse proxy (such as
[nginx](https://docs.nginx.com/waf/configure/secure-mtls/) or
[Traefik](https://doc.traefik.io/traefik/reference/routing-configuration/http/tls/tls-options/#client-authentication-mtls))
that handles mTLS termination.
If your setup is configured for mTLS authentication, you can provide a client
certificate and private key:
```yaml
loki:
url: https://loki.fqdn/loki/api/v1/push
labels:
app: borgmatic
tls:
cert_path: /etc/borgmatic/loki-client.crt
key_path: /etc/borgmatic/loki-client.key
```
Both `cert_path` and `key_path` must be
[PEM-encoded](https://en.wikipedia.org/wiki/Privacy-Enhanced_Mail). They are
passed directly to the underlying HTTP client, so the standard mutual TLS
handshake is performed for every request borgmatic sends to Loki.
@@ -29,11 +29,18 @@ probes the following values:
You can see the runtime directory path that borgmatic selects by running with
`--verbosity 2` and looking for `Using runtime directory` in the output.
Regardless of the runtime directory selected, borgmatic stores its files
within a `borgmatic` subdirectory of the runtime directory. Additionally, in
the case of `TMPDIR`, `TEMP`, and the hard-coded `/tmp`, borgmatic creates a
randomly named subdirectory in an effort to reduce path collisions in shared
system temporary directories.
Regardless of the runtime directory selected, borgmatic stores its files within
a `borgmatic` subdirectory of the runtime directory. Additionally, in the case
of `TMPDIR`, `TEMP`, and the hard-coded `/tmp`, borgmatic creates a randomly
named subdirectory in an effort to reduce path collisions and predictable
temporary path attacks in shared temporary directories.
<span class="minilink minilink-addedin">New in version 2.1.6</span> When
constructing the runtime directory, borgmatic now creates a subdirectory named
after the Borg ID of the current repository. This means that borgmatic supports
multiple borgmatic instances run [in
parallel](https://torsion.org/borgmatic/how-to/make-per-application-backups/#parallelism)—as
long as they're operating on different Borg repositories.
<span class="minilink minilink-addedin">Prior to version 1.9.0</span>
borgmatic created temporary streaming database dumps within the `~/.borgmatic`
+40 -6
View File
@@ -1,14 +1,48 @@
---
title: Search the documentation
eleventyExcludeFromCollections: true
templateEngineOverride: md
head_additions: >
<link href="/borgmatic/pagefind/pagefind-component-ui.css" rel="stylesheet">
<script src="/borgmatic/pagefind/pagefind-component-ui.js" type="module"></script>
<style>
@media (prefers-color-scheme: light) {
:root {
--pf-summary-font-size: 14px;
--pf-result-title-font-size: 16px;
--pf-result-excerpt-font-size: 15px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--pf-summary-font-size: 14px;
--pf-result-title-font-size: 16px;
--pf-result-excerpt-font-size: 15px;
}
}
.pf-result .pf-result-title, .pf-heading-link {
font-weight: 600 !important;
}
.pf-result .pf-result-excerpt mark, .pf-heading-excerpt mark {
background-color: yellow !important;
}
</style>
---
<link href="/borgmatic/pagefind/pagefind-ui.css" rel="stylesheet">
<script src="/borgmatic/pagefind/pagefind-ui.js"></script>
<div id="search"></div>
<script>
<pagefind-config bundle-path="/borgmatic/pagefind/" base-url="/borgmatic/"></pagefind-config>
<p><pagefind-input placeholder="Search"></pagefind-input></p>
<p><pagefind-summary></pagefind-summary></p>
<p><pagefind-results></pagefind-results></p>
<script type="module">
const manager = window.PagefindComponents.getInstanceManager();
const instance = manager.getInstance('default');
window.addEventListener('DOMContentLoaded', (event) => {
let search = new PagefindUI({ element: '#search', showSubResults: true, autofocus: true });
let url_parameters = new URLSearchParams(window.location.search);
search.triggerSearch(url_parameters.get('query'));
instance.triggerSearch(url_parameters.get('query'));
});
</script>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "borgmatic"
version = "2.1.4"
version = "2.1.6.dev0"
authors = [
{ name="Dan Helfman", email="witten@torsion.org" },
]
+2 -2
View File
@@ -44,9 +44,9 @@ scripts/export-docs-from-image
docs_path=dist/borgmatic-docs.tar.gz
# Build stand-alone binary.
uv venv --clear binary
uv venv --python 3.13 --clear binary
source binary/bin/activate
uv pip install -r binary_requirements.txt
uv pip install -r binary_requirements.txt nuitka[onefile]
nuitka --mode=onefile --enable-plugin=upx --include-package-data=borgmatic --include-data-dir=borgmatic.egg-info=borgmatic.egg-info --include-package=borgmatic.hooks --include-package=apprise --no-deployment-flag=self-execution borgmatic/commands/borgmatic.py
deactivate
rm -fr binary borgmatic.build borgmatic.dist
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
set -eou pipefail
uv pip compile --quiet --upgrade --annotation-style line test_requirements.in -o test_requirements.txt
uv pip compile --quiet --upgrade --annotation-style line binary_requirements.in -o binary_requirements.txt
# Carry forward certain packages with version ranges instead of absolute pins. This is because these
# packages are a pain to compile, and pre-built wheels available on pypi may not be available for
# pinned versions on particular platforms.
while IFS= read -r package_line; do
package_name=$(echo "$package_line" | cut -d ">" -f 1)
sed -i "s/^$package_name==.*/$package_line/" test_requirements.txt
done <<< $(grep ">" test_requirements.in)
sed -i "s/was autogenerated/was (mostly) autogenerated/" test_requirements.txt
+28
View File
@@ -0,0 +1,28 @@
apprise
attrs
certifi
charset-normalizer
click>=8.1.8
codespell
coverage
flexmock
idna
iniconfig
jsonschema
jsonschema-specifications
markdown
oauthlib
packaging
pluggy
pygments
pytest
pytest-cov
pytest-timeout
pyyaml>5.0.0
referencing
requests
requests-oauthlib
rpds-py
ruamel-yaml>0.15.0
typing-extensions
urllib3
+27 -25
View File
@@ -1,28 +1,30 @@
apprise==1.9.9
attrs==26.1.0
certifi==2026.2.25
charset-normalizer==3.4.6
# This file was (mostly) autogenerated by uv via the following command:
# uv pip compile --annotation-style line test_requirements.in -o test_requirements.txt
apprise==1.10.0 # via -r test_requirements.in
attrs==26.1.0 # via jsonschema, referencing, -r test_requirements.in
certifi==2026.4.22 # via apprise, requests, -r test_requirements.in
charset-normalizer==3.4.7 # via requests, -r test_requirements.in
click>=8.1.8
codespell==2.4.2
coverage==7.13.5
flexmock==0.13.0
idna==3.11
iniconfig==2.3.0
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
markdown==3.10.2
oauthlib==3.3.1
packaging==26.0
pluggy==1.6.0
pygments==2.20.0
pytest==9.0.2
pytest-cov==7.1.0
pytest-timeout==2.4.0
codespell==2.4.2 # via -r test_requirements.in
coverage==7.13.5 # via pytest-cov, -r test_requirements.in
flexmock==0.13.0 # via -r test_requirements.in
idna==3.13 # via requests, -r test_requirements.in
iniconfig==2.3.0 # via pytest, -r test_requirements.in
jsonschema==4.26.0 # via -r test_requirements.in
jsonschema-specifications==2025.9.1 # via jsonschema, -r test_requirements.in
markdown==3.10.2 # via apprise, -r test_requirements.in
oauthlib==3.3.1 # via requests-oauthlib, -r test_requirements.in
packaging==26.2 # via pytest, -r test_requirements.in
pluggy==1.6.0 # via pytest, pytest-cov, -r test_requirements.in
pygments==2.20.0 # via pytest, -r test_requirements.in
pytest==9.0.3 # via pytest-cov, pytest-timeout, -r test_requirements.in
pytest-cov==7.1.0 # via -r test_requirements.in
pytest-timeout==2.4.0 # via -r test_requirements.in
pyyaml>5.0.0
referencing==0.37.0
requests==2.33.1
requests-oauthlib==2.0.0
rpds-py==0.30.0
referencing==0.37.0 # via jsonschema, jsonschema-specifications, -r test_requirements.in
requests==2.33.1 # via apprise, requests-oauthlib, -r test_requirements.in
requests-oauthlib==2.0.0 # via apprise, -r test_requirements.in
rpds-py==0.30.0 # via jsonschema, referencing, -r test_requirements.in
ruamel-yaml>0.15.0
typing-extensions==4.15.0
urllib3==2.6.3
typing-extensions==4.15.0 # via -r test_requirements.in
urllib3==2.6.3 # via requests, -r test_requirements.in
@@ -57,11 +57,15 @@ mariadb_databases:
hostname: mariadb
username: root
password: test
# Needed for restoring users to the system database, because we're not actually dropping
# users first. (Dropping root and mariadb.sys would cause problems.)
restore_options: --force
- name: all
{mariadb_mysql_dump_format_option}
hostname: mariadb
username: root
password: test
restore_options: --force
mysql_databases:
- name: test
hostname: not-actually-mysql
@@ -132,6 +136,7 @@ mariadb_databases:
restore_port: 3307
restore_username: root
restore_password: test2
restore_options: --force
mysql_databases:
- name: test
container: not-actually-mysql
@@ -201,6 +206,7 @@ mariadb_databases:
restore_port: 3307
restore_username: root
restore_password: test2
restore_options: --force
mysql_databases:
- name: test
hostname: not-actually-mysql
+2 -2
View File
@@ -315,10 +315,10 @@ def test_write_configuration_with_already_existing_file_raises():
module.write_configuration('config.yaml', 'config: yaml')
def test_write_configuration_with_already_existing_file_and_overwrite_does_not_raise():
def test_write_configuration_with_already_existing_file_and_overwrite_does_not_raise(tmp_path):
flexmock(os.path).should_receive('exists').and_return(True)
module.write_configuration('/tmp/config.yaml', 'config: yaml', overwrite=True)
module.write_configuration(str(tmp_path / 'config.yaml'), 'config: yaml', overwrite=True)
def test_write_configuration_with_already_existing_directory_does_not_raise():
+3 -3
View File
@@ -222,11 +222,11 @@ def test_parse_configuration_merges_include():
assert logs == []
def test_parse_configuration_raises_for_missing_config_file():
def test_parse_configuration_raises_for_missing_config_file(tmp_path):
with pytest.raises(FileNotFoundError):
module.parse_configuration(
'/tmp/config.yaml',
'/tmp/schema.yaml',
str(tmp_path / 'nonexistent' / 'config.yaml'),
str(tmp_path / 'nonexistent' / 'schema.yaml'),
arguments={'global': flexmock()},
)
@@ -90,7 +90,7 @@ def test_ping_monitor_sends_log_message():
config_filename = 'test.yaml'
post_called = False
def post(url, data, timeout, headers):
def post(url, data, timeout, headers, **kwargs):
nonlocal post_called
post_called = True
+11 -8
View File
@@ -137,15 +137,18 @@ def test_log_outputs_logs_stderr_as_error():
(),
).and_return((echo_process.stdout, echo_process.stderr))
assert tuple(
module.log_outputs(
(echo_process,),
exclude_stdouts=(),
output_log_level=logging.INFO,
borg_local_path='borg',
borg_exit_codes=None,
assert (
tuple(
module.log_outputs(
(echo_process,),
exclude_stdouts=(),
output_log_level=logging.INFO,
borg_local_path='borg',
borg_exit_codes=None,
)
)
) == ('error',)
== ()
)
def test_log_outputs_skips_logs_for_process_with_none_stdout():
@@ -171,6 +171,7 @@ def test_run_bootstrap_does_not_raise():
flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return(
'archive',
)
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('repo')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
flexmock(),
)
@@ -222,6 +223,7 @@ def test_run_bootstrap_translates_ssh_command_argument_to_config():
local_path='borg7',
remote_path='borg8',
).and_return('archive')
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('repo')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
flexmock(),
)
+4 -4
View File
@@ -1838,7 +1838,7 @@ def test_spot_check_without_any_source_paths_errors():
def test_run_check_checks_archives_for_configured_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.check).should_receive('get_repository_id').and_return(flexmock())
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module).should_receive('upgrade_check_times')
flexmock(module).should_receive('parse_checks')
flexmock(module.borgmatic.borg.check).should_receive('make_archive_filter_flags').and_return(())
@@ -1873,7 +1873,7 @@ def test_run_check_checks_archives_for_configured_repository():
def test_run_check_runs_configured_extract_check():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.check).should_receive('get_repository_id').and_return(flexmock())
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module).should_receive('upgrade_check_times')
flexmock(module).should_receive('parse_checks')
flexmock(module.borgmatic.borg.check).should_receive('make_archive_filter_flags').and_return(())
@@ -1906,7 +1906,7 @@ def test_run_check_runs_configured_extract_check():
def test_run_check_runs_configured_spot_check():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.check).should_receive('get_repository_id').and_return(flexmock())
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module).should_receive('upgrade_check_times')
flexmock(module).should_receive('parse_checks')
flexmock(module.borgmatic.borg.check).should_receive('make_archive_filter_flags').and_return(())
@@ -1942,7 +1942,7 @@ def test_run_check_runs_configured_spot_check():
def test_run_check_without_checks_runs_nothing_except_hooks():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.check).should_receive('get_repository_id').and_return(flexmock())
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module).should_receive('upgrade_check_times')
flexmock(module).should_receive('parse_checks')
flexmock(module.borgmatic.borg.check).should_receive('make_archive_filter_flags').and_return(())
+7
View File
@@ -11,6 +11,7 @@ from borgmatic.actions import create as module
def test_run_create_executes_and_calls_hooks_for_configured_repository():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
flexmock(),
)
@@ -49,6 +50,7 @@ def test_run_create_executes_and_calls_hooks_for_configured_repository():
def test_run_create_with_both_list_and_json_errors():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').never()
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').never()
flexmock(module.borgmatic.borg.create).should_receive('create_archive').never()
create_arguments = flexmock(
@@ -80,6 +82,7 @@ def test_run_create_with_both_list_and_json_errors():
def test_run_create_with_both_list_and_progress_errors():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').never()
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').never()
flexmock(module.borgmatic.borg.create).should_receive('create_archive').never()
create_arguments = flexmock(
@@ -111,6 +114,7 @@ def test_run_create_with_both_list_and_progress_errors():
def test_run_create_produces_json():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
flexmock(),
)
@@ -156,6 +160,7 @@ def test_run_create_with_active_dumps_roundtrips_via_checkpoint_archive():
mock_dump_process.should_receive('poll').and_return(None).and_return(0)
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
flexmock(),
)
@@ -234,6 +239,7 @@ def test_run_create_with_active_dumps_json_updates_archive_info():
}
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
flexmock(),
)
@@ -316,6 +322,7 @@ def mock_dump_cleanup(config, borgmatic_runtime_directory, patterns, dry_run):
def test_run_create_with_active_dumps_removes_data_source_dumps_with_original_patterns():
flexmock(module.logger).answer = lambda message: None
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
flexmock(),
)
+29 -7
View File
@@ -35,7 +35,7 @@ def test_parse_pattern_with_invalid_pattern_line_errors():
def test_collect_patterns_converts_source_directories():
assert module.collect_patterns({'source_directories': ['/foo', '/bar']}) == (
assert module.collect_patterns({'source_directories': ['/foo', '/bar']}, '/working') == (
Pattern('/foo', source=Pattern_source.CONFIG),
Pattern('/bar', source=Pattern_source.CONFIG),
)
@@ -48,14 +48,18 @@ def test_collect_patterns_parses_config_patterns():
flexmock(module).should_receive('parse_pattern').with_args(' ').never()
flexmock(module).should_receive('parse_pattern').with_args('R /bar').and_return(Pattern('/bar'))
assert module.collect_patterns({'patterns': ['R /foo', '# comment', '', ' ', 'R /bar']}) == (
assert module.collect_patterns(
{'patterns': ['R /foo', '# comment', '', ' ', 'R /bar']}, '/working'
) == (
Pattern('/foo'),
Pattern('/bar'),
)
def test_collect_patterns_converts_exclude_patterns():
assert module.collect_patterns({'exclude_patterns': ['/foo', '/bar', 'sh:**/baz']}) == (
assert module.collect_patterns(
{'exclude_patterns': ['/foo', '/bar', 'sh:**/baz']}, '/working'
) == (
Pattern(
'/foo',
Pattern_type.NO_RECURSE,
@@ -78,6 +82,12 @@ def test_collect_patterns_converts_exclude_patterns():
def test_collect_patterns_reads_config_patterns_from_file():
flexmock(module).should_receive('expand_directory').with_args(
'file1.txt', '/working'
).and_return(['file1.txt'])
flexmock(module).should_receive('expand_directory').with_args(
'file2.txt', '/working'
).and_return(['file2.txt'])
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('file1.txt', encoding='utf-8').and_return(
io.StringIO('R /foo')
@@ -92,7 +102,7 @@ def test_collect_patterns_reads_config_patterns_from_file():
flexmock(module).should_receive('parse_pattern').with_args('R /bar').and_return(Pattern('/bar'))
flexmock(module).should_receive('parse_pattern').with_args('R /baz').and_return(Pattern('/baz'))
assert module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']}) == (
assert module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']}, '/working') == (
Pattern('/foo'),
Pattern('/bar'),
Pattern('/baz'),
@@ -101,16 +111,25 @@ def test_collect_patterns_reads_config_patterns_from_file():
def test_collect_patterns_errors_on_missing_config_patterns_from_file():
builtins = flexmock(sys.modules['builtins'])
flexmock(module).should_receive('expand_directory').with_args(
'file1.txt', '/working'
).and_return(['file1.txt'])
builtins.should_receive('open').with_args('file1.txt', encoding='utf-8').and_raise(
FileNotFoundError
)
flexmock(module).should_receive('parse_pattern').never()
with pytest.raises(ValueError):
module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']})
module.collect_patterns({'patterns_from': ['file1.txt', 'file2.txt']}, '/working')
def test_collect_patterns_reads_config_exclude_from_file():
flexmock(module).should_receive('expand_directory').with_args(
'file1.txt', '/working'
).and_return(['file1.txt'])
flexmock(module).should_receive('expand_directory').with_args(
'file2.txt', '/working'
).and_return(['file2.txt'])
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('file1.txt', encoding='utf-8').and_return(
io.StringIO('/foo')
@@ -134,7 +153,7 @@ def test_collect_patterns_reads_config_exclude_from_file():
default_style=Pattern_style.FNMATCH,
).and_return(Pattern('/baz', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH))
assert module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}) == (
assert module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}, '/working') == (
Pattern('/foo', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
Pattern('/bar', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
Pattern('/baz', Pattern_type.NO_RECURSE, Pattern_style.FNMATCH),
@@ -142,12 +161,15 @@ def test_collect_patterns_reads_config_exclude_from_file():
def test_collect_patterns_errors_on_missing_config_exclude_from_file():
flexmock(module).should_receive('expand_directory').with_args(
'file1.txt', '/working'
).and_return(['file1.txt'])
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('file1.txt', encoding='utf-8').and_raise(OSError)
flexmock(module).should_receive('parse_pattern').never()
with pytest.raises(ValueError):
module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']})
module.collect_patterns({'exclude_from': ['file1.txt', 'file2.txt']}, '/working')
def test_expand_directory_with_basic_path_passes_it_through():
+5
View File
@@ -1175,6 +1175,7 @@ def test_run_restore_restores_each_data_source():
}
borgmatic_runtime_directory = flexmock()
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
borgmatic_runtime_directory,
)
@@ -1249,6 +1250,7 @@ def test_run_restore_restores_data_source_by_falling_back_to_all_name():
}
borgmatic_runtime_directory = flexmock()
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
borgmatic_runtime_directory,
)
@@ -1311,6 +1313,7 @@ def test_run_restore_restores_data_source_configured_with_all_name():
}
borgmatic_runtime_directory = flexmock()
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
borgmatic_runtime_directory,
)
@@ -1395,6 +1398,7 @@ def test_run_restore_skips_missing_data_source():
}
borgmatic_runtime_directory = flexmock()
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
borgmatic_runtime_directory,
)
@@ -1479,6 +1483,7 @@ def test_run_restore_restores_data_sources_from_different_hooks():
}
borgmatic_runtime_directory = flexmock()
flexmock(module.borgmatic.borg.repo_info).should_receive('get_repository_id').and_return('id')
flexmock(module.borgmatic.config.paths).should_receive('Runtime_directory').and_return(
borgmatic_runtime_directory,
)
-48
View File
@@ -272,54 +272,6 @@ def test_make_check_name_flags_with_extract_omits_extract_flag():
assert flags == ()
def test_get_repository_id_with_valid_json_does_not_raise():
config = {}
flexmock(module.repo_info).should_receive('display_repository_info').and_return(
'{"repository": {"id": "repo"}}',
)
assert module.get_repository_id(
repository_path='repo',
config=config,
local_borg_version='1.2.3',
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
)
def test_get_repository_id_with_json_error_raises():
config = {}
flexmock(module.repo_info).should_receive('display_repository_info').and_return(
'{"unexpected": {"id": "repo"}}',
)
with pytest.raises(ValueError):
module.get_repository_id(
repository_path='repo',
config=config,
local_borg_version='1.2.3',
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
)
def test_get_repository_id_with_missing_json_keys_raises():
config = {}
flexmock(module.repo_info).should_receive('display_repository_info').and_return('{invalid JSON')
with pytest.raises(ValueError):
module.get_repository_id(
repository_path='repo',
config=config,
local_borg_version='1.2.3',
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
)
def test_check_archives_with_progress_passes_through_to_borg():
config = {'progress': True}
flexmock(module).should_receive('make_check_name_flags').with_args(
+21 -4
View File
@@ -34,19 +34,36 @@ def test_write_patterns_file_appends_to_existing():
def test_check_all_root_patterns_exist_with_existent_pattern_path_does_not_raise():
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os.path).should_receive('exists').with_args('foo').and_return(True)
module.check_all_root_patterns_exist([Pattern('foo')])
module.check_all_root_patterns_exist([Pattern('foo')], working_directory=None)
def test_check_all_root_patterns_exist_with_existent_relative_pattern_path_and_working_directory_does_not_raise():
flexmock(module.os.path).should_receive('exists').with_args('foo').never()
flexmock(module.os.path).should_receive('exists').with_args('/working/foo').and_return(True)
module.check_all_root_patterns_exist([Pattern('foo')], working_directory='/working')
def test_check_all_root_patterns_exist_with_non_root_pattern_skips_existence_check():
flexmock(module.os.path).should_receive('exists').never()
module.check_all_root_patterns_exist([Pattern('foo', Pattern_type.INCLUDE)])
module.check_all_root_patterns_exist(
[Pattern('foo', Pattern_type.INCLUDE)], working_directory=None
)
def test_check_all_root_patterns_exist_with_non_existent_pattern_path_raises():
flexmock(module.os.path).should_receive('exists').and_return(False)
with pytest.raises(ValueError):
module.check_all_root_patterns_exist([Pattern('foo')])
module.check_all_root_patterns_exist([Pattern('foo')], working_directory=None)
def test_check_all_root_patterns_exist_with_non_existent_relative_pattern_path_and_working_directory_raises():
flexmock(module.os.path).should_receive('exists').with_args('foo').never()
flexmock(module.os.path).should_receive('exists').with_args('/working/foo').and_return(False)
with pytest.raises(ValueError):
module.check_all_root_patterns_exist([Pattern('foo')], working_directory='/working')
+49
View File
@@ -1,5 +1,6 @@
import logging
import pytest
from flexmock import flexmock
from borgmatic.borg import repo_info as module
@@ -612,3 +613,51 @@ def test_display_repository_info_calls_borg_with_working_directory():
repo_info_arguments=flexmock(json=False),
global_arguments=flexmock(),
)
def test_get_repository_id_with_valid_json_does_not_raise():
config = {}
flexmock(module).should_receive('display_repository_info').and_return(
'{"repository": {"id": "repo"}}',
)
assert module.get_repository_id(
repository_path='repo',
config=config,
local_borg_version='1.2.3',
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
)
def test_get_repository_id_with_json_error_raises():
config = {}
flexmock(module).should_receive('display_repository_info').and_return(
'{"unexpected": {"id": "repo"}}',
)
with pytest.raises(ValueError):
module.get_repository_id(
repository_path='repo',
config=config,
local_borg_version='1.2.3',
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
)
def test_get_repository_id_with_missing_json_keys_raises():
config = {}
flexmock(module).should_receive('display_repository_info').and_return('{invalid JSON')
with pytest.raises(ValueError):
module.get_repository_id(
repository_path='repo',
config=config,
local_borg_version='1.2.3',
global_arguments=flexmock(),
local_path='borg',
remote_path=None,
)
+81 -107
View File
@@ -62,65 +62,58 @@ def test_replace_temporary_subdirectory_with_glob_uses_custom_temporary_director
)
def test_fixed_name_temporary_directory_cleanup_does_not_raise():
flexmock(module.shutil).should_receive('rmtree')
module.Fixed_name_temporary_directory('/path').cleanup()
def test_runtime_directory_uses_config_option():
flexmock(module).should_receive('Fixed_name_temporary_directory').and_return(
flexmock(cleanup=lambda: None)
)
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os).should_receive('makedirs')
config = {'user_runtime_directory': '/run', 'borgmatic_source_directory': '/nope'}
with module.Runtime_directory(config) as borgmatic_runtime_directory:
assert borgmatic_runtime_directory == '/run/./borgmatic'
def test_runtime_directory_uses_config_option_without_adding_duplicate_borgmatic_subdirectory():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os).should_receive('makedirs')
config = {'user_runtime_directory': '/run/borgmatic', 'borgmatic_source_directory': '/nope'}
with module.Runtime_directory(config) as borgmatic_runtime_directory:
assert borgmatic_runtime_directory == '/run/./borgmatic'
with module.Runtime_directory(config, repository_id='id') as borgmatic_runtime_directory:
assert borgmatic_runtime_directory == '/run/borgmatic-id/./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), module.Runtime_directory(config):
with pytest.raises(ValueError), module.Runtime_directory(config, repository_id='id'):
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(
'/run',
def test_runtime_directory_falls_back_to_xdg_runtime_dir(monkeypatch):
flexmock(module).should_receive('Fixed_name_temporary_directory').and_return(
flexmock(cleanup=lambda: None)
)
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
monkeypatch.setenv('XDG_RUNTIME_DIR', '/run')
flexmock(module.os).should_receive('makedirs')
with module.Runtime_directory({}) as borgmatic_runtime_directory:
assert borgmatic_runtime_directory == '/run/./borgmatic'
with module.Runtime_directory({}, repository_id='id') as borgmatic_runtime_directory:
assert borgmatic_runtime_directory == '/run/borgmatic-id/./borgmatic'
def test_runtime_directory_falls_back_to_xdg_runtime_dir_without_adding_duplicate_borgmatic_subdirectory():
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(
'/run/borgmatic',
)
flexmock(module.os).should_receive('makedirs')
with module.Runtime_directory({}) as borgmatic_runtime_directory:
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')
def test_runtime_directory_with_relative_xdg_runtime_dir_errors(monkeypatch):
monkeypatch.setenv('XDG_RUNTIME_DIR', 'run')
flexmock(module.os).should_receive('makedirs').never()
with pytest.raises(ValueError), module.Runtime_directory({}):
with pytest.raises(ValueError), module.Runtime_directory({}, repository_id='id'):
pass
def test_runtime_directory_falls_back_to_runtime_directory():
def test_runtime_directory_falls_back_to_runtime_directory(monkeypatch):
flexmock(module).should_receive('Fixed_name_temporary_directory').and_return(
flexmock(cleanup=lambda: None)
)
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)
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
flexmock(module).should_receive('resolve_systemd_directory').with_args(
module.Systemd_directories.RUNTIME_DIRECTORY
).and_return(
@@ -128,26 +121,12 @@ def test_runtime_directory_falls_back_to_runtime_directory():
)
flexmock(module.os).should_receive('makedirs')
with module.Runtime_directory({}) as borgmatic_runtime_directory:
assert borgmatic_runtime_directory == '/run/./borgmatic'
with module.Runtime_directory({}, repository_id='id') as borgmatic_runtime_directory:
assert borgmatic_runtime_directory == '/run/borgmatic-id/./borgmatic'
def test_runtime_directory_falls_back_to_runtime_directory_without_adding_duplicate_borgmatic_subdirectory():
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).should_receive('resolve_systemd_directory').with_args(
module.Systemd_directories.RUNTIME_DIRECTORY
).and_return(
'/run/borgmatic',
)
flexmock(module.os).should_receive('makedirs')
with module.Runtime_directory({}) as borgmatic_runtime_directory:
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)
def test_runtime_directory_with_relative_runtime_directory_errors(monkeypatch):
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
flexmock(module).should_receive('resolve_systemd_directory').with_args(
module.Systemd_directories.RUNTIME_DIRECTORY
).and_return(
@@ -155,19 +134,21 @@ def test_runtime_directory_with_relative_runtime_directory_errors():
)
flexmock(module.os).should_receive('makedirs').never()
with pytest.raises(ValueError), module.Runtime_directory({}):
with pytest.raises(ValueError), module.Runtime_directory({}, repository_id='id'):
pass
def test_runtime_directory_falls_back_to_tmpdir_and_adds_temporary_subdirectory_that_get_cleaned_up():
def test_runtime_directory_falls_back_to_tmpdir_and_adds_temporary_subdirectory_that_get_cleaned_up(
monkeypatch,
):
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)
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
flexmock(module).should_receive('resolve_systemd_directory').with_args(
module.Systemd_directories.RUNTIME_DIRECTORY
).and_return(
None,
)
flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return('/run')
monkeypatch.setenv('TMPDIR', '/run')
temporary_directory = flexmock(name='/run/borgmatic-1234')
temporary_directory.should_receive('cleanup').once()
flexmock(module.tempfile).should_receive('TemporaryDirectory').with_args(
@@ -176,35 +157,37 @@ def test_runtime_directory_falls_back_to_tmpdir_and_adds_temporary_subdirectory_
).and_return(temporary_directory)
flexmock(module.os).should_receive('makedirs')
with module.Runtime_directory({}) as borgmatic_runtime_directory:
with module.Runtime_directory({}, repository_id='id') as borgmatic_runtime_directory:
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)
def test_runtime_directory_with_relative_tmpdir_errors(monkeypatch):
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
flexmock(module).should_receive('resolve_systemd_directory').with_args(
module.Systemd_directories.RUNTIME_DIRECTORY
).and_return(
None,
)
flexmock(module.os.environ).should_receive('get').with_args('TMPDIR').and_return('run')
monkeypatch.setenv('TMPDIR', 'run')
flexmock(module.tempfile).should_receive('TemporaryDirectory').never()
flexmock(module.os).should_receive('makedirs').never()
with pytest.raises(ValueError), module.Runtime_directory({}):
with pytest.raises(ValueError), module.Runtime_directory({}, repository_id='id'):
pass
def test_runtime_directory_falls_back_to_temp_and_adds_temporary_subdirectory_that_get_cleaned_up():
def test_runtime_directory_falls_back_to_temp_and_adds_temporary_subdirectory_that_get_cleaned_up(
monkeypatch,
):
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)
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
flexmock(module).should_receive('resolve_systemd_directory').with_args(
module.Systemd_directories.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')
monkeypatch.delenv('TMPDIR', raising=False)
monkeypatch.setenv('TEMP', '/run')
temporary_directory = flexmock(name='/run/borgmatic-1234')
temporary_directory.should_receive('cleanup').once()
flexmock(module.tempfile).should_receive('TemporaryDirectory').with_args(
@@ -213,36 +196,38 @@ def test_runtime_directory_falls_back_to_temp_and_adds_temporary_subdirectory_th
).and_return(temporary_directory)
flexmock(module.os).should_receive('makedirs')
with module.Runtime_directory({}) as borgmatic_runtime_directory:
with module.Runtime_directory({}, repository_id='id') as borgmatic_runtime_directory:
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)
def test_runtime_directory_with_relative_temp_errors(monkeypatch):
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
flexmock(module).should_receive('resolve_systemd_directory').with_args(
module.Systemd_directories.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')
monkeypatch.delenv('TMPDIR', raising=False)
monkeypatch.setenv('TEMP', 'run')
flexmock(module.tempfile).should_receive('TemporaryDirectory').never()
flexmock(module.os).should_receive('makedirs')
with pytest.raises(ValueError), module.Runtime_directory({}):
with pytest.raises(ValueError), module.Runtime_directory({}, repository_id='id'):
pass
def test_runtime_directory_falls_back_to_hard_coded_tmp_path_and_adds_temporary_subdirectory_that_get_cleaned_up():
def test_runtime_directory_falls_back_to_hard_coded_tmp_path_and_adds_temporary_subdirectory_that_get_cleaned_up(
monkeypatch,
):
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)
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
flexmock(module).should_receive('resolve_systemd_directory').with_args(
module.Systemd_directories.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)
monkeypatch.delenv('TMPDIR', raising=False)
monkeypatch.delenv('TEMP', raising=False)
temporary_directory = flexmock(name='/tmp/borgmatic-1234')
temporary_directory.should_receive('cleanup').once()
flexmock(module.tempfile).should_receive('TemporaryDirectory').with_args(
@@ -251,20 +236,20 @@ def test_runtime_directory_falls_back_to_hard_coded_tmp_path_and_adds_temporary_
).and_return(temporary_directory)
flexmock(module.os).should_receive('makedirs')
with module.Runtime_directory({}) as borgmatic_runtime_directory:
with module.Runtime_directory({}, repository_id='id') as borgmatic_runtime_directory:
assert borgmatic_runtime_directory == '/tmp/borgmatic-1234/./borgmatic'
def test_runtime_directory_with_erroring_cleanup_does_not_raise():
def test_runtime_directory_with_erroring_cleanup_does_not_raise(monkeypatch):
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)
monkeypatch.delenv('XDG_RUNTIME_DIR', raising=False)
flexmock(module).should_receive('resolve_systemd_directory').with_args(
module.Systemd_directories.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)
monkeypatch.delenv('TMPDIR', raising=False)
monkeypatch.delenv('TEMP', raising=False)
temporary_directory = flexmock(name='/tmp/borgmatic-1234')
temporary_directory.should_receive('cleanup').and_raise(OSError).once()
flexmock(module.tempfile).should_receive('TemporaryDirectory').with_args(
@@ -273,7 +258,7 @@ def test_runtime_directory_with_erroring_cleanup_does_not_raise():
).and_return(temporary_directory)
flexmock(module.os).should_receive('makedirs')
with module.Runtime_directory({}) as borgmatic_runtime_directory:
with module.Runtime_directory({}, repository_id='id') as borgmatic_runtime_directory:
assert borgmatic_runtime_directory == '/tmp/borgmatic-1234/./borgmatic'
@@ -291,7 +276,6 @@ def test_make_runtime_directory_glob(borgmatic_runtime_directory, expected_glob)
def test_get_borgmatic_state_directory_uses_config_option():
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os.environ).should_receive('get').never()
assert (
module.get_borgmatic_state_directory(
@@ -301,16 +285,16 @@ def test_get_borgmatic_state_directory_uses_config_option():
)
def test_get_borgmatic_state_directory_falls_back_to_xdg_state_home():
def test_get_borgmatic_state_directory_falls_back_to_xdg_state_home(monkeypatch):
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os.environ).should_receive('get').with_args('XDG_STATE_HOME').and_return('/tmp')
monkeypatch.setenv('XDG_STATE_HOME', '/tmp')
assert module.get_borgmatic_state_directory({}) == '/tmp/borgmatic'
def test_get_borgmatic_state_directory_falls_back_to_state_directory():
def test_get_borgmatic_state_directory_falls_back_to_state_directory(monkeypatch):
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os.environ).should_receive('get').with_args('XDG_STATE_HOME').and_return(None)
monkeypatch.delenv('XDG_STATE_HOME', raising=False)
flexmock(module).should_receive('resolve_systemd_directory').with_args(
module.Systemd_directories.STATE_DIRECTORY
).and_return(
@@ -320,32 +304,26 @@ def test_get_borgmatic_state_directory_falls_back_to_state_directory():
assert module.get_borgmatic_state_directory({}) == '/tmp/borgmatic'
def test_get_borgmatic_state_directory_defaults_to_hard_coded_path():
def test_get_borgmatic_state_directory_defaults_to_hard_coded_path(monkeypatch):
flexmock(module).should_receive('expand_user_in_path').replace_with(lambda path: path)
flexmock(module.os.environ).should_receive('get').and_return(None)
monkeypatch.delenv('XDG_STATE_HOME', raising=False)
assert module.get_borgmatic_state_directory({}) == '~/.local/state/borgmatic'
def test_resolve_systemd_directory_none():
flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return(
None
)
flexmock(module.os.environ).should_receive('get').with_args('STATE_DIRECTORY').and_return(None)
def test_resolve_systemd_directory_none(monkeypatch):
monkeypatch.delenv('RUNTIME_DIRECTORY', raising=False)
monkeypatch.delenv('STATE_DIRECTORY', raising=False)
assert module.resolve_systemd_directory(module.Systemd_directories.RUNTIME_DIRECTORY) is None
assert module.resolve_systemd_directory(module.Systemd_directories.STATE_DIRECTORY) is None
def test_resolve_systemd_directory_single():
def test_resolve_systemd_directory_single(monkeypatch):
runtime_dir = '/run/borgmatic'
state_dir = '/var/lib/borgmatic'
flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return(
runtime_dir
)
flexmock(module.os.environ).should_receive('get').with_args('STATE_DIRECTORY').and_return(
state_dir
)
monkeypatch.setenv('RUNTIME_DIRECTORY', runtime_dir)
monkeypatch.setenv('STATE_DIRECTORY', state_dir)
assert (
module.resolve_systemd_directory(module.Systemd_directories.RUNTIME_DIRECTORY)
@@ -354,16 +332,12 @@ def test_resolve_systemd_directory_single():
assert module.resolve_systemd_directory(module.Systemd_directories.STATE_DIRECTORY) == state_dir
def test_resolve_systemd_directory_multiple():
def test_resolve_systemd_directory_multiple(monkeypatch):
runtime_dirs = '/run/borgmatic:/run/second:/run/third'
state_dirs = '/var/lib/borgmatic:/var/lib/second:/var/lib/third'
flexmock(module.os.environ).should_receive('get').with_args('RUNTIME_DIRECTORY').and_return(
runtime_dirs
)
flexmock(module.os.environ).should_receive('get').with_args('STATE_DIRECTORY').and_return(
state_dirs
)
monkeypatch.setenv('RUNTIME_DIRECTORY', runtime_dirs)
monkeypatch.setenv('STATE_DIRECTORY', state_dirs)
assert (
module.resolve_systemd_directory(module.Systemd_directories.RUNTIME_DIRECTORY)
@@ -31,6 +31,55 @@ def test_load_credential_with_missing_database_raises():
)
def test_load_credential_with_secret_service_database_path_fetches_password_via_secret_tool():
flexmock(module.os.path).should_receive('expanduser').never()
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(
(
'secret-tool',
'lookup',
'Path',
'mypassword',
),
).and_yield('password').once()
assert (
module.load_credential(
hook_config={},
config={},
credential_parameters=('secret-service', 'mypassword'),
)
== 'password'
)
def test_load_credential_with_secret_service_database_path_and_secret_tool_command_calls_it():
flexmock(module.os.path).should_receive('expanduser').never()
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(
(
'/usr/local/bin/secret-tool',
'--some-option',
'lookup',
'Path',
'mypassword',
),
).and_yield('password').once()
assert (
module.load_credential(
hook_config={'secret_tool_command': '/usr/local/bin/secret-tool --some-option'},
config={},
credential_parameters=('secret-service', 'mypassword'),
)
== 'password'
)
def test_load_credential_with_present_database_fetches_password_from_keepassxc():
flexmock(module.os.path).should_receive('expanduser').with_args('database.kdbx').and_return(
'database.kdbx',
+21 -3
View File
@@ -7,14 +7,12 @@ from borgmatic.hooks.data_source import bootstrap as module
def test_resolve_config_path_symlinks_passes_through_non_symlink():
flexmock(module.os.path).should_receive('abspath').replace_with(lambda path: path)
flexmock(module.os.path).should_receive('islink').and_return(False)
assert tuple(module.resolve_config_path_symlinks('test.yaml')) == ('test.yaml',)
def test_resolve_config_path_symlinks_follows_each_symlink():
flexmock(module.os.path).should_receive('abspath').replace_with(lambda path: path)
flexmock(module.os.path).should_receive('islink').with_args('test.yaml').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('dest1.yaml').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('dest2.yaml').and_return(False)
@@ -29,9 +27,29 @@ def test_resolve_config_path_symlinks_follows_each_symlink():
)
def test_resolve_config_path_symlinks_follows_each_relative_symlink():
flexmock(module.os.path).should_receive('islink').with_args('foo/bar/test.yaml').and_return(
True
)
flexmock(module.os.path).should_receive('islink').with_args('foo/dest1.yaml').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('dest2.yaml').and_return(False)
flexmock(module.os).should_receive('readlink').with_args('foo/bar/test.yaml').and_return(
'../dest1.yaml'
)
flexmock(module.os).should_receive('readlink').with_args('foo/dest1.yaml').and_return(
'../dest2.yaml'
)
flexmock(module.os).should_receive('readlink').with_args('dest2.yaml').never()
assert tuple(module.resolve_config_path_symlinks('foo/bar/test.yaml')) == (
'foo/bar/test.yaml',
'foo/dest1.yaml',
'dest2.yaml',
)
def test_resolve_config_path_symlinks_with_too_many_symlinks_raises():
flexmock(module).MAXIMUM_CONFIG_SYMLINKS_TO_FOLLOW = 2
flexmock(module.os.path).should_receive('abspath').replace_with(lambda path: path)
flexmock(module.os.path).should_receive('islink').with_args('test.yaml').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('dest1.yaml').and_return(True)
flexmock(module.os.path).should_receive('islink').with_args('dest2.yaml').and_return(True)
+102 -6
View File
@@ -64,6 +64,23 @@ def test_make_defaults_file_escapes_password_containing_backslash():
)
def test_make_defaults_file_escapes_password_containing_quote():
read_descriptor = 99
write_descriptor = flexmock()
flexmock(module.os).should_receive('pipe').and_return(read_descriptor, write_descriptor)
flexmock(module.os).should_receive('write').with_args(
write_descriptor,
b'[client]\nuser=root\n' + rb'password="trust\"some1"',
).once()
flexmock(module.os).should_receive('close')
flexmock(module.os).should_receive('set_inheritable')
assert module.make_defaults_file_options(username='root', password=r'trust"some1') == (
'--defaults-extra-file=/dev/fd/99',
)
def test_make_defaults_file_pipe_with_only_username_writes_it_to_file_descriptor():
read_descriptor = 99
write_descriptor = flexmock()
@@ -201,7 +218,7 @@ def test_database_names_to_dump_queries_mariadb_for_database_names():
),
environment=environment,
working_directory='/path/to/working/dir',
).and_yield('foo', 'bar', 'mysql').once()
).and_yield('foo', 'bar').once()
names = module.database_names_to_dump(
{'name': 'all'},
@@ -239,7 +256,7 @@ def test_database_names_to_dump_with_database_name_all_and_skip_names_filters_ou
),
environment=environment,
working_directory=None,
).and_yield('foo', 'bar', 'baz', 'mysql').once()
).and_yield('foo', 'bar', 'baz').once()
names = module.database_names_to_dump(
{'name': 'all', 'skip_names': ('foo', 'bar')},
@@ -279,7 +296,7 @@ def test_database_names_to_dump_runs_mariadb_with_socket_path():
),
environment=environment,
working_directory=None,
).and_yield('foo', 'bar', 'mysql').once()
).and_yield('foo', 'bar').once()
names = module.database_names_to_dump(
{'name': 'all', 'socket_path': '/socket'},
@@ -314,7 +331,7 @@ def test_database_names_to_dump_with_environment_password_transport_skips_defaul
),
environment=environment,
working_directory=None,
).and_yield('foo', 'bar', 'mysql').once()
).and_yield('foo', 'bar').once()
names = module.database_names_to_dump(
{'name': 'all', 'password_transport': 'environment'},
@@ -353,7 +370,7 @@ def test_database_names_to_dump_runs_mariadb_with_tls():
),
environment=environment,
working_directory=None,
).and_yield('foo', 'bar', 'mysql').once()
).and_yield('foo', 'bar').once()
names = module.database_names_to_dump(
{'name': 'all', 'tls': True},
@@ -392,7 +409,7 @@ def test_database_names_to_dump_runs_mariadb_without_tls():
),
environment=environment,
working_directory=None,
).and_yield('foo', 'bar', 'mysql').once()
).and_yield('foo', 'bar').once()
names = module.database_names_to_dump(
{'name': 'all', 'tls': False},
@@ -839,6 +856,9 @@ def test_execute_dump_command_runs_mariadb_dump():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -864,6 +884,58 @@ def test_execute_dump_command_runs_mariadb_dump():
)
def test_execute_dump_command_substitutes_system_flag_for_system_database_name():
process = flexmock()
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
flexmock(module.os.path).should_receive('exists').and_return(False)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
flexmock(module).should_receive('parse_extra_options').and_return((), None)
flexmock(module.database_config).should_receive('resolve_database_option').and_return(None)
flexmock(module).should_receive('make_defaults_file_options').with_args(
'root',
'trustsome1',
None,
).and_return(('--defaults-extra-file=/dev/fd/99',))
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command').with_args(
(
'mariadb-dump',
'--defaults-extra-file=/dev/fd/99',
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'--system=users,udfs,servers',
'--result-file',
'dump',
),
environment=None,
run_to_completion=False,
working_directory=None,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'mysql'},
config={},
username='root',
password='trustsome1',
dump_path=flexmock(),
database_names=('mysql',),
environment=None,
dry_run=False,
dry_run_label='',
)
== process
)
def test_execute_dump_command_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
process = flexmock()
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
@@ -885,6 +957,9 @@ def test_execute_dump_command_with_environment_password_transport_skips_defaults
'--user',
'root',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -933,6 +1008,9 @@ def test_execute_dump_command_runs_mariadb_dump_without_add_drop_database():
'--defaults-extra-file=/dev/fd/99',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -990,6 +1068,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_hostname_and_port():
'--protocol',
'tcp',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1040,6 +1121,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_tls():
'--single-transaction',
'--ssl',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1090,6 +1174,9 @@ def test_execute_dump_command_runs_mariadb_dump_without_tls():
'--single-transaction',
'--skip-ssl',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1139,6 +1226,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_username_and_password():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1189,6 +1279,9 @@ def test_execute_dump_command_runs_mariadb_dump_with_options():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1239,6 +1332,9 @@ def test_execute_dump_command_runs_non_default_mariadb_dump_with_options():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -719,6 +719,9 @@ def test_execute_dump_command_runs_mysqldump():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -769,6 +772,9 @@ def test_execute_dump_command_with_environment_password_transport_skips_defaults
'--user',
'root',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -817,6 +823,9 @@ def test_execute_dump_command_runs_mysqldump_without_add_drop_database():
'--defaults-extra-file=/dev/fd/99',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -874,6 +883,9 @@ def test_execute_dump_command_runs_mysqldump_with_hostname_and_port():
'--protocol',
'tcp',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -924,6 +936,9 @@ def test_execute_dump_command_runs_mysqldump_with_tls():
'--single-transaction',
'--ssl',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -974,6 +989,9 @@ def test_execute_dump_command_runs_mysqldump_without_tls():
'--single-transaction',
'--skip-ssl',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1023,6 +1041,9 @@ def test_execute_dump_command_runs_mysqldump_with_username_and_password():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1073,6 +1094,9 @@ def test_execute_dump_command_runs_mysqldump_with_options():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -1122,6 +1146,9 @@ def test_execute_dump_command_runs_non_default_mysqldump():
'--add-drop-database',
'--single-transaction',
'--databases',
'--events',
'--routines',
'--all-tablespaces',
'foo',
'--result-file',
'dump',
@@ -172,13 +172,22 @@ def test_database_names_to_dump_with_all_and_format_lists_databases_with_usernam
def test_database_names_to_dump_with_all_and_format_lists_databases_with_options():
database = {'name': 'all', 'format': 'custom', 'list_options': '--harder'}
database = {'name': 'all', 'format': 'custom', 'list_options': '--harder "foo bar"'}
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
('psql', '--list', '--no-password', '--no-psqlrc', '--csv', '--tuples-only', '--harder'),
(
'psql',
'--list',
'--no-password',
'--no-psqlrc',
'--csv',
'--tuples-only',
'--harder',
"'foo bar'",
),
environment=object,
working_directory=None,
).and_yield('foo,test,', 'bar,test,"stuff and such"')
@@ -803,7 +812,7 @@ def test_dump_data_sources_runs_pg_dump_with_integer_compression():
def test_dump_data_sources_runs_pg_dump_with_options():
databases = [{'name': 'foo', 'options': '--stuff=such'}]
databases = [{'name': 'foo', 'options': '--stuff "foo bar"'}]
process = flexmock()
flexmock(module).should_receive('make_environment').and_return({'PGSSLMODE': 'disable'})
flexmock(module).should_receive('make_dump_path').and_return('')
@@ -826,7 +835,8 @@ def test_dump_data_sources_runs_pg_dump_with_options():
'--if-exists',
'--format',
'custom',
'--stuff=such',
'--stuff',
"'foo bar'",
'foo',
'>',
'databases/localhost/foo',
@@ -1340,8 +1350,8 @@ def test_restore_data_source_dump_runs_pg_restore_with_options():
hook_config = [
{
'name': 'foo',
'restore_options': '--harder',
'analyze_options': '--smarter',
'restore_options': '--harder "foo bar"',
'analyze_options': '--smarter "baz quux"',
'schemas': None,
},
]
@@ -1364,6 +1374,7 @@ def test_restore_data_source_dump_runs_pg_restore_with_options():
'--dbname',
'foo',
'--harder',
"'foo bar'",
),
processes=[extract_process],
output_log_level=logging.DEBUG,
@@ -1381,6 +1392,7 @@ def test_restore_data_source_dump_runs_pg_restore_with_options():
'--dbname',
'foo',
'--smarter',
"'baz quux'",
'--command',
'ANALYZE',
),
+79 -67
View File
@@ -223,40 +223,34 @@ def test_get_datasets_to_backup_with_invalid_list_output_raises():
module.get_datasets_to_backup('zfs', patterns=(Pattern('/foo'), Pattern('/bar')))
def test_get_all_dataset_mount_points_omits_none():
def test_get_all_dataset_mount_points_omits_none_and_reverse_orders_by_mount_path():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).and_yield(
'/dataset',
'none',
'/other',
'dataset\t/path',
'thing\tnone',
'other\t/other',
)
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns',
).and_return((Pattern('/dataset'),))
assert module.get_all_dataset_mount_points('zfs') == (
('/dataset'),
('/other'),
assert tuple(module.get_all_dataset_mount_points('zfs').items()) == (
('dataset', '/path'),
('other', '/other'),
)
def test_get_all_dataset_mount_points_omits_duplicates():
def test_get_all_dataset_mount_points_omits_duplicates_and_reverse_orders_by_mount_path():
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).and_return(
'/dataset',
'/other',
'/dataset',
'/other',
'dataset\t/path',
'other\t/other',
'dataset\t/path',
'other\t/other',
)
flexmock(module.borgmatic.hooks.data_source.snapshot).should_receive(
'get_contained_patterns',
).and_return((Pattern('/dataset'),))
assert module.get_all_dataset_mount_points('zfs') == (
('/dataset'),
('/other'),
assert tuple(module.get_all_dataset_mount_points('zfs').items()) == (
('dataset', '/path'),
('other', '/other'),
)
@@ -525,7 +519,12 @@ def test_get_all_snapshots_parses_list_output():
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).should_receive('get_all_dataset_mount_points').and_return(
{'dataset': '/mnt/dataset'}
)
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid'),
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob',
).and_return('/run/borgmatic')
@@ -533,15 +532,11 @@ def test_remove_data_source_dumps_unmounts_and_destroys_snapshots():
lambda path: [path.replace('*', 'b33f')],
)
flexmock(module.os.path).should_receive('isdir').and_return(True)
flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
flexmock(module.shutil).should_receive('rmtree')
flexmock(module).should_receive('unmount_snapshot').with_args(
'umount',
'/run/borgmatic/zfs_snapshots/b33f/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',
@@ -557,7 +552,12 @@ def test_remove_data_source_dumps_unmounts_and_destroys_snapshots():
def test_remove_data_source_dumps_use_custom_commands():
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(
{'dataset': '/mnt/dataset'}
)
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid'),
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob',
).and_return('/run/borgmatic')
@@ -565,15 +565,11 @@ def test_remove_data_source_dumps_use_custom_commands():
lambda path: [path.replace('*', 'b33f')],
)
flexmock(module.os.path).should_receive('isdir').and_return(True)
flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
flexmock(module.shutil).should_receive('rmtree')
flexmock(module).should_receive('unmount_snapshot').with_args(
'/usr/local/bin/umount',
'/run/borgmatic/zfs_snapshots/b33f/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',
@@ -639,7 +635,12 @@ def test_remove_data_source_dumps_bails_for_zfs_command_error():
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).should_receive('get_all_dataset_mount_points').and_return(
{'dataset': '/mnt/dataset'}
)
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid'),
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob',
).and_return('/run/borgmatic')
@@ -647,13 +648,11 @@ def test_remove_data_source_dumps_bails_for_missing_umount_command():
lambda path: [path.replace('*', 'b33f')],
)
flexmock(module.os.path).should_receive('isdir').and_return(True)
flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
flexmock(module.shutil).should_receive('rmtree')
flexmock(module).should_receive('unmount_snapshot').with_args(
'/usr/local/bin/umount',
'/run/borgmatic/zfs_snapshots/b33f/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'}
@@ -667,7 +666,12 @@ def test_remove_data_source_dumps_bails_for_missing_umount_command():
def test_remove_data_source_dumps_swallows_umount_command_error():
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(
{'dataset': '/mnt/dataset'}
)
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid'),
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob',
).and_return('/run/borgmatic')
@@ -675,15 +679,11 @@ def test_remove_data_source_dumps_swallows_umount_command_error():
lambda path: [path.replace('*', 'b33f')],
)
flexmock(module.os.path).should_receive('isdir').and_return(True)
flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
flexmock(module.shutil).should_receive('rmtree')
flexmock(module).should_receive('unmount_snapshot').with_args(
'/usr/local/bin/umount',
'/run/borgmatic/zfs_snapshots/b33f/mnt/dataset',
).and_raise(module.subprocess.CalledProcessError(1, 'wtf'))
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',
@@ -700,7 +700,12 @@ def test_remove_data_source_dumps_swallows_umount_command_error():
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).should_receive('get_all_dataset_mount_points').and_return(
{'dataset': '/mnt/dataset'}
)
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid'),
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob',
).and_return('/run/borgmatic')
@@ -710,9 +715,6 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_directories_that_are_no
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',
@@ -728,7 +730,12 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_directories_that_are_no
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).should_receive('get_all_dataset_mount_points').and_return(
{'dataset': '/mnt/dataset'}
)
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid'),
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob',
).and_return('/run/borgmatic')
@@ -741,12 +748,8 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_no
flexmock(module.os.path).should_receive('isdir').with_args(
'/run/borgmatic/zfs_snapshots/b33f/mnt/dataset',
).and_return(False)
flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
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',
@@ -761,8 +764,13 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_no
)
def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_empty():
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(('/mnt/dataset',))
def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_for_unknown_shapshots():
flexmock(module).should_receive('get_all_dataset_mount_points').and_return(
{'dataset': '/mnt/dataset', 'sub': '/mnt/dataset/shadow'}
)
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid'),
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob',
).and_return('/run/borgmatic')
@@ -775,14 +783,16 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_em
flexmock(module.os.path).should_receive('isdir').with_args(
'/run/borgmatic/zfs_snapshots/b33f/mnt/dataset',
).and_return(True)
flexmock(module.os).should_receive('listdir').with_args(
'/run/borgmatic/zfs_snapshots/b33f/mnt/dataset',
).and_return([])
flexmock(module.os.path).should_receive('isdir').with_args(
'/run/borgmatic/zfs_snapshots/b33f/mnt/dataset/shadow',
).and_return(True)
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('unmount_snapshot').with_args(
'umount', '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset'
).once()
flexmock(module).should_receive('unmount_snapshot').with_args(
'umount', '/run/borgmatic/zfs_snapshots/b33f/mnt/dataset/shadow'
).never()
flexmock(module).should_receive('destroy_snapshot').with_args(
'zfs',
'dataset@borgmatic-1234',
@@ -798,7 +808,12 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_that_are_em
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).should_receive('get_all_dataset_mount_points').and_return(
{'dataset': '/mnt/dataset'}
)
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid'),
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob',
).and_return('/run/borgmatic')
@@ -811,12 +826,8 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_after_rmtre
flexmock(module.os.path).should_receive('isdir').with_args(
'/run/borgmatic/zfs_snapshots/b33f/mnt/dataset',
).and_return(True).and_return(False)
flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
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',
@@ -832,7 +843,12 @@ def test_remove_data_source_dumps_skips_unmount_snapshot_mount_paths_after_rmtre
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).should_receive('get_all_dataset_mount_points').and_return(
{'dataset': '/mnt/dataset'}
)
flexmock(module).should_receive('get_all_snapshots').and_return(
('dataset@borgmatic-1234', 'dataset@other', 'other@other', 'invalid'),
)
flexmock(module.borgmatic.config.paths).should_receive(
'replace_temporary_subdirectory_with_glob',
).and_return('/run/borgmatic')
@@ -840,12 +856,8 @@ def test_remove_data_source_dumps_with_dry_run_skips_unmount_and_destroy():
lambda path: [path.replace('*', 'b33f')],
)
flexmock(module.os.path).should_receive('isdir').and_return(True)
flexmock(module.os).should_receive('listdir').and_return(['file.txt'])
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(
+47
View File
@@ -1,5 +1,6 @@
import json
import pytest
from flexmock import flexmock
from borgmatic.hooks.monitoring import loki as module
@@ -78,3 +79,49 @@ def test_loki_log_handler_flush_with_empty_buffer_does_not_raise():
handler = module.Loki_log_handler(flexmock(), send_logs=False, log_level=10, dry_run=False)
handler.flush()
def test_loki_log_buffer_init_with_tls_stores_cert_and_key_paths():
buffer = module.Loki_log_buffer(
flexmock(),
dry_run=False,
tls_cert_path='/path/to/cert.crt',
tls_key_path='/path/to/key.key',
)
assert buffer.tls_cert_path == '/path/to/cert.crt'
assert buffer.tls_key_path == '/path/to/key.key'
def test_loki_log_handler_init_with_tls_passes_paths_to_buffer():
handler = module.Loki_log_handler(
flexmock(),
send_logs=False,
log_level=10,
dry_run=False,
tls_cert_path='/path/to/cert.crt',
tls_key_path='/path/to/key.key',
)
assert handler.buffer.tls_cert_path == '/path/to/cert.crt'
assert handler.buffer.tls_key_path == '/path/to/key.key'
def test_initialize_monitor_with_only_cert_path_raises():
hook_config = {
'url': 'http://localhost:3100/loki/api/v1/push',
'tls': {'cert_path': '/path/to/cert.crt'},
}
with pytest.raises(ValueError):
module.initialize_monitor(hook_config, {}, 'test.yaml', 1, False)
def test_initialize_monitor_with_only_key_path_raises():
hook_config = {
'url': 'http://localhost:3100/loki/api/v1/push',
'tls': {'key_path': '/path/to/key.key'},
}
with pytest.raises(ValueError):
module.initialize_monitor(hook_config, {}, 'test.yaml', 1, False)
+4 -4
View File
@@ -486,7 +486,7 @@ def test_log_buffer_lines_with_ready_buffer_and_higher_log_level_and_capture_pro
)
def test_log_buffer_lines_with_ready_buffer_and_lower_log_level_and_capture_process_yields_each_line():
def test_log_buffer_lines_with_ready_buffer_and_log_level_equal_to_output_log_level_and_capture_process_yields_each_line():
process = flexmock(poll=lambda: None, stderr=flexmock(), args=flexmock())
buffer_readers = {
flexmock(): module.Buffer_reader(lines=iter((('hi', 'there'),)), process=process)
@@ -497,7 +497,7 @@ def test_log_buffer_lines_with_ready_buffer_and_lower_log_level_and_capture_proc
).and_return(list(buffer_readers.keys()), [], [])
flexmock(module).should_receive('parse_log_line').and_return(flexmock())
flexmock(module).should_receive('handle_log_record').and_return(
flexmock(levelno=module.logging.ERROR, getMessage=lambda: 'message')
flexmock(levelno=module.logging.INFO, getMessage=lambda: 'message')
).twice()
assert tuple(
@@ -1256,7 +1256,7 @@ def test_log_remaining_buffer_lines_with_higher_log_level_and_capture_process_do
)
def test_log_remaining_buffer_lines_with_lower_log_level_and_capture_process_does_yields_each_line():
def test_log_remaining_buffer_lines_with_log_level_equal_to_output_log_level_and_capture_process_yields_each_line():
process = flexmock(stderr=flexmock(), args=flexmock())
buffer_readers = {
flexmock(): module.Buffer_reader(
@@ -1269,7 +1269,7 @@ def test_log_remaining_buffer_lines_with_lower_log_level_and_capture_process_doe
line=str, log_level=object, elevate_stderr=False, borg_local_path=object, command=object
).and_return(flexmock()).twice()
flexmock(module).should_receive('handle_log_record').and_return(
flexmock(levelno=module.logging.ERROR, getMessage=lambda: 'message')
flexmock(levelno=module.logging.INFO, getMessage=lambda: 'message')
).twice()
assert tuple(
+15 -12
View File
@@ -699,7 +699,7 @@ def test_configure_logging_skips_log_file_if_log_file_logging_is_disabled():
)
def test_configure_logging_to_log_file_instead_of_syslog():
def test_configure_logging_to_log_file_instead_of_syslog(tmp_path):
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
fake_formatter = flexmock()
@@ -716,20 +716,21 @@ def test_configure_logging_to_log_file_instead_of_syslog():
)
flexmock(module.os.path).should_receive('exists').never()
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
file_handler = logging.handlers.WatchedFileHandler('/tmp/logfile')
log_file = str(tmp_path / 'logfile')
file_handler = logging.handlers.WatchedFileHandler(log_file)
flexmock(module.logging.handlers).should_receive('WatchedFileHandler').with_args(
'/tmp/logfile',
log_file,
).and_return(file_handler).once()
module.configure_logging(
console_log_level=logging.INFO,
syslog_log_level=logging.DISABLED,
log_file_log_level=logging.DEBUG,
log_file='/tmp/logfile',
log_file=log_file,
)
def test_configure_logging_to_both_log_file_and_syslog():
def test_configure_logging_to_both_log_file_and_syslog(tmp_path):
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
fake_formatter = flexmock()
@@ -752,20 +753,21 @@ def test_configure_logging_to_both_log_file_and_syslog():
flexmock(module.logging.handlers).should_receive('SysLogHandler').with_args(
address='/dev/log',
).and_return(syslog_handler).once()
file_handler = logging.handlers.WatchedFileHandler('/tmp/logfile')
log_file = str(tmp_path / 'logfile')
file_handler = logging.handlers.WatchedFileHandler(log_file)
flexmock(module.logging.handlers).should_receive('WatchedFileHandler').with_args(
'/tmp/logfile',
log_file,
).and_return(file_handler).once()
module.configure_logging(
console_log_level=logging.INFO,
syslog_log_level=logging.DEBUG,
log_file_log_level=logging.DEBUG,
log_file='/tmp/logfile',
log_file=log_file,
)
def test_configure_logging_to_log_file_formats_with_custom_log_format():
def test_configure_logging_to_log_file_formats_with_custom_log_format(tmp_path):
flexmock(module).should_receive('add_custom_log_levels')
flexmock(module.logging).ANSWER = module.ANSWER
flexmock(module).should_receive('Log_prefix_formatter').with_args(
@@ -786,15 +788,16 @@ def test_configure_logging_to_log_file_formats_with_custom_log_format():
)
flexmock(module.os.path).should_receive('exists').with_args('/dev/log').and_return(True)
flexmock(module.logging.handlers).should_receive('SysLogHandler').never()
file_handler = logging.handlers.WatchedFileHandler('/tmp/logfile')
log_file = str(tmp_path / 'logfile')
file_handler = logging.handlers.WatchedFileHandler(log_file)
flexmock(module.logging.handlers).should_receive('WatchedFileHandler').with_args(
'/tmp/logfile',
log_file,
).and_return(file_handler).once()
module.configure_logging(
console_log_level=logging.INFO,
log_file_log_level=logging.DEBUG,
log_file='/tmp/logfile',
log_file=log_file,
log_file_format='{message}',
)