From d7b5eef3d1c019b057020f6be6fa6707c6df2606 Mon Sep 17 00:00:00 2001 From: Sebastian Preisner Date: Fri, 14 Aug 2026 17:06:01 +0200 Subject: [PATCH] fix: distinguish LockFailed causes instead of always blaming permissions Borg raises LockFailed for any failure to create the lock file, but Vorta reported every one of them as "No Repository Permissions". A repository whose backing storage is full ([Errno 122] on Linux, [Errno 28] anywhere) therefore sent users looking at SSH keys and permissions. Pass Borg's message along in the log signal context and branch on the errno it contains. Borg's original message is now always shown as detailed text, so a cause the branching does not know about is still visible. Refs #2544 --- src/vorta/application.py | 40 ++++++++++++++++--- src/vorta/borg/borg_job.py | 1 + .../create_lockfail_stderr.json | 2 + .../create_lockfail_stdout.json | 0 .../borg_json_output/create_quota_stderr.json | 2 + .../borg_json_output/create_quota_stdout.json | 0 tests/unit/test_lock.py | 32 +++++++++++++++ 7 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 tests/unit/borg_json_output/create_lockfail_stderr.json create mode 100644 tests/unit/borg_json_output/create_lockfail_stdout.json create mode 100644 tests/unit/borg_json_output/create_quota_stderr.json create mode 100644 tests/unit/borg_json_output/create_quota_stdout.json diff --git a/src/vorta/application.py b/src/vorta/application.py index b441a7a72..56ca86190 100644 --- a/src/vorta/application.py +++ b/src/vorta/application.py @@ -1,5 +1,6 @@ import logging import os +import re import sys from pathlib import Path from typing import Any, Dict, List, Tuple @@ -258,13 +259,42 @@ def react_to_log(self, mgs, context): msg.show() elif msgid == 'LockFailed': repo_url = context.get('repo_url') + borg_message = context.get('message', '') + + # Borg reports any failure to create the lock file as LockFailed, so the + # errno in its message tells us what actually went wrong. Match the number + # and not the text after it, because that text comes from strerror and is + # localized. ENOSPC is 28 everywhere, EDQUOT is 122 on Linux and 69 on + # macOS/BSD. For a remote repository the errno originates on the server, so + # the local platform's errno constants are not a valid comparison here. + errno_match = re.search(r'\[Errno (\d+)\]', borg_message) + err = int(errno_match.group(1)) if errno_match else None + msg = QMessageBox() - msg.setText( - self.tr( - f"You do not have permission to access the repository at {repo_url}. Gain access and try again." + msg.setIcon(QMessageBox.Icon.Critical) + if err in (28, 122, 69): + msg.setWindowTitle(self.tr("Repository Storage Full")) + msg.setText(self.tr("The storage backing the repository at {} is full.").format(repo_url)) + msg.setInformativeText( + self.tr( + "Borg could not create its lock file, so no operation can run — including pruning, " + "which would free space. Free up space outside the repository, then try again." + ) ) - ) # noqa: E501 - msg.setWindowTitle(self.tr("No Repository Permissions")) + elif err in (1, 13): + msg.setWindowTitle(self.tr("No Repository Permissions")) + msg.setText( + self.tr( + "You do not have permission to access the repository at {}. Gain access and try again." + ).format(repo_url) + ) + else: + msg.setWindowTitle(self.tr("Repository Lock Failed")) + msg.setText(self.tr("Borg could not lock the repository at {}.").format(repo_url)) + + if borg_message: + msg.setDetailedText(borg_message) + self._msg = msg msg.show() diff --git a/src/vorta/borg/borg_job.py b/src/vorta/borg/borg_job.py index a946dc77b..56167004d 100644 --- a/src/vorta/borg/borg_job.py +++ b/src/vorta/borg/borg_job.py @@ -283,6 +283,7 @@ def read_async(fd): if parsed['type'] == 'log_message': context = { 'msgid': parsed.get('msgid'), + 'message': parsed.get('message', ''), 'repo_url': self.params['repo_url'], 'profile_name': self.params.get('profile_name'), 'cmd': self.params['cmd'][1], diff --git a/tests/unit/borg_json_output/create_lockfail_stderr.json b/tests/unit/borg_json_output/create_lockfail_stderr.json new file mode 100644 index 000000000..d20e20845 --- /dev/null +++ b/tests/unit/borg_json_output/create_lockfail_stderr.json @@ -0,0 +1,2 @@ +{"type": "log_message", "time": 1786000100.0, "message": "Failed to create/acquire the lock /mnt/repo/lock.exclusive ([Errno 30] Read-only file system: '/mnt/repo/lock.exclusive.xn0egqgt.tmp').", "levelname": "ERROR", "name": "borg.archiver", "msgid": "LockFailed"} +{"type": "log_message", "time": 1786000100.5, "message": "Traceback (most recent call last):\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 4591, in main\n exit_code = archiver.run(args)\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 4523, in run\n return set_ec(func(args))\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 161, in wrapper\n with repository:\n File \"/usr/lib/python3/dist-packages/borg/repository.py\", line 190, in __enter__\n self.open(self.path, bool(self.exclusive), lock_wait=self.lock_wait, lock=self.do_lock)\n File \"/usr/lib/python3/dist-packages/borg/repository.py\", line 421, in open\n self.lock = Lock(os.path.join(path, 'lock'), exclusive, timeout=lock_wait, kill_stale_locks=hostname_is_unique()).acquire()\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 350, in acquire\n self._wait_for_readers_finishing(remove, sleep)\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 363, in _wait_for_readers_finishing\n self._lock.acquire()\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 138, in acquire\n raise LockFailed(self.path, str(err)) from None\nborg.locking.LockFailed: Failed to create/acquire the lock /mnt/repo/lock.exclusive ([Errno 30] Read-only file system: '/mnt/repo/lock.exclusive.xn0egqgt.tmp').\n\nPlatform: Linux github 6.9.0-30-generic #30-Ubuntu SMP Thu Jul 10 12:37:59 UTC 2025 x86_64\nLinux: Unknown Linux \nBorg: 1.4.5 Python: CPython 3.14.6 msgpack: 0.5.6\nPID: 64701 CWD: /home/user/Projects/vorta/tests/borg_json_output\nsys.argv: ['/usr/bin/borg', 'create', '--list', '--progress', '--info', '--log-json', '--json', '--filter=AM', '-C', 'lz4', '/mnt/repo::github-asdf-2026-08-14T00:05:49', '/home/user/bashrc']\nSSH_ORIGINAL_COMMAND: None\n", "levelname": "ERROR", "name": "borg.archiver"} diff --git a/tests/unit/borg_json_output/create_lockfail_stdout.json b/tests/unit/borg_json_output/create_lockfail_stdout.json new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/borg_json_output/create_quota_stderr.json b/tests/unit/borg_json_output/create_quota_stderr.json new file mode 100644 index 000000000..f74d18fc7 --- /dev/null +++ b/tests/unit/borg_json_output/create_quota_stderr.json @@ -0,0 +1,2 @@ +{"type": "log_message", "time": 1786000000.0, "message": "Failed to create/acquire the lock /home/repo/lock.exclusive ([Errno 122] Disk quota exceeded: '/home/repo/lock.exclusive.xn0egqgt.tmp').", "levelname": "ERROR", "name": "borg.archiver", "msgid": "LockFailed"} +{"type": "log_message", "time": 1786000000.5, "message": "Traceback (most recent call last):\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 4591, in main\n exit_code = archiver.run(args)\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 4523, in run\n return set_ec(func(args))\n File \"/usr/lib/python3/dist-packages/borg/archiver.py\", line 161, in wrapper\n with repository:\n File \"/usr/lib/python3/dist-packages/borg/repository.py\", line 190, in __enter__\n self.open(self.path, bool(self.exclusive), lock_wait=self.lock_wait, lock=self.do_lock)\n File \"/usr/lib/python3/dist-packages/borg/repository.py\", line 421, in open\n self.lock = Lock(os.path.join(path, 'lock'), exclusive, timeout=lock_wait, kill_stale_locks=hostname_is_unique()).acquire()\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 350, in acquire\n self._wait_for_readers_finishing(remove, sleep)\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 363, in _wait_for_readers_finishing\n self._lock.acquire()\n File \"/usr/lib/python3/dist-packages/borg/locking.py\", line 138, in acquire\n raise LockFailed(self.path, str(err)) from None\nborg.locking.LockFailed: Failed to create/acquire the lock /home/repo/lock.exclusive ([Errno 122] Disk quota exceeded: '/home/repo/lock.exclusive.xn0egqgt.tmp').\n\nPlatform: Linux github 6.9.0-30-generic #30-Ubuntu SMP Thu Jul 10 12:37:59 UTC 2025 x86_64\nLinux: Unknown Linux \nBorg: 1.4.5 Python: CPython 3.14.6 msgpack: 0.5.6\nPID: 64701 CWD: /home/user/Projects/vorta/tests/borg_json_output\nsys.argv: ['/usr/bin/borg', 'create', '--list', '--progress', '--info', '--log-json', '--json', '--filter=AM', '-C', 'lz4', '/home/repo::github-asdf-2026-08-14T00:05:49', '/home/user/bashrc']\nSSH_ORIGINAL_COMMAND: None\n", "levelname": "ERROR", "name": "borg.archiver"} diff --git a/tests/unit/borg_json_output/create_quota_stdout.json b/tests/unit/borg_json_output/create_quota_stdout.json new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/test_lock.py b/tests/unit/test_lock.py index 3157a2305..86aebdce9 100644 --- a/tests/unit/test_lock.py +++ b/tests/unit/test_lock.py @@ -20,6 +20,38 @@ def test_create_perm_error(qapp, borg_json_output, mocker, qtbot): del qapp._msg +def test_create_quota_error(qapp, borg_json_output, mocker, qtbot): + main = qapp.main_window + mocker.patch.object(vorta.application.QMessageBox, 'show') + + stdout, stderr = borg_json_output('create_quota') + popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) + mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) + + qtbot.mouseClick(main.createStartBtn, QtCore.Qt.MouseButton.LeftButton) + + qtbot.waitUntil(lambda: hasattr(qapp, '_msg'), **pytest._wait_defaults) + assert "is full" in qapp._msg.text() + assert "Disk quota exceeded" in qapp._msg.detailedText() + del qapp._msg + + +def test_create_lock_failed_generic(qapp, borg_json_output, mocker, qtbot): + main = qapp.main_window + mocker.patch.object(vorta.application.QMessageBox, 'show') + + stdout, stderr = borg_json_output('create_lockfail') + popen_result = mocker.MagicMock(stdout=stdout, stderr=stderr, returncode=0) + mocker.patch.object(vorta.borg.borg_job, 'Popen', return_value=popen_result) + + qtbot.mouseClick(main.createStartBtn, QtCore.Qt.MouseButton.LeftButton) + + qtbot.waitUntil(lambda: hasattr(qapp, '_msg'), **pytest._wait_defaults) + assert "Borg could not lock the repository" in qapp._msg.text() + assert "Read-only file system" in qapp._msg.detailedText() + del qapp._msg + + def test_create_lock(qapp, borg_json_output, mocker, qtbot): main = qapp.main_window mocker.patch.object(vorta.application.QMessageBox, 'show')