Files
maziggy 5bbfeefa65 fix(backup): diagnose an unwritable backup path instead of quoting errno 30 (#2544)
Nightly backups to a mounted NAS share ran from May and then stopped, failing
with [Errno 30] Read-only file system. The reporter checked folder permissions
-- correctly: the mount is gid=backup,dir_mode=0775, the service user is in that
group, and his own shell writes to the share fine.

Errno 30 is EROFS. A permission problem is errno 13. EROFS means the filesystem
refused the write, and it refused because we told it to: our systemd unit ships
ProtectSystem=strict, which mounts everything read-only inside the service's
mount namespace and carves back out only ReadWritePaths=<install> <data> <logs>.
A NAS share is not one of those three. Reads are unaffected -- which is why the
UI happily listed his existing backups from the share while being unable to
write a new one -- and his shell is outside the namespace entirely, so every
check he could think to run said the directory was fine.

Both installers write the unit file wholesale, so a ReadWritePaths line added by
hand disappeared on the next install, taking the backups with it. They now back
the old unit up (.bak-<timestamp>) and carry the operator's extra writable paths
forward, reporting which ones they kept. The unit template documents the
carve-out.

The output directory is probed with a real write when it is saved and when the
backup card loads, so an unwritable path is caught there rather than at 03:00
for a week. On failure the card names the cause and hands over the fix with the
operator's path already in it (systemctl edit bambuddy -> ReadWritePaths=...),
and a failed run reports the same diagnosis rather than the raw OSError. EROFS
outside systemd, permission-denied, out-of-space, not-a-directory and missing are
told apart, in all 11 locales.

Docker: a backup path that is not bind-mounted is writable -- the write lands in
the container's ephemeral layer and is lost on the next compose up. The probe
compares the directory's device against the container root and warns, with the
compose snippet that mounts it properly.
2026-07-12 08:44:53 +02:00

154 lines
6.0 KiB
Python

"""A backup directory the service cannot write to must say so, and say why (#2544).
The reporting bug this guards against: our own systemd unit ships
``ProtectSystem=strict``, so a NAS share the operator mounted and can write to
from their shell is read-only *for the service*. The kernel calls that EROFS,
the UI showed the raw ``[Errno 30] Read-only file system``, and the reporter
spent a week checking folder permissions — which were fine, because EROFS is not
a permission error.
"""
from __future__ import annotations
import errno
from pathlib import Path
import pytest
from backend.app.services import backup_path
from backend.app.services.backup_path import (
classify_backup_dir_error,
probe_backup_dir,
systemd_unit_name,
)
NAS = Path("/mnt/nasbackup")
class TestSystemdUnitName:
def test_none_when_not_started_by_systemd(self, monkeypatch):
monkeypatch.delenv("INVOCATION_ID", raising=False)
assert systemd_unit_name() is None
@pytest.mark.parametrize(
("cgroup", "expected"),
[
("0::/system.slice/bambuddy.service\n", "bambuddy.service"),
("0::/system.slice/system-bambuddy.slice/bambuddy@1.service\n", "bambuddy@1.service"),
# No .service in the path (a user scope, say) — still name something usable.
("0::/user.slice/user-1000.slice/session-3.scope\n", "bambuddy.service"),
],
)
def test_reads_the_unit_name_from_the_cgroup(self, monkeypatch, cgroup, expected):
monkeypatch.setenv("INVOCATION_ID", "deadbeef")
monkeypatch.setattr(Path, "read_text", lambda _self, *a, **k: cgroup)
assert systemd_unit_name() == expected
def test_falls_back_to_bambuddy_when_the_cgroup_is_unreadable(self, monkeypatch):
monkeypatch.setenv("INVOCATION_ID", "deadbeef")
def boom(_path):
raise OSError("no /proc here")
monkeypatch.setattr(Path, "read_text", boom)
assert systemd_unit_name() == "bambuddy.service"
class TestClassifyReadOnly:
def test_erofs_under_systemd_blames_the_sandbox_and_hands_over_the_fix(self, monkeypatch):
monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
result = classify_backup_dir_error(OSError(errno.EROFS, "Read-only file system"), NAS)
assert result["writable"] is False
assert result["code"] == "sandboxed"
assert "ProtectSystem=strict" in result["message"]
# The remedy has to be copy-pasteable, with their path already in it.
assert "systemctl edit bambuddy.service" in result["remedy"]
assert "ReadWritePaths=/mnt/nasbackup" in result["remedy"]
def test_erofs_outside_systemd_does_not_blame_a_unit_that_does_not_exist(self, monkeypatch):
monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: None)
result = classify_backup_dir_error(OSError(errno.EROFS, "Read-only file system"), NAS)
assert result["code"] == "read_only"
assert result["remedy"] is None
assert "systemd" not in result["message"]
def test_eacces_is_a_permission_problem_not_a_sandbox_one(self, monkeypatch):
monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
result = classify_backup_dir_error(OSError(errno.EACCES, "Permission denied"), NAS)
assert result["code"] == "permission_denied"
assert result["remedy"] is None
@pytest.mark.parametrize(
("errno_value", "expected"),
[
(errno.ENOSPC, "no_space"),
(errno.ENOTDIR, "not_a_directory"),
(errno.ENOENT, "missing"),
(errno.EIO, "error"),
],
)
def test_other_errnos_keep_their_own_identity(self, errno_value, expected):
result = classify_backup_dir_error(OSError(errno_value, "boom"), NAS)
assert result["code"] == expected
assert result["writable"] is False
class TestProbe:
def test_a_writable_directory_is_reported_writable_and_left_clean(self, tmp_path, monkeypatch):
monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: False)
target = tmp_path / "backups"
result = probe_backup_dir(target)
assert result["writable"] is True
assert result["code"] == "ok"
assert result["warning"] is None
assert target.is_dir()
# The probe file must not survive — it would show up in the backup list.
assert list(target.iterdir()) == []
def test_a_read_only_directory_is_diagnosed_not_just_reported(self, tmp_path, monkeypatch):
monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
target = tmp_path / "nasbackup"
target.mkdir()
def refuse(*_args, **_kwargs):
raise OSError(errno.EROFS, "Read-only file system")
monkeypatch.setattr(backup_path.tempfile, "NamedTemporaryFile", refuse)
result = probe_backup_dir(target)
assert result["writable"] is False
assert result["code"] == "sandboxed"
assert str(target) in result["remedy"]
def test_docker_path_on_the_container_layer_is_writable_but_flagged(self, tmp_path, monkeypatch):
"""Writable is not the same as persistent: an un-mounted host path inside a
container accepts the write and then loses it on the next `up`.
"""
monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: True)
monkeypatch.setattr(backup_path, "_is_container_ephemeral", lambda _p: True)
result = probe_backup_dir(tmp_path / "backups")
assert result["writable"] is True
assert result["warning"] == "container_ephemeral"
assert "volumes:" in result["remedy"]
def test_docker_path_on_a_mounted_volume_is_not_flagged(self, tmp_path, monkeypatch):
monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: True)
monkeypatch.setattr(backup_path, "_is_container_ephemeral", lambda _p: False)
result = probe_backup_dir(tmp_path / "backups")
assert result["writable"] is True
assert result["warning"] is None