custom_dir.py 9.9 KB

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