test_backup_path.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. """A backup directory the service cannot write to must say so, and say why (#2544).
  2. The reporting bug this guards against: our own systemd unit ships
  3. ``ProtectSystem=strict``, so a NAS share the operator mounted and can write to
  4. from their shell is read-only *for the service*. The kernel calls that EROFS,
  5. the UI showed the raw ``[Errno 30] Read-only file system``, and the reporter
  6. spent a week checking folder permissions — which were fine, because EROFS is not
  7. a permission error.
  8. """
  9. from __future__ import annotations
  10. import errno
  11. from pathlib import Path
  12. import pytest
  13. from backend.app.services import backup_path
  14. from backend.app.services.backup_path import (
  15. classify_backup_dir_error,
  16. probe_backup_dir,
  17. systemd_unit_name,
  18. )
  19. NAS = Path("/mnt/nasbackup")
  20. class TestSystemdUnitName:
  21. def test_none_when_not_started_by_systemd(self, monkeypatch):
  22. monkeypatch.delenv("INVOCATION_ID", raising=False)
  23. assert systemd_unit_name() is None
  24. @pytest.mark.parametrize(
  25. ("cgroup", "expected"),
  26. [
  27. ("0::/system.slice/bambuddy.service\n", "bambuddy.service"),
  28. ("0::/system.slice/system-bambuddy.slice/bambuddy@1.service\n", "bambuddy@1.service"),
  29. # No .service in the path (a user scope, say) — still name something usable.
  30. ("0::/user.slice/user-1000.slice/session-3.scope\n", "bambuddy.service"),
  31. ],
  32. )
  33. def test_reads_the_unit_name_from_the_cgroup(self, monkeypatch, cgroup, expected):
  34. monkeypatch.setenv("INVOCATION_ID", "deadbeef")
  35. monkeypatch.setattr(Path, "read_text", lambda _self, *a, **k: cgroup)
  36. assert systemd_unit_name() == expected
  37. def test_falls_back_to_bambuddy_when_the_cgroup_is_unreadable(self, monkeypatch):
  38. monkeypatch.setenv("INVOCATION_ID", "deadbeef")
  39. def boom(_path):
  40. raise OSError("no /proc here")
  41. monkeypatch.setattr(Path, "read_text", boom)
  42. assert systemd_unit_name() == "bambuddy.service"
  43. class TestClassifyReadOnly:
  44. def test_erofs_under_systemd_blames_the_sandbox_and_hands_over_the_fix(self, monkeypatch):
  45. monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
  46. result = classify_backup_dir_error(OSError(errno.EROFS, "Read-only file system"), NAS)
  47. assert result["writable"] is False
  48. assert result["code"] == "sandboxed"
  49. assert "ProtectSystem=strict" in result["message"]
  50. # The remedy has to be copy-pasteable, with their path already in it.
  51. assert "systemctl edit bambuddy.service" in result["remedy"]
  52. assert "ReadWritePaths=/mnt/nasbackup" in result["remedy"]
  53. def test_erofs_outside_systemd_does_not_blame_a_unit_that_does_not_exist(self, monkeypatch):
  54. monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: None)
  55. result = classify_backup_dir_error(OSError(errno.EROFS, "Read-only file system"), NAS)
  56. assert result["code"] == "read_only"
  57. assert result["remedy"] is None
  58. assert "systemd" not in result["message"]
  59. def test_eacces_is_a_permission_problem_not_a_sandbox_one(self, monkeypatch):
  60. monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
  61. result = classify_backup_dir_error(OSError(errno.EACCES, "Permission denied"), NAS)
  62. assert result["code"] == "permission_denied"
  63. assert result["remedy"] is None
  64. @pytest.mark.parametrize(
  65. ("errno_value", "expected"),
  66. [
  67. (errno.ENOSPC, "no_space"),
  68. (errno.ENOTDIR, "not_a_directory"),
  69. (errno.ENOENT, "missing"),
  70. (errno.EIO, "error"),
  71. ],
  72. )
  73. def test_other_errnos_keep_their_own_identity(self, errno_value, expected):
  74. result = classify_backup_dir_error(OSError(errno_value, "boom"), NAS)
  75. assert result["code"] == expected
  76. assert result["writable"] is False
  77. class TestProbe:
  78. def test_a_writable_directory_is_reported_writable_and_left_clean(self, tmp_path, monkeypatch):
  79. monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: False)
  80. target = tmp_path / "backups"
  81. result = probe_backup_dir(target)
  82. assert result["writable"] is True
  83. assert result["code"] == "ok"
  84. assert result["warning"] is None
  85. assert target.is_dir()
  86. # The probe file must not survive — it would show up in the backup list.
  87. assert list(target.iterdir()) == []
  88. def test_a_read_only_directory_is_diagnosed_not_just_reported(self, tmp_path, monkeypatch):
  89. monkeypatch.setattr(backup_path, "systemd_unit_name", lambda: "bambuddy.service")
  90. target = tmp_path / "nasbackup"
  91. target.mkdir()
  92. def refuse(*_args, **_kwargs):
  93. raise OSError(errno.EROFS, "Read-only file system")
  94. monkeypatch.setattr(backup_path.tempfile, "NamedTemporaryFile", refuse)
  95. result = probe_backup_dir(target)
  96. assert result["writable"] is False
  97. assert result["code"] == "sandboxed"
  98. assert str(target) in result["remedy"]
  99. def test_docker_path_on_the_container_layer_is_writable_but_flagged(self, tmp_path, monkeypatch):
  100. """Writable is not the same as persistent: an un-mounted host path inside a
  101. container accepts the write and then loses it on the next `up`.
  102. """
  103. monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: True)
  104. monkeypatch.setattr(backup_path, "_is_container_ephemeral", lambda _p: True)
  105. result = probe_backup_dir(tmp_path / "backups")
  106. assert result["writable"] is True
  107. assert result["warning"] == "container_ephemeral"
  108. assert "volumes:" in result["remedy"]
  109. def test_docker_path_on_a_mounted_volume_is_not_flagged(self, tmp_path, monkeypatch):
  110. monkeypatch.setattr(backup_path, "is_running_in_docker", lambda: True)
  111. monkeypatch.setattr(backup_path, "_is_container_ephemeral", lambda _p: False)
  112. result = probe_backup_dir(tmp_path / "backups")
  113. assert result["writable"] is True
  114. assert result["warning"] is None