mirror of
https://github.com/borgmatic-collective/borgmatic.git
synced 2026-07-22 18:13:02 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8d074eff | ||
|
|
fc7439af3a | ||
|
|
ea05a4660c | ||
|
|
957f6be4a2 | ||
|
|
730a4b2f18 | ||
|
|
c64c79ad0e | ||
|
|
acd1a8d1dd | ||
|
|
c66fde4a93 | ||
|
|
dbe3891819 | ||
|
|
dc89c9ec73 | ||
|
|
a27dc95c87 | ||
|
|
6b5390f5dd | ||
|
|
fd485e64a3 | ||
|
|
f5de6bf43c | ||
|
|
aa25dc7b31 | ||
|
|
aba45f03d6 | ||
|
|
f6124528df | ||
|
|
b67dcf829e | ||
|
|
71e25756f2 | ||
|
|
ff2f9fd5ee | ||
|
|
ca4447ffab | ||
|
|
104fe35e39 | ||
|
|
248fa1db64 | ||
|
|
97f7c65f6c | ||
|
|
765eba5315 | ||
|
|
bd051beced | ||
|
|
d5cd4efecd | ||
|
|
13fd225a0b | ||
|
|
87c5863218 | ||
|
|
677871aa89 | ||
|
|
d2390581e7 | ||
|
|
efd0f0d618 | ||
|
|
4ff7dccab4 | ||
|
|
76537f6c11 |
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -441,6 +441,7 @@ def run_actions( # noqa: PLR0912, PLR0915
|
||||
local_borg_version,
|
||||
action_arguments,
|
||||
global_arguments,
|
||||
dry_run_label,
|
||||
local_path,
|
||||
remote_path,
|
||||
)
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "borgmatic"
|
||||
version = "2.1.0"
|
||||
version = "2.1.2"
|
||||
authors = [
|
||||
{ name="Dan Helfman", email="witten@torsion.org" },
|
||||
]
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user