| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153 |
- """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
|