Compare commits

...
34 Commits
Author SHA1 Message Date
Dan Helfman ad8d074eff Bump version for release. 2026-02-06 12:53:27 -08:00
Dan Helfman fc7439af3a If a source file is deleted during a "spot" check, consider the file as non-matching and move on instead of immediately failing the entire (#1231). 2026-02-05 23:11:59 -08:00
Dan Helfman ea05a4660c Deduplicate overlapping source directories and patterns so they don't throw off "spot" check file counts and cause spurious check failures. 2026-02-05 12:50:39 -08:00
Dan Helfman 957f6be4a2 Switch to iterable unpacking (#1252). 2026-02-05 10:38:07 -08:00
Dan Helfman 730a4b2f18 Work around Borg returning a warning exit code when a repository/archive check fails (#1252). 2026-02-05 10:19:06 -08:00
Dan Helfman c64c79ad0e Fix for SSH warnings from Borg showing up as JSON logs even without the "--log-json" flag (#1260). 2026-02-03 10:12:15 -08:00
Dan Helfman acd1a8d1dd Update ntfy test to use @pytest.mark.parametrize (#1251) 2026-02-02 11:58:50 -08:00
Dan Helfman c66fde4a93 Fix ntfy monitoring hook to send tags as an array (#1251).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1259
Reviewed-by: Dan Helfman <witten@torsion.org>
2026-02-02 19:43:50 +00:00
Mashrafi Rahman dbe3891819 Merge branch 'main' into fix-ntfy-tags-schema-array 2026-02-02 13:16:27 +00:00
Mashrafi Rahman dc89c9ec73 Update and test the convert_string_to_array function. 2026-02-02 18:26:38 +08:00
Dan Helfman a27dc95c87 Fix a "codec can't decode byte" error when running commands that output multi-byte unicode characters (#1258). 2026-02-01 20:15:03 -08:00
Mashrafi Rahman 6b5390f5dd Send tags as array in ntfy monitoring hook. 2026-02-01 15:47:13 +08:00
Dan Helfman fd485e64a3 Fix incorrect deprecated hook option name in documentation. 2026-01-30 22:43:54 -08:00
Dan Helfman f5de6bf43c Clarify command hooks documentation about YAML sequences (#1255). 2026-01-30 22:37:12 -08:00
Dan Helfman aa25dc7b31 Fix a regression in which the "--stats" flag hides statistics at default verbosity (#1250). 2026-01-28 11:49:24 -08:00
Dan Helfman aba45f03d6 Bump version for release. 2026-01-27 13:09:10 -08:00
Dan Helfman f6124528df When the "unsafe_skip_path_validation_before_create" option is enabled, don't log a warning about it (#1244). 2026-01-25 12:17:35 -08:00
Dan Helfman b67dcf829e Fix a regression in which the ntfy monitoring hook failed to send a ping when the "priority" option was set (#1246). 2026-01-24 16:35:55 -08:00
Dan Helfman 71e25756f2 Fix a regression in which the KeePassXC credential hook password prompt was invisible (#1245). 2026-01-24 16:17:32 -08:00
Dan Helfman ff2f9fd5ee Add an additional test and "fix" code coverage (#1242). 2026-01-24 12:28:33 -08:00
Dan Helfman ca4447ffab Fix "spot" check hang (#1242).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1247
2026-01-24 20:20:08 +00:00
Dan Helfman 104fe35e39 Add another test to get some additional coverage that's timing dependent (#1242). 2026-01-23 23:04:15 -08:00
Dan Helfman 248fa1db64 Add automated tests for new code (#1242). 2026-01-23 22:50:37 -08:00
Dan Helfman 97f7c65f6c More refactoring and test fixes (#1242). 2026-01-22 23:01:34 -08:00
Dan Helfman 765eba5315 Get existing unit/integration tests passing (#1242). 2026-01-22 17:29:30 -08:00
Dan Helfman bd051beced Structural refactor just to get code out of log_outputs() and into separate utility functions (#1242). 2026-01-22 14:02:48 -08:00
Dan Helfman d5cd4efecd Fix spot check hang (#1242). 2026-01-22 10:27:47 -08:00
Dan Helfman 13fd225a0b Abolish ICE. 2026-01-21 19:29:22 -08:00
Dan Helfman 87c5863218 Some cleanup and also fix delayed logs (#1242). 2026-01-19 22:22:15 -08:00
Dan Helfman 677871aa89 Initial stab at fixing spot check hang by actually draining and consuming buffers after a process exits (#1242). 2026-01-19 16:26:22 -08:00
Dan Helfman d2390581e7 Fix implicit string concatenation instead of trying to paper over it (incidental work included in #1241). 2026-01-18 20:56:29 -08:00
Dan Helfman efd0f0d618 For the "recreate" action, actually pass the "--dry-run" flag through to Borg instead of just skipping the Borg call (#1241). 2026-01-18 18:39:29 -08:00
Dan Helfman 4ff7dccab4 Expand on archive argument to recreate (#1239).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1239
2026-01-17 03:15:21 +00:00
Jason Lingohr 76537f6c11 Expand on archive argument
Make a small but specific help expansion on the `archive` option.
2026-01-17 02:45:15 +00:00
25 changed files with 1757 additions and 310 deletions
+24
View File
@@ -1,3 +1,27 @@
2.1.2
* #1231: If a source file is deleted during a "spot" check, consider the file as non-matching
and move on instead of immediately failing the entire check.
* #1250: Fix a regression in which the "--stats" flag hides statistics at default verbosity.
* #1251: Fix a regression in the ntfy monitoring hook in which borgmatic sends tags incorrectly,
resulting in "400 Bad Request" from ntfy.
* #1258: Fix a "codec can't decode byte" error when running commands that output multi-byte unicode
characters.
* #1260: Fix for SSH warnings from Borg showing up as JSON logs even without the "--log-json" flag.
* #1252: Work around Borg returning a warning exit code when a repository/archive check fails. Now,
borgmatic interprets such failures as errors.
* Deduplicate overlapping source directories and patterns so they don't throw off "spot" check file
counts and cause spurious check failures.
2.1.1
* #1241: For the "recreate" action, actually pass the "--dry-run" flag through to Borg instead of
just skipping the Borg call.
* #1242: Fix a regression in which the "spot" check hung while collecting archive contents.
* #1244: When the "unsafe_skip_path_validation_before_create" option is enabled, don't log a
warning about it.
* #1245: Fix a regression in which the KeePassXC credential hook password prompt was invisible.
* #1246: Fix a regression in which the ntfy monitoring hook failed to send a ping when the
"priority" option was set.
2.1.0
* TL;DR: Many logging, memory, and performance improvements. Mind those breaking changes!
* #485: When running commands (database clients, command hooks, etc.), elevate stderr output to
+69 -33
View File
@@ -9,6 +9,7 @@ import pathlib
import random
import shlex
import shutil
import subprocess
import textwrap
import borgmatic.actions.config.bootstrap
@@ -418,7 +419,13 @@ def collect_spot_check_source_paths(
)
return tuple(
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
# Use dict.fromkeys() to deduplicate file paths, which are present in Borg's dry run output
# when there are overlapping source patterns. For instance, if both "/foo" and
# "/foo/file.txt" are in configured patterns, then "/foo/file.txt" will show up in Borg's
# dry run output twice.
dict.fromkeys(
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
)
)
@@ -524,41 +531,70 @@ def compare_spot_check_hashes(
hash_paths = tuple(
path for path in source_sample_paths_subset if path in hashable_source_sample_path
)
hash_lines = borgmatic.execute.execute_command_and_capture_output(
tuple(
shlex.quote(part)
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
)
+ hash_paths,
working_directory=working_directory,
)
source_hashes.update(
**dict(
zip(
# xxh64sum rewrites/escapes the paths that it returns alongside its hashes, for
# instance if they contain special characters. When that happens, they don't
# match the original source paths and therefore hash lookups fail. So when
# building this lookup dict, use the original unaltered paths we provided as
# input to xxh64sum.
hash_paths,
(
# For some reason, xxh64sum prefixes the hash with a backslash if the path
# contains a newline. Work around that.
line.split(' ', 1)[0].lstrip('\\')
for line in hash_lines
try:
hash_lines = borgmatic.execute.execute_command_and_capture_output(
tuple(
shlex.quote(part)
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
)
+ hash_paths,
working_directory=working_directory,
)
source_hashes.update(
**dict(
zip(
# xxh64sum rewrites/escapes the paths that it returns alongside its hashes, for
# instance if they contain special characters. When that happens, they don't
# match the original source paths and therefore hash lookups fail. So when
# building this lookup dict, use the original unaltered paths we provided as
# input to xxh64sum.
hash_paths,
(
# For some reason, xxh64sum prefixes the hash with a backslash if the path
# contains a newline. Work around that.
line.split(' ', 1)[0].lstrip('\\')
for line in hash_lines
),
),
# Represent non-existent files as having empty hashes so the comparison below still
# works. Same thing for filesystem links, since Borg produces empty archive hashes
# for them.
**{
path: ''
for path in source_sample_paths_subset
if path not in hashable_source_sample_path
},
),
# Represent non-existent files as having empty hashes so the comparison below still
# works. Same thing for filesystem links, since Borg produces empty archive hashes
# for them.
**{
path: ''
for path in source_sample_paths_subset
if path not in hashable_source_sample_path
},
),
)
)
except subprocess.CalledProcessError:
# This can happen if a file we planned to hash gets deleted right before we try to hash
# it. Falling back to individual file hashing allows us to find and mark just the
# file(s) with problems instead of failing the whole batch.
logger.warning(
'Bulk source path hashing failed for this batch; falling back to individual file hashing'
)
for hash_path in hash_paths:
try:
hash_lines = borgmatic.execute.execute_command_and_capture_output(
(
*(
shlex.quote(part)
for part in shlex.split(
spot_check_config.get('xxh64sum_command', 'xxh64sum')
)
),
hash_path,
),
working_directory=working_directory,
)
source_hashes[hash_path] = next(hash_lines).split(' ', 1)[0].lstrip('\\')
except (subprocess.CalledProcessError, StopIteration): # noqa: PERF203
logger.warning(
f'Source path hashing failed for {hash_path}; treating as missing'
)
source_hashes[hash_path] = ''
# Get the hash for each file in the archive.
archive_hashes.update(
+3 -2
View File
@@ -18,6 +18,7 @@ def run_recreate(
local_borg_version,
recreate_arguments,
global_arguments,
dry_run_label,
local_path,
remote_path,
):
@@ -25,9 +26,9 @@ def run_recreate(
Run the "recreate" action for the given repository.
'''
if recreate_arguments.archive:
logger.answer(f'Recreating archive {recreate_arguments.archive}')
logger.answer(f'Recreating archive {recreate_arguments.archive}{dry_run_label}')
else:
logger.answer('Recreating repository')
logger.answer(f'Recreating repository{dry_run_label}')
# Collect and process patterns.
processed_patterns = borgmatic.actions.pattern.process_patterns(
+3 -1
View File
@@ -149,8 +149,10 @@ def check_archives(
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
# If not configured, elevate Borg's exit code 1 (an ostensible warning) to error, because Borg
# returns exit code 1 for repository check errors!
borg_exit_codes = [*config.get('borg_exit_codes', []), *[{'code': 1, 'treat_as': 'error'}]]
umask = config.get('umask')
borg_exit_codes = config.get('borg_exit_codes')
working_directory = borgmatic.config.paths.get_working_directory(config)
if 'data' in checks:
+1 -1
View File
@@ -270,7 +270,7 @@ def make_base_create_command( # noqa: PLR0912
working_directory = borgmatic.config.paths.get_working_directory(config)
if config.get('unsafe_skip_path_validation_before_create'):
logger.warning(
logger.debug(
'Skipping pre-backup path validation due to "unsafe_skip_path_validation_before_create" option.'
)
+1 -4
View File
@@ -72,6 +72,7 @@ def recreate_archive(
+ (('--chunker-params', chunker_params) if chunker_params else ())
+ (('--recompress', recompress) if recompress else ())
+ exclude_flags
+ (('--dry-run',) if global_arguments.dry_run else ())
+ (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ())
+ (
(
@@ -94,10 +95,6 @@ def recreate_archive(
)
)
if global_arguments.dry_run:
logger.info('Skipping the archive recreation (dry run)')
return
borgmatic.execute.execute_command(
full_command=recreate_command,
output_log_level=logging.INFO,
+1 -1
View File
@@ -1931,7 +1931,7 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915
)
recreate_group.add_argument(
'--archive',
help='Archive name, hash, or series to recreate',
help='Archive name, hash, or series to recreate, defaults to all archives in the repository (if specified), or all archives across all repositories',
)
recreate_group.add_argument(
'--list',
+1
View File
@@ -441,6 +441,7 @@ def run_actions( # noqa: PLR0912, PLR0915
local_borg_version,
action_arguments,
global_arguments,
dry_run_label,
local_path,
remote_path,
)
+1 -1
View File
@@ -39,7 +39,7 @@ def bash_completion():
'check_version() {',
' local this_script="$(cat "$BASH_SOURCE" 2> /dev/null)"',
' local installed_script="$(borgmatic --bash-completion 2> /dev/null)"',
' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];'
' if [ "$this_script" != "$installed_script" ] && [ "$installed_script" != "" ];',
f''' then cat << EOF\n{borgmatic.commands.completion.actions.upgrade_message(
'bash',
'sudo sh -c "borgmatic --bash-completion > $BASH_SOURCE"',
+21
View File
@@ -2342,6 +2342,13 @@ properties:
example: Your backups have started.
priority:
type: string
enum:
- max
- urgent
- high
- default
- low
- min
description: |
The priority to set.
example: min
@@ -2366,6 +2373,13 @@ properties:
example: Your backups have finished.
priority:
type: string
enum:
- max
- urgent
- high
- default
- low
- min
description: |
The priority to set.
example: min
@@ -2390,6 +2404,13 @@ properties:
example: Your backups have failed.
priority:
type: string
enum:
- max
- urgent
- high
- default
- low
- min
description: |
The priority to set.
example: max
+277 -126
View File
@@ -3,6 +3,7 @@ import contextlib
import enum
import json
import logging
import os
import select
import subprocess
import textwrap
@@ -28,6 +29,19 @@ class Exit_status(enum.Enum):
ERROR = 4
def command_is_borg(command, borg_local_path):
'''
Given a command as a sequence and the Borg local path, return whether that command is a call to
Borg.
'''
parsed_command = command.split(' ', 1) if isinstance(command, str) else command
if not parsed_command:
return False
return bool(borg_local_path and parsed_command[0] == borg_local_path)
def interpret_exit_code(command, exit_code, borg_local_path=None, borg_exit_codes=None): # noqa: PLR0911
'''
Return an Exit_status value (e.g. SUCCESS, ERROR, or WARNING) based on interpreting the given
@@ -41,7 +55,7 @@ def interpret_exit_code(command, exit_code, borg_local_path=None, borg_exit_code
if exit_code == 0:
return Exit_status.SUCCESS
if borg_local_path and command[0] == borg_local_path:
if command_is_borg(command, borg_local_path):
# First try looking for the exit code in the borg_exit_codes configuration.
for entry in borg_exit_codes or ():
if entry.get('code') == exit_code:
@@ -102,19 +116,41 @@ def output_buffers_for_process(process, exclude_stdouts):
)
BORG_LOG_LEVEL_ELEVATION_THRESHOLD = 10
def borg_json_log_line_to_record(line, log_level):
'''
Given a single Borg "--log-json"-style log line and a log level, return the line converted to a
logging.LogRecord instance. Return None if the line can't be parsed as JSON.
If Borg provides a log level in its JSON, prefer logging at that level. But if Borg doesn't
provide a log level—or the log level given to this function is just a little bit higher than
Borg's—elevate to that level. This supports use cases like elevating Borg's INFO level logs to
borgmatic's custom ANSWER level so that requested data shows up even at the default verbosity.
'''
with contextlib.suppress(json.JSONDecodeError, TypeError, KeyError, AttributeError):
log_data = json.loads(line)
log_type = log_data.get('type')
if log_type == 'log_message':
borg_log_level = logging._nameToLevel.get(log_data.get('levelname'))
log_level_delta = 0 if log_level is None else log_level - borg_log_level
if log_level_delta > 0 and log_level_delta < BORG_LOG_LEVEL_ELEVATION_THRESHOLD:
return logging.makeLogRecord(
dict(
levelno=log_level,
created=log_data.get('time'),
msg=log_data.get('message'),
levelname=logging.getLevelName(log_level),
name=log_data.get('name'),
)
)
return logging.makeLogRecord(
dict(
levelno=logging._nameToLevel.get(log_data.get('levelname')),
levelno=borg_log_level,
created=log_data.get('time'),
msg=log_data.get('message'),
levelname=log_data.get('levelname'),
@@ -163,7 +199,7 @@ def parse_log_line(line, log_level, elevate_stderr, borg_local_path, command):
came from stderr and the string "warning:" appears at the start of the log line. In that case,
just elevate the log level to a WARN.
'''
if borg_local_path and command[0] == borg_local_path:
if command_is_borg(command, borg_local_path):
log_record = borg_json_log_line_to_record(line, log_level)
if log_record:
@@ -177,18 +213,20 @@ def parse_log_line(line, log_level, elevate_stderr, borg_local_path, command):
return log_line_to_record(line, log_level)
def handle_log_record(log_record, last_lines):
def handle_log_record(log_record, last_lines=None):
'''
Given a log record to be logged and a rolling list of last lines, append the record's message to
the last lines. Then (if the log level is not None), log the record.
the last lines (if given). Then (if the log level is not None), log the record.
Return the log record.
'''
log_message = log_record.getMessage()
last_lines.append(log_message)
if len(last_lines) > ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.pop(0)
if last_lines is not None:
last_lines.append(log_message)
if len(last_lines) > ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.pop(0)
if log_record.levelno is not None:
logger.handle(log_record)
@@ -196,7 +234,207 @@ def handle_log_record(log_record, last_lines):
return log_record
def log_outputs( # noqa: PLR0912
READ_CHUNK_SIZE = 4096
def read_lines(buffer, process, line_separator='\n'):
'''
Given a Python buffer (like stdout) ready for reading, its process, and a line separator,
repeatedly yield a tuple of (decoded) lines from the buffer until the process has exited.
It is assumed that this function's generator is used in conjunction with an external select()
call to know when to read more lines. Otherwise, the generator will busywait if it's called in a
tight loop.
'''
data = b''
encoded_separator = line_separator.encode()
separator_size = len(encoded_separator)
while True:
chunk = os.read(buffer.fileno(), READ_CHUNK_SIZE)
if not chunk: # EOF
# The process is still running, so we keep running too.
if process.poll() is None: # pragma: no cover
continue
break
data += chunk
lines = []
# Split the data into lines, holding back anything leftover that might
# be a partial line.
while True:
separator_position = data.find(encoded_separator)
if separator_position == -1:
break
lines.append(data[:separator_position].decode())
data = data[separator_position + separator_size :]
yield tuple(lines)
# Yield any leftover data from the end of the buffer.
if data:
yield (data.decode().rstrip(),)
Buffer_reader = collections.namedtuple(
'Buffer_reader',
('lines', 'process'),
)
Process_metadata = collections.namedtuple(
'Process_metadata',
('last_lines', 'capture'),
)
def log_buffer_lines(
buffer_readers, process_metadatas, output_log_level, borg_local_path, capture_stderr=False
):
'''
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,
if the log level is None for any log record, then 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.
'''
if not buffer_readers:
return
(ready_buffers, _, _) = select.select(buffer_readers.keys(), [], [])
for ready_buffer in ready_buffers:
reader = buffer_readers[ready_buffer]
# The "ready" process has exited, but it might be a pipe destination with other
# processes (pipe sources) waiting to be read from. So as a measure to prevent
# hangs, vent all processes when one exits.
if reader.process and reader.process.poll() is not None:
for other_process in process_metadatas:
if (
other_process.poll() is None
and other_process.stdout
and other_process.stdout not in buffer_readers
):
# Add the process's output to buffer_readers to ensure it'll get read.
buffer_readers[other_process.stdout] = Buffer_reader(
read_lines(other_process.stdout, other_process), other_process
)
try:
lines = next(reader.lines)
except StopIteration:
continue
for line in lines:
if not line or not reader.process:
continue
# Keep the last few lines of output in case the process errors and we need the
# output for the exception below.
log_record = handle_log_record(
parse_log_line(
line=line,
log_level=output_log_level,
elevate_stderr=(ready_buffer == reader.process.stderr and not capture_stderr),
borg_local_path=borg_local_path,
command=reader.process.args,
),
last_lines=process_metadatas[reader.process].last_lines,
)
if log_record.levelno is None and process_metadatas[reader.process].capture:
yield log_record.getMessage()
def raise_for_process_errors(buffer_readers, process_metadatas, borg_local_path, borg_exit_codes):
'''
Given a dict from buffer object to Buffer_reader, a dict from subprocess.Popen() instance to
Process_metadata instance, Borg's local path, a sequence of exit code configuration dicts, check
the given processes for error or warning exit codes. If found, vent or kill any running
processes. In the case of an error exit code, raise. In the case of warning, return
Exit_status.WARNING. Otherwise, return None.
'''
result_status = None
for process in process_metadatas:
exit_code = process.poll() if buffer_readers else process.wait()
if exit_code is None:
continue
exit_status = interpret_exit_code(process.args, exit_code, borg_local_path, borg_exit_codes)
if exit_status not in {Exit_status.ERROR, Exit_status.WARNING}:
continue
# Something has gone wrong. So vent each process' output buffer to prevent it from
# hanging. And then kill the process.
for other_process in process_metadatas:
if other_process.poll() is None:
other_process.stdout.read(0)
other_process.kill()
if exit_status == Exit_status.WARNING:
result_status = Exit_status.WARNING
continue
last_lines = process_metadatas[process].last_lines
# If an error occurs, include its output in the raised exception so that we don't
# inadvertently hide error output.
if len(last_lines) >= ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.insert(0, '...')
raise subprocess.CalledProcessError(
exit_code,
command_for_process(process),
'\n'.join(last_lines),
)
return result_status
def log_remaining_buffer_lines(
buffer_readers, process_metadatas, output_log_level, borg_local_path, capture_stderr=False
):
'''
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, if the log level is None for any log record, then yield those log
messages for capture.
'''
for output_buffer, reader in buffer_readers.items():
if not reader.process:
continue
for lines in reader.lines:
for line in lines:
log_record = handle_log_record(
parse_log_line(
line=line.rstrip(),
log_level=output_log_level,
elevate_stderr=(
output_buffer == reader.process.stderr and not capture_stderr
),
borg_local_path=borg_local_path,
command=reader.process.args,
),
)
if log_record.levelno is None and process_metadatas[reader.process].capture:
yield log_record.getMessage()
def log_outputs(
processes,
exclude_stdouts,
output_log_level,
@@ -221,131 +459,42 @@ def log_outputs( # noqa: PLR0912
buffers. Also note that stdout for a process can be None if output is intentionally not
captured, in which case it won't be logged.
'''
# Map from output buffer to sequence of last lines.
process_last_lines = collections.defaultdict(list)
process_for_output_buffer = {
buffer: process
# Map from output buffer to Process_metadata instance. By convention, the last process is the
# process to capture.
process_metadatas = {
process: Process_metadata(last_lines=[], capture=bool(process == processes[-1]))
for process in processes
}
# Map from buffer to Buffer_reader instance.
buffer_readers = {
buffer: Buffer_reader(read_lines(buffer, process), process)
for process in processes
if process.stdout or process.stderr
for buffer in output_buffers_for_process(process, exclude_stdouts)
}
output_buffers = list(process_for_output_buffer.keys())
process_to_capture = processes[-1]
still_running = True
# Log output for each process until they all exit.
while True: # noqa: PLR1702
if output_buffers:
(ready_buffers, _, _) = select.select(output_buffers, [], [])
# Log output lines for each process until they all exit.
while True:
yield from log_buffer_lines(
buffer_readers, process_metadatas, output_log_level, borg_local_path, capture_stderr
)
for ready_buffer in ready_buffers:
ready_process = process_for_output_buffer.get(ready_buffer)
# The "ready" process has exited, but it might be a pipe destination with other
# processes (pipe sources) waiting to be read from. So as a measure to prevent
# hangs, vent all processes when one exits.
if ready_process and ready_process.poll() is not None:
for other_process in processes:
if (
other_process.poll() is None
and other_process.stdout
and other_process.stdout not in output_buffers
):
# Add the process's output to output_buffers to ensure it'll get read.
output_buffers.append(other_process.stdout)
while True:
line = ready_buffer.readline().rstrip().decode()
if not line or not ready_process:
break
command = (
ready_process.args.split(' ')
if isinstance(ready_process.args, str)
else ready_process.args
)
# Keep the last few lines of output in case the process errors and we need the
# output for the exception below.
log_record = handle_log_record(
parse_log_line(
line=line,
log_level=output_log_level,
elevate_stderr=(
ready_buffer == ready_process.stderr and not capture_stderr
),
borg_local_path=borg_local_path,
command=command,
),
last_lines=process_last_lines[ready_process],
)
if log_record.levelno is None and ready_process == process_to_capture:
yield log_record.getMessage()
if not still_running:
if (
raise_for_process_errors(
buffer_readers, process_metadatas, borg_local_path, borg_exit_codes
)
== Exit_status.WARNING
):
break
still_running = False
if all(process.poll() is not None for process in processes):
break
for process in processes:
exit_code = process.poll() if output_buffers else process.wait()
if exit_code is None:
still_running = True
command = process.args.split(' ') if isinstance(process.args, str) else process.args
continue
command = process.args.split(' ') if isinstance(process.args, str) else process.args
exit_status = interpret_exit_code(command, exit_code, borg_local_path, borg_exit_codes)
if exit_status in {Exit_status.ERROR, Exit_status.WARNING}:
last_lines = process_last_lines[process]
# If an error occurs, include its output in the raised exception so that we don't
# inadvertently hide error output.
for output_buffer in output_buffers_for_process(process, exclude_stdouts):
# Collect any straggling output lines that came in since we last gathered output.
while output_buffer: # pragma: no cover
line = output_buffer.readline().rstrip().decode()
if not line:
break
log_record = handle_log_record(
parse_log_line(
line=line,
log_level=output_log_level,
elevate_stderr=(
output_buffer == process.stderr and not capture_stderr
),
borg_local_path=borg_local_path,
command=command,
),
last_lines=last_lines,
)
if log_record.levelno is None and process == process_to_capture:
yield log_record.getMessage()
if len(last_lines) == ERROR_OUTPUT_MAX_LINE_COUNT:
last_lines.insert(0, '...')
# Something has gone wrong. So vent each process' output buffer to prevent it from
# hanging. And then kill the process.
for other_process in processes:
if other_process.poll() is None:
other_process.stdout.read(0)
other_process.kill()
if exit_status == Exit_status.ERROR:
raise subprocess.CalledProcessError(
exit_code,
command_for_process(process),
'\n'.join(last_lines),
)
still_running = False
break
# Now that all processes have exited, drain and consume any last output.
yield from log_remaining_buffer_lines(
buffer_readers, process_metadatas, output_log_level, borg_local_path, capture_stderr
)
SECRET_COMMAND_FLAG_NAMES = {'--password'}
@@ -496,7 +645,9 @@ def execute_command_and_capture_output(
command,
stdin=input_file,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stderr=subprocess.PIPE
if capture_stderr or command_is_borg(command, borg_local_path)
else None,
shell=shell,
env=environment,
cwd=working_directory,
+27 -4
View File
@@ -22,6 +22,29 @@ def initialize_monitor(
'''
def convert_string_to_array(value):
value = '' if value is None else str(value)
items = []
for item in value.split(','):
stripped = item.strip()
if stripped:
items.append(stripped)
return items
PRIORITY_NAME_TO_ID = {
'max': 5,
'urgent': 5,
'high': 4,
'default': 3,
'low': 2,
'min': 1,
}
def ping_monitor(hook_config, config, config_filename, state, monitoring_log_level, dry_run):
'''
Ping the configured Ntfy topic. Use the given configuration filename in any log entries.
@@ -31,13 +54,13 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
if state.name.lower() in run_states:
dry_run_label = ' (dry run; not actually pinging)' if dry_run else ''
default_priority = PRIORITY_NAME_TO_ID['default']
state_config = hook_config.get(
state.name.lower(),
{
'title': f'A borgmatic {state.name} event happened',
'message': f'A borgmatic {state.name} event happened',
'priority': 'default',
'priority': default_priority,
'tags': 'borgmatic',
},
)
@@ -55,8 +78,8 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
'topic': topic,
'title': state_config.get('title'),
'message': state_config.get('message'),
'priority': state_config.get('priority'),
'tags': state_config.get('tags'),
'priority': PRIORITY_NAME_TO_ID.get(state_config.get('priority'), default_priority),
'tags': convert_string_to_array(state_config.get('tags')),
}
try:
@@ -82,7 +82,7 @@ before_actions:
option in the `hooks:` section of your configuration.
<span class="minilink minilink-addedin">Prior to version 1.7.0</span> Use
`before_create` or similar instead of `before_actions`, which was introduced in
`before_backup` or similar instead of `before_actions`, which was introduced in
borgmatic 1.7.0.
What this does is check if the `findmnt` command errors when probing for a
+1 -1
View File
@@ -60,7 +60,7 @@ follows:
unsafe_skip_path_validation_before_create: true
```
However, this is indeed unsafe, and could lead to hangs or data being left out
However, this is indeed unsafe and could lead to hangs or data being left out
of backups. Use this option at your own risk.
@@ -12,11 +12,15 @@ list of `commands:` in your borgmatic configuration file. For example:
```yaml
commands:
- before: action
when: [create]
when: [check] # This is an inline YAML sequence.
run:
- echo "Before create!"
- before: action
when: [create, prune] # Also an inline YAML sequence.
run:
- echo "Before create or prune!"
- after: action
when:
when: # Multi-line YAML sequence, equivalent to "[create, prune]".
- create
- prune
run:
@@ -29,7 +33,7 @@ commands:
Each command in the `commands:` list has the following options:
* `before` or `after`: Name for the point in borgmatic's execution that the commands should be run before or after, one of:
* `action` runs before or after each action for each repository. This replaces the deprecated `before_create`, `after_prune`, etc.
* `action` runs before or after each action for each repository. This replaces the deprecated `before_backup`, `after_prune`, etc.
* `repository` runs before or after all actions for each repository. This replaces the deprecated `before_actions` and `after_actions`.
* `configuration` runs before or after all actions and repositories in the current configuration file.
* `everything` runs before or after all configuration files. Errors here do not trigger `error` hooks or the `fail` state in monitoring hooks. This replaces the deprecated `before_everything` and `after_everything`.
+3 -1
View File
@@ -129,7 +129,9 @@ Note the lack of "`//`" after `s3:` or `b2:`.
When selecting your cloud hosting provider, be aware that Amazon in particular
has [financially
supported](https://en.wikipedia.org/wiki/White_House_State_Ballroom) the Trump
regime.
regime. Additionally, U.S. Immigration and Customs Enforcement (ICE) is [powered
by
Amazon](https://medium.com/@noazureforapartheid/microsoft-powers-ice-why-doesnt-microsoft-want-to-talk-about-its-contracts-with-immigration-and-bc04fae8d43b).
## Related documentation
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "borgmatic"
version = "2.1.0"
version = "2.1.2"
authors = [
{ name="Dan Helfman", email="witten@torsion.org" },
]
+76 -18
View File
@@ -8,6 +8,68 @@ from flexmock import flexmock
from borgmatic import execute as module
def test_read_lines_yields_single_line():
process = subprocess.Popen(['echo', 'hi'], stdout=subprocess.PIPE)
assert tuple(module.read_lines(process.stdout, process)) == (('hi',),)
def test_read_lines_yields_single_line_longer_than_chunk_size():
process = subprocess.Popen(
['echo', 'this line is longer than the chunk size'], stdout=subprocess.PIPE
)
assert tuple(flexmock(module, READ_CHUNK_SIZE=16).read_lines(process.stdout, process)) == (
(),
(),
('this line is longer than the chunk size',),
)
def test_read_lines_yields_single_line_with_multibyte_unicode_character_spanning_chunk_boundary():
# In case it's not clear, "ñ" is a multi-byte UTF-8 character. The "a" shifts it over one byte
# so it straddles the chunk boundary.
process = subprocess.Popen(['echo', 'aññññññññññññññññññññññññññññññ'], stdout=subprocess.PIPE)
assert tuple(flexmock(module, READ_CHUNK_SIZE=16).read_lines(process.stdout, process)) == (
(),
(),
(),
('aññññññññññññññññññññññññññññññ',),
)
def test_read_lines_yields_multiple_lines():
process = subprocess.Popen(['echo', 'hi\nthere'], stdout=subprocess.PIPE)
assert tuple(module.read_lines(process.stdout, process)) == (('hi', 'there'),)
def test_read_lines_yields_multiple_lines_plus_partial_line():
process = subprocess.Popen(['echo', '-n', 'hi\nthere\npartial'], stdout=subprocess.PIPE)
assert tuple(module.read_lines(process.stdout, process)) == (('hi', 'there'), ('partial',))
def test_read_lines_with_longer_running_process_yields_many_lines():
process = subprocess.Popen(
[
sys.executable,
'-c',
"import random, string; print('\\n'.join(random.choice(string.ascii_letters) for _ in range(1000)))",
],
stdout=subprocess.PIPE,
)
assert tuple(module.read_lines(process.stdout, process))
def test_read_lines_yields_nothing():
process = subprocess.Popen(['echo', '-n'], stdout=subprocess.PIPE)
assert tuple(module.read_lines(process.stdout, process)) == ()
def test_log_outputs_logs_each_line_separately():
hi_record = flexmock(
msg='hi',
@@ -269,7 +331,7 @@ def test_log_outputs_kills_other_processes_and_raises_when_one_errors():
other_process,
(),
).and_return((other_process.stdout,))
flexmock(other_process).should_receive('kill').once()
flexmock(other_process).should_call('kill').once()
with pytest.raises(subprocess.CalledProcessError) as error:
tuple(
@@ -291,12 +353,6 @@ def test_log_outputs_kills_other_processes_and_returns_when_one_exits_with_warni
flexmock(module).should_receive('command_for_process').and_return('grep')
process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
flexmock(module).should_receive('interpret_exit_code').with_args(
['grep'],
None,
'borg',
None,
).and_return(module.Exit_status.SUCCESS)
flexmock(module).should_receive('interpret_exit_code').with_args(
['grep'],
2,
@@ -313,7 +369,7 @@ def test_log_outputs_kills_other_processes_and_returns_when_one_exits_with_warni
None,
'borg',
None,
).and_return(module.Exit_status.SUCCESS)
).and_return(module.Exit_status.STILL_RUNNING)
flexmock(module).should_receive('output_buffers_for_process').with_args(process, ()).and_return(
(process.stdout,),
)
@@ -321,7 +377,7 @@ def test_log_outputs_kills_other_processes_and_returns_when_one_exits_with_warni
other_process,
(),
).and_return((other_process.stdout,))
flexmock(other_process).should_receive('kill').once()
flexmock(other_process).should_call('kill').once()
assert (
tuple(
@@ -370,7 +426,15 @@ def test_log_outputs_vents_other_processes_when_one_exits():
other_process,
(process.stdout,),
).and_return((other_process.stdout,))
flexmock(process.stdout).should_call('readline').at_least().once()
flexmock(module.os).should_call('read').with_args(
process.stderr.fileno(), int
).at_least().once()
flexmock(module.os).should_call('read').with_args(
process.stdout.fileno(), int
).at_least().once()
flexmock(module.os).should_call('read').with_args(
other_process.stdout.fileno(), int
).at_least().once()
assert (
tuple(
@@ -433,12 +497,6 @@ def test_log_outputs_truncates_long_error_output():
flexmock(module).should_receive('command_for_process').and_return('grep')
process = subprocess.Popen(['grep'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
flexmock(module).should_receive('interpret_exit_code').with_args(
['grep'],
None,
'borg',
None,
).and_return(module.Exit_status.SUCCESS)
flexmock(module).should_receive('interpret_exit_code').with_args(
['grep'],
2,
@@ -487,8 +545,8 @@ def test_log_outputs_with_unfinished_process_re_polls():
flexmock(module.logger).should_receive('log').never()
flexmock(module).should_receive('interpret_exit_code').and_return(module.Exit_status.SUCCESS)
process = subprocess.Popen(['true'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
flexmock(process).should_receive('poll').and_return(None).and_return(0).times(3)
process = subprocess.Popen(['sleep', '0.001'], stdout=subprocess.PIPE)
flexmock(process).should_call('poll').at_least().times(3)
flexmock(module).should_receive('output_buffers_for_process').and_return((process.stdout,))
assert (
+112
View File
@@ -986,6 +986,65 @@ def test_collect_spot_check_source_paths_uses_working_directory():
) == ('foo', 'bar')
def test_collect_spot_check_source_paths_deduplicates_borg_output_paths():
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': True},
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
flexmock(),
)
flexmock(module.borgmatic.actions.pattern).should_receive('collect_patterns').and_return(
(Pattern('collected'),),
)
flexmock(module.borgmatic.actions.pattern).should_receive('process_patterns').with_args(
(
Pattern('collected', source=module.borgmatic.borg.pattern.Pattern_source.HOOK),
Pattern('extra.yaml', source=module.borgmatic.borg.pattern.Pattern_source.INTERNAL),
),
config=object,
working_directory=None,
).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=object,
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=True,
).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_yield(
'warning: stuff',
'- /etc/path',
'+ /etc/other',
'? /nope',
'- /etc/path',
)
flexmock(module.os.path).should_receive('isfile').and_return(True)
assert module.collect_spot_check_source_paths(
repository={'path': 'repo'},
config={'working_directory': '/'},
local_borg_version=flexmock(),
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
bootstrap_config_paths=('extra.yaml',),
) == ('/etc/path', '/etc/other')
def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
@@ -1115,6 +1174,59 @@ def test_compare_spot_check_hashes_handles_incorrect_path_names_from_xxh64sum():
) == ('/bar',)
def test_compare_spot_check_hashes_with_xxh64sum_failure_falls_back_to_individual_file_hashing():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
)
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os.path).should_receive('islink').and_return(False)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/foo', '/bar'), working_directory=None).and_raise(
module.subprocess.CalledProcessError(1, 'wtf')
)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/foo'), working_directory=None).and_raise(
module.subprocess.CalledProcessError(1, 'wtf')
).once()
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/bar'), working_directory=None).and_yield(
'hash2 /bar',
).once()
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'hash2', 'path': 'bar'},
)
assert module.compare_spot_check_hashes(
repository={'path': 'repo'},
archive='archive',
config={
'checks': [
{
'name': 'archives',
'frequency': '2 weeks',
},
{
'name': 'spot',
'data_sample_percentage': 50,
},
],
},
local_borg_version=flexmock(),
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
source_paths=('/foo', '/bar', '/baz', '/quux'),
) == ('/foo',)
def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
+7
View File
@@ -22,6 +22,7 @@ def test_run_recreate_does_not_raise():
local_borg_version=None,
recreate_arguments=flexmock(repository=flexmock(), archive=None),
global_arguments=flexmock(),
dry_run_label='',
local_path=None,
remote_path=None,
)
@@ -45,6 +46,7 @@ def test_run_recreate_with_archive_does_not_raise():
local_borg_version=None,
recreate_arguments=flexmock(repository=flexmock(), archive='test-archive'),
global_arguments=flexmock(),
dry_run_label='',
local_path=None,
remote_path=None,
)
@@ -69,6 +71,7 @@ def test_run_recreate_with_leftover_recreate_archive_raises():
local_borg_version=None,
recreate_arguments=flexmock(repository=flexmock(), archive='test-archive.recreate'),
global_arguments=flexmock(),
dry_run_label='',
local_path=None,
remote_path=None,
)
@@ -93,6 +96,7 @@ def test_run_recreate_with_latest_archive_resolving_to_leftover_recreate_archive
local_borg_version=None,
recreate_arguments=flexmock(repository=flexmock(), archive='latest'),
global_arguments=flexmock(),
dry_run_label='',
local_path=None,
remote_path=None,
)
@@ -122,6 +126,7 @@ def test_run_recreate_with_archive_already_exists_error_raises():
local_borg_version=None,
recreate_arguments=flexmock(repository=flexmock(), archive='test-archive', target=None),
global_arguments=flexmock(),
dry_run_label='',
local_path=None,
remote_path=None,
)
@@ -155,6 +160,7 @@ def test_run_recreate_with_target_and_archive_already_exists_error_raises():
target='target-archive',
),
global_arguments=flexmock(),
dry_run_label='',
local_path=None,
remote_path=None,
)
@@ -188,6 +194,7 @@ def test_run_recreate_with_other_called_process_error_passes_it_through():
target='target-archive',
),
global_arguments=flexmock(),
dry_run_label='',
local_path=None,
remote_path=None,
)
+10 -10
View File
@@ -24,7 +24,7 @@ def insert_execute_command_mock(
environment=None,
working_directory=working_directory,
borg_local_path=command[0],
borg_exit_codes=borg_exit_codes,
borg_exit_codes=(borg_exit_codes or []) + [{'code': 1, 'treat_as': 'error'}],
).once()
@@ -335,7 +335,7 @@ def test_check_archives_with_progress_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -371,7 +371,7 @@ def test_check_archives_with_log_json_and_progress_passes_through_both_to_borg()
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -407,7 +407,7 @@ def test_check_archives_with_repair_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -443,7 +443,7 @@ def test_check_archives_with_log_json_and_repair_passes_through_both_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -479,7 +479,7 @@ def test_check_archives_with_max_duration_flag_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -515,7 +515,7 @@ def test_check_archives_with_max_duration_option_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -689,7 +689,7 @@ def test_check_archives_with_max_duration_flag_overrides_max_duration_option():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -854,7 +854,7 @@ def test_check_archives_with_local_path_calls_borg_via_local_path():
def test_check_archives_with_exit_codes_calls_borg_using_them():
checks = {'repository'}
borg_exit_codes = flexmock()
borg_exit_codes = [{'code': 101, 'treat_as': 'error'}]
config = {'borg_exit_codes': borg_exit_codes}
flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
@@ -1026,7 +1026,7 @@ def test_check_archives_with_match_archives_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
-1
View File
@@ -1128,7 +1128,6 @@ def test_make_base_create_command_with_unsafe_skip_path_validation_before_create
(f'repo::{module.flags.get_default_archive_name_format()}',),
)
flexmock(module).should_receive('validate_planned_backup_paths').never()
flexmock(module.logger).should_receive('warning').once()
module.make_base_create_command(
dry_run=False,
+35 -38
View File
@@ -20,44 +20,6 @@ def insert_execute_command_mock(command, working_directory=None, borg_exit_codes
).once()
def test_recreate_archive_dry_run_skips_execution():
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('')
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive(
'make_repository_archive_flags',
).and_return(
(
'--repo',
'repo',
),
)
flexmock(module.borgmatic.execute).should_receive('execute_command').never()
recreate_arguments = flexmock(
repository=flexmock(),
list=None,
target=None,
comment=None,
timestamp=None,
match_archives=None,
)
result = module.recreate_archive(
repository='repo',
archive='archive',
config={},
local_borg_version='1.2.3',
recreate_arguments=recreate_arguments,
global_arguments=flexmock(dry_run=True),
local_path='borg',
)
assert result is None
def test_recreate_calls_borg_with_required_flags():
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
@@ -93,6 +55,41 @@ def test_recreate_calls_borg_with_required_flags():
)
def test_recreate_with_dry_run_calls_borg_with_dry_run_flag():
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
flexmock(module.borgmatic.borg.flags).should_receive('make_list_filter_flags').and_return('')
flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
flexmock(module.borgmatic.borg.flags).should_receive(
'make_repository_archive_flags',
).and_return(
(
'--repo',
'repo',
),
)
insert_execute_command_mock(('borg', 'recreate', '--log-json', '--dry-run', '--repo', 'repo'))
module.recreate_archive(
repository='repo',
archive='archive',
config={},
local_borg_version='1.2.3',
recreate_arguments=flexmock(
list=None,
target=None,
comment=None,
timestamp=None,
match_archives=None,
),
global_arguments=flexmock(dry_run=True),
local_path='borg',
remote_path=None,
patterns=None,
)
def test_recreate_with_remote_path():
flexmock(module.borgmatic.borg.flags).should_receive('make_exclude_flags').and_return(())
flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return(None)
+63 -4
View File
@@ -1,10 +1,26 @@
from enum import Enum
import pytest
from flexmock import flexmock
import borgmatic.hooks.monitoring.monitor
from borgmatic.hooks.monitoring import ntfy as module
@pytest.mark.parametrize(
'tags_input,expected_tags_output',
(
(None, []),
('', []),
('foo', ['foo']),
(' foo , bar ,baz ', ['foo', 'bar', 'baz']),
('foo,,bar,', ['foo', 'bar']),
),
)
def test_convert_string_to_array(tags_input, expected_tags_output):
assert module.convert_string_to_array(tags_input) == expected_tags_output
DEFAULT_BASE_URL = 'https://ntfy.sh'
CUSTOM_BASE_URL = 'https://ntfy.example.com'
TOPIC = 'borgmatic-unit-testing'
@@ -24,8 +40,8 @@ CUSTOM_MESSAGE_PAYLOAD = {
'topic': TOPIC,
'title': CUSTOM_MESSAGE_CONFIG['title'],
'message': CUSTOM_MESSAGE_CONFIG['message'],
'priority': CUSTOM_MESSAGE_CONFIG['priority'],
'tags': CUSTOM_MESSAGE_CONFIG['tags'],
'priority': 1,
'tags': [CUSTOM_MESSAGE_CONFIG['tags']],
}
@@ -34,13 +50,16 @@ def default_message_payload(state=Enum):
'topic': TOPIC,
'title': f'A borgmatic {state.name} event happened',
'message': f'A borgmatic {state.name} event happened',
'priority': 'default',
'tags': 'borgmatic',
'priority': 3,
'tags': ['borgmatic'],
}
def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -67,6 +86,9 @@ def test_ping_monitor_with_access_token_hits_hosted_ntfy_on_fail():
'topic': TOPIC,
'access_token': 'abc123',
}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -95,6 +117,9 @@ def test_ping_monitor_with_username_password_and_access_token_ignores_username_p
'password': 'fakepassword',
'access_token': 'abc123',
}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -123,6 +148,9 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
'username': 'testuser',
'password': 'fakepassword',
}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -146,6 +174,9 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
def test_ping_monitor_with_password_but_no_username_warns():
hook_config = {'topic': TOPIC, 'password': 'fakepassword'}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -170,6 +201,9 @@ def test_ping_monitor_with_password_but_no_username_warns():
def test_ping_monitor_with_username_but_no_password_warns():
hook_config = {'topic': TOPIC, 'username': 'testuser'}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -194,6 +228,9 @@ def test_ping_monitor_with_username_but_no_password_warns():
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -211,6 +248,9 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -228,6 +268,9 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
hook_config = {'topic': TOPIC, 'server': CUSTOM_BASE_URL}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -251,6 +294,9 @@ def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -268,6 +314,7 @@ def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
hook_config = {'topic': TOPIC, 'fail': CUSTOM_MESSAGE_CONFIG}
flexmock(module).should_receive('convert_string_to_array').with_args('+1').and_return(['+1'])
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -291,6 +338,9 @@ def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
hook_config = {'topic': TOPIC, 'states': ['start', 'fail']}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -314,6 +364,9 @@ def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
def test_ping_monitor_with_connection_error_logs_warning():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
@@ -338,6 +391,9 @@ def test_ping_monitor_with_connection_error_logs_warning():
def test_ping_monitor_with_credential_error_logs_warning():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).and_raise(ValueError)
@@ -356,6 +412,9 @@ def test_ping_monitor_with_credential_error_logs_warning():
def test_ping_monitor_with_other_error_logs_warning():
hook_config = {'topic': TOPIC}
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
['borgmatic']
)
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
'resolve_credential',
).replace_with(lambda value, config: value)
+1012 -59
View File
File diff suppressed because it is too large Load Diff