mirror of
https://github.com/borgmatic-collective/borgmatic.git
synced 2026-08-02 22:53:01 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8d074eff | ||
|
|
fc7439af3a | ||
|
|
ea05a4660c | ||
|
|
957f6be4a2 | ||
|
|
730a4b2f18 | ||
|
|
c64c79ad0e | ||
|
|
acd1a8d1dd | ||
|
|
c66fde4a93 | ||
|
|
dbe3891819 | ||
|
|
dc89c9ec73 | ||
|
|
a27dc95c87 | ||
|
|
6b5390f5dd | ||
|
|
fd485e64a3 | ||
|
|
f5de6bf43c | ||
|
|
aa25dc7b31 |
@@ -1,3 +1,17 @@
|
|||||||
|
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
|
2.1.1
|
||||||
* #1241: For the "recreate" action, actually pass the "--dry-run" flag through to Borg instead of
|
* #1241: For the "recreate" action, actually pass the "--dry-run" flag through to Borg instead of
|
||||||
just skipping the Borg call.
|
just skipping the Borg call.
|
||||||
|
|||||||
+69
-33
@@ -9,6 +9,7 @@ import pathlib
|
|||||||
import random
|
import random
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
import borgmatic.actions.config.bootstrap
|
import borgmatic.actions.config.bootstrap
|
||||||
@@ -418,7 +419,13 @@ def collect_spot_check_source_paths(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return tuple(
|
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(
|
hash_paths = tuple(
|
||||||
path for path in source_sample_paths_subset if path in hashable_source_sample_path
|
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(
|
try:
|
||||||
**dict(
|
hash_lines = borgmatic.execute.execute_command_and_capture_output(
|
||||||
zip(
|
tuple(
|
||||||
# xxh64sum rewrites/escapes the paths that it returns alongside its hashes, for
|
shlex.quote(part)
|
||||||
# instance if they contain special characters. When that happens, they don't
|
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
|
||||||
# match the original source paths and therefore hash lookups fail. So when
|
)
|
||||||
# building this lookup dict, use the original unaltered paths we provided as
|
+ hash_paths,
|
||||||
# input to xxh64sum.
|
working_directory=working_directory,
|
||||||
hash_paths,
|
)
|
||||||
(
|
source_hashes.update(
|
||||||
# For some reason, xxh64sum prefixes the hash with a backslash if the path
|
**dict(
|
||||||
# contains a newline. Work around that.
|
zip(
|
||||||
line.split(' ', 1)[0].lstrip('\\')
|
# xxh64sum rewrites/escapes the paths that it returns alongside its hashes, for
|
||||||
for line in hash_lines
|
# 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
|
except subprocess.CalledProcessError:
|
||||||
# for them.
|
# 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
|
||||||
path: ''
|
# file(s) with problems instead of failing the whole batch.
|
||||||
for path in source_sample_paths_subset
|
logger.warning(
|
||||||
if path not in hashable_source_sample_path
|
'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.
|
# Get the hash for each file in the archive.
|
||||||
archive_hashes.update(
|
archive_hashes.update(
|
||||||
|
|||||||
@@ -149,8 +149,10 @@ def check_archives(
|
|||||||
|
|
||||||
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
|
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')
|
umask = config.get('umask')
|
||||||
borg_exit_codes = config.get('borg_exit_codes')
|
|
||||||
working_directory = borgmatic.config.paths.get_working_directory(config)
|
working_directory = borgmatic.config.paths.get_working_directory(config)
|
||||||
|
|
||||||
if 'data' in checks:
|
if 'data' in checks:
|
||||||
|
|||||||
+49
-14
@@ -29,6 +29,19 @@ class Exit_status(enum.Enum):
|
|||||||
ERROR = 4
|
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
|
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
|
Return an Exit_status value (e.g. SUCCESS, ERROR, or WARNING) based on interpreting the given
|
||||||
@@ -42,9 +55,7 @@ def interpret_exit_code(command, exit_code, borg_local_path=None, borg_exit_code
|
|||||||
if exit_code == 0:
|
if exit_code == 0:
|
||||||
return Exit_status.SUCCESS
|
return Exit_status.SUCCESS
|
||||||
|
|
||||||
parsed_command = command.split(' ', 1) if isinstance(command, str) else command
|
if command_is_borg(command, borg_local_path):
|
||||||
|
|
||||||
if borg_local_path and parsed_command[0] == borg_local_path:
|
|
||||||
# First try looking for the exit code in the borg_exit_codes configuration.
|
# First try looking for the exit code in the borg_exit_codes configuration.
|
||||||
for entry in borg_exit_codes or ():
|
for entry in borg_exit_codes or ():
|
||||||
if entry.get('code') == exit_code:
|
if entry.get('code') == exit_code:
|
||||||
@@ -105,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):
|
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
|
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.
|
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):
|
with contextlib.suppress(json.JSONDecodeError, TypeError, KeyError, AttributeError):
|
||||||
log_data = json.loads(line)
|
log_data = json.loads(line)
|
||||||
log_type = log_data.get('type')
|
log_type = log_data.get('type')
|
||||||
|
|
||||||
if log_type == 'log_message':
|
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(
|
return logging.makeLogRecord(
|
||||||
dict(
|
dict(
|
||||||
levelno=logging._nameToLevel.get(log_data.get('levelname')),
|
levelno=borg_log_level,
|
||||||
created=log_data.get('time'),
|
created=log_data.get('time'),
|
||||||
msg=log_data.get('message'),
|
msg=log_data.get('message'),
|
||||||
levelname=log_data.get('levelname'),
|
levelname=log_data.get('levelname'),
|
||||||
@@ -166,9 +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,
|
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.
|
just elevate the log level to a WARN.
|
||||||
'''
|
'''
|
||||||
parsed_command = command.split(' ', 1) if isinstance(command, str) else command
|
if command_is_borg(command, borg_local_path):
|
||||||
|
|
||||||
if borg_local_path and parsed_command[0] == borg_local_path:
|
|
||||||
log_record = borg_json_log_line_to_record(line, log_level)
|
log_record = borg_json_log_line_to_record(line, log_level)
|
||||||
|
|
||||||
if log_record:
|
if log_record:
|
||||||
@@ -215,10 +246,12 @@ def read_lines(buffer, process, line_separator='\n'):
|
|||||||
call to know when to read more lines. Otherwise, the generator will busywait if it's called in a
|
call to know when to read more lines. Otherwise, the generator will busywait if it's called in a
|
||||||
tight loop.
|
tight loop.
|
||||||
'''
|
'''
|
||||||
data = ''
|
data = b''
|
||||||
|
encoded_separator = line_separator.encode()
|
||||||
|
separator_size = len(encoded_separator)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
chunk = os.read(buffer.fileno(), READ_CHUNK_SIZE).decode()
|
chunk = os.read(buffer.fileno(), READ_CHUNK_SIZE)
|
||||||
|
|
||||||
if not chunk: # EOF
|
if not chunk: # EOF
|
||||||
# The process is still running, so we keep running too.
|
# The process is still running, so we keep running too.
|
||||||
@@ -233,19 +266,19 @@ def read_lines(buffer, process, line_separator='\n'):
|
|||||||
# Split the data into lines, holding back anything leftover that might
|
# Split the data into lines, holding back anything leftover that might
|
||||||
# be a partial line.
|
# be a partial line.
|
||||||
while True:
|
while True:
|
||||||
separator_position = data.find(line_separator)
|
separator_position = data.find(encoded_separator)
|
||||||
|
|
||||||
if separator_position == -1:
|
if separator_position == -1:
|
||||||
break
|
break
|
||||||
|
|
||||||
lines.append(data[:separator_position].rstrip())
|
lines.append(data[:separator_position].decode())
|
||||||
data = data[separator_position + 1 :]
|
data = data[separator_position + separator_size :]
|
||||||
|
|
||||||
yield tuple(lines)
|
yield tuple(lines)
|
||||||
|
|
||||||
# Yield any leftover data from the end of the buffer.
|
# Yield any leftover data from the end of the buffer.
|
||||||
if data:
|
if data:
|
||||||
yield (data.rstrip(),)
|
yield (data.decode().rstrip(),)
|
||||||
|
|
||||||
|
|
||||||
Buffer_reader = collections.namedtuple(
|
Buffer_reader = collections.namedtuple(
|
||||||
@@ -612,7 +645,9 @@ def execute_command_and_capture_output(
|
|||||||
command,
|
command,
|
||||||
stdin=input_file,
|
stdin=input_file,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE if capture_stderr else None,
|
stderr=subprocess.PIPE
|
||||||
|
if capture_stderr or command_is_borg(command, borg_local_path)
|
||||||
|
else None,
|
||||||
shell=shell,
|
shell=shell,
|
||||||
env=environment,
|
env=environment,
|
||||||
cwd=working_directory,
|
cwd=working_directory,
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ 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 = {
|
PRIORITY_NAME_TO_ID = {
|
||||||
'max': 5,
|
'max': 5,
|
||||||
'urgent': 5,
|
'urgent': 5,
|
||||||
@@ -66,7 +79,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
|
|||||||
'title': state_config.get('title'),
|
'title': state_config.get('title'),
|
||||||
'message': state_config.get('message'),
|
'message': state_config.get('message'),
|
||||||
'priority': PRIORITY_NAME_TO_ID.get(state_config.get('priority'), default_priority),
|
'priority': PRIORITY_NAME_TO_ID.get(state_config.get('priority'), default_priority),
|
||||||
'tags': state_config.get('tags'),
|
'tags': convert_string_to_array(state_config.get('tags')),
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ before_actions:
|
|||||||
option in the `hooks:` section of your configuration.
|
option in the `hooks:` section of your configuration.
|
||||||
|
|
||||||
<span class="minilink minilink-addedin">Prior to version 1.7.0</span> Use
|
<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.
|
borgmatic 1.7.0.
|
||||||
|
|
||||||
What this does is check if the `findmnt` command errors when probing for a
|
What this does is check if the `findmnt` command errors when probing for a
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ list of `commands:` in your borgmatic configuration file. For example:
|
|||||||
```yaml
|
```yaml
|
||||||
commands:
|
commands:
|
||||||
- before: action
|
- before: action
|
||||||
when: [create]
|
when: [check] # This is an inline YAML sequence.
|
||||||
run:
|
run:
|
||||||
- echo "Before create!"
|
- echo "Before create!"
|
||||||
|
- before: action
|
||||||
|
when: [create, prune] # Also an inline YAML sequence.
|
||||||
|
run:
|
||||||
|
- echo "Before create or prune!"
|
||||||
- after: action
|
- after: action
|
||||||
when:
|
when: # Multi-line YAML sequence, equivalent to "[create, prune]".
|
||||||
- create
|
- create
|
||||||
- prune
|
- prune
|
||||||
run:
|
run:
|
||||||
@@ -29,7 +33,7 @@ commands:
|
|||||||
Each command in the `commands:` list has the following options:
|
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:
|
* `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`.
|
* `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.
|
* `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`.
|
* `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`.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "borgmatic"
|
name = "borgmatic"
|
||||||
version = "2.1.1"
|
version = "2.1.2"
|
||||||
authors = [
|
authors = [
|
||||||
{ name="Dan Helfman", email="witten@torsion.org" },
|
{ name="Dan Helfman", email="witten@torsion.org" },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ def test_read_lines_yields_single_line_longer_than_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():
|
def test_read_lines_yields_multiple_lines():
|
||||||
process = subprocess.Popen(['echo', 'hi\nthere'], stdout=subprocess.PIPE)
|
process = subprocess.Popen(['echo', 'hi\nthere'], stdout=subprocess.PIPE)
|
||||||
|
|
||||||
|
|||||||
@@ -986,6 +986,65 @@ def test_collect_spot_check_source_paths_uses_working_directory():
|
|||||||
) == ('foo', 'bar')
|
) == ('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():
|
def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
|
||||||
flexmock(module.random).should_receive('SystemRandom').and_return(
|
flexmock(module.random).should_receive('SystemRandom').and_return(
|
||||||
flexmock(sample=lambda population, count: population[:count]),
|
flexmock(sample=lambda population, count: population[:count]),
|
||||||
@@ -1115,6 +1174,59 @@ def test_compare_spot_check_hashes_handles_incorrect_path_names_from_xxh64sum():
|
|||||||
) == ('/bar',)
|
) == ('/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():
|
def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes():
|
||||||
flexmock(module.random).should_receive('SystemRandom').and_return(
|
flexmock(module.random).should_receive('SystemRandom').and_return(
|
||||||
flexmock(sample=lambda population, count: population[:count]),
|
flexmock(sample=lambda population, count: population[:count]),
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ def insert_execute_command_mock(
|
|||||||
environment=None,
|
environment=None,
|
||||||
working_directory=working_directory,
|
working_directory=working_directory,
|
||||||
borg_local_path=command[0],
|
borg_local_path=command[0],
|
||||||
borg_exit_codes=borg_exit_codes,
|
borg_exit_codes=(borg_exit_codes or []) + [{'code': 1, 'treat_as': 'error'}],
|
||||||
).once()
|
).once()
|
||||||
|
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@ def test_check_archives_with_progress_passes_through_to_borg():
|
|||||||
environment=None,
|
environment=None,
|
||||||
working_directory=None,
|
working_directory=None,
|
||||||
borg_local_path='borg',
|
borg_local_path='borg',
|
||||||
borg_exit_codes=None,
|
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
|
||||||
).once()
|
).once()
|
||||||
|
|
||||||
module.check_archives(
|
module.check_archives(
|
||||||
@@ -371,7 +371,7 @@ def test_check_archives_with_log_json_and_progress_passes_through_both_to_borg()
|
|||||||
environment=None,
|
environment=None,
|
||||||
working_directory=None,
|
working_directory=None,
|
||||||
borg_local_path='borg',
|
borg_local_path='borg',
|
||||||
borg_exit_codes=None,
|
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
|
||||||
).once()
|
).once()
|
||||||
|
|
||||||
module.check_archives(
|
module.check_archives(
|
||||||
@@ -407,7 +407,7 @@ def test_check_archives_with_repair_passes_through_to_borg():
|
|||||||
environment=None,
|
environment=None,
|
||||||
working_directory=None,
|
working_directory=None,
|
||||||
borg_local_path='borg',
|
borg_local_path='borg',
|
||||||
borg_exit_codes=None,
|
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
|
||||||
).once()
|
).once()
|
||||||
|
|
||||||
module.check_archives(
|
module.check_archives(
|
||||||
@@ -443,7 +443,7 @@ def test_check_archives_with_log_json_and_repair_passes_through_both_to_borg():
|
|||||||
environment=None,
|
environment=None,
|
||||||
working_directory=None,
|
working_directory=None,
|
||||||
borg_local_path='borg',
|
borg_local_path='borg',
|
||||||
borg_exit_codes=None,
|
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
|
||||||
).once()
|
).once()
|
||||||
|
|
||||||
module.check_archives(
|
module.check_archives(
|
||||||
@@ -479,7 +479,7 @@ def test_check_archives_with_max_duration_flag_passes_through_to_borg():
|
|||||||
environment=None,
|
environment=None,
|
||||||
working_directory=None,
|
working_directory=None,
|
||||||
borg_local_path='borg',
|
borg_local_path='borg',
|
||||||
borg_exit_codes=None,
|
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
|
||||||
).once()
|
).once()
|
||||||
|
|
||||||
module.check_archives(
|
module.check_archives(
|
||||||
@@ -515,7 +515,7 @@ def test_check_archives_with_max_duration_option_passes_through_to_borg():
|
|||||||
environment=None,
|
environment=None,
|
||||||
working_directory=None,
|
working_directory=None,
|
||||||
borg_local_path='borg',
|
borg_local_path='borg',
|
||||||
borg_exit_codes=None,
|
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
|
||||||
).once()
|
).once()
|
||||||
|
|
||||||
module.check_archives(
|
module.check_archives(
|
||||||
@@ -689,7 +689,7 @@ def test_check_archives_with_max_duration_flag_overrides_max_duration_option():
|
|||||||
environment=None,
|
environment=None,
|
||||||
working_directory=None,
|
working_directory=None,
|
||||||
borg_local_path='borg',
|
borg_local_path='borg',
|
||||||
borg_exit_codes=None,
|
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
|
||||||
).once()
|
).once()
|
||||||
|
|
||||||
module.check_archives(
|
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():
|
def test_check_archives_with_exit_codes_calls_borg_using_them():
|
||||||
checks = {'repository'}
|
checks = {'repository'}
|
||||||
borg_exit_codes = flexmock()
|
borg_exit_codes = [{'code': 101, 'treat_as': 'error'}]
|
||||||
config = {'borg_exit_codes': borg_exit_codes}
|
config = {'borg_exit_codes': borg_exit_codes}
|
||||||
flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(())
|
flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(())
|
||||||
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
|
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,
|
environment=None,
|
||||||
working_directory=None,
|
working_directory=None,
|
||||||
borg_local_path='borg',
|
borg_local_path='borg',
|
||||||
borg_exit_codes=None,
|
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
|
||||||
).once()
|
).once()
|
||||||
|
|
||||||
module.check_archives(
|
module.check_archives(
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
import pytest
|
||||||
from flexmock import flexmock
|
from flexmock import flexmock
|
||||||
|
|
||||||
import borgmatic.hooks.monitoring.monitor
|
import borgmatic.hooks.monitoring.monitor
|
||||||
from borgmatic.hooks.monitoring import ntfy as module
|
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'
|
DEFAULT_BASE_URL = 'https://ntfy.sh'
|
||||||
CUSTOM_BASE_URL = 'https://ntfy.example.com'
|
CUSTOM_BASE_URL = 'https://ntfy.example.com'
|
||||||
TOPIC = 'borgmatic-unit-testing'
|
TOPIC = 'borgmatic-unit-testing'
|
||||||
@@ -25,7 +41,7 @@ CUSTOM_MESSAGE_PAYLOAD = {
|
|||||||
'title': CUSTOM_MESSAGE_CONFIG['title'],
|
'title': CUSTOM_MESSAGE_CONFIG['title'],
|
||||||
'message': CUSTOM_MESSAGE_CONFIG['message'],
|
'message': CUSTOM_MESSAGE_CONFIG['message'],
|
||||||
'priority': 1,
|
'priority': 1,
|
||||||
'tags': CUSTOM_MESSAGE_CONFIG['tags'],
|
'tags': [CUSTOM_MESSAGE_CONFIG['tags']],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -35,12 +51,15 @@ def default_message_payload(state=Enum):
|
|||||||
'title': f'A borgmatic {state.name} event happened',
|
'title': f'A borgmatic {state.name} event happened',
|
||||||
'message': f'A borgmatic {state.name} event happened',
|
'message': f'A borgmatic {state.name} event happened',
|
||||||
'priority': 3,
|
'priority': 3,
|
||||||
'tags': 'borgmatic',
|
'tags': ['borgmatic'],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
|
def test_ping_monitor_minimal_config_hits_hosted_ntfy_on_fail():
|
||||||
hook_config = {'topic': TOPIC}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).replace_with(lambda value, config: value)
|
||||||
@@ -67,6 +86,9 @@ def test_ping_monitor_with_access_token_hits_hosted_ntfy_on_fail():
|
|||||||
'topic': TOPIC,
|
'topic': TOPIC,
|
||||||
'access_token': 'abc123',
|
'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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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',
|
'password': 'fakepassword',
|
||||||
'access_token': 'abc123',
|
'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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).replace_with(lambda value, config: value)
|
||||||
@@ -123,6 +148,9 @@ def test_ping_monitor_with_username_password_hits_hosted_ntfy_on_fail():
|
|||||||
'username': 'testuser',
|
'username': 'testuser',
|
||||||
'password': 'fakepassword',
|
'password': 'fakepassword',
|
||||||
}
|
}
|
||||||
|
flexmock(module).should_receive('convert_string_to_array').with_args('borgmatic').and_return(
|
||||||
|
['borgmatic']
|
||||||
|
)
|
||||||
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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():
|
def test_ping_monitor_with_password_but_no_username_warns():
|
||||||
hook_config = {'topic': TOPIC, 'password': 'fakepassword'}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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():
|
def test_ping_monitor_with_username_but_no_password_warns():
|
||||||
hook_config = {'topic': TOPIC, 'username': 'testuser'}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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():
|
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_start():
|
||||||
hook_config = {'topic': TOPIC}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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():
|
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_finish():
|
||||||
hook_config = {'topic': TOPIC}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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():
|
def test_ping_monitor_minimal_config_hits_selfhosted_ntfy_on_fail():
|
||||||
hook_config = {'topic': TOPIC, 'server': CUSTOM_BASE_URL}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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():
|
def test_ping_monitor_minimal_config_does_not_hit_hosted_ntfy_on_fail_dry_run():
|
||||||
hook_config = {'topic': TOPIC}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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():
|
def test_ping_monitor_custom_message_hits_hosted_ntfy_on_fail():
|
||||||
hook_config = {'topic': TOPIC, 'fail': CUSTOM_MESSAGE_CONFIG}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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():
|
def test_ping_monitor_custom_state_hits_hosted_ntfy_on_start():
|
||||||
hook_config = {'topic': TOPIC, 'states': ['start', 'fail']}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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():
|
def test_ping_monitor_with_connection_error_logs_warning():
|
||||||
hook_config = {'topic': TOPIC}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).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():
|
def test_ping_monitor_with_credential_error_logs_warning():
|
||||||
hook_config = {'topic': TOPIC}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).and_raise(ValueError)
|
).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():
|
def test_ping_monitor_with_other_error_logs_warning():
|
||||||
hook_config = {'topic': TOPIC}
|
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(
|
flexmock(module.borgmatic.hooks.credential.parse).should_receive(
|
||||||
'resolve_credential',
|
'resolve_credential',
|
||||||
).replace_with(lambda value, config: value)
|
).replace_with(lambda value, config: value)
|
||||||
|
|||||||
+132
-57
@@ -7,72 +7,80 @@ from borgmatic import execute as module
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
'command,exit_code,borg_local_path,borg_exit_codes,expected_result',
|
'command,borg_local_path,expected_result',
|
||||||
(
|
(
|
||||||
(['grep'], 2, None, None, module.Exit_status.ERROR),
|
(['foo', 'bar'], 'borg', False),
|
||||||
(['grep'], 2, 'borg', None, module.Exit_status.ERROR),
|
(['borg', 'list'], 'borg', True),
|
||||||
(['borg'], 2, 'borg', None, module.Exit_status.ERROR),
|
(['borg1', 'list'], 'borg', False),
|
||||||
(['borg1'], 2, 'borg1', None, module.Exit_status.ERROR),
|
(['borg', 'list'], 'borg1', False),
|
||||||
(['grep'], 1, None, None, module.Exit_status.ERROR),
|
([], 'borg', False),
|
||||||
(['grep'], 1, 'borg', None, module.Exit_status.ERROR),
|
('foo bar', 'borg', False),
|
||||||
(['borg'], 1, 'borg', None, module.Exit_status.WARNING),
|
('borg list', 'borg', True),
|
||||||
(['borg1'], 1, 'borg1', None, module.Exit_status.WARNING),
|
('', 'borg', False),
|
||||||
(['grep'], 100, None, None, module.Exit_status.ERROR),
|
),
|
||||||
(['grep'], 100, 'borg', None, module.Exit_status.ERROR),
|
)
|
||||||
('grep', 2, None, None, module.Exit_status.ERROR),
|
def test_command_is_borg_matches_local_path_to_command(command, borg_local_path, expected_result):
|
||||||
('borg', 2, 'borg', None, module.Exit_status.ERROR),
|
assert module.command_is_borg(command, borg_local_path) == expected_result
|
||||||
(['borg'], 100, 'borg', None, module.Exit_status.WARNING),
|
|
||||||
(['borg1'], 100, 'borg1', None, module.Exit_status.WARNING),
|
|
||||||
('borg', 100, 'borg', None, module.Exit_status.WARNING),
|
@pytest.mark.parametrize(
|
||||||
('borg1', 100, 'borg1', None, module.Exit_status.WARNING),
|
'command_is_borg,exit_code,borg_exit_codes,expected_result',
|
||||||
(['grep'], 0, None, None, module.Exit_status.SUCCESS),
|
(
|
||||||
(['grep'], 0, 'borg', None, module.Exit_status.SUCCESS),
|
(False, 2, None, module.Exit_status.ERROR),
|
||||||
(['borg'], 0, 'borg', None, module.Exit_status.SUCCESS),
|
(True, 2, None, module.Exit_status.ERROR),
|
||||||
(['borg1'], 0, 'borg1', None, module.Exit_status.SUCCESS),
|
(False, 1, None, module.Exit_status.ERROR),
|
||||||
('grep', 0, None, None, module.Exit_status.SUCCESS),
|
(True, 1, None, module.Exit_status.WARNING),
|
||||||
('grep', 0, 'borg', None, module.Exit_status.SUCCESS),
|
(False, 100, None, module.Exit_status.ERROR),
|
||||||
|
(False, 2, None, module.Exit_status.ERROR),
|
||||||
|
(True, 2, None, module.Exit_status.ERROR),
|
||||||
|
(True, 100, None, module.Exit_status.WARNING),
|
||||||
|
(False, 0, None, module.Exit_status.SUCCESS),
|
||||||
|
(True, 0, None, module.Exit_status.SUCCESS),
|
||||||
# -9 exit code occurs when child process get SIGKILLed.
|
# -9 exit code occurs when child process get SIGKILLed.
|
||||||
(['grep'], -9, None, None, module.Exit_status.ERROR),
|
(False, -9, None, module.Exit_status.ERROR),
|
||||||
(['grep'], -9, 'borg', None, module.Exit_status.ERROR),
|
(True, -9, None, module.Exit_status.ERROR),
|
||||||
(['borg'], -9, 'borg', None, module.Exit_status.ERROR),
|
(True, None, None, module.Exit_status.STILL_RUNNING),
|
||||||
(['borg1'], -9, 'borg1', None, module.Exit_status.ERROR),
|
(True, 1, [], module.Exit_status.WARNING),
|
||||||
(['borg'], None, None, None, module.Exit_status.STILL_RUNNING),
|
(True, 1, [{'code': 1}], module.Exit_status.WARNING),
|
||||||
(['borg'], 1, 'borg', [], module.Exit_status.WARNING),
|
(False, 1, [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
|
||||||
(['borg'], 1, 'borg', [{}], module.Exit_status.WARNING),
|
(True, 1, [{'code': 100, 'treat_as': 'error'}], module.Exit_status.WARNING),
|
||||||
(['borg'], 1, 'borg', [{'code': 1}], module.Exit_status.WARNING),
|
(True, 1, [{'code': 1, 'treat_as': 'error'}], module.Exit_status.ERROR),
|
||||||
(['grep'], 1, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
|
(True, 2, [{'code': 99, 'treat_as': 'warning'}], module.Exit_status.ERROR),
|
||||||
(['borg'], 1, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.WARNING),
|
(True, 2, [{'code': 2, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
||||||
(['borg'], 1, 'borg', [{'code': 1, 'treat_as': 'error'}], module.Exit_status.ERROR),
|
(True, 100, [{'code': 1, 'treat_as': 'error'}], module.Exit_status.WARNING),
|
||||||
(['borg'], 2, 'borg', [{'code': 99, 'treat_as': 'warning'}], module.Exit_status.ERROR),
|
(True, 100, [], module.Exit_status.WARNING),
|
||||||
(['borg'], 2, 'borg', [{'code': 2, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
(True, 100, [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
|
||||||
(['borg'], 100, 'borg', [{'code': 1, 'treat_as': 'error'}], module.Exit_status.WARNING),
|
(True, 101, [], module.Exit_status.ERROR),
|
||||||
(['borg'], 100, 'borg', [], module.Exit_status.WARNING),
|
(True, 101, [{'code': 101, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
||||||
(['borg'], 100, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
|
(True, 102, [], module.Exit_status.ERROR),
|
||||||
(['borg'], 101, 'borg', [], module.Exit_status.ERROR),
|
(True, 102, [{'code': 102, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
||||||
(['borg'], 101, 'borg', [{'code': 101, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
(True, 103, [], module.Exit_status.WARNING),
|
||||||
(['borg'], 102, 'borg', [], module.Exit_status.ERROR),
|
(True, 103, [{'code': 103, 'treat_as': 'error'}], module.Exit_status.ERROR),
|
||||||
(['borg'], 102, 'borg', [{'code': 102, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
(True, 104, [], module.Exit_status.ERROR),
|
||||||
(['borg'], 103, 'borg', [], module.Exit_status.WARNING),
|
(True, 104, [{'code': 104, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
||||||
(['borg'], 103, 'borg', [{'code': 103, 'treat_as': 'error'}], module.Exit_status.ERROR),
|
(True, 105, [], module.Exit_status.ERROR),
|
||||||
(['borg'], 104, 'borg', [], module.Exit_status.ERROR),
|
(True, 105, [{'code': 105, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
||||||
(['borg'], 104, 'borg', [{'code': 104, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
(True, 106, [], module.Exit_status.ERROR),
|
||||||
(['borg'], 105, 'borg', [], module.Exit_status.ERROR),
|
(True, 106, [{'code': 106, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
||||||
(['borg'], 105, 'borg', [{'code': 105, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
(True, 107, [], module.Exit_status.ERROR),
|
||||||
(['borg'], 106, 'borg', [], module.Exit_status.ERROR),
|
(True, 107, [{'code': 107, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
||||||
(['borg'], 106, 'borg', [{'code': 106, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
|
||||||
(['borg'], 107, 'borg', [], module.Exit_status.ERROR),
|
|
||||||
(['borg'], 107, 'borg', [{'code': 107, 'treat_as': 'warning'}], module.Exit_status.WARNING),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_interpret_exit_code_respects_exit_code_and_borg_local_path(
|
def test_interpret_exit_code_respects_exit_code_and_borg_local_path(
|
||||||
command,
|
command_is_borg,
|
||||||
exit_code,
|
exit_code,
|
||||||
borg_local_path,
|
|
||||||
borg_exit_codes,
|
borg_exit_codes,
|
||||||
expected_result,
|
expected_result,
|
||||||
):
|
):
|
||||||
|
flexmock(module).should_receive('command_is_borg').and_return(command_is_borg)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
module.interpret_exit_code(command, exit_code, borg_local_path, borg_exit_codes)
|
module.interpret_exit_code(
|
||||||
|
command=flexmock(),
|
||||||
|
exit_code=exit_code,
|
||||||
|
borg_local_path=flexmock(),
|
||||||
|
borg_exit_codes=borg_exit_codes,
|
||||||
|
)
|
||||||
is expected_result
|
is expected_result
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -122,6 +130,42 @@ def test_borg_json_log_line_to_record_parses_log_message_line():
|
|||||||
assert record.name == 'borg.something'
|
assert record.name == 'borg.something'
|
||||||
|
|
||||||
|
|
||||||
|
def test_borg_json_log_line_to_record_elevates_log_message_info_level_to_small_jump_in_log_level():
|
||||||
|
line = '{"type": "log_message", "levelname": "INFO", "time": 12345, "message": "All done", "name": "borg.something"}'
|
||||||
|
|
||||||
|
record = module.borg_json_log_line_to_record(line, 25)
|
||||||
|
|
||||||
|
assert record.levelno == 25
|
||||||
|
assert record.created == 12345
|
||||||
|
assert record.msg == 'All done'
|
||||||
|
assert record.levelname in {'ANSWER', 'Level 25'}
|
||||||
|
assert record.name == 'borg.something'
|
||||||
|
|
||||||
|
|
||||||
|
def test_borg_json_log_line_to_record_does_not_elevate_log_message_info_level_to_big_jump_in_log_level():
|
||||||
|
line = '{"type": "log_message", "levelname": "INFO", "time": 12345, "message": "All done", "name": "borg.something"}'
|
||||||
|
|
||||||
|
record = module.borg_json_log_line_to_record(line, 40)
|
||||||
|
|
||||||
|
assert record.levelno == module.logging.INFO
|
||||||
|
assert record.created == 12345
|
||||||
|
assert record.msg == 'All done'
|
||||||
|
assert record.levelname == 'INFO'
|
||||||
|
assert record.name == 'borg.something'
|
||||||
|
|
||||||
|
|
||||||
|
def test_borg_json_log_line_with_none_log_level_parses_log_message_line():
|
||||||
|
line = '{"type": "log_message", "levelname": "INFO", "time": 12345, "message": "All done", "name": "borg.something"}'
|
||||||
|
|
||||||
|
record = module.borg_json_log_line_to_record(line, None)
|
||||||
|
|
||||||
|
assert record.levelno == module.logging.INFO
|
||||||
|
assert record.created == 12345
|
||||||
|
assert record.msg == 'All done'
|
||||||
|
assert record.levelname == 'INFO'
|
||||||
|
assert record.name == 'borg.something'
|
||||||
|
|
||||||
|
|
||||||
def test_borg_json_log_line_to_record_parses_file_status_line():
|
def test_borg_json_log_line_to_record_parses_file_status_line():
|
||||||
flexmock(module.time).should_receive('time').and_return(12345)
|
flexmock(module.time).should_receive('time').and_return(12345)
|
||||||
line = '{"type": "file_status", "status": "-", "path": "/foo/bar"}'
|
line = '{"type": "file_status", "status": "-", "path": "/foo/bar"}'
|
||||||
@@ -1380,6 +1424,7 @@ def test_execute_command_without_run_to_completion_returns_process():
|
|||||||
def test_execute_command_and_capture_output_returns_stdout():
|
def test_execute_command_and_capture_output_returns_stdout():
|
||||||
full_command = ['foo', 'bar']
|
full_command = ['foo', 'bar']
|
||||||
flexmock(module).should_receive('log_command')
|
flexmock(module).should_receive('log_command')
|
||||||
|
flexmock(module).should_receive('command_is_borg').and_return(False)
|
||||||
process = flexmock()
|
process = flexmock()
|
||||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||||
full_command,
|
full_command,
|
||||||
@@ -1399,8 +1444,10 @@ def test_execute_command_and_capture_output_returns_stdout():
|
|||||||
assert output_lines == ('out',)
|
assert output_lines == ('out',)
|
||||||
|
|
||||||
|
|
||||||
def test_execute_command_and_capture_output_with_capture_stderr_returns_stderr():
|
def test_execute_command_and_capture_output_with_capture_stderr_popens_stderr():
|
||||||
full_command = ['foo', 'bar']
|
full_command = ['foo', 'bar']
|
||||||
|
flexmock(module).should_receive('log_command')
|
||||||
|
flexmock(module).should_receive('command_is_borg').and_return(False)
|
||||||
process = flexmock()
|
process = flexmock()
|
||||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||||
full_command,
|
full_command,
|
||||||
@@ -1422,10 +1469,34 @@ def test_execute_command_and_capture_output_with_capture_stderr_returns_stderr()
|
|||||||
assert output_lines == ('out',)
|
assert output_lines == ('out',)
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute_command_and_capture_output_with_borg_command_popens_stderr():
|
||||||
|
full_command = ['borg', 'list']
|
||||||
|
flexmock(module).should_receive('log_command')
|
||||||
|
flexmock(module).should_receive('command_is_borg').and_return(True)
|
||||||
|
process = flexmock()
|
||||||
|
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||||
|
full_command,
|
||||||
|
stdin=None,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
shell=False,
|
||||||
|
env=None,
|
||||||
|
cwd=None,
|
||||||
|
close_fds=False,
|
||||||
|
).and_return(process).once()
|
||||||
|
flexmock(module.borgmatic.logger).should_receive('Log_prefix').and_return(flexmock())
|
||||||
|
flexmock(module).should_receive('log_outputs').and_yield('out')
|
||||||
|
|
||||||
|
output_lines = tuple(module.execute_command_and_capture_output(full_command))
|
||||||
|
|
||||||
|
assert output_lines == ('out',)
|
||||||
|
|
||||||
|
|
||||||
def test_execute_command_and_capture_output_returns_output_when_process_error_is_not_considered_an_error():
|
def test_execute_command_and_capture_output_returns_output_when_process_error_is_not_considered_an_error():
|
||||||
full_command = ['foo', 'bar']
|
full_command = ['foo', 'bar']
|
||||||
err_output = b'[]'
|
err_output = b'[]'
|
||||||
flexmock(module).should_receive('log_command')
|
flexmock(module).should_receive('log_command')
|
||||||
|
flexmock(module).should_receive('command_is_borg').and_return(False)
|
||||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||||
full_command,
|
full_command,
|
||||||
stdin=None,
|
stdin=None,
|
||||||
@@ -1448,6 +1519,7 @@ def test_execute_command_and_capture_output_returns_output_when_process_error_is
|
|||||||
def test_execute_command_and_capture_output_raises_when_command_errors():
|
def test_execute_command_and_capture_output_raises_when_command_errors():
|
||||||
full_command = ['foo', 'bar']
|
full_command = ['foo', 'bar']
|
||||||
flexmock(module).should_receive('log_command')
|
flexmock(module).should_receive('log_command')
|
||||||
|
flexmock(module).should_receive('command_is_borg').and_return(False)
|
||||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||||
full_command,
|
full_command,
|
||||||
stdin=None,
|
stdin=None,
|
||||||
@@ -1469,6 +1541,7 @@ def test_execute_command_and_capture_output_raises_when_command_errors():
|
|||||||
def test_execute_command_and_capture_output_with_shell_returns_output():
|
def test_execute_command_and_capture_output_with_shell_returns_output():
|
||||||
full_command = ['foo', 'bar']
|
full_command = ['foo', 'bar']
|
||||||
flexmock(module).should_receive('log_command')
|
flexmock(module).should_receive('log_command')
|
||||||
|
flexmock(module).should_receive('command_is_borg').and_return(False)
|
||||||
process = flexmock()
|
process = flexmock()
|
||||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||||
'foo bar',
|
'foo bar',
|
||||||
@@ -1491,6 +1564,7 @@ def test_execute_command_and_capture_output_with_shell_returns_output():
|
|||||||
def test_execute_command_and_capture_output_with_enviroment_returns_output():
|
def test_execute_command_and_capture_output_with_enviroment_returns_output():
|
||||||
full_command = ['foo', 'bar']
|
full_command = ['foo', 'bar']
|
||||||
flexmock(module).should_receive('log_command')
|
flexmock(module).should_receive('log_command')
|
||||||
|
flexmock(module).should_receive('command_is_borg').and_return(False)
|
||||||
process = flexmock()
|
process = flexmock()
|
||||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||||
full_command,
|
full_command,
|
||||||
@@ -1519,6 +1593,7 @@ def test_execute_command_and_capture_output_with_enviroment_returns_output():
|
|||||||
def test_execute_command_and_capture_output_returns_output_with_working_directory():
|
def test_execute_command_and_capture_output_returns_output_with_working_directory():
|
||||||
full_command = ['foo', 'bar']
|
full_command = ['foo', 'bar']
|
||||||
flexmock(module).should_receive('log_command')
|
flexmock(module).should_receive('log_command')
|
||||||
|
flexmock(module).should_receive('command_is_borg').and_return(False)
|
||||||
process = flexmock()
|
process = flexmock()
|
||||||
flexmock(module.subprocess).should_receive('Popen').with_args(
|
flexmock(module.subprocess).should_receive('Popen').with_args(
|
||||||
full_command,
|
full_command,
|
||||||
|
|||||||
Reference in New Issue
Block a user