db_dump.py 9.3 KB

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