retention.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. import os
  2. import re
  3. import shlex
  4. import subprocess
  5. from datetime import datetime, timedelta
  6. def apply_retention(job, new_archive_name, backup_dir):
  7. """Applique la politique de rétention après une sauvegarde réussie."""
  8. import json as _json
  9. archives = _list_archives_for_job(job, backup_dir)
  10. if job.retention_mode == "count":
  11. to_delete = _retention_count(archives, job.retention_value)
  12. elif job.retention_mode == "daily":
  13. to_delete = _retention_daily(archives, job.retention_value)
  14. elif job.retention_mode == "gfs":
  15. cfg = _json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
  16. to_delete = _retention_gfs(archives, cfg)
  17. else:
  18. return []
  19. from jobs.utils import sudo_rm
  20. deleted = []
  21. failed = []
  22. for archive_filename in to_delete:
  23. base = os.path.splitext(archive_filename)[0]
  24. tar_path = os.path.join(backup_dir, base + ".tar")
  25. if sudo_rm(tar_path):
  26. deleted.append(archive_filename)
  27. sudo_rm(os.path.join(backup_dir, base + ".info.json"))
  28. else:
  29. failed.append(archive_filename)
  30. return deleted, failed
  31. def _job_archive_prefix(job, instance_name):
  32. """Retourne le préfixe des archives pour ce job (ex: jerry_nextcloud_)."""
  33. if job.type == "ynh_app":
  34. import json
  35. cfg = json.loads(job.config_json or "{}")
  36. return f"{instance_name}_{cfg.get('app_id', '')}_"
  37. elif job.type == "ynh_system":
  38. return f"{instance_name}_system_"
  39. elif job.type in ("mysql", "postgresql"):
  40. import json
  41. cfg = json.loads(job.config_json or "{}")
  42. return f"{instance_name}_{job.type}_{cfg.get('database', '')}_"
  43. elif job.type == "custom_dir":
  44. label = re.sub(r'[^a-z0-9]+', '-', job.name.lower().strip()).strip('-')
  45. return f"{instance_name}_{label}_"
  46. else:
  47. return f"{instance_name}_{job.name.lower().replace(' ', '-')}_"
  48. def _list_archives_for_job(job, backup_dir):
  49. """Liste les archives correspondant à ce job, triées par date (plus ancienne en premier)."""
  50. from flask import current_app
  51. instance = current_app.config["INSTANCE_NAME"]
  52. prefix = _job_archive_prefix(job, instance)
  53. from jobs.utils import sudo_listdir
  54. archives = [
  55. fname for fname in sudo_listdir(backup_dir)
  56. if fname.startswith(prefix) and fname.endswith(".tar")
  57. ]
  58. archives.sort(key=_extract_date)
  59. return archives
  60. def apply_ssh_retention(job, destination, data_dir):
  61. """Applique la rétention sur une destination SSH après un transfert réussi."""
  62. import json as _json
  63. from flask import current_app
  64. instance = current_app.config["INSTANCE_NAME"]
  65. prefix = _job_archive_prefix(job, instance)
  66. key_path = os.path.join(data_dir, "keys", destination.key_name)
  67. ssh_base = [
  68. "ssh",
  69. "-i", key_path,
  70. "-p", str(destination.port),
  71. "-o", "StrictHostKeyChecking=accept-new",
  72. "-o", "BatchMode=yes",
  73. "-o", "ConnectTimeout=30",
  74. f"{destination.user}@{destination.host}",
  75. ]
  76. remote_dir = shlex.quote(destination.remote_path)
  77. result = subprocess.run(
  78. ssh_base + [
  79. f"cd {remote_dir} && find . -maxdepth 1 -name '*.tar' -type f 2>/dev/null"
  80. " | sed 's|^\\./||'; true"
  81. ],
  82. capture_output=True, text=True, timeout=30,
  83. )
  84. if result.returncode != 0:
  85. raise RuntimeError(f"Listage distant échoué : {result.stderr.strip()}")
  86. remote_archives = sorted(
  87. [f.strip() for f in result.stdout.splitlines()
  88. if f.strip().startswith(prefix) and f.strip().endswith(".tar")],
  89. key=_extract_date,
  90. )
  91. if job.retention_mode == "count":
  92. to_delete = _retention_count(remote_archives, job.retention_value)
  93. elif job.retention_mode == "daily":
  94. to_delete = _retention_daily(remote_archives, job.retention_value)
  95. elif job.retention_mode == "gfs":
  96. cfg = _json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
  97. to_delete = _retention_gfs(remote_archives, cfg)
  98. else:
  99. return []
  100. deleted = []
  101. for archive_filename in to_delete:
  102. base = os.path.splitext(archive_filename)[0]
  103. tar_q = shlex.quote(f"{destination.remote_path}/{base}.tar")
  104. info_q = shlex.quote(f"{destination.remote_path}/{base}.info.json")
  105. r = subprocess.run(
  106. ssh_base + [f"rm -f {tar_q} {info_q}"],
  107. capture_output=True, timeout=30,
  108. )
  109. if r.returncode == 0:
  110. deleted.append(archive_filename)
  111. return deleted
  112. def apply_remote_retention(job, client):
  113. """Applique la rétention sur l'instance distante après un push.
  114. Filtre les archives par le même préfixe que le job local et applique
  115. la même politique (count/daily). Ne touche pas aux archives des autres jobs.
  116. """
  117. from flask import current_app
  118. instance = current_app.config["INSTANCE_NAME"]
  119. prefix = _job_archive_prefix(job, instance)
  120. try:
  121. remote_archives = client.get_archives()
  122. except Exception:
  123. return []
  124. matching = sorted(
  125. [a["name"] + ".tar" for a in remote_archives if a["name"].startswith(prefix)],
  126. key=_extract_date,
  127. )
  128. if job.retention_mode == "count":
  129. to_delete = _retention_count(matching, job.retention_value)
  130. elif job.retention_mode == "daily":
  131. to_delete = _retention_daily(matching, job.retention_value)
  132. elif job.retention_mode == "gfs":
  133. import json as _json
  134. cfg = _json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
  135. to_delete = _retention_gfs(matching, cfg)
  136. else:
  137. return []
  138. deleted = []
  139. for archive_filename in to_delete:
  140. base = os.path.splitext(archive_filename)[0]
  141. try:
  142. client.delete_archive(base)
  143. deleted.append(base)
  144. except Exception:
  145. pass
  146. return deleted
  147. def _extract_date(filename):
  148. match = re.search(r'(\d{8})', filename)
  149. if match:
  150. try:
  151. return datetime.strptime(match.group(1), "%Y%m%d")
  152. except ValueError:
  153. pass
  154. return datetime.min
  155. def _retention_count(archives, keep_n):
  156. if len(archives) <= keep_n:
  157. return []
  158. return archives[: len(archives) - keep_n]
  159. def _retention_daily(archives, days):
  160. cutoff = datetime.utcnow() - timedelta(days=days)
  161. to_delete = []
  162. seen_dates = set()
  163. for archive in reversed(archives):
  164. date = _extract_date(archive)
  165. if date < cutoff:
  166. to_delete.append(archive)
  167. continue
  168. date_key = date.date()
  169. if date_key in seen_dates:
  170. to_delete.append(archive)
  171. else:
  172. seen_dates.add(date_key)
  173. return to_delete
  174. def _retention_gfs(archives, config):
  175. """Politique Grandfather-Father-Son.
  176. config: {"daily": N, "weekly": M, "monthly": P}
  177. - Fils (daily) : conserve les N archives les plus récentes
  178. - Père (weekly) : conserve 1 archive par semaine sur M semaines
  179. - Grand-Père (monthly): conserve 1 archive par mois sur P mois
  180. Une archive peut satisfaire plusieurs catégories simultanément.
  181. """
  182. daily_keep = int(config.get("daily", 7))
  183. weekly_keep = int(config.get("weekly", 4))
  184. monthly_keep = int(config.get("monthly", 12))
  185. dated = []
  186. for archive in archives:
  187. d = _extract_date(archive)
  188. if d != datetime.min:
  189. dated.append((d, archive))
  190. if not dated:
  191. return []
  192. # Trier du plus récent au plus ancien
  193. dated.sort(key=lambda x: x[0], reverse=True)
  194. keepers = set()
  195. # Fils : N archives les plus récentes
  196. for _, archive in dated[:daily_keep]:
  197. keepers.add(archive)
  198. # Père : 1 archive par semaine (la plus récente de chaque semaine), M semaines
  199. seen_weeks = {}
  200. for d, archive in dated:
  201. wk = (d.isocalendar()[0], d.isocalendar()[1])
  202. if wk not in seen_weeks:
  203. seen_weeks[wk] = archive # premier = plus récent de la semaine
  204. for wk in sorted(seen_weeks, reverse=True)[:weekly_keep]:
  205. keepers.add(seen_weeks[wk])
  206. # Grand-Père : 1 archive par mois (la plus récente du mois), P mois
  207. seen_months = {}
  208. for d, archive in dated:
  209. mk = (d.year, d.month)
  210. if mk not in seen_months:
  211. seen_months[mk] = archive
  212. for mk in sorted(seen_months, reverse=True)[:monthly_keep]:
  213. keepers.add(seen_months[mk])
  214. return [archive for _, archive in dated if archive not in keepers]