Files
bambuddy/backend/tests/unit/test_systemd_backup_paths.py
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

61 lines
2.5 KiB
Python

"""Reinstalling must not silently take away a writable path (#2544).
``ProtectSystem=strict`` means the unit's ``ReadWritePaths`` is the *complete*
list of places Bambuddy can write. An operator who backs up to a NAS adds their
share to it by hand — and both installers overwrite the unit file wholesale, so
that line used to vanish on the next install. The backups then failed with EROFS
every night, which looks like a NAS permission problem and is not one.
So the installers keep the operator's extra paths, and the unit says why they
matter.
"""
from __future__ import annotations
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[3]
INSTALLERS = ["install/install.sh", "spoolbuddy/install/install.sh"]
def _read(rel: str) -> str:
path = REPO / rel
assert path.is_file(), f"launcher moved or was removed: {rel}"
return path.read_text()
class TestUnitTemplate:
def test_readwritepaths_still_grants_the_three_app_dirs(self):
unit = _read("deploy/bambuddy.service")
line = next(line for line in unit.splitlines() if line.startswith("ReadWritePaths="))
assert "DATA_DIR" in line and "LOG_DIR" in line and "INSTALL_PATH" in line
def test_unit_explains_how_to_add_a_backup_share(self):
"""Whoever reads this unit next has to be able to work out why their NAS
is read-only for the service but not for their shell.
"""
unit = _read("deploy/bambuddy.service")
assert "systemctl edit" in unit, "the unit should show how to add a writable path via a drop-in"
class TestInstallersPreserveCustomPaths:
@pytest.mark.parametrize("installer", INSTALLERS)
def test_generated_unit_appends_the_carried_over_paths(self, installer):
script = _read(installer)
line = next(line for line in script.splitlines() if line.startswith("ReadWritePaths="))
assert "$extra_rw" in line, (
f"{installer} writes ReadWritePaths without $extra_rw, so a NAS share the operator "
"added to the unit is dropped on reinstall:\n" + line
)
@pytest.mark.parametrize("installer", INSTALLERS)
def test_existing_unit_is_read_for_custom_paths_and_backed_up(self, installer):
script = _read(installer)
assert "ReadWritePaths=" in script and "extra_rw+=" in script, (
f"{installer} no longer carries the previous unit's ReadWritePaths forward"
)
assert ".bak-" in script, f"{installer} overwrites the unit without backing it up first"