Compare commits

..
15 Commits
Author SHA1 Message Date
Dan Helfman ad8d074eff Bump version for release. 2026-02-06 12:53:27 -08:00
Dan Helfman fc7439af3a If a source file is deleted during a "spot" check, consider the file as non-matching and move on instead of immediately failing the entire (#1231). 2026-02-05 23:11:59 -08:00
Dan Helfman ea05a4660c Deduplicate overlapping source directories and patterns so they don't throw off "spot" check file counts and cause spurious check failures. 2026-02-05 12:50:39 -08:00
Dan Helfman 957f6be4a2 Switch to iterable unpacking (#1252). 2026-02-05 10:38:07 -08:00
Dan Helfman 730a4b2f18 Work around Borg returning a warning exit code when a repository/archive check fails (#1252). 2026-02-05 10:19:06 -08:00
Dan Helfman c64c79ad0e Fix for SSH warnings from Borg showing up as JSON logs even without the "--log-json" flag (#1260). 2026-02-03 10:12:15 -08:00
Dan Helfman acd1a8d1dd Update ntfy test to use @pytest.mark.parametrize (#1251) 2026-02-02 11:58:50 -08:00
Dan Helfman c66fde4a93 Fix ntfy monitoring hook to send tags as an array (#1251).
Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1259
Reviewed-by: Dan Helfman <witten@torsion.org>
2026-02-02 19:43:50 +00:00
Mashrafi Rahman dbe3891819 Merge branch 'main' into fix-ntfy-tags-schema-array 2026-02-02 13:16:27 +00:00
Mashrafi Rahman dc89c9ec73 Update and test the convert_string_to_array function. 2026-02-02 18:26:38 +08:00
Dan Helfman a27dc95c87 Fix a "codec can't decode byte" error when running commands that output multi-byte unicode characters (#1258). 2026-02-01 20:15:03 -08:00
Mashrafi Rahman 6b5390f5dd Send tags as array in ntfy monitoring hook. 2026-02-01 15:47:13 +08:00
Dan Helfman fd485e64a3 Fix incorrect deprecated hook option name in documentation. 2026-01-30 22:43:54 -08:00
Dan Helfman f5de6bf43c Clarify command hooks documentation about YAML sequences (#1255). 2026-01-30 22:37:12 -08:00
Dan Helfman aa25dc7b31 Fix a regression in which the "--stats" flag hides statistics at default verbosity (#1250). 2026-01-28 11:49:24 -08:00
13 changed files with 486 additions and 123 deletions
+14
View File
@@ -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
* #1241: For the "recreate" action, actually pass the "--dry-run" flag through to Borg instead of
just skipping the Borg call.
+69 -33
View File
@@ -9,6 +9,7 @@ import pathlib
import random
import shlex
import shutil
import subprocess
import textwrap
import borgmatic.actions.config.bootstrap
@@ -418,7 +419,13 @@ def collect_spot_check_source_paths(
)
return tuple(
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
# Use dict.fromkeys() to deduplicate file paths, which are present in Borg's dry run output
# when there are overlapping source patterns. For instance, if both "/foo" and
# "/foo/file.txt" are in configured patterns, then "/foo/file.txt" will show up in Borg's
# dry run output twice.
dict.fromkeys(
path for path in paths if os.path.isfile(os.path.join(working_directory or '', path))
)
)
@@ -524,41 +531,70 @@ def compare_spot_check_hashes(
hash_paths = tuple(
path for path in source_sample_paths_subset if path in hashable_source_sample_path
)
hash_lines = borgmatic.execute.execute_command_and_capture_output(
tuple(
shlex.quote(part)
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
)
+ hash_paths,
working_directory=working_directory,
)
source_hashes.update(
**dict(
zip(
# xxh64sum rewrites/escapes the paths that it returns alongside its hashes, for
# instance if they contain special characters. When that happens, they don't
# match the original source paths and therefore hash lookups fail. So when
# building this lookup dict, use the original unaltered paths we provided as
# input to xxh64sum.
hash_paths,
(
# For some reason, xxh64sum prefixes the hash with a backslash if the path
# contains a newline. Work around that.
line.split(' ', 1)[0].lstrip('\\')
for line in hash_lines
try:
hash_lines = borgmatic.execute.execute_command_and_capture_output(
tuple(
shlex.quote(part)
for part in shlex.split(spot_check_config.get('xxh64sum_command', 'xxh64sum'))
)
+ hash_paths,
working_directory=working_directory,
)
source_hashes.update(
**dict(
zip(
# xxh64sum rewrites/escapes the paths that it returns alongside its hashes, for
# instance if they contain special characters. When that happens, they don't
# match the original source paths and therefore hash lookups fail. So when
# building this lookup dict, use the original unaltered paths we provided as
# input to xxh64sum.
hash_paths,
(
# For some reason, xxh64sum prefixes the hash with a backslash if the path
# contains a newline. Work around that.
line.split(' ', 1)[0].lstrip('\\')
for line in hash_lines
),
),
# Represent non-existent files as having empty hashes so the comparison below still
# works. Same thing for filesystem links, since Borg produces empty archive hashes
# for them.
**{
path: ''
for path in source_sample_paths_subset
if path not in hashable_source_sample_path
},
),
# Represent non-existent files as having empty hashes so the comparison below still
# works. Same thing for filesystem links, since Borg produces empty archive hashes
# for them.
**{
path: ''
for path in source_sample_paths_subset
if path not in hashable_source_sample_path
},
),
)
)
except subprocess.CalledProcessError:
# This can happen if a file we planned to hash gets deleted right before we try to hash
# it. Falling back to individual file hashing allows us to find and mark just the
# file(s) with problems instead of failing the whole batch.
logger.warning(
'Bulk source path hashing failed for this batch; falling back to individual file hashing'
)
for hash_path in hash_paths:
try:
hash_lines = borgmatic.execute.execute_command_and_capture_output(
(
*(
shlex.quote(part)
for part in shlex.split(
spot_check_config.get('xxh64sum_command', 'xxh64sum')
)
),
hash_path,
),
working_directory=working_directory,
)
source_hashes[hash_path] = next(hash_lines).split(' ', 1)[0].lstrip('\\')
except (subprocess.CalledProcessError, StopIteration): # noqa: PERF203
logger.warning(
f'Source path hashing failed for {hash_path}; treating as missing'
)
source_hashes[hash_path] = ''
# Get the hash for each file in the archive.
archive_hashes.update(
+3 -1
View File
@@ -149,8 +149,10 @@ def check_archives(
max_duration = check_arguments.max_duration or repository_check_config.get('max_duration')
# If not configured, elevate Borg's exit code 1 (an ostensible warning) to error, because Borg
# returns exit code 1 for repository check errors!
borg_exit_codes = [*config.get('borg_exit_codes', []), *[{'code': 1, 'treat_as': 'error'}]]
umask = config.get('umask')
borg_exit_codes = config.get('borg_exit_codes')
working_directory = borgmatic.config.paths.get_working_directory(config)
if 'data' in checks:
+49 -14
View File
@@ -29,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
@@ -42,9 +55,7 @@ def interpret_exit_code(command, exit_code, borg_local_path=None, borg_exit_code
if exit_code == 0:
return Exit_status.SUCCESS
parsed_command = command.split(' ', 1) if isinstance(command, str) else command
if borg_local_path and parsed_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:
@@ -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):
'''
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'),
@@ -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,
just elevate the log level to a WARN.
'''
parsed_command = command.split(' ', 1) if isinstance(command, str) else command
if borg_local_path and parsed_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:
@@ -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
tight loop.
'''
data = ''
data = b''
encoded_separator = line_separator.encode()
separator_size = len(encoded_separator)
while True:
chunk = os.read(buffer.fileno(), READ_CHUNK_SIZE).decode()
chunk = os.read(buffer.fileno(), READ_CHUNK_SIZE)
if not chunk: # EOF
# 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
# be a partial line.
while True:
separator_position = data.find(line_separator)
separator_position = data.find(encoded_separator)
if separator_position == -1:
break
lines.append(data[:separator_position].rstrip())
data = data[separator_position + 1 :]
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.rstrip(),)
yield (data.decode().rstrip(),)
Buffer_reader = collections.namedtuple(
@@ -612,7 +645,9 @@ def execute_command_and_capture_output(
command,
stdin=input_file,
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,
env=environment,
cwd=working_directory,
+14 -1
View File
@@ -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 = {
'max': 5,
'urgent': 5,
@@ -66,7 +79,7 @@ def ping_monitor(hook_config, config, config_filename, state, monitoring_log_lev
'title': state_config.get('title'),
'message': state_config.get('message'),
'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:
@@ -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
@@ -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`.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "borgmatic"
version = "2.1.1"
version = "2.1.2"
authors = [
{ name="Dan Helfman", email="witten@torsion.org" },
]
+13
View File
@@ -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():
process = subprocess.Popen(['echo', 'hi\nthere'], stdout=subprocess.PIPE)
+112
View File
@@ -986,6 +986,65 @@ def test_collect_spot_check_source_paths_uses_working_directory():
) == ('foo', 'bar')
def test_collect_spot_check_source_paths_deduplicates_borg_output_paths():
flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return(
{'hook1': False, 'hook2': True},
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
flexmock(),
)
flexmock(module.borgmatic.actions.pattern).should_receive('collect_patterns').and_return(
(Pattern('collected'),),
)
flexmock(module.borgmatic.actions.pattern).should_receive('process_patterns').with_args(
(
Pattern('collected', source=module.borgmatic.borg.pattern.Pattern_source.HOOK),
Pattern('extra.yaml', source=module.borgmatic.borg.pattern.Pattern_source.INTERNAL),
),
config=object,
working_directory=None,
).and_return(
[Pattern('foo'), Pattern('bar')],
)
flexmock(module.borgmatic.borg.create).should_receive('make_base_create_command').with_args(
dry_run=True,
repository_path='repo',
config=object,
patterns=[Pattern('foo'), Pattern('bar')],
local_borg_version=object,
global_arguments=object,
borgmatic_runtime_directory='/run/borgmatic',
local_path=object,
remote_path=object,
stream_processes=True,
).and_return((('borg', 'create'), ('repo::archive',), flexmock()))
flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return(
flexmock(),
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(None)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).and_yield(
'warning: stuff',
'- /etc/path',
'+ /etc/other',
'? /nope',
'- /etc/path',
)
flexmock(module.os.path).should_receive('isfile').and_return(True)
assert module.collect_spot_check_source_paths(
repository={'path': 'repo'},
config={'working_directory': '/'},
local_borg_version=flexmock(),
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
borgmatic_runtime_directory='/run/borgmatic',
bootstrap_config_paths=('extra.yaml',),
) == ('/etc/path', '/etc/other')
def test_compare_spot_check_hashes_returns_paths_having_failing_hashes():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
@@ -1115,6 +1174,59 @@ def test_compare_spot_check_hashes_handles_incorrect_path_names_from_xxh64sum():
) == ('/bar',)
def test_compare_spot_check_hashes_with_xxh64sum_failure_falls_back_to_individual_file_hashing():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
)
flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return(
None,
)
flexmock(module.os.path).should_receive('exists').and_return(True)
flexmock(module.os.path).should_receive('islink').and_return(False)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/foo', '/bar'), working_directory=None).and_raise(
module.subprocess.CalledProcessError(1, 'wtf')
)
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/foo'), working_directory=None).and_raise(
module.subprocess.CalledProcessError(1, 'wtf')
).once()
flexmock(module.borgmatic.execute).should_receive(
'execute_command_and_capture_output',
).with_args(('xxh64sum', '/bar'), working_directory=None).and_yield(
'hash2 /bar',
).once()
flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_yield(
{'xxh64': 'hash1', 'path': 'foo'},
{'xxh64': 'hash2', 'path': 'bar'},
)
assert module.compare_spot_check_hashes(
repository={'path': 'repo'},
archive='archive',
config={
'checks': [
{
'name': 'archives',
'frequency': '2 weeks',
},
{
'name': 'spot',
'data_sample_percentage': 50,
},
],
},
local_borg_version=flexmock(),
global_arguments=flexmock(),
local_path=flexmock(),
remote_path=flexmock(),
source_paths=('/foo', '/bar', '/baz', '/quux'),
) == ('/foo',)
def test_compare_spot_check_hashes_returns_relative_paths_having_failing_hashes():
flexmock(module.random).should_receive('SystemRandom').and_return(
flexmock(sample=lambda population, count: population[:count]),
+10 -10
View File
@@ -24,7 +24,7 @@ def insert_execute_command_mock(
environment=None,
working_directory=working_directory,
borg_local_path=command[0],
borg_exit_codes=borg_exit_codes,
borg_exit_codes=(borg_exit_codes or []) + [{'code': 1, 'treat_as': 'error'}],
).once()
@@ -335,7 +335,7 @@ def test_check_archives_with_progress_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -371,7 +371,7 @@ def test_check_archives_with_log_json_and_progress_passes_through_both_to_borg()
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -407,7 +407,7 @@ def test_check_archives_with_repair_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -443,7 +443,7 @@ def test_check_archives_with_log_json_and_repair_passes_through_both_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -479,7 +479,7 @@ def test_check_archives_with_max_duration_flag_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -515,7 +515,7 @@ def test_check_archives_with_max_duration_option_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -689,7 +689,7 @@ def test_check_archives_with_max_duration_flag_overrides_max_duration_option():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
@@ -854,7 +854,7 @@ def test_check_archives_with_local_path_calls_borg_via_local_path():
def test_check_archives_with_exit_codes_calls_borg_using_them():
checks = {'repository'}
borg_exit_codes = flexmock()
borg_exit_codes = [{'code': 101, 'treat_as': 'error'}]
config = {'borg_exit_codes': borg_exit_codes}
flexmock(module).should_receive('make_check_name_flags').with_args(checks, ()).and_return(())
flexmock(module.flags).should_receive('make_repository_flags').and_return(('repo',))
@@ -1026,7 +1026,7 @@ def test_check_archives_with_match_archives_passes_through_to_borg():
environment=None,
working_directory=None,
borg_local_path='borg',
borg_exit_codes=None,
borg_exit_codes=[{'code': 1, 'treat_as': 'error'}],
).once()
module.check_archives(
+61 -2
View File
@@ -1,10 +1,26 @@
from enum import Enum
import pytest
from flexmock import flexmock
import borgmatic.hooks.monitoring.monitor
from borgmatic.hooks.monitoring import ntfy as module
@pytest.mark.parametrize(
'tags_input,expected_tags_output',
(
(None, []),
('', []),
('foo', ['foo']),
(' foo , bar ,baz ', ['foo', 'bar', 'baz']),
('foo,,bar,', ['foo', 'bar']),
),
)
def test_convert_string_to_array(tags_input, expected_tags_output):
assert module.convert_string_to_array(tags_input) == expected_tags_output
DEFAULT_BASE_URL = 'https://ntfy.sh'
CUSTOM_BASE_URL = 'https://ntfy.example.com'
TOPIC = 'borgmatic-unit-testing'
@@ -25,7 +41,7 @@ CUSTOM_MESSAGE_PAYLOAD = {
'title': CUSTOM_MESSAGE_CONFIG['title'],
'message': CUSTOM_MESSAGE_CONFIG['message'],
'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',
'message': f'A borgmatic {state.name} event happened',
'priority': 3,
'tags': 'borgmatic',
'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)
+132 -57
View File
@@ -7,72 +7,80 @@ from borgmatic import execute as module
@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),
(['grep'], 2, 'borg', None, module.Exit_status.ERROR),
(['borg'], 2, 'borg', None, module.Exit_status.ERROR),
(['borg1'], 2, 'borg1', None, module.Exit_status.ERROR),
(['grep'], 1, None, None, module.Exit_status.ERROR),
(['grep'], 1, 'borg', None, module.Exit_status.ERROR),
(['borg'], 1, 'borg', None, module.Exit_status.WARNING),
(['borg1'], 1, 'borg1', None, module.Exit_status.WARNING),
(['grep'], 100, None, None, module.Exit_status.ERROR),
(['grep'], 100, 'borg', None, module.Exit_status.ERROR),
('grep', 2, None, None, module.Exit_status.ERROR),
('borg', 2, 'borg', None, module.Exit_status.ERROR),
(['borg'], 100, 'borg', None, module.Exit_status.WARNING),
(['borg1'], 100, 'borg1', None, module.Exit_status.WARNING),
('borg', 100, 'borg', None, module.Exit_status.WARNING),
('borg1', 100, 'borg1', None, module.Exit_status.WARNING),
(['grep'], 0, None, None, module.Exit_status.SUCCESS),
(['grep'], 0, 'borg', None, module.Exit_status.SUCCESS),
(['borg'], 0, 'borg', None, module.Exit_status.SUCCESS),
(['borg1'], 0, 'borg1', None, module.Exit_status.SUCCESS),
('grep', 0, None, None, module.Exit_status.SUCCESS),
('grep', 0, 'borg', None, module.Exit_status.SUCCESS),
(['foo', 'bar'], 'borg', False),
(['borg', 'list'], 'borg', True),
(['borg1', 'list'], 'borg', False),
(['borg', 'list'], 'borg1', False),
([], 'borg', False),
('foo bar', 'borg', False),
('borg list', 'borg', True),
('', 'borg', False),
),
)
def test_command_is_borg_matches_local_path_to_command(command, borg_local_path, expected_result):
assert module.command_is_borg(command, borg_local_path) == expected_result
@pytest.mark.parametrize(
'command_is_borg,exit_code,borg_exit_codes,expected_result',
(
(False, 2, None, module.Exit_status.ERROR),
(True, 2, None, module.Exit_status.ERROR),
(False, 1, None, module.Exit_status.ERROR),
(True, 1, None, module.Exit_status.WARNING),
(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.
(['grep'], -9, None, None, module.Exit_status.ERROR),
(['grep'], -9, 'borg', None, module.Exit_status.ERROR),
(['borg'], -9, 'borg', None, module.Exit_status.ERROR),
(['borg1'], -9, 'borg1', None, module.Exit_status.ERROR),
(['borg'], None, None, None, module.Exit_status.STILL_RUNNING),
(['borg'], 1, 'borg', [], module.Exit_status.WARNING),
(['borg'], 1, 'borg', [{}], module.Exit_status.WARNING),
(['borg'], 1, 'borg', [{'code': 1}], module.Exit_status.WARNING),
(['grep'], 1, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
(['borg'], 1, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.WARNING),
(['borg'], 1, 'borg', [{'code': 1, 'treat_as': 'error'}], module.Exit_status.ERROR),
(['borg'], 2, 'borg', [{'code': 99, 'treat_as': 'warning'}], module.Exit_status.ERROR),
(['borg'], 2, 'borg', [{'code': 2, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 100, 'borg', [{'code': 1, 'treat_as': 'error'}], module.Exit_status.WARNING),
(['borg'], 100, 'borg', [], module.Exit_status.WARNING),
(['borg'], 100, 'borg', [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
(['borg'], 101, 'borg', [], module.Exit_status.ERROR),
(['borg'], 101, 'borg', [{'code': 101, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 102, 'borg', [], module.Exit_status.ERROR),
(['borg'], 102, 'borg', [{'code': 102, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 103, 'borg', [], module.Exit_status.WARNING),
(['borg'], 103, 'borg', [{'code': 103, 'treat_as': 'error'}], module.Exit_status.ERROR),
(['borg'], 104, 'borg', [], module.Exit_status.ERROR),
(['borg'], 104, 'borg', [{'code': 104, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 105, 'borg', [], module.Exit_status.ERROR),
(['borg'], 105, 'borg', [{'code': 105, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(['borg'], 106, 'borg', [], module.Exit_status.ERROR),
(['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),
(False, -9, None, module.Exit_status.ERROR),
(True, -9, None, module.Exit_status.ERROR),
(True, None, None, module.Exit_status.STILL_RUNNING),
(True, 1, [], module.Exit_status.WARNING),
(True, 1, [{'code': 1}], module.Exit_status.WARNING),
(False, 1, [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
(True, 1, [{'code': 100, 'treat_as': 'error'}], module.Exit_status.WARNING),
(True, 1, [{'code': 1, 'treat_as': 'error'}], module.Exit_status.ERROR),
(True, 2, [{'code': 99, 'treat_as': 'warning'}], module.Exit_status.ERROR),
(True, 2, [{'code': 2, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 100, [{'code': 1, 'treat_as': 'error'}], module.Exit_status.WARNING),
(True, 100, [], module.Exit_status.WARNING),
(True, 100, [{'code': 100, 'treat_as': 'error'}], module.Exit_status.ERROR),
(True, 101, [], module.Exit_status.ERROR),
(True, 101, [{'code': 101, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 102, [], module.Exit_status.ERROR),
(True, 102, [{'code': 102, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 103, [], module.Exit_status.WARNING),
(True, 103, [{'code': 103, 'treat_as': 'error'}], module.Exit_status.ERROR),
(True, 104, [], module.Exit_status.ERROR),
(True, 104, [{'code': 104, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 105, [], module.Exit_status.ERROR),
(True, 105, [{'code': 105, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 106, [], module.Exit_status.ERROR),
(True, 106, [{'code': 106, 'treat_as': 'warning'}], module.Exit_status.WARNING),
(True, 107, [], module.Exit_status.ERROR),
(True, 107, [{'code': 107, 'treat_as': 'warning'}], module.Exit_status.WARNING),
),
)
def test_interpret_exit_code_respects_exit_code_and_borg_local_path(
command,
command_is_borg,
exit_code,
borg_local_path,
borg_exit_codes,
expected_result,
):
flexmock(module).should_receive('command_is_borg').and_return(command_is_borg)
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
)
@@ -122,6 +130,42 @@ def test_borg_json_log_line_to_record_parses_log_message_line():
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():
flexmock(module.time).should_receive('time').and_return(12345)
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():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
@@ -1399,8 +1444,10 @@ def test_execute_command_and_capture_output_returns_stdout():
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']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
@@ -1422,10 +1469,34 @@ def test_execute_command_and_capture_output_with_capture_stderr_returns_stderr()
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():
full_command = ['foo', 'bar']
err_output = b'[]'
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
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():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,
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():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
'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():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
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():
full_command = ['foo', 'bar']
flexmock(module).should_receive('log_command')
flexmock(module).should_receive('command_is_borg').and_return(False)
process = flexmock()
flexmock(module.subprocess).should_receive('Popen').with_args(
full_command,