custom_dir.py 9.8 KB

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