custom_dir.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import csv
  2. import json
  3. import os
  4. import pwd
  5. import re
  6. import subprocess
  7. import tempfile
  8. import time
  9. from datetime import datetime
  10. # ---------------------------------------------------------------------------
  11. # Backup
  12. # ---------------------------------------------------------------------------
  13. def backup_custom_dir(job, instance, backup_dir):
  14. """Sauvegarde un répertoire arbitraire au format compatible YunoHost."""
  15. cfg = json.loads(job.config_json or "{}")
  16. source_path = cfg.get("source_path", "").rstrip("/")
  17. excludes = cfg.get("excludes", [])
  18. if not source_path:
  19. raise ValueError("source_path manquant dans la configuration du job.")
  20. if not os.path.isabs(source_path):
  21. raise ValueError(f"source_path doit être un chemin absolu : {source_path}")
  22. from jobs.utils import unique_archive_name
  23. label = _slugify(job.name)
  24. base_name = f"{instance}_{label}_{datetime.utcnow().strftime('%Y%m%d')}"
  25. archive_name = unique_archive_name(base_name, backup_dir)
  26. archive_path = os.path.join(backup_dir, archive_name + ".tar")
  27. from flask import current_app
  28. instance_url = current_app.config.get("INSTANCE_URL", "")
  29. tmpdir = tempfile.mkdtemp(prefix="backupmanager_")
  30. try:
  31. # Répertoire de destination dans l'archive : data/custom{source_path}
  32. dest_in_archive = os.path.join(tmpdir, "data", "custom" + source_path)
  33. os.makedirs(dest_in_archive, exist_ok=True)
  34. # Copie avec sudo rsync (accès root pour lire tous les fichiers)
  35. rsync_cmd = ["sudo", "rsync", "-az", "--delete"]
  36. for exc in excludes:
  37. rsync_cmd += ["--exclude", exc]
  38. rsync_cmd += [source_path + "/", dest_in_archive + "/"]
  39. result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=7200)
  40. log = (result.stdout + result.stderr).strip()
  41. # Codes 23/24 : transfert partiel (ex. fichiers illisibles comme des
  42. # montages rclone) — on continue avec ce qui a pu être copié plutôt
  43. # que de faire échouer tout le job.
  44. warning = False
  45. if result.returncode in (23, 24):
  46. warning = True
  47. log = f"⚠ rsync : transfert partiel (code {result.returncode}) :\n{log}"
  48. elif result.returncode != 0:
  49. raise RuntimeError(f"rsync a échoué (code {result.returncode}) :\n{log}")
  50. # backup.csv (requis YunoHost)
  51. csv_path = os.path.join(tmpdir, "backup.csv")
  52. with open(csv_path, "w", newline="") as f:
  53. writer = csv.writer(f, delimiter=";")
  54. writer.writerow(["source", "dest"])
  55. writer.writerow([f"data/custom{source_path}", source_path])
  56. # backup_info.json (métadonnées BackupManager)
  57. info = {
  58. "instance_name": instance,
  59. "instance_url": instance_url,
  60. "type": "custom_dir",
  61. "source_path": source_path,
  62. "job_name": job.name,
  63. "created_at": datetime.utcnow().isoformat(),
  64. "backupmanager_version": "1.0.0",
  65. "restore": cfg.get("restore", {}),
  66. }
  67. info_path = os.path.join(tmpdir, "backup_info.json")
  68. with open(info_path, "w") as f:
  69. json.dump(info, f, indent=2)
  70. # Création du .tar via sudo (pour lire les fichiers root-owned dans tmpdir)
  71. result = subprocess.run(
  72. ["sudo", "tar", "-cf", archive_path, "-C", tmpdir,
  73. "backup.csv", "backup_info.json", "data"],
  74. capture_output=True, text=True, timeout=600,
  75. )
  76. if result.returncode != 0:
  77. raise RuntimeError(f"tar a échoué : {result.stderr.strip()}")
  78. # Rendre l'archive accessible à l'app
  79. subprocess.run(
  80. ["sudo", "chown", f"{_get_current_user()}:", archive_path],
  81. check=True,
  82. )
  83. # .info.json YunoHost — écrit dans tmpdir puis copié via sudo rsync
  84. from jobs.utils import sudo_getsize
  85. size = sudo_getsize(archive_path)
  86. ynh_info = {
  87. "created_at": int(time.time()),
  88. "description": f"BackupManager: custom_dir {source_path}",
  89. "size": size,
  90. "from_before_upgrade": False,
  91. "apps": {},
  92. "system": {},
  93. }
  94. tmp_ynh_info = os.path.join(tmpdir, archive_name + ".info.json")
  95. with open(tmp_ynh_info, "w") as f:
  96. json.dump(ynh_info, f, indent=2)
  97. subprocess.run(
  98. ["sudo", "rsync", tmp_ynh_info,
  99. os.path.join(backup_dir, archive_name + ".info.json")],
  100. capture_output=True,
  101. )
  102. finally:
  103. subprocess.run(["sudo", "rm", "-rf", tmpdir], check=False)
  104. return archive_name, log or "rsync terminé sans sortie.", warning
  105. # ---------------------------------------------------------------------------
  106. # Restore
  107. # ---------------------------------------------------------------------------
  108. def restore_custom_dir(archive_name, backup_dir):
  109. """Restauration complète d'un custom_dir : fichiers + user + service + permissions."""
  110. archive_path = os.path.join(backup_dir, archive_name + ".tar")
  111. from jobs.utils import sudo_exists
  112. if not sudo_exists(archive_path):
  113. raise FileNotFoundError(f"Archive introuvable : {archive_path}")
  114. info = _read_backup_info(archive_path)
  115. source_path = info.get("source_path", "").rstrip("/")
  116. restore_cfg = info.get("restore", {})
  117. log_lines = []
  118. tmpdir = tempfile.mkdtemp(prefix="backupmanager_restore_")
  119. try:
  120. # Extraction complète dans tmpdir
  121. result = subprocess.run(
  122. ["sudo", "tar", "-xf", archive_path, "-C", tmpdir],
  123. capture_output=True, text=True, timeout=600,
  124. )
  125. if result.returncode != 0:
  126. raise RuntimeError(f"Extraction échouée : {result.stderr.strip()}")
  127. log_lines.append("Archive extraite.")
  128. extracted_data = os.path.join(tmpdir, "data", "custom" + source_path)
  129. if not os.path.isdir(extracted_data):
  130. raise RuntimeError(
  131. f"Chemin attendu absent dans l'archive : data/custom{source_path}"
  132. )
  133. # Créer le répertoire de destination si absent
  134. subprocess.run(["sudo", "mkdir", "-p", source_path], check=True)
  135. # Restauration des fichiers
  136. result = subprocess.run(
  137. ["sudo", "rsync", "-az", "--delete",
  138. extracted_data + "/", source_path + "/"],
  139. capture_output=True, text=True, timeout=7200,
  140. )
  141. if result.returncode in (23, 24):
  142. log_lines.append(
  143. f"⚠ Fichiers restaurés vers {source_path} avec transfert partiel "
  144. f"(code {result.returncode}) : {result.stderr.strip()}"
  145. )
  146. elif result.returncode != 0:
  147. raise RuntimeError(f"rsync restore a échoué : {result.stderr.strip()}")
  148. else:
  149. log_lines.append(f"Fichiers restaurés vers {source_path}.")
  150. # User système
  151. user_cfg = restore_cfg.get("system_user", {})
  152. if user_cfg.get("name"):
  153. _restore_system_user(user_cfg, log_lines)
  154. # Permissions
  155. perms_cfg = restore_cfg.get("permissions", {})
  156. if perms_cfg:
  157. _restore_permissions(source_path, perms_cfg, log_lines)
  158. # Service systemd
  159. service_cfg = restore_cfg.get("systemd_service", {})
  160. if service_cfg.get("name"):
  161. _restore_systemd_service(service_cfg, log_lines)
  162. # Commandes post-restauration
  163. post_cmds = restore_cfg.get("post_restore_commands", [])
  164. for cmd in post_cmds:
  165. _run_command(cmd, log_lines)
  166. finally:
  167. subprocess.run(["sudo", "rm", "-rf", tmpdir], check=False)
  168. return "\n".join(log_lines)
  169. def _restore_system_user(user_cfg, log_lines):
  170. name = user_cfg["name"]
  171. home = user_cfg.get("home", "/opt/" + name)
  172. shell = user_cfg.get("shell", "/bin/false")
  173. try:
  174. pwd.getpwnam(name)
  175. log_lines.append(f"Utilisateur système '{name}' déjà existant.")
  176. except KeyError:
  177. subprocess.run(
  178. ["sudo", "useradd",
  179. "--system",
  180. "--home-dir", home,
  181. "--no-create-home",
  182. "--shell", shell,
  183. name],
  184. check=True,
  185. )
  186. log_lines.append(f"Utilisateur système '{name}' créé.")
  187. def _restore_permissions(source_path, perms_cfg, log_lines):
  188. owner = perms_cfg.get("owner")
  189. mode = perms_cfg.get("mode")
  190. if owner:
  191. subprocess.run(["sudo", "chown", "-R", owner, source_path], check=True)
  192. log_lines.append(f"Propriétaire défini : {owner}.")
  193. if mode:
  194. subprocess.run(["sudo", "chmod", "-R", mode, source_path], check=True)
  195. log_lines.append(f"Permissions définies : {mode}.")
  196. def _restore_systemd_service(service_cfg, log_lines):
  197. name = service_cfg["name"]
  198. service_file = service_cfg.get("service_file", "")
  199. if service_file and os.path.exists(service_file):
  200. subprocess.run(["sudo", "systemctl", "daemon-reload"], check=False)
  201. log_lines.append("systemctl daemon-reload effectué.")
  202. subprocess.run(["sudo", "systemctl", "enable", name], check=False)
  203. result = subprocess.run(
  204. ["sudo", "systemctl", "start", name],
  205. capture_output=True, text=True,
  206. )
  207. if result.returncode == 0:
  208. log_lines.append(f"Service '{name}' activé et démarré.")
  209. else:
  210. log_lines.append(
  211. f"Service '{name}' : démarrage échoué — {result.stderr.strip()}"
  212. )
  213. def _run_command(cmd, log_lines):
  214. result = subprocess.run(
  215. cmd, shell=True, capture_output=True, text=True, timeout=120
  216. )
  217. out = (result.stdout + result.stderr).strip()
  218. status = "✓" if result.returncode == 0 else "✗"
  219. log_lines.append(f"{status} {cmd}" + (f"\n {out}" if out else ""))
  220. # ---------------------------------------------------------------------------
  221. # Utilitaires
  222. # ---------------------------------------------------------------------------
  223. def _slugify(s):
  224. return re.sub(r'[^a-z0-9]+', '-', s.lower().strip()).strip('-')
  225. def _get_current_user():
  226. import getpass
  227. return getpass.getuser()
  228. def _read_backup_info(archive_path):
  229. from jobs.utils import sudo_read_backup_info
  230. return sudo_read_backup_info(archive_path)
  231. def read_backup_info_from_dir(archive_name, backup_dir):
  232. """Utilisé par app.py pour afficher les infos de restauration."""
  233. from jobs.utils import sudo_read_backup_info
  234. archive_path = os.path.join(backup_dir, archive_name + ".tar")
  235. return sudo_read_backup_info(archive_path)