db_dump.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import json
  2. import os
  3. import subprocess
  4. import tarfile
  5. import tempfile
  6. import time
  7. from datetime import datetime
  8. from db import db, Job, Run
  9. def run_db_dump(job, instance, backup_dir):
  10. """Point d'entrée commun mysql et postgresql."""
  11. if job.type == "mysql":
  12. return _run_mysql(job, instance, backup_dir)
  13. elif job.type == "postgresql":
  14. return _run_postgresql(job, instance, backup_dir)
  15. raise ValueError(f"Type inconnu pour db_dump : {job.type}")
  16. # ---------------------------------------------------------------------------
  17. # MySQL
  18. # ---------------------------------------------------------------------------
  19. def _run_mysql(job, instance, backup_dir):
  20. from flask import current_app
  21. cfg = json.loads(job.config_json or "{}")
  22. dbname = cfg.get("database", "")
  23. if not dbname:
  24. raise ValueError("Nom de base de données manquant dans la configuration du job.")
  25. archive_name = _archive_name(instance, "mysql", dbname)
  26. _abort_if_exists(archive_name, backup_dir)
  27. with tempfile.TemporaryDirectory() as tmpdir:
  28. dump_path = os.path.join(tmpdir, f"{dbname}.sql")
  29. result = subprocess.run(
  30. [
  31. "sudo", "mysqldump",
  32. "--single-transaction",
  33. "--routines",
  34. "--triggers",
  35. "--result-file", dump_path,
  36. dbname,
  37. ],
  38. capture_output=True,
  39. text=True,
  40. timeout=7200,
  41. )
  42. log = (result.stdout + result.stderr).strip()
  43. if result.returncode != 0:
  44. raise RuntimeError(f"mysqldump a échoué (code {result.returncode}) :\n{log}")
  45. _write_tar(tmpdir, dump_path, dbname, archive_name, backup_dir, job,
  46. instance, current_app.config.get("INSTANCE_URL", ""))
  47. return archive_name, log or "mysqldump terminé sans sortie."
  48. # ---------------------------------------------------------------------------
  49. # PostgreSQL
  50. # ---------------------------------------------------------------------------
  51. def _run_postgresql(job, instance, backup_dir):
  52. from flask import current_app
  53. cfg = json.loads(job.config_json or "{}")
  54. dbname = cfg.get("database", "")
  55. if not dbname:
  56. raise ValueError("Nom de base de données manquant dans la configuration du job.")
  57. archive_name = _archive_name(instance, "postgresql", dbname)
  58. _abort_if_exists(archive_name, backup_dir)
  59. with tempfile.TemporaryDirectory() as tmpdir:
  60. dump_path = os.path.join(tmpdir, f"{dbname}.sql")
  61. # pg_dump doit tourner en tant qu'utilisateur postgres
  62. result = subprocess.run(
  63. ["sudo", "-u", "postgres", "pg_dump", "--format=plain", dbname],
  64. capture_output=True,
  65. timeout=7200,
  66. )
  67. if result.returncode != 0:
  68. log = result.stderr.decode("utf-8", errors="replace").strip()
  69. raise RuntimeError(f"pg_dump a échoué (code {result.returncode}) :\n{log}")
  70. with open(dump_path, "wb") as f:
  71. f.write(result.stdout)
  72. log = result.stderr.decode("utf-8", errors="replace").strip()
  73. _write_tar(tmpdir, dump_path, dbname, archive_name, backup_dir, job,
  74. instance, current_app.config.get("INSTANCE_URL", ""))
  75. return archive_name, log or "pg_dump terminé sans sortie."
  76. # ---------------------------------------------------------------------------
  77. # Helpers partagés
  78. # ---------------------------------------------------------------------------
  79. def _archive_name(instance, db_type, dbname):
  80. date_str = datetime.utcnow().strftime("%Y%m%d")
  81. return f"{instance}_{db_type}_{dbname}_{date_str}"
  82. def _abort_if_exists(archive_name, backup_dir):
  83. path = os.path.join(backup_dir, archive_name + ".tar")
  84. if os.path.exists(path):
  85. raise RuntimeError(
  86. f"L'archive {archive_name}.tar existe déjà. "
  87. "Supprimez-la ou attendez le prochain cycle."
  88. )
  89. # ---------------------------------------------------------------------------
  90. # Restore
  91. # ---------------------------------------------------------------------------
  92. def restore_db_dump(archive_name, backup_dir):
  93. """Restauration d'une base MySQL ou PostgreSQL depuis une archive BackupManager."""
  94. archive_path = os.path.join(backup_dir, archive_name + ".tar")
  95. if not os.path.exists(archive_path):
  96. raise FileNotFoundError(f"Archive introuvable : {archive_path}")
  97. info = _read_backup_info(archive_path)
  98. db_type = info.get("type")
  99. dbname = info.get("database", "")
  100. if not dbname:
  101. raise ValueError("Nom de base de données introuvable dans backup_info.json.")
  102. with tempfile.TemporaryDirectory() as tmpdir:
  103. dump_path = os.path.join(tmpdir, f"{dbname}.sql")
  104. # Extraction du dump depuis l'archive
  105. with tarfile.open(archive_path) as tar:
  106. member = tar.getmember(f"db/{dbname}.sql")
  107. with tar.extractfile(member) as src, open(dump_path, "wb") as dst:
  108. dst.write(src.read())
  109. if db_type == "mysql":
  110. return _restore_mysql(dbname, dump_path)
  111. elif db_type == "postgresql":
  112. return _restore_postgresql(dbname, dump_path)
  113. else:
  114. raise ValueError(f"Type de base inconnu dans l'archive : {db_type}")
  115. def _restore_mysql(dbname, dump_path):
  116. log_lines = []
  117. # Suppression + recréation propre de la base
  118. result = subprocess.run(
  119. ["sudo", "mysql", "-e",
  120. f"DROP DATABASE IF EXISTS `{dbname}`; CREATE DATABASE `{dbname}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"],
  121. capture_output=True, text=True, timeout=60,
  122. )
  123. if result.returncode != 0:
  124. raise RuntimeError(f"Impossible de recréer la base MySQL '{dbname}' :\n{result.stderr.strip()}")
  125. log_lines.append(f"Base MySQL '{dbname}' recréée.")
  126. # Restauration du dump
  127. with open(dump_path, "rb") as f:
  128. result = subprocess.run(
  129. ["sudo", "mysql", dbname],
  130. stdin=f,
  131. capture_output=True,
  132. timeout=7200,
  133. )
  134. log = result.stderr.decode("utf-8", errors="replace").strip()
  135. if result.returncode != 0:
  136. raise RuntimeError(f"mysql restore a échoué (code {result.returncode}) :\n{log}")
  137. log_lines.append(f"Dump restauré dans '{dbname}'.")
  138. if log:
  139. log_lines.append(log)
  140. return "\n".join(log_lines)
  141. def _restore_postgresql(dbname, dump_path):
  142. log_lines = []
  143. # Terminer les connexions actives puis drop + recreate
  144. subprocess.run(
  145. ["sudo", "-u", "postgres", "psql", "-c",
  146. f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{dbname}' AND pid <> pg_backend_pid();"],
  147. capture_output=True, timeout=30,
  148. )
  149. subprocess.run(
  150. ["sudo", "-u", "postgres", "dropdb", "--if-exists", dbname],
  151. capture_output=True, timeout=60,
  152. )
  153. result = subprocess.run(
  154. ["sudo", "-u", "postgres", "createdb", dbname],
  155. capture_output=True, text=True, timeout=60,
  156. )
  157. if result.returncode != 0:
  158. raise RuntimeError(f"Impossible de recréer la base PostgreSQL '{dbname}' :\n{result.stderr.strip()}")
  159. log_lines.append(f"Base PostgreSQL '{dbname}' recréée.")
  160. # Restauration du dump
  161. with open(dump_path, "rb") as f:
  162. result = subprocess.run(
  163. ["sudo", "-u", "postgres", "psql", "-d", dbname, "-v", "ON_ERROR_STOP=1"],
  164. stdin=f,
  165. capture_output=True,
  166. timeout=7200,
  167. )
  168. log = result.stderr.decode("utf-8", errors="replace").strip()
  169. if result.returncode != 0:
  170. raise RuntimeError(f"psql restore a échoué (code {result.returncode}) :\n{log}")
  171. log_lines.append(f"Dump restauré dans '{dbname}'.")
  172. if log:
  173. log_lines.append(log)
  174. return "\n".join(log_lines)
  175. def _read_backup_info(archive_path):
  176. try:
  177. with tarfile.open(archive_path) as tar:
  178. member = tar.extractfile("backup_info.json")
  179. if member:
  180. return json.loads(member.read())
  181. except Exception:
  182. pass
  183. return {}
  184. def _write_tar(tmpdir, dump_path, dbname, archive_name, backup_dir, job, instance, instance_url):
  185. """Crée le .tar dans backup_dir et le .info.json YunoHost à côté."""
  186. archive_path = os.path.join(backup_dir, archive_name + ".tar")
  187. # backup_info.json embarqué dans le tar (métadonnées BackupManager)
  188. import json as _json
  189. info = {
  190. "instance_name": instance,
  191. "instance_url": instance_url,
  192. "type": job.type,
  193. "database": dbname,
  194. "created_at": datetime.utcnow().isoformat(),
  195. "backupmanager_version": "1.0.0",
  196. }
  197. info_path = os.path.join(tmpdir, "backup_info.json")
  198. with open(info_path, "w") as f:
  199. _json.dump(info, f, indent=2)
  200. with tarfile.open(archive_path, "w") as tar:
  201. tar.add(dump_path, arcname=f"db/{dbname}.sql")
  202. tar.add(info_path, arcname="backup_info.json")
  203. # .info.json YunoHost (hors tar, requis pour listing webadmin)
  204. size = os.path.getsize(archive_path)
  205. ynh_info = {
  206. "created_at": int(time.time()),
  207. "description": f"BackupManager: {job.type} {dbname}",
  208. "size": size,
  209. "from_before_upgrade": False,
  210. "apps": {},
  211. "system": {},
  212. }
  213. ynh_info_path = os.path.join(backup_dir, archive_name + ".info.json")
  214. with open(ynh_info_path, "w") as f:
  215. _json.dump(ynh_info, f, indent=2)