custom_dir.py 9.8 KB

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