Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/vorta/borg/borg_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,27 @@
FakeRepo = namedtuple('Repo', ['url', 'name', 'id', 'extra_borg_arguments', 'encryption'])
FakeProfile = namedtuple('FakeProfile', ['id', 'repo', 'name', 'ssh_key'])

TRACEBACK_HEADER = 'Traceback (most recent call last):'


def summarize_traceback(message: str) -> str:
"""
Borg logs unexpected errors as a full Python traceback, followed by system info.
Return only the line with the exception, e.g. `PermissionError: [Errno 13] Permission denied: '/repo'`,
so it can be shown to the user. Other messages are returned unchanged.
"""
if not message.startswith(TRACEBACK_HEADER):
return message

# With chained exceptions, the last traceback has the exception that was raised in the end.
last_traceback = message.rsplit(TRACEBACK_HEADER, 1)[1]
for line in last_traceback.splitlines():
# The lines of the stack frames are indented, the exception line is not.
if line.strip() and not line[0].isspace():
return line
return message


"""
All methods in this class must be thread safe. Particularly,
I strongly unadvised global variable and class variables.
Expand Down Expand Up @@ -285,6 +306,7 @@ def read_async(fd):
parsed = json.loads(line)

if parsed['type'] == 'log_message':
message = summarize_traceback(parsed['message'])
context = {
'msgid': parsed.get('msgid'),
'message': parsed.get('message', ''),
Expand All @@ -293,14 +315,14 @@ def read_async(fd):
'cmd': self.params['cmd'][1],
}
self.app.backup_log_event.emit(
f'[{self.params["profile_name"]}] {parsed["levelname"]}: {parsed["message"]}', context
f'[{self.params["profile_name"]}] {parsed["levelname"]}: {message}', context
)
level_int = getattr(logging, parsed["levelname"])
logger.log(level_int, parsed["message"])

if level_int >= logging.WARNING:
# Append log to list of error messages
error_messages.append((level_int, parsed["message"]))
error_messages.append((level_int, message))

elif parsed['type'] == 'file_status':
self.app.backup_log_event.emit(
Expand Down
63 changes: 62 additions & 1 deletion tests/unit/test_borg.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import os
import traceback

import pytest

import vorta.application
import vorta.borg
import vorta.store.models
from vorta.borg.borg_job import BorgJob
from vorta.borg.borg_job import BorgJob, summarize_traceback
from vorta.borg.prune import BorgPruneJob


Expand Down Expand Up @@ -35,3 +37,62 @@ def test_prepare_bin_does_not_grow_path(monkeypatch):
assert os.environ['PATH'] == path_after_first_call
path_dirs = path_after_first_call.split(os.pathsep)
assert len(path_dirs) == len(set(path_dirs))


def _traceback_of(func):
try:
func()
except Exception:
return traceback.format_exc()


def _chained_error():
try:
open('/does/not/exist')
except OSError as e:
raise RuntimeError('could not open the repository') from e


def test_summarize_traceback():
sysinfo = 'Platform: Linux host 6.8.0\nBorg: 1.4.4 Python: CPython 3.12.3\nSSH_ORIGINAL_COMMAND: None\n'
file_error = _traceback_of(lambda: open('/no/such/dir/file'))
assert file_error.startswith('Traceback (most recent call last):')

assert summarize_traceback(file_error + '\n' + sysinfo).startswith('FileNotFoundError: [Errno 2]')
assert summarize_traceback(_traceback_of(_chained_error)) == 'RuntimeError: could not open the repository'

# Other messages are not changed.
for message in ['Failed to create/acquire the lock /repo/lock.exclusive (timeout).', '', sysinfo]:
assert summarize_traceback(message) == message


def test_borg_job_shows_exception_instead_of_traceback(qapp, qtbot, mocker, borg_json_output):
"""Borg logs a traceback after the error. Only its last line is shown and returned as error."""
mocker.patch.object(vorta.application.QMessageBox, 'show') # dialog for the LockFailed message
stdout, stderr = borg_json_output('create_perm')
popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=2)
mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result)
log_texts = []

def record_log(text, context):
log_texts.append(text)

qapp.backup_log_event.connect(record_log)

params = BorgPruneJob.prepare(vorta.store.models.BackupProfileModel.select().first())
thread = BorgPruneJob(params['cmd'], params, qapp)
try:
with qtbot.waitSignal(thread.result, **pytest._wait_defaults) as blocker:
thread.run()
finally:
qapp.backup_log_event.disconnect(record_log)
if hasattr(qapp, '_msg'):
del qapp._msg

exception_line = (
"borg.locking.LockFailed: Failed to create/acquire the lock /tmp/another/lock.exclusive "
"([Errno 13] Permission denied: '/tmp/another/lock.exclusive')."
)
assert not any('Traceback' in text for text in log_texts)
assert log_texts[-1].endswith(f'ERROR: {exception_line}')
assert blocker.args[0]['errors'][-1][1] == exception_line
Loading