custom_dir.py 9.7 KB

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