Compare commits

..
41 Commits
Author SHA1 Message Date
Dan Helfman b208c5352a Bump version for release. 2025-05-14 21:16:33 -07:00
Dan Helfman 79cf3495ee Fix for the "spot" check erroring when a checksum command errors (#1089). 2025-05-14 21:13:48 -07:00
Dan Helfman 15607d2ccd During the "spot" check, truncate log messages containing many file paths. 2025-05-14 20:24:28 -07:00
Dan Helfman ae1fbf7940 Fix incorrect option names in config normalize warning message (#1096).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1096
2025-05-15 02:32:23 +00:00
Jeffery To a1eb612a08 Fix incorrect option names in config normalize warning message. 2025-05-15 08:02:37 +08:00
Dan Helfman 7f3e9c5347 Fix for the "spot" check's "xxh64sum_command" option erroring on commands containing spaces (#1095). 2025-05-14 14:09:19 -07:00
Dan Helfman d83c444d9e More documentation fixes/clarifications for customizing warnings and errors (#1094). 2025-05-13 12:34:21 -07:00
Dan Helfman 2221be4549 Update Borg 2.0.0 documentation URLs. 2025-05-13 11:21:09 -07:00
Dan Helfman 9973c5fb7d Fix indentation in exit code documentation (#1094). 2025-05-12 14:17:32 -07:00
Dan Helfman ec7101ccf3 Fix incorrect documentation about customizing Borg exit codes (#1094). 2025-05-12 12:17:38 -07:00
Dan Helfman 5966022b67 Fix for the LVM hook erroring when the "--dry-run" flag is used (#1093). 2025-05-12 11:50:55 -07:00
Dan Helfman b68211cc0c Fix for the "config generate" action generating invalid configuration when upgrading deprecated command hooks (#1091). 2025-05-08 12:59:53 -07:00
Dan Helfman 1f773124f6 Add support for Borg 2's "s3:" and "b2:" repository URLs, so you can backup to S3 or B2 cloud storage services even without using Rclone. 2025-05-04 09:59:07 -07:00
Dan Helfman 3ff9a33468 Fix for the "spot" check breaking when the "--progress" flag is used (#1086). 2025-05-01 18:46:34 -07:00
Dan Helfman 02644c3bb6 Add LVM file descriptor warning fix to NEWS (#1068). 2025-04-30 08:29:55 -07:00
Dan Helfman 0e42ba21ba Explicitly close file descriptors when invoking LVM commands (#1068).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1074
2025-04-30 15:23:42 +00:00
Florian Apollonerandapollo13 d876e8636a Close fds for btrfs. 2025-04-30 07:42:55 +00:00
Florian Apollonerandapollo13 3127da54b5 Close fds for zfs. 2025-04-30 07:42:55 +00:00
Florian Apollonerandapollo13 3d44e95c1a Explicitly close filedescriptors when invoking lvm commands. Closes #1068 2025-04-30 07:42:55 +00:00
Dan Helfman cdbfe1c1e9 Bump version for release. 2025-04-29 15:20:50 -07:00
Dan Helfman 4c8ba5c5ab Fix some outdated config (section headers) in documentation (#1033). 2025-04-29 14:21:47 -07:00
Dan Helfman 091e9fa9ee Add a "password_transport" option to the MariaDB and MySQL database hooks for customizing how borgmatic transmits passwords to the database client (#1033). 2025-04-29 14:19:15 -07:00
Dan Helfman 0f4e93c9c8 GitHub Mistake Pull Closer was a mistake to use, because it doesn't work... 2025-04-26 17:39:36 -07:00
Dan Helfman 43bd42fb31 Tweak GitHub pull request template. 2025-04-26 17:36:01 -07:00
Dan Helfman bb54068152 Back out accidentally added partial feature. 2025-04-26 17:32:31 -07:00
Dan Helfman 6c5eecac20 Add a GitHub pull request template to discourage PRs there. 2025-04-26 17:27:22 -07:00
Dan Helfman 3f92b0b6ed The borgmatic project no longer accepts pull requests on GitHub. Use https://torsion.org/borgmatic/#contributing instead. 2025-04-26 17:19:13 -07:00
Dan Helfman 30d2108399 Move from Fosstodon to FLOSS.social. 2025-04-26 16:49:41 -07:00
Dan Helfman d36e5f24ce Add Borg 1.4.1 features to NEWS (#1078). 2025-04-25 12:06:49 -07:00
Dan Helfman 76c50555d3 Add Borg 1.4.1 features (#1078).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1081
2025-04-25 19:02:27 +00:00
Vandal 1cde65b4eb fix dry run not working borg 1.4.1+ 2025-04-26 00:21:48 +05:30
Vandal 4e05539825 add review changes 2025-04-25 22:56:07 +05:30
Vandal d17aa3e7cc review changes 2025-04-25 22:28:43 +05:30
Dan Helfman 3eff794f44 Fix a regression in which the default monitoring verbosity is 0 instead of 1 (#1085). 2025-04-24 23:27:09 -07:00
Vandal 1ac51e34bc pass dry run flag 2025-04-25 11:40:25 +05:30
Dan Helfman 345b4cfb09 Add support for new Borg placeholder "{unixtime}". 2025-04-23 10:07:33 -07:00
Vandal 27a2bbc231 add --dry-run feature check 2025-04-23 08:11:43 +05:30
Vandal dbf1d0946a make review changes 2025-04-23 07:38:52 +05:30
Vandal 2078527539 add BORG_USE_CHUNKS_ARCHIVE 2025-04-20 09:58:09 +05:30
Vandal 620bf52e01 add prune test 2025-04-20 09:31:51 +05:30
Vandal fdb08b6c56 add 13weekly and 3monthly quarterly 2025-04-19 10:14:41 +05:30
33 changed files with 1095 additions and 266 deletions
+8
View File
@@ -0,0 +1,8 @@
## Hold up!
Thanks for your contribution. Unfortunately, we don't use GitHub pull requests to manage code contributions to this repository (and GitHub doesn't have any way to disable pull requests entirely). Instead, please see:
https://torsion.org/borgmatic/#contributing
... which provides full instructions on how to submit pull requests. You can even use your GitHub account to login.
+35 -4
View File
@@ -1,3 +1,34 @@
2.0.6
* #1068: Fix a warning from LVM about leaked file descriptors.
* #1086: Fix for the "spot" check breaking when the "--progress" flag is used.
* #1089: Fix for the "spot" check erroring when a checksum command errors.
* #1091: Fix for the "config generate" action generating invalid configuration when upgrading
deprecated command hooks.
* #1093: Fix for the LVM hook erroring when the "--dry-run" flag is used.
* #1094: Fix incorrect documentation about customizing Borg exit codes:
https://torsion.org/borgmatic/docs/how-to/customize-warnings-and-errors/
* #1095: Fix for the "spot" check's "xxh64sum_command" option erroring on commands containing
spaces.
* Add support for Borg 2's "s3:" and "b2:" repository URLs, so you can backup to S3 or B2 cloud
storage services even without using Rclone.
* During the "spot" check, truncate log messages containing many file paths.
2.0.5
* #1033: Add a "password_transport" option to the MariaDB and MySQL database hooks for customizing
how borgmatic transmits passwords to the database client.
* #1078: Add "keep_3monthly" and "keep_13weekly" options for customizing "prune" action archive
retention.
* #1078: Add a "use_chunks_archive" option for controlling whether Borg uses its chunks cache
directory.
* #1078: For the "compact" action, pass "--dry-run" through to Borg.
* #1085: Fix a regression in which the default monitoring verbosity is 0 (warnings only) instead of
1 (info about steps borgmatic is taking). This prevented logs from showing up in monitoring
services like Healthchecks unless you had an explicit monitoring verbosity set.
* Move Mastodon social hosting from Fosstodon to FLOSS.social: https://floss.social/@borgmatic
* The borgmatic project no longer accepts pull requests on GitHub. But see
https://torsion.org/borgmatic/#contributing for how you can still submit pull requests. You can
even use your GitHub account to login.
2.0.4
* #1072: Fix path rewriting for non-root patterns in the ZFS, Btrfs, and LVM hooks.
* #1073: Clarify the documentation about when an "after: error" command hook runs and how it
@@ -62,7 +93,7 @@
* #837: Add custom command options for the MongoDB hook.
* #1010: When using Borg 2, don't pass the "--stats" flag to "borg prune".
* #1020: Document a database use case involving a temporary database client container:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-containers
* #1037: Fix an error with the "extract" action when both a remote repository and a
"working_directory" are used.
* #1044: Fix an error in the systemd credential hook when the credential name contains a "."
@@ -312,7 +343,7 @@
paths when a "working_directory" is set.
* #906: Add documentation details for how to run custom database dump commands using binaries from
running containers:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-containers
* Fix a regression in which the "color" option had no effect.
* Add a recent contributors section to the documentation, because credit where credit's due! See:
https://torsion.org/borgmatic/#recent-contributors
@@ -368,7 +399,7 @@
* Fix handling of the NO_COLOR environment variable to ignore an empty value.
* Add documentation about backing up containerized databases by configuring borgmatic to exec into
a container to run a dump command:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-containers
1.8.9
* #311: Add custom dump/restore command options for MySQL and MariaDB.
@@ -638,7 +669,7 @@
at the command-line. See the configuration reference for more information:
https://torsion.org/borgmatic/docs/reference/configuration/
* #649: Add documentation on backing up a database running in a container:
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#containers
https://torsion.org/borgmatic/docs/how-to/backup-your-databases/#database-containers
* #655: Fix error when databases are configured and a source directory doesn't exist.
* Add code style plugins to enforce use of Python f-strings and prevent single-letter variables.
To join in the pedantry, refresh your test environment with "tox --recreate".
+9 -8
View File
@@ -138,7 +138,8 @@ issues.
### Social
Follow [borgmatic on Mastodon](https://fosstodon.org/@borgmatic).
Follow <a rel="me" href="https://floss.social/@borgmatic">borgmatic on
Mastodon</a>.
### Chat
@@ -159,21 +160,21 @@ Other questions or comments? Contact
### Contributing
borgmatic [source code is
available](https://projects.torsion.org/borgmatic-collective/borgmatic) and is also mirrored
on [GitHub](https://github.com/borgmatic-collective/borgmatic) for convenience.
available](https://projects.torsion.org/borgmatic-collective/borgmatic) and also
has a read-only mirror on
[GitHub](https://github.com/borgmatic-collective/borgmatic) for convenience.
borgmatic is licensed under the GNU General Public License version 3 or any
later version.
If you'd like to contribute to borgmatic development, please feel free to
submit a [Pull
Request](https://projects.torsion.org/borgmatic-collective/borgmatic/pulls) or
If you'd like to contribute to borgmatic development, please feel free to submit
a [pull
request](https://projects.torsion.org/borgmatic-collective/borgmatic/pulls) or
open an
[issue](https://projects.torsion.org/borgmatic-collective/borgmatic/issues) to
discuss your idea. Note that you'll need to
[register](https://projects.torsion.org/user/sign_up?invite_code=borgmatic)
first. We also accept Pull Requests on GitHub, if that's more your thing. In
general, contributions are very welcome. We don't bite!
first. In general, contributions are very welcome. We don't bite!
Also, please check out the [borgmatic development
how-to](https://torsion.org/borgmatic/docs/how-to/develop-on-borgmatic/) for
+39 -9
View File
@@ -6,7 +6,9 @@ import logging
import os
import pathlib
import random
import shlex
import shutil
import textwrap
import borgmatic.actions.pattern
import borgmatic.borg.check
@@ -372,7 +374,11 @@ def collect_spot_check_source_paths(
borgmatic.borg.create.make_base_create_command(
dry_run=True,
repository_path=repository['path'],
config=dict(config, list_details=True),
# Omit "progress" because it interferes with "list_details".
config=dict(
{option: value for option, value in config.items() if option != 'progress'},
list_details=True,
),
patterns=borgmatic.actions.pattern.process_patterns(
borgmatic.actions.pattern.collect_patterns(config),
working_directory,
@@ -454,7 +460,7 @@ def collect_spot_check_archive_paths(
)
SAMPLE_PATHS_SUBSET_COUNT = 10000
SAMPLE_PATHS_SUBSET_COUNT = 5000
def compare_spot_check_hashes(
@@ -508,7 +514,10 @@ def compare_spot_check_hashes(
break
hash_output = borgmatic.execute.execute_command_and_capture_output(
(spot_check_config.get('xxh64sum_command', 'xxh64sum'),)
tuple(
shlex.quote(part)
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
)
+ tuple(
path for path in source_sample_paths_subset if path in hashable_source_sample_path
),
@@ -562,6 +571,9 @@ def compare_spot_check_hashes(
return tuple(failing_paths)
MAX_SPOT_CHECK_PATHS_LENGTH = 1000
def spot_check(
repository,
config,
@@ -629,9 +641,12 @@ def spot_check(
logger.debug(f'{len(archive_paths)} total archive paths for spot check')
if len(source_paths) == 0:
logger.debug(
f'Paths in latest archive but not source paths: {", ".join(set(archive_paths)) or "none"}'
truncated_archive_paths = textwrap.shorten(
', '.join(set(archive_paths)) or 'none',
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(f'Paths in latest archive but not source paths: {truncated_archive_paths}')
raise ValueError(
'Spot check failed: There are no source paths to compare against the archive'
)
@@ -642,11 +657,21 @@ def spot_check(
if count_delta_percentage > spot_check_config['count_tolerance_percentage']:
rootless_source_paths = set(path.lstrip(os.path.sep) for path in source_paths)
logger.debug(
f'Paths in source paths but not latest archive: {", ".join(rootless_source_paths - set(archive_paths)) or "none"}'
truncated_exclusive_source_paths = textwrap.shorten(
', '.join(rootless_source_paths - set(archive_paths)) or 'none',
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(
f'Paths in latest archive but not source paths: {", ".join(set(archive_paths) - rootless_source_paths) or "none"}'
f'Paths in source paths but not latest archive: {truncated_exclusive_source_paths}'
)
truncated_exclusive_archive_paths = textwrap.shorten(
', '.join(set(archive_paths) - rootless_source_paths) or 'none',
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(
f'Paths in latest archive but not source paths: {truncated_exclusive_archive_paths}'
)
raise ValueError(
f'Spot check failed: {count_delta_percentage:.2f}% file count delta between source paths and latest archive (tolerance is {spot_check_config["count_tolerance_percentage"]}%)'
@@ -669,8 +694,13 @@ def spot_check(
failing_percentage = (len(failing_paths) / len(source_paths)) * 100
if failing_percentage > data_tolerance_percentage:
truncated_failing_paths = textwrap.shorten(
', '.join(failing_paths),
width=MAX_SPOT_CHECK_PATHS_LENGTH,
placeholder=' ...',
)
logger.debug(
f'Source paths with data not matching the latest archive: {", ".join(failing_paths)}'
f'Source paths with data not matching the latest archive: {truncated_failing_paths}'
)
raise ValueError(
f'Spot check failed: {failing_percentage:.2f}% of source paths with data not matching the latest archive (tolerance is {data_tolerance_percentage}%)'
+7 -2
View File
@@ -1,7 +1,7 @@
import logging
import borgmatic.config.paths
from borgmatic.borg import environment, flags
from borgmatic.borg import environment, feature, flags
from borgmatic.execute import execute_command
logger = logging.getLogger(__name__)
@@ -37,11 +37,16 @@ def compact_segments(
+ (('--threshold', str(threshold)) if threshold else ())
+ (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+ (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+ (
('--dry-run',)
if dry_run and feature.available(feature.Feature.DRY_RUN_COMPACT, local_borg_version)
else ()
)
+ (tuple(extra_borg_options.split(' ')) if extra_borg_options else ())
+ flags.make_repository_flags(repository_path, local_borg_version)
)
if dry_run:
if dry_run and not feature.available(feature.Feature.DRY_RUN_COMPACT, local_borg_version):
logging.info('Skipping compact (dry run)')
return
+1
View File
@@ -17,6 +17,7 @@ OPTION_TO_ENVIRONMENT_VARIABLE = {
DEFAULT_BOOL_OPTION_TO_DOWNCASE_ENVIRONMENT_VARIABLE = {
'relocated_repo_access_is_ok': 'BORG_RELOCATED_REPO_ACCESS_IS_OK',
'unknown_unencrypted_repo_access_is_ok': 'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK',
'use_chunks_archive': 'BORG_USE_CHUNKS_ARCHIVE',
}
DEFAULT_BOOL_OPTION_TO_UPPERCASE_ENVIRONMENT_VARIABLE = {
+2
View File
@@ -18,6 +18,7 @@ class Feature(Enum):
EXCLUDED_FILES_MINUS = 12
ARCHIVE_SERIES = 13
NO_PRUNE_STATS = 14
DRY_RUN_COMPACT = 15
FEATURE_TO_MINIMUM_BORG_VERSION = {
@@ -35,6 +36,7 @@ FEATURE_TO_MINIMUM_BORG_VERSION = {
Feature.EXCLUDED_FILES_MINUS: parse('2.0.0b5'), # --list --filter uses "-" for excludes
Feature.ARCHIVE_SERIES: parse('2.0.0b11'), # identically named archives form a series
Feature.NO_PRUNE_STATS: parse('2.0.0b10'), # prune --stats is not available
Feature.DRY_RUN_COMPACT: parse('1.4.1'), # borg compact --dry-run support
}
+3 -1
View File
@@ -95,7 +95,9 @@ class Monitoring_hooks:
self.config_filename = config_filename
self.config = config
self.dry_run = global_arguments.dry_run
self.monitoring_log_level = verbosity_to_log_level(config.get('monitoring_verbosity'))
self.monitoring_log_level = verbosity_to_log_level(
get_verbosity({config_filename: config}, 'monitoring_verbosity')
)
self.monitoring_hooks_are_activated = (
using_primary_action and self.monitoring_log_level != DISABLED
)
+27 -5
View File
@@ -51,7 +51,7 @@ def schema_to_sample_configuration(schema, source_config=None, level=0, parent_i
add_comments_to_configuration_sequence(config, schema, indent=(level * INDENT))
elif borgmatic.config.schema.compare_types(schema_type, {'object'}):
if source_config and isinstance(source_config, list) and isinstance(source_config[0], dict):
source_config = dict(collections.ChainMap(*source_config))
source_config = source_config[0]
config = (
ruamel.yaml.comments.CommentedMap(
@@ -71,7 +71,7 @@ def schema_to_sample_configuration(schema, source_config=None, level=0, parent_i
)
indent = (level * INDENT) + (SEQUENCE_INDENT if parent_is_sequence else 0)
add_comments_to_configuration_object(
config, schema, source_config, indent=indent, skip_first=parent_is_sequence
config, schema, source_config, indent=indent, skip_first_field=parent_is_sequence
)
elif borgmatic.config.schema.compare_types(schema_type, SCALAR_SCHEMA_TYPES, match=all):
return example
@@ -108,17 +108,30 @@ def comment_out_optional_configuration(rendered_config):
'''
lines = []
optional = False
indent_characters = None
indent_characters_at_sentinel = None
for line in rendered_config.split('\n'):
indent_characters = len(line) - len(line.lstrip())
# Upon encountering an optional configuration option, comment out lines until the next blank
# line.
if line.strip().startswith(f'# {COMMENTED_OUT_SENTINEL}'):
optional = True
indent_characters_at_sentinel = indent_characters
continue
# Hit a blank line, so reset commenting.
if not line.strip():
optional = False
indent_characters_at_sentinel = None
# Dedented, so reset commenting.
elif (
indent_characters_at_sentinel is not None
and indent_characters < indent_characters_at_sentinel
):
optional = False
indent_characters_at_sentinel = None
lines.append(comment_out_line(line) if optional else line)
@@ -198,25 +211,28 @@ COMMENTED_OUT_SENTINEL = 'COMMENT_OUT'
def add_comments_to_configuration_object(
config, schema, source_config=None, indent=0, skip_first=False
config, schema, source_config=None, indent=0, skip_first_field=False
):
'''
Using descriptions from a schema as a source, add those descriptions as comments to the given
configuration dict, putting them before each field. Indent the comment the given number of
characters.
If skip_first_field is True, omit the comment for the initial field. This is useful for
sequences, where the comment for the first field goes before the sequence itself.
And a sentinel for commenting out options that are neither in DEFAULT_KEYS nor the the given
source configuration dict. The idea is that any options used in the source configuration should
stay active in the generated configuration.
'''
for index, field_name in enumerate(config.keys()):
if skip_first and index == 0:
if skip_first_field and index == 0:
continue
field_schema = borgmatic.config.schema.get_properties(schema).get(field_name, {})
description = field_schema.get('description', '').strip()
# If this isn't a default key, add an indicator to the comment flagging it to be commented
# If this isn't a default key, add an indicator to the comment, flagging it to be commented
# out from the sample configuration. This sentinel is consumed by downstream processing that
# does the actual commenting out.
if field_name not in DEFAULT_KEYS and (
@@ -299,6 +315,12 @@ def generate_sample_configuration(
source_config = load.load_configuration(source_filename)
normalize.normalize(source_filename, source_config)
# The borgmatic.config.normalize.normalize() function tacks on an empty "bootstrap" if
# needed, so the hook gets used by default. But we don't want it to end up in the generated
# config unless the user has set it explicitly, as an empty "bootstrap:" won't validate.
if source_config and source_config.get('bootstrap') == {}:
del source_config['bootstrap']
destination_config = merge_source_configuration_into_destination(
schema_to_sample_configuration(schema, source_config), source_config
)
+4 -2
View File
@@ -159,7 +159,7 @@ def normalize(config_filename, config):
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: The borgmatic_source_directory option is deprecated and will be removed from a future release. Use borgmatic_runtime_directory and borgmatic_state_directory instead.',
msg=f'{config_filename}: The borgmatic_source_directory option is deprecated and will be removed from a future release. Use user_runtime_directory and user_state_directory instead.',
)
)
)
@@ -356,6 +356,8 @@ def normalize(config_filename, config):
repository_path.startswith('ssh://')
or repository_path.startswith('sftp://')
or repository_path.startswith('rclone:')
or repository_path.startswith('s3:')
or repository_path.startswith('b2:')
):
config['repositories'].append(repository_dict)
else:
@@ -365,7 +367,7 @@ def normalize(config_filename, config):
dict(
levelno=logging.WARNING,
levelname='WARNING',
msg=f'{config_filename}: Remote repository paths without ssh:// or rclone: syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
msg=f'{config_filename}: Remote repository paths without ssh://, sftp://, rclone:, s3:, or b2:, syntax are deprecated and support will be removed from a future release. Interpreting "{repository_path}" as "{rewritten_repository_path}"',
)
)
)
+49
View File
@@ -389,6 +389,13 @@ properties:
Path for Borg cache files. Defaults to
$borg_base_directory/.cache/borg
example: /path/to/base/cache
use_chunks_archive:
type: boolean
description: |
Enables or disables the use of chunks.archive.d for faster cache
resyncs in Borg. If true, value is set to "yes" (default) else
it's set to "no", reducing disk usage but slowing resyncs.
example: true
borg_files_cache_ttl:
type: integer
description: |
@@ -558,6 +565,14 @@ properties:
type: integer
description: Number of yearly archives to keep.
example: 1
keep_13weekly:
type: integer
description: Number of quarterly archives to keep (13 week strategy).
example: 13
keep_3monthly:
type: integer
description: Number of quarterly archives to keep (3 month strategy).
example: 3
prefix:
type: string
description: |
@@ -1545,6 +1560,23 @@ properties:
Defaults to the "password" option. Supports the
"{credential ...}" syntax.
example: trustsome1
password_transport:
type: string
enum:
- pipe
- environment
description: |
How to transmit database passwords from borgmatic to the
MariaDB client, one of:
* "pipe": Securely transmit passwords via anonymous
pipe. Only works if the database client is on the
same host as borgmatic. (The server can be
somewhere else.) This is the default value.
* "environment": Transmit passwords via environment
variable. Potentially less secure than a pipe, but
necessary when the database client is elsewhere, e.g.
when "mariadb_dump_command" is configured to "exec"
into a container and run a client there.
tls:
type: boolean
description: |
@@ -1693,6 +1725,23 @@ properties:
Defaults to the "password" option. Supports the
"{credential ...}" syntax.
example: trustsome1
password_transport:
type: string
enum:
- pipe
- environment
description: |
How to transmit database passwords from borgmatic to the
MySQL client, one of:
* "pipe": Securely transmit passwords via anonymous
pipe. Only works if the database client is on the
same host as borgmatic. (The server can be
somewhere else.) This is the default value.
* "environment": Transmit passwords via environment
variable. Potentially less secure than a pipe, but
necessary when the database client is elsewhere, e.g.
when "mysql_dump_command" is configured to "exec"
into a container and run a client there.
tls:
type: boolean
description: |
+6 -6
View File
@@ -288,6 +288,7 @@ def execute_command(
borg_local_path=None,
borg_exit_codes=None,
run_to_completion=True,
close_fds=False, # Necessary for passing credentials via anonymous pipe.
):
'''
Execute the given command (a sequence of command/argument strings) and log its output at the
@@ -315,8 +316,7 @@ def execute_command(
shell=shell,
env=environment,
cwd=working_directory,
# Necessary for passing credentials via anonymous pipe.
close_fds=False,
close_fds=close_fds,
)
if not run_to_completion:
return process
@@ -340,6 +340,7 @@ def execute_command_and_capture_output(
working_directory=None,
borg_local_path=None,
borg_exit_codes=None,
close_fds=False, # Necessary for passing credentials via anonymous pipe.
):
'''
Execute the given command (a sequence of command/argument strings), capturing and returning its
@@ -365,8 +366,7 @@ def execute_command_and_capture_output(
shell=shell,
env=environment,
cwd=working_directory,
# Necessary for passing credentials via anonymous pipe.
close_fds=False,
close_fds=close_fds,
)
except subprocess.CalledProcessError as error:
if (
@@ -390,6 +390,7 @@ def execute_command_with_processes(
working_directory=None,
borg_local_path=None,
borg_exit_codes=None,
close_fds=False, # Necessary for passing credentials via anonymous pipe.
):
'''
Execute the given command (a sequence of command/argument strings) and log its output at the
@@ -425,8 +426,7 @@ def execute_command_with_processes(
shell=shell,
env=environment,
cwd=working_directory,
# Necessary for passing credentials via anonymous pipe.
close_fds=False,
close_fds=close_fds,
)
except (subprocess.CalledProcessError, OSError):
# Something has gone wrong. So vent each process' output buffer to prevent it from hanging.
+1
View File
@@ -19,6 +19,7 @@ BORG_PLACEHOLDERS = {
'{reverse-fqdn}',
'{now}',
'{utcnow}',
'{unixtime}',
'{user}',
'{pid}',
'{borgversion}',
+5 -1
View File
@@ -32,7 +32,8 @@ def get_subvolume_mount_points(findmnt_command):
'btrfs',
'--json',
'--list', # Request a flat list instead of a nested subvolume hierarchy.
)
),
close_fds=True,
)
try:
@@ -59,6 +60,7 @@ def get_subvolume_property(btrfs_command, subvolume_path, property_name):
subvolume_path,
property_name,
),
close_fds=True,
)
try:
@@ -227,6 +229,7 @@ def snapshot_subvolume(btrfs_command, subvolume_path, snapshot_path): # pragma:
snapshot_path,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -298,6 +301,7 @@ def delete_snapshot(btrfs_command, snapshot_path): # pragma: no cover
snapshot_path,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
+15 -8
View File
@@ -50,7 +50,8 @@ def get_logical_volumes(lsblk_command, patterns=None):
'name,path,mountpoint,type',
'--json',
'--list',
)
),
close_fds=True,
)
)
except json.JSONDecodeError as error:
@@ -109,6 +110,7 @@ def snapshot_logical_volume(
logical_volume_device,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -129,6 +131,7 @@ def mount_snapshot(mount_command, snapshot_device, snapshot_mount_path): # prag
snapshot_mount_path,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -227,12 +230,13 @@ def dump_data_sources(
)
# Get the device path for the snapshot we just created.
try:
snapshot = get_snapshots(
hook_config.get('lvs_command', 'lvs'), snapshot_name=snapshot_name
)[0]
except IndexError:
raise ValueError(f'Cannot find LVM snapshot {snapshot_name}')
if not dry_run:
try:
snapshot = get_snapshots(
hook_config.get('lvs_command', 'lvs'), snapshot_name=snapshot_name
)[0]
except IndexError:
raise ValueError(f'Cannot find LVM snapshot {snapshot_name}')
# Mount the snapshot into a particular named temporary directory so that the snapshot ends
# up in the Borg archive at the "original" logical volume mount point path.
@@ -277,6 +281,7 @@ def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
borgmatic.execute.execute_command(
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -291,6 +296,7 @@ def remove_snapshot(lvremove_command, snapshot_device_path): # pragma: no cover
snapshot_device_path,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -318,7 +324,8 @@ def get_snapshots(lvs_command, snapshot_name=None):
'lv_name,lv_path',
'--select',
'lv_attr =~ ^s', # Filter to just snapshots.
)
),
close_fds=True,
)
)
except json.JSONDecodeError as error:
+33 -5
View File
@@ -120,13 +120,19 @@ def database_names_to_dump(database, config, username, password, environment, dr
shlex.quote(part) for part in shlex.split(database.get('mariadb_command') or 'mariadb')
)
extra_options, defaults_extra_filename = parse_extra_options(database.get('list_options'))
password_transport = database.get('password_transport', 'pipe')
show_command = (
mariadb_show_command
+ make_defaults_file_options(username, password, defaults_extra_filename)
+ (
make_defaults_file_options(username, password, defaults_extra_filename)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--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 ())
+ ('--skip-column-names', '--batch')
@@ -184,14 +190,20 @@ def execute_dump_command(
for part in shlex.split(database.get('mariadb_dump_command') or 'mariadb-dump')
)
extra_options, defaults_extra_filename = parse_extra_options(database.get('options'))
password_transport = database.get('password_transport', 'pipe')
dump_command = (
mariadb_dump_command
+ make_defaults_file_options(username, password, defaults_extra_filename)
+ (
make_defaults_file_options(username, password, defaults_extra_filename)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ (('--add-drop-database',) if database.get('add_drop_database', True) else ())
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--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',)
@@ -255,7 +267,14 @@ def dump_data_sources(
password = borgmatic.hooks.credential.parse.resolve_credential(
database.get('password'), config
)
environment = dict(os.environ)
environment = dict(
os.environ,
**(
{'MYSQL_PWD': password}
if password and database.get('password_transport') == 'environment'
else {}
),
)
dump_database_names = database_names_to_dump(
database, config, username, password, environment, dry_run
)
@@ -383,18 +402,27 @@ def restore_data_source_dump(
shlex.quote(part) for part in shlex.split(data_source.get('mariadb_command') or 'mariadb')
)
extra_options, defaults_extra_filename = parse_extra_options(data_source.get('restore_options'))
password_transport = data_source.get('password_transport', 'pipe')
restore_command = (
mariadb_restore_command
+ make_defaults_file_options(username, password, defaults_extra_filename)
+ (
make_defaults_file_options(username, password, defaults_extra_filename)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ ('--batch',)
+ (('--host', hostname) if hostname else ())
+ (('--port', str(port)) if port else ())
+ (('--protocol', 'tcp') if hostname or port else ())
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if tls is True else ())
+ (('--skip-ssl',) if tls is False else ())
)
environment = dict(os.environ)
environment = dict(
os.environ,
**({'MYSQL_PWD': password} if password and password_transport == 'environment' else {}),
)
logger.debug(f"Restoring MariaDB database {data_source['name']}{dry_run_label}")
if dry_run:
+36 -8
View File
@@ -45,15 +45,21 @@ def database_names_to_dump(database, config, username, password, environment, dr
extra_options, defaults_extra_filename = (
borgmatic.hooks.data_source.mariadb.parse_extra_options(database.get('list_options'))
)
password_transport = database.get('password_transport', 'pipe')
show_command = (
mysql_show_command
+ borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
+ (
borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--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 ())
+ ('--skip-column-names', '--batch')
@@ -109,16 +115,22 @@ def execute_dump_command(
extra_options, defaults_extra_filename = (
borgmatic.hooks.data_source.mariadb.parse_extra_options(database.get('options'))
)
password_transport = database.get('password_transport', 'pipe')
dump_command = (
mysql_dump_command
+ borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
+ (
borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ (('--add-drop-database',) if database.get('add_drop_database', True) else ())
+ (('--host', database['hostname']) if 'hostname' in database else ())
+ (('--port', str(database['port'])) if 'port' in database else ())
+ (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+ (('--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',)
@@ -182,7 +194,14 @@ def dump_data_sources(
password = borgmatic.hooks.credential.parse.resolve_credential(
database.get('password'), config
)
environment = dict(os.environ)
environment = dict(
os.environ,
**(
{'MYSQL_PWD': password}
if password and database.get('password_transport') == 'environment'
else {}
),
)
dump_database_names = database_names_to_dump(
database, config, username, password, environment, dry_run
)
@@ -312,20 +331,29 @@ def restore_data_source_dump(
extra_options, defaults_extra_filename = (
borgmatic.hooks.data_source.mariadb.parse_extra_options(data_source.get('restore_options'))
)
password_transport = data_source.get('password_transport', 'pipe')
restore_command = (
mysql_restore_command
+ borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
+ (
borgmatic.hooks.data_source.mariadb.make_defaults_file_options(
username, password, defaults_extra_filename
)
if password_transport == 'pipe'
else ()
)
+ extra_options
+ ('--batch',)
+ (('--host', hostname) if hostname else ())
+ (('--port', str(port)) if port else ())
+ (('--protocol', 'tcp') if hostname or port else ())
+ (('--user', username) if username and password_transport == 'environment' else ())
+ (('--ssl',) if tls is True else ())
+ (('--skip-ssl',) if tls is False else ())
)
environment = dict(os.environ)
environment = dict(
os.environ,
**({'MYSQL_PWD': password} if password and password_transport == 'environment' else {}),
)
logger.debug(f"Restoring MySQL database {data_source['name']}{dry_run_label}")
if dry_run:
+10 -3
View File
@@ -53,7 +53,8 @@ def get_datasets_to_backup(zfs_command, patterns):
'filesystem',
'-o',
f'name,mountpoint,canmount,{BORGMATIC_USER_PROPERTY}',
)
),
close_fds=True,
)
try:
@@ -131,7 +132,8 @@ def get_all_dataset_mount_points(zfs_command):
'filesystem',
'-o',
'mountpoint',
)
),
close_fds=True,
)
return tuple(
@@ -158,6 +160,7 @@ def snapshot_dataset(zfs_command, full_snapshot_name): # pragma: no cover
full_snapshot_name,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -180,6 +183,7 @@ def mount_snapshot(mount_command, full_snapshot_name, snapshot_mount_path): # p
snapshot_mount_path,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -310,6 +314,7 @@ def unmount_snapshot(umount_command, snapshot_mount_path): # pragma: no cover
borgmatic.execute.execute_command(
tuple(umount_command.split(' ')) + (snapshot_mount_path,),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -325,6 +330,7 @@ def destroy_snapshot(zfs_command, full_snapshot_name): # pragma: no cover
full_snapshot_name,
),
output_log_level=logging.DEBUG,
close_fds=True,
)
@@ -342,7 +348,8 @@ def get_all_snapshots(zfs_command):
'snapshot',
'-o',
'name',
)
),
close_fds=True,
)
return tuple(line.rstrip() for line in list_output.splitlines())
+51 -23
View File
@@ -191,7 +191,7 @@ mysql_databases:
format: sql
```
### Containers
### Database containers
If your database server is running within a container and borgmatic is too, no
problem—configure borgmatic to connect to the container's name on its exposed
@@ -209,6 +209,9 @@ postgresql_databases:
<span class="minilink minilink-addedin">Prior to version 1.8.0</span> Put
these options in the `hooks:` section of your configuration.
#### Database client on the host
But what if borgmatic is running on the host? You can still connect to a
database server container if its ports are properly exposed to the host. For
instance, when running the database container, you can specify `--publish
@@ -228,17 +231,19 @@ And then you can configure borgmatic running on the host to connect to the
database:
```yaml
hooks:
postgresql_databases:
- name: users
hostname: 127.0.0.1
port: 5433
username: postgres
password: trustsome1
postgresql_databases:
- name: users
hostname: 127.0.0.1
port: 5433
username: postgres
password: trustsome1
```
Alter the ports in these examples to suit your particular database system.
#### Database client in a running container
Normally, borgmatic dumps a database by running a database dump command (e.g.
`pg_dump`) on the host or wherever borgmatic is running, and this command
connects to your containerized database via the given `hostname` and `port`. But
@@ -250,14 +255,13 @@ to run the dump command.
For instance, if using Docker and PostgreSQL, something like this might work:
```yaml
hooks:
postgresql_databases:
- name: users
hostname: 127.0.0.1
port: 5433
username: postgres
password: trustsome1
pg_dump_command: docker exec my_pg_container pg_dump
postgresql_databases:
- name: users
hostname: 127.0.0.1
port: 5433
username: postgres
password: trustsome1
pg_dump_command: docker exec my_pg_container pg_dump
```
... where `my_pg_container` is the name of your running database container.
@@ -282,19 +286,43 @@ services:
- /run/user/1000:/run/user/1000
```
And here's an example of using a MariaDB database client within a running Docker
container:
```yaml
mariadb_databases:
- name: users
hostname: 127.0.0.1
username: example
password: trustsome1
password_transport: environment
mariadb_dump_command: docker exec --env MYSQL_PWD my_mariadb_container mariadb-dump
```
The `password_transport: environment` option tells borgmatic to transmit the
password via environment variable instead of the default behavior of using an
anonymous pipe. The environment variable transport is potentially less secure
than the pipe, but it's necessary when the database client is running in a
container separate from borgmatic.
A similar approach can work with MySQL, using `mysql_dump_command` instead of
`mariadb_dump_command` to run `mysqldump` in a container.
#### Database client in a temporary container
Another variation: If you're running borgmatic on the host but want to spin up a
temporary `pg_dump` container whenever borgmatic dumps a database, for
instance to make use of a `pg_dump` version not present on the host, try
something like this:
```yaml
hooks:
postgresql_databases:
- name: users
hostname: your-database-hostname
username: postgres
password: trustsome1
pg_dump_command: docker run --rm --env PGPASSWORD postgres:17-alpine pg_dump
postgresql_databases:
- name: users
hostname: your-database-hostname
username: postgres
password: trustsome1
pg_dump_command: docker run --rm --env PGPASSWORD postgres:17-alpine pg_dump
```
The `--env PGPASSWORD` is necessary here for borgmatic to provide your database
+43 -46
View File
@@ -12,52 +12,18 @@ numeric ID indicating success, warning, or error. borgmatic consumes this exit
code to decide how to respond. Normally, a Borg error results in a borgmatic
error, while a Borg warning or success doesn't.
But if that default behavior isn't sufficient for your needs, you can
customize how borgmatic interprets [Borg's exit
<span class="minilink minilink-addedin">With Borg version 1.4+</span> If the
default behavior isn't sufficient for your needs, you can customize how
borgmatic interprets [Borg's exit
codes](https://borgbackup.readthedocs.io/en/stable/usage/general.html#return-codes).
For instance, to elevate Borg warnings to errors, thereby causing borgmatic to
error on them, use the following borgmatic configuration:
```yaml
borg_exit_codes:
- exit_code: 1
treat_as: error
```
Be aware though that Borg exits with a warning code for a variety of benign
situations such as files changing while they're being read, so this example
may not meet your needs. Keep reading though for more granular exit code
configuration.
Here's an example that squashes Borg errors to warnings:
```yaml
borg_exit_codes:
- exit_code: 2
treat_as: warning
```
Be careful with this example though, because it prevents borgmatic from
erroring when Borg errors, which may not be desirable.
### More granular configuration
<span class="minilink minilink-addedin">New in Borg version 1.4</span> Borg
support for [more granular exit
codes](https://borgbackup.readthedocs.io/en/1.4-maint/usage/general.html#return-codes)
means that you can configure borgmatic to respond to specific Borg conditions.
See the full list of [Borg 1.4 error and warning exit
codes](https://borgbackup.readthedocs.io/en/stable/internals/frontends.html#message-ids).
The `rc:` numeric value there tells you the exit code for each.
For instance, this borgmatic configuration elevates all Borg backup file
permission warnings (exit code `105`)—and only those warnings—to errors:
```yaml
borg_exit_codes:
- exit_code: 105
treat_as: error
- code: 105
treat_as: error
```
The following configuration does that *and* elevates backup file not found
@@ -65,16 +31,20 @@ warnings (exit code `107`) to errors as well:
```yaml
borg_exit_codes:
- exit_code: 105
treat_as: error
- exit_code: 107
treat_as: error
- code: 105
treat_as: error
- code: 107
treat_as: error
```
See the full list of [Borg 1.4 error and warning exit
codes](https://borgbackup.readthedocs.io/en/stable/internals/frontends.html#message-ids).
The `rc:` numeric value there tells you the exit code for each.
If you don't know the exit code for a particular Borg error or warning you're
experiencing, you can usually find it in your borgmatic output when
`--verbosity 2` is enabled. For instance, here's a snippet of that output when
a backup file is not found:
experiencing, you can usually find it in your borgmatic output when `--verbosity
2` is enabled. For instance, here's a snippet of that output when a backup file
is not found:
```
/noexist: stat: [Errno 2] No such file or directory: '/noexist'
@@ -84,3 +54,30 @@ terminating with warning status, rc 107
So if you want to configure borgmatic to treat this as an error instead of a
warning, the exit status to use is `107`.
<span class="minilink minilink-addedin">With Borg version 1.2 and earlier</span>
Older versions of Borg didn't support granular exit codes, but still
distinguished between Borg errors and warnings. For instance, to elevate Borg
warnings to errors, thereby causing borgmatic to error on them, use the
following borgmatic configuration with Borg 1.2 or below:
```yaml
borg_exit_codes:
- code: 1
treat_as: error
```
Be aware though that Borg exits with a warning code for a variety of benign
situations such as files changing while they're being read, so this example
may not meet your needs.
Here's another Borg 1.2 example that squashes Borg errors to warnings:
```yaml
borg_exit_codes:
- code: 2
treat_as: warning
```
Be careful with this example though, because it prevents borgmatic from
erroring when Borg errors, which may not be desirable.
+1 -1
View File
@@ -141,7 +141,7 @@ for more information.
<span class="minilink minilink-addedin">With Borg version 2.x</span>See the
[match archives
documentation](https://borgbackup.readthedocs.io/en/2.0.0b13/usage/help.html#borg-help-match-archives).
documentation](https://borgbackup.readthedocs.io/en/2.0.0b16/usage/help.html#borg-help-match-archives).
Some borgmatic command-line actions also have a `--match-archives` flag that
overrides both the auto-matching behavior and the `match_archives`
+1 -1
View File
@@ -193,7 +193,7 @@ confirmation of success—or tells you if something hasn't been transferred yet.
Note that by omitting the `--upgrader` flag, you can also do archive transfers
between related Borg 2 repositories without upgrading, even down to individual
archives. For more on that functionality, see the [Borg transfer
documentation](https://borgbackup.readthedocs.io/en/2.0.0b13/usage/transfer.html).
documentation](https://borgbackup.readthedocs.io/en/2.0.0b16/usage/transfer.html).
That's it! Now you can use your new Borg 2 repository as normal with
borgmatic. If you've got multiple repositories, repeat the above process for
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "borgmatic"
version = "2.0.4"
version = "2.0.6"
authors = [
{ name="Dan Helfman", email="witten@torsion.org" },
]
+91 -9
View File
@@ -126,6 +126,45 @@ def test_schema_to_sample_configuration_comments_out_non_source_config_options_i
assert 'field3' not in config[0].ca.items
def test_schema_to_sample_configuration_comments_out_non_source_config_options_in_sequence_of_maps_with_different_subschemas():
schema = {
'type': 'array',
'items': {
'type': 'object',
'oneOf': [
{
'properties': dict(
[
('field1', {'type': 'string', 'example': 'Example 1'}),
('field2', {'type': 'string', 'example': 'Example 2'}),
]
)
},
{
'properties': dict(
[
('field2', {'type': 'string', 'example': 'Example 2'}),
('field3', {'type': 'string', 'example': 'Example 3'}),
]
)
},
],
},
}
source_config = [{'field1': 'value'}, {'field3': 'value'}]
config = module.schema_to_sample_configuration(schema, source_config)
assert config == [
dict([('field1', 'Example 1'), ('field2', 'Example 2'), ('field3', 'Example 3')])
]
# The first field in a sequence does not get commented.
assert 'field1' not in config[0].ca.items
assert 'COMMENT_OUT' in config[0].ca.items['field2'][1][-1]._value
assert 'COMMENT_OUT' in config[0].ca.items['field3'][1][-1]._value
def test_comment_out_line_skips_blank_line():
line = ' \n'
@@ -169,8 +208,11 @@ foo:
- quux
repositories:
- one
- two
- path: foo
# COMMENT_OUT
label: bar
- path: baz
label: quux
# This comment should be kept.
# COMMENT_OUT
@@ -185,8 +227,10 @@ other: thing
# - quux
repositories:
- one
- two
- path: foo
# label: bar
- path: baz
label: quux
# This comment should be kept.
# other: thing
@@ -297,14 +341,28 @@ def test_add_comments_to_configuration_comments_out_non_source_config_options():
assert 'baz' not in config.ca.items
def test_add_comments_to_configuration_object_with_skip_first_does_not_comment_out_first_option():
def test_add_comments_to_configuration_object_with_skip_first_field_does_not_comment_out_first_option():
config = module.ruamel.yaml.comments.CommentedMap([('foo', 33), ('bar', 44), ('baz', 55)])
schema = {
'type': 'object',
'properties': {'foo': {'description': 'Foo'}, 'bar': {'description': 'Bar'}},
}
module.add_comments_to_configuration_object(config, schema, skip_first=True)
module.add_comments_to_configuration_object(config, schema, skip_first_field=True)
assert 'foo' not in config.ca.items
assert 'COMMENT_OUT' in config.ca.items['bar'][1][-1]._value
assert 'baz' not in config.ca.items
def test_add_comments_to_configuration_object_with_skip_first_field_does_not_comment_out_first_option():
config = module.ruamel.yaml.comments.CommentedMap([('foo', 33), ('bar', 44), ('baz', 55)])
schema = {
'type': 'object',
'properties': {'foo': {'description': 'Foo'}, 'bar': {'description': 'Bar'}},
}
module.add_comments_to_configuration_object(config, schema, skip_first_field=True)
assert 'foo' not in config.ca.items
assert 'COMMENT_OUT' in config.ca.items['bar'][1][-1]._value
@@ -326,15 +384,39 @@ def test_generate_sample_configuration_does_not_raise():
module.generate_sample_configuration(False, None, 'dest.yaml', 'schema.yaml')
def test_generate_sample_configuration_with_source_filename_does_not_raise():
def test_generate_sample_configuration_with_source_filename_omits_empty_bootstrap_field():
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.ruamel.yaml).should_receive('YAML').and_return(
flexmock(load=lambda filename: {})
)
flexmock(module.load).should_receive('load_configuration')
flexmock(module.load).should_receive('load_configuration').and_return(
{'bootstrap': {}, 'foo': 'bar'}
)
flexmock(module.normalize).should_receive('normalize')
flexmock(module).should_receive('schema_to_sample_configuration')
flexmock(module).should_receive('schema_to_sample_configuration').with_args(
object, {'foo': 'bar'}
).once()
flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('render_configuration')
flexmock(module).should_receive('comment_out_optional_configuration')
flexmock(module).should_receive('write_configuration')
module.generate_sample_configuration(False, 'source.yaml', 'dest.yaml', 'schema.yaml')
def test_generate_sample_configuration_with_source_filename_keeps_non_empty_bootstrap_field():
builtins = flexmock(sys.modules['builtins'])
builtins.should_receive('open').with_args('schema.yaml').and_return('')
flexmock(module.ruamel.yaml).should_receive('YAML').and_return(
flexmock(load=lambda filename: {})
)
source_config = {'bootstrap': {'stuff': 'here'}, 'foo': 'bar'}
flexmock(module.load).should_receive('load_configuration').and_return(source_config)
flexmock(module.normalize).should_receive('normalize')
flexmock(module).should_receive('schema_to_sample_configuration').with_args(
object, source_config
).once()
flexmock(module).should_receive('merge_source_configuration_into_destination')
flexmock(module).should_receive('render_configuration')
flexmock(module).should_receive('comment_out_optional_configuration')
+51 -2
View File
@@ -601,6 +601,53 @@ def test_collect_spot_check_source_paths_parses_borg_output():
) == ('/etc/path', '/etc/other')
def test_collect_spot_check_source_paths_omits_progress_from_create_dry_run_command():
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': False}
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
flexmock()
)
flexmock(module.borgmatic.actions.pattern).should_receive('collect_patterns').and_return(
flexmock()
)
flexmock(module.borgmatic.actions.pattern).should_receive('process_patterns').and_return(
[Pattern('foo'), Pattern('bar')]
)
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
dry_run=True,
repository_path='repo',
config={'working_directory': '/', 'list_details': True},
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version=object,
global_arguments=object,
borgmatic_runtime_directory='/run/borgmatic',
local_path=object,
remote_path=object,
stream_processes=False,
).and_return((('borg', 'create'), ('repo::archive',), flexmock()))
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
flexmock()
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).and_return(
'warning: stuff\n- /etc/path\n+ /etc/other\n? /nope',
)
flexmock(module.os.path).should_receive('isfile').and_return(True)
assert module.collect_spot_check_source_paths(
repository={'path': 'repo'},
config={'working_directory': '/', 'progress': True},
local_borg_version=flexmock(),
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
) == ('/etc/path', '/etc/other')
def test_collect_spot_check_source_paths_passes_through_stream_processes_false():
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': False}
@@ -1024,7 +1071,9 @@ def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
flexmock(module.os.path).should_receive('islink').and_return(False)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output'
).with_args(('/usr/local/bin/xxh64sum', '/foo', '/bar'), working_directory=None).and_return(
).with_args(
('/usr/local/bin/xxhsum', '-H64', '/foo', '/bar'), working_directory=None
).and_return(
'hash1 /foo\nhash2 /bar'
)
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
@@ -1039,7 +1088,7 @@ def test_compare_spot_check_hashes_uses_xxh64sum_command_option():
{
'name': 'spot',
'data_sample_percentage': 50,
'xxh64sum_command': '/usr/local/bin/xxh64sum',
'xxh64sum_command': '/usr/local/bin/xxhsum -H64',
},
]
},
+26 -1
View File
@@ -68,8 +68,15 @@ def test_compact_segments_with_log_debug_calls_borg_with_debug_flag():
)
def test_compact_segments_with_dry_run_skips_borg_call():
def test_compact_segments_with_dry_run_skips_borg_call_when_feature_unavailable():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.feature).should_receive('available').with_args(
module.feature.Feature.DRY_RUN_COMPACT, '1.2.3'
).and_return(False)
flexmock(module.environment).should_receive('make_environment').never()
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').never()
flexmock(module).should_receive('execute_command').never()
flexmock(logging).should_receive('info').with_args('Skipping compact (dry run)').once()
module.compact_segments(
repository_path='repo',
@@ -80,6 +87,24 @@ def test_compact_segments_with_dry_run_skips_borg_call():
)
def test_compact_segments_with_dry_run_executes_borg_call_when_feature_available():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
flexmock(module.feature).should_receive('available').with_args(
module.feature.Feature.DRY_RUN_COMPACT, '1.4.1'
).and_return(True)
flexmock(module.environment).should_receive('make_environment').once()
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').once()
flexmock(module).should_receive('execute_command').once()
module.compact_segments(
repository_path='repo',
config={},
local_borg_version='1.4.1',
global_arguments=flexmock(),
dry_run=True,
)
def test_compact_segments_with_local_path_calls_borg_via_local_path():
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
insert_execute_command_mock(('borg1',) + COMPACT_COMMAND[1:] + ('repo',), logging.INFO)
+17
View File
@@ -92,6 +92,7 @@ def test_make_environment_without_configuration_sets_certain_environment_variabl
'BORG_EXIT_CODES': 'modern',
'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'no',
'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'no',
'BORG_USE_CHUNKS_ARCHIVE': 'no',
}
@@ -101,6 +102,7 @@ def test_make_environment_without_configuration_passes_through_default_environme
'USER': 'root',
'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'yup',
'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'nah',
'BORG_USE_CHUNKS_ARCHIVE': 'yup',
}
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
@@ -113,6 +115,7 @@ def test_make_environment_without_configuration_passes_through_default_environme
'USER': 'root',
'BORG_RELOCATED_REPO_ACCESS_IS_OK': 'yup',
'BORG_UNKNOWN_UNENCRYPTED_REPO_ACCESS_IS_OK': 'nah',
'BORG_USE_CHUNKS_ARCHIVE': 'yup',
'BORG_EXIT_CODES': 'modern',
}
@@ -170,3 +173,17 @@ def test_make_environment_with_integer_variable_value():
environment = module.make_environment({'borg_files_cache_ttl': 40})
assert environment.get('BORG_FILES_CACHE_TTL') == '40'
def test_make_environment_with_use_chunks_archive_should_set_correct_environment_value():
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).and_return(None)
flexmock(module.os).should_receive('pipe').never()
environment = module.make_environment({'use_chunks_archive': True})
assert environment.get('BORG_USE_CHUNKS_ARCHIVE') == 'yes'
environment = module.make_environment({'use_chunks_archive': False})
assert environment.get('BORG_USE_CHUNKS_ARCHIVE') == 'no'
+22
View File
@@ -43,6 +43,28 @@ def test_make_prune_flags_returns_flags_from_config():
assert result == BASE_PRUNE_FLAGS
def test_make_prune_flags_with_keep_13weekly_and_keep_3monthly():
config = {
'keep_13weekly': 4,
'keep_3monthly': 5,
}
flexmock(module.feature).should_receive('available').and_return(True)
flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
result = module.make_prune_flags(
config, flexmock(match_archives=None), local_borg_version='1.2.3'
)
expected = (
'--keep-13weekly',
'4',
'--keep-3monthly',
'5',
)
assert result == expected
def test_make_prune_flags_accepts_prefix_with_placeholders():
config = {
'keep_daily': 1,
+10 -2
View File
@@ -28,6 +28,7 @@ def test_get_skip_actions_uses_config_and_arguments(config, arguments, expected_
def test_monitoring_hooks_with_monioring_disabled_bails():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(module.DISABLED)
flexmock(module.dispatch).should_receive('call_hooks').never()
@@ -41,6 +42,7 @@ def test_monitoring_hooks_with_monioring_disabled_bails():
def test_monitoring_hooks_with_non_primary_action_bails():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').never()
@@ -54,6 +56,7 @@ def test_monitoring_hooks_with_non_primary_action_bails():
def test_monitoring_hooks_pings_monitors():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -117,6 +120,7 @@ def test_monitoring_hooks_pings_monitors():
def test_monitoring_hooks_with_start_ping_error_raises():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -172,6 +176,7 @@ def test_monitoring_hooks_with_start_ping_error_raises():
def test_monitoring_hooks_with_log_ping_error_raises():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -227,6 +232,7 @@ def test_monitoring_hooks_with_log_ping_error_raises():
def test_monitoring_hooks_with_finish_ping_error_raises():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -281,7 +287,8 @@ def test_monitoring_hooks_with_finish_ping_error_raises():
pass
def test_monitoring_with_wrapped_code_error_pings_fail():
def test_monitoring_hooks_with_wrapped_code_error_pings_fail():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
@@ -345,7 +352,8 @@ def test_monitoring_with_wrapped_code_error_pings_fail():
raise OSError()
def test_monitoring_with_fail_ping_error_raise_original_error():
def test_monitoring_hooks_with_fail_ping_error_raise_original_error():
flexmock(module).should_receive('get_verbosity').and_return(module.logging.INFO)
flexmock(module).should_receive('verbosity_to_log_level').and_return(flexmock())
flexmock(module.dispatch).should_receive('call_hooks').with_args(
'initialize_monitor',
+10
View File
@@ -334,6 +334,16 @@ def test_normalize_commands_moves_individual_command_hooks_to_unified_commands(
{'repositories': [{'path': 'rclone:host:repo'}]},
True,
),
(
{'repositories': ['s3:stuff']},
{'repositories': [{'path': 's3:stuff'}]},
True,
),
(
{'repositories': ['b2:stuff']},
{'repositories': [{'path': 'b2:stuff'}]},
True,
),
(
{'repositories': ['file:///repo']},
{'repositories': [{'path': '/repo'}]},
+3 -10
View File
@@ -214,6 +214,7 @@ def test_snapshot_logical_volume_with_percentage_snapshot_name_uses_lvcreate_ext
'/dev/snap',
),
output_log_level=object,
close_fds=True,
)
module.snapshot_logical_volume('lvcreate', 'snap', '/dev/snap', '10%ORIGIN')
@@ -233,6 +234,7 @@ def test_snapshot_logical_volume_with_non_percentage_snapshot_name_uses_lvcreate
'/dev/snap',
),
output_log_level=object,
close_fds=True,
)
module.snapshot_logical_volume('lvcreate', 'snap', '/dev/snap', '10TB')
@@ -554,16 +556,7 @@ def test_dump_data_sources_with_dry_run_skips_snapshots_and_does_not_touch_patte
)
flexmock(module.os).should_receive('getpid').and_return(1234)
flexmock(module).should_receive('snapshot_logical_volume').never()
flexmock(module).should_receive('get_snapshots').with_args(
'lvs', snapshot_name='lvolume1_borgmatic-1234'
).and_return(
(module.Snapshot(name='lvolume1_borgmatic-1234', device_path='/dev/lvolume1_snap'),)
)
flexmock(module).should_receive('get_snapshots').with_args(
'lvs', snapshot_name='lvolume2_borgmatic-1234'
).and_return(
(module.Snapshot(name='lvolume2_borgmatic-1234', device_path='/dev/lvolume2_snap'),)
)
flexmock(module).should_receive('get_snapshots').never()
flexmock(module).should_receive('mount_snapshot').never()
assert (
+233 -54
View File
@@ -167,6 +167,38 @@ def test_database_names_to_dump_queries_mariadb_for_database_names():
assert names == ('foo', 'bar')
def test_database_names_to_dump_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
environment = flexmock()
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).should_receive('make_defaults_file_options').never()
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
(
'mariadb',
'--user',
'root',
'--skip-column-names',
'--batch',
'--execute',
'show schemas',
),
environment=environment,
).and_return('foo\nbar\nmysql\n').once()
names = module.database_names_to_dump(
{'name': 'all', 'password_transport': 'environment'},
{},
'root',
'trustsome1',
environment,
dry_run=False,
)
assert names == ('foo', 'bar')
def test_database_names_to_dump_runs_mariadb_with_tls():
environment = flexmock()
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
@@ -247,9 +279,22 @@ def test_dump_data_sources_dumps_each_database():
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module).should_receive('database_names_to_dump').with_args(
database=databases[0],
config={},
username=None,
password=None,
environment={'USER': 'root'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('database_names_to_dump').with_args(
database=databases[1],
config={},
username=None,
password=None,
environment={'USER': 'root'},
dry_run=False,
).and_return(('bar',))
for name, process in zip(('foo', 'bar'), processes):
flexmock(module).should_receive('execute_dump_command').with_args(
@@ -285,9 +330,14 @@ def test_dump_data_sources_dumps_with_password():
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module).should_receive('database_names_to_dump').with_args(
database=database,
config={},
username='root',
password='trustsome1',
environment={'USER': 'root'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('execute_dump_command').with_args(
database=database,
@@ -311,6 +361,50 @@ def test_dump_data_sources_dumps_with_password():
) == [process]
def test_dump_data_sources_dumps_with_environment_password_transport_passes_password_environment_variable():
database = {
'name': 'foo',
'username': 'root',
'password': 'trustsome1',
'password_transport': 'environment',
}
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module).should_receive('database_names_to_dump').with_args(
database=database,
config={},
username='root',
password='trustsome1',
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('execute_dump_command').with_args(
database=database,
config={},
username='root',
password='trustsome1',
dump_path=object,
database_names=('foo',),
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
dry_run=object,
dry_run_label=object,
).and_return(process).once()
assert module.dump_data_sources(
[database],
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
) == [process]
def test_dump_data_sources_dumps_all_databases_at_once():
databases = [{'name': 'all'}]
process = flexmock()
@@ -378,6 +472,54 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
)
def test_dump_data_sources_errors_for_missing_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
with pytest.raises(ValueError):
assert module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
)
def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
assert (
module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=True,
)
== []
)
def test_database_names_to_dump_runs_mariadb_with_list_options():
database = {'name': 'all', 'list_options': '--defaults-extra-file=mariadb.cnf --skip-ssl'}
flexmock(module).should_receive('parse_extra_options').and_return(
@@ -479,6 +621,48 @@ def test_execute_dump_command_runs_mariadb_dump():
)
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')
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).should_receive('make_defaults_file_options').never()
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
(
'mariadb-dump',
'--add-drop-database',
'--user',
'root',
'--databases',
'foo',
'--result-file',
'dump',
),
environment=None,
run_to_completion=False,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'foo', 'password_transport': 'environment'},
config={},
username='root',
password='trustsome1',
dump_path=flexmock(),
database_names=('foo',),
environment=None,
dry_run=False,
dry_run_label='',
)
== process
)
def test_execute_dump_command_runs_mariadb_dump_without_add_drop_database():
process = flexmock()
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
@@ -849,54 +1033,6 @@ def test_execute_dump_command_with_dry_run_skips_mariadb_dump():
)
def test_dump_data_sources_errors_for_missing_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
with pytest.raises(ValueError):
assert module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
)
def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
assert (
module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=True,
)
== []
)
def test_restore_data_source_dump_runs_mariadb_to_restore():
hook_config = [{'name': 'foo'}, {'name': 'bar'}]
extract_process = flexmock(stdout=flexmock())
@@ -1168,6 +1304,49 @@ def test_restore_data_source_dump_runs_mariadb_with_username_and_password():
)
def test_restore_data_source_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
hook_config = [
{
'name': 'foo',
'username': 'root',
'password': 'trustsome1',
'password_transport': 'environment',
}
]
extract_process = flexmock(stdout=flexmock())
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).should_receive('make_defaults_file_options').never()
flexmock(module.os).should_receive('environ').and_return(
{'USER': 'root', 'MYSQL_PWD': 'trustsome1'}
)
flexmock(module).should_receive('execute_command_with_processes').with_args(
('mariadb', '--batch', '--user', 'root'),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
).once()
module.restore_data_source_dump(
hook_config,
{},
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
borgmatic_runtime_directory='/run/borgmatic',
)
def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore():
hook_config = [
{
+245 -54
View File
@@ -60,6 +60,42 @@ def test_database_names_to_dump_queries_mysql_for_database_names():
assert names == ('foo', 'bar')
def test_database_names_to_dump_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
environment = flexmock()
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'parse_extra_options'
).and_return((), None)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'make_defaults_file_options'
).never()
flexmock(module).should_receive('execute_command_and_capture_output').with_args(
(
'mysql',
'--user',
'root',
'--skip-column-names',
'--batch',
'--execute',
'show schemas',
),
environment=environment,
).and_return('foo\nbar\nmysql\n').once()
names = module.database_names_to_dump(
{'name': 'all', 'password_transport': 'environment'},
{},
'root',
'trustsome1',
environment,
dry_run=False,
)
assert names == ('foo', 'bar')
def test_database_names_to_dump_runs_mysql_with_tls():
environment = flexmock()
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
@@ -141,9 +177,22 @@ def test_dump_data_sources_dumps_each_database():
'resolve_credential'
).and_return(None)
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module).should_receive('database_names_to_dump').with_args(
database=databases[0],
config={},
username=None,
password=None,
environment={'USER': 'root'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('database_names_to_dump').with_args(
database=databases[1],
config={},
username=None,
password=None,
environment={'USER': 'root'},
dry_run=False,
).and_return(('bar',))
for name, process in zip(('foo', 'bar'), processes):
flexmock(module).should_receive('execute_dump_command').with_args(
@@ -179,9 +228,14 @@ def test_dump_data_sources_dumps_with_password():
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
('bar',)
)
flexmock(module).should_receive('database_names_to_dump').with_args(
database=database,
config={},
username='root',
password='trustsome1',
environment={'USER': 'root'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('execute_dump_command').with_args(
database=database,
@@ -205,6 +259,50 @@ def test_dump_data_sources_dumps_with_password():
) == [process]
def test_dump_data_sources_dumps_with_environment_password_transport_passes_password_environment_variable():
database = {
'name': 'foo',
'username': 'root',
'password': 'trustsome1',
'password_transport': 'environment',
}
process = flexmock()
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module).should_receive('database_names_to_dump').with_args(
database=database,
config={},
username='root',
password='trustsome1',
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
dry_run=False,
).and_return(('foo',))
flexmock(module).should_receive('execute_dump_command').with_args(
database=database,
config={},
username='root',
password='trustsome1',
dump_path=object,
database_names=('foo',),
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
dry_run=object,
dry_run_label=object,
).and_return(process).once()
assert module.dump_data_sources(
[database],
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
) == [process]
def test_dump_data_sources_dumps_all_databases_at_once():
databases = [{'name': 'all'}]
process = flexmock()
@@ -272,6 +370,54 @@ def test_dump_data_sources_dumps_all_databases_separately_when_format_configured
)
def test_dump_data_sources_errors_for_missing_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
with pytest.raises(ValueError):
assert module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
)
def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
assert (
module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=True,
)
== []
)
def test_database_names_to_dump_runs_mysql_with_list_options():
database = {'name': 'all', 'list_options': '--defaults-extra-file=my.cnf --skip-ssl'}
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
@@ -375,6 +521,52 @@ def test_execute_dump_command_runs_mysqldump():
)
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')
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.borgmatic.hooks.data_source.mariadb).should_receive(
'parse_extra_options'
).and_return((), None)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'make_defaults_file_options'
).never()
flexmock(module.dump).should_receive('create_named_pipe_for_dump')
flexmock(module).should_receive('execute_command').with_args(
(
'mysqldump',
'--add-drop-database',
'--user',
'root',
'--databases',
'foo',
'--result-file',
'dump',
),
environment=None,
run_to_completion=False,
).and_return(process).once()
assert (
module.execute_dump_command(
database={'name': 'foo', 'password_transport': 'environment'},
config={},
username='root',
password='trustsome1',
dump_path=flexmock(),
database_names=('foo',),
environment=None,
dry_run=False,
dry_run_label='',
)
== process
)
def test_execute_dump_command_runs_mysqldump_without_add_drop_database():
process = flexmock()
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return('dump')
@@ -761,54 +953,6 @@ def test_execute_dump_command_with_dry_run_skips_mysqldump():
)
def test_dump_data_sources_errors_for_missing_all_databases():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
with pytest.raises(ValueError):
assert module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=False,
)
def test_dump_data_sources_does_not_error_for_missing_all_databases_with_dry_run():
databases = [{'name': 'all'}]
flexmock(module).should_receive('make_dump_path').and_return('')
flexmock(module.os).should_receive('environ').and_return({'USER': 'root'})
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.dump).should_receive('make_data_source_dump_filename').and_return(
'databases/localhost/all'
)
flexmock(module).should_receive('database_names_to_dump').and_return(())
assert (
module.dump_data_sources(
databases,
{},
config_paths=('test.yaml',),
borgmatic_runtime_directory='/run/borgmatic',
patterns=[],
dry_run=True,
)
== []
)
def test_restore_data_source_dump_runs_mysql_to_restore():
hook_config = [{'name': 'foo'}, {'name': 'bar'}]
extract_process = flexmock(stdout=flexmock())
@@ -1092,6 +1236,53 @@ def test_restore_data_source_dump_runs_mysql_with_username_and_password():
)
def test_restore_data_source_with_environment_password_transport_skips_defaults_file_and_passes_user_flag():
hook_config = [
{
'name': 'foo',
'username': 'root',
'password': 'trustsome1',
'password_transport': 'environment',
}
]
extract_process = flexmock(stdout=flexmock())
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential'
).replace_with(lambda value, config: value)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'parse_extra_options'
).and_return((), None)
flexmock(module.borgmatic.hooks.data_source.mariadb).should_receive(
'make_defaults_file_options'
).never()
flexmock(module.os).should_receive('environ').and_return(
{'USER': 'root', 'MYSQL_PWD': 'trustsome1'}
)
flexmock(module).should_receive('execute_command_with_processes').with_args(
('mysql', '--batch', '--user', 'root'),
processes=[extract_process],
output_log_level=logging.DEBUG,
input_file=extract_process.stdout,
environment={'USER': 'root', 'MYSQL_PWD': 'trustsome1'},
).once()
module.restore_data_source_dump(
hook_config,
{},
data_source=hook_config[0],
dry_run=False,
extract_process=extract_process,
connection_params={
'hostname': None,
'port': None,
'username': None,
'password': None,
},
borgmatic_runtime_directory='/run/borgmatic',
)
def test_restore_data_source_dump_with_connection_params_uses_connection_params_for_restore():
hook_config = [
{