backup_path.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """Why a backup directory is not writable — and what to actually do about it.
  2. Bambuddy's systemd unit runs with ``ProtectSystem=strict``. That mounts the
  3. entire filesystem read-only inside the service's own mount namespace and carves
  4. back out only ``ReadWritePaths=<install> <data> <logs>``. A backup output path
  5. on a NAS mount is therefore read-only *to the service* while the operator's own
  6. shell writes to it happily. The kernel reports this as ``EROFS``, not
  7. ``EACCES``, so the obvious move — checking folder permissions — turns up nothing
  8. and the real cause (our own unit file) is the last place anyone looks (#2544).
  9. Docker has the same shape with a different cause: a host path that was never
  10. bind-mounted into the container is simply not the host path. Worse, it is still
  11. *writable* — the write lands in the container's ephemeral layer and vanishes on
  12. the next ``docker compose up``. A backup that silently goes nowhere is the one
  13. failure mode a backup feature must not have.
  14. So: probe the directory with a real write before trusting it, and when that
  15. write fails, name which of these it is and hand back the exact command that
  16. fixes it.
  17. """
  18. from __future__ import annotations
  19. import errno
  20. import logging
  21. import os
  22. import re
  23. import tempfile
  24. from pathlib import Path
  25. from backend.app.services.discovery import is_running_in_docker
  26. logger = logging.getLogger(__name__)
  27. # Cgroup line for a systemd service, e.g.
  28. # 0::/system.slice/bambuddy.service
  29. # 0::/system.slice/system-bambuddy.slice/bambuddy@1.service
  30. _SERVICE_CGROUP = re.compile(r"/([^/]+\.service)\b")
  31. def systemd_unit_name() -> str | None:
  32. """Name of the systemd unit we are running as, or None if we are not one.
  33. ``INVOCATION_ID`` is set by systemd for every unit it starts and by nothing
  34. else, so it is the signal that we are a unit at all. The name itself comes
  35. from the cgroup path — systemd exports no environment variable for it.
  36. """
  37. if not os.environ.get("INVOCATION_ID"):
  38. return None
  39. try:
  40. cgroup = Path("/proc/self/cgroup").read_text()
  41. except OSError:
  42. return "bambuddy.service"
  43. match = _SERVICE_CGROUP.search(cgroup)
  44. return match.group(1) if match else "bambuddy.service"
  45. def _systemd_remedy(unit: str, path: Path) -> str:
  46. return (
  47. f"sudo systemctl edit {unit}\n"
  48. "\n"
  49. "Add these two lines to the drop-in, save, then restart:\n"
  50. "\n"
  51. "[Service]\n"
  52. f"ReadWritePaths={path}\n"
  53. "\n"
  54. f"sudo systemctl restart {unit}"
  55. )
  56. def _docker_remedy(path: Path) -> str:
  57. return f"services:\n bambuddy:\n volumes:\n - {path}:{path}"
  58. def classify_backup_dir_error(exc: OSError, backup_dir: Path) -> dict:
  59. """Map an OSError raised while writing to ``backup_dir`` onto a diagnosis.
  60. ``message`` is English and goes to the log and the API. The frontend
  61. translates from ``code`` and renders ``remedy`` verbatim as a snippet.
  62. """
  63. detail = str(exc)
  64. unit = systemd_unit_name()
  65. if exc.errno == errno.EROFS:
  66. if unit:
  67. return {
  68. "writable": False,
  69. "path": str(backup_dir),
  70. "code": "sandboxed",
  71. "detail": detail,
  72. "remedy": _systemd_remedy(unit, backup_dir),
  73. "message": (
  74. f"{backup_dir} is read-only for the Bambuddy service. Its systemd unit runs with "
  75. "ProtectSystem=strict, which makes every path outside the install, data and log "
  76. f"directories read-only — add ReadWritePaths={backup_dir} to a drop-in "
  77. f"(sudo systemctl edit {unit}) and restart. If the path is on a network share, also "
  78. "confirm the share itself is not mounted read-only."
  79. ),
  80. }
  81. return {
  82. "writable": False,
  83. "path": str(backup_dir),
  84. "code": "read_only",
  85. "detail": detail,
  86. "remedy": None,
  87. "message": f"{backup_dir} is on a read-only filesystem.",
  88. }
  89. if exc.errno in (errno.EACCES, errno.EPERM):
  90. return {
  91. "writable": False,
  92. "path": str(backup_dir),
  93. "code": "permission_denied",
  94. "detail": detail,
  95. "remedy": None,
  96. "message": f"Bambuddy is not allowed to write to {backup_dir}. Check the directory's owner and mode.",
  97. }
  98. if exc.errno == errno.ENOSPC:
  99. return {
  100. "writable": False,
  101. "path": str(backup_dir),
  102. "code": "no_space",
  103. "detail": detail,
  104. "remedy": None,
  105. "message": f"No space left on the filesystem holding {backup_dir}.",
  106. }
  107. if exc.errno in (errno.ENOTDIR, errno.EEXIST):
  108. return {
  109. "writable": False,
  110. "path": str(backup_dir),
  111. "code": "not_a_directory",
  112. "detail": detail,
  113. "remedy": None,
  114. "message": f"{backup_dir} exists but is not a directory.",
  115. }
  116. if exc.errno == errno.ENOENT:
  117. return {
  118. "writable": False,
  119. "path": str(backup_dir),
  120. "code": "missing",
  121. "detail": detail,
  122. "remedy": None,
  123. "message": f"{backup_dir} does not exist and could not be created.",
  124. }
  125. return {
  126. "writable": False,
  127. "path": str(backup_dir),
  128. "code": "error",
  129. "detail": detail,
  130. "remedy": None,
  131. "message": f"Bambuddy cannot write to {backup_dir}: {exc}",
  132. }
  133. def _is_container_ephemeral(backup_dir: Path) -> bool:
  134. """True if this path lives in the container's own writable layer.
  135. A bind mount or named volume always sits on a different device than the
  136. container root, so a matching ``st_dev`` means nothing was mounted here and
  137. the backups die with the container.
  138. """
  139. try:
  140. return backup_dir.stat().st_dev == Path("/").stat().st_dev
  141. except OSError:
  142. return False
  143. def probe_backup_dir(backup_dir: Path) -> dict:
  144. """Create the directory and write a throwaway file in it.
  145. Returns the same shape as :func:`classify_backup_dir_error`, plus a
  146. ``warning`` code for a directory that is writable but not persistent.
  147. """
  148. try:
  149. backup_dir.mkdir(parents=True, exist_ok=True)
  150. with tempfile.NamedTemporaryFile(dir=backup_dir, prefix=".bambuddy-write-test-") as probe:
  151. probe.write(b"bambuddy")
  152. probe.flush()
  153. except OSError as e:
  154. result = classify_backup_dir_error(e, backup_dir)
  155. logger.warning("Backup path check failed: %s", result["message"])
  156. return {**result, "warning": None}
  157. warning = None
  158. if is_running_in_docker() and _is_container_ephemeral(backup_dir):
  159. warning = "container_ephemeral"
  160. return {
  161. "writable": True,
  162. "path": str(backup_dir),
  163. "code": "ok",
  164. "detail": None,
  165. "remedy": _docker_remedy(backup_dir) if warning else None,
  166. "message": f"{backup_dir} is writable.",
  167. "warning": warning,
  168. }