retention.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  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 + [f"find {remote_dir} -maxdepth 1 -name '*.tar' -printf '%f\\n' 2>/dev/null || true"],
  79. capture_output=True, text=True, timeout=30,
  80. )
  81. if result.returncode != 0:
  82. raise RuntimeError(f"Listage distant échoué : {result.stderr.strip()}")
  83. remote_archives = sorted(
  84. [f for f in result.stdout.splitlines() if f.startswith(prefix) and f.endswith(".tar")],
  85. key=_extract_date,
  86. )
  87. if job.retention_mode == "count":
  88. to_delete = _retention_count(remote_archives, job.retention_value)
  89. elif job.retention_mode == "daily":
  90. to_delete = _retention_daily(remote_archives, job.retention_value)
  91. elif job.retention_mode == "gfs":
  92. cfg = _json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
  93. to_delete = _retention_gfs(remote_archives, cfg)
  94. else:
  95. return []
  96. deleted = []
  97. for archive_filename in to_delete:
  98. base = os.path.splitext(archive_filename)[0]
  99. tar_q = shlex.quote(f"{destination.remote_path}/{base}.tar")
  100. info_q = shlex.quote(f"{destination.remote_path}/{base}.info.json")
  101. r = subprocess.run(
  102. ssh_base + [f"rm -f {tar_q} {info_q}"],
  103. capture_output=True, timeout=30,
  104. )
  105. if r.returncode == 0:
  106. deleted.append(archive_filename)
  107. return deleted
  108. def apply_remote_retention(job, client):
  109. """Applique la rétention sur l'instance distante après un push.
  110. Filtre les archives par le même préfixe que le job local et applique
  111. la même politique (count/daily). Ne touche pas aux archives des autres jobs.
  112. """
  113. from flask import current_app
  114. instance = current_app.config["INSTANCE_NAME"]
  115. prefix = _job_archive_prefix(job, instance)
  116. try:
  117. remote_archives = client.get_archives()
  118. except Exception:
  119. return []
  120. matching = sorted(
  121. [a["name"] + ".tar" for a in remote_archives if a["name"].startswith(prefix)],
  122. key=_extract_date,
  123. )
  124. if job.retention_mode == "count":
  125. to_delete = _retention_count(matching, job.retention_value)
  126. elif job.retention_mode == "daily":
  127. to_delete = _retention_daily(matching, job.retention_value)
  128. elif job.retention_mode == "gfs":
  129. import json as _json
  130. cfg = _json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
  131. to_delete = _retention_gfs(matching, cfg)
  132. else:
  133. return []
  134. deleted = []
  135. for archive_filename in to_delete:
  136. base = os.path.splitext(archive_filename)[0]
  137. try:
  138. client.delete_archive(base)
  139. deleted.append(base)
  140. except Exception:
  141. pass
  142. return deleted
  143. def _extract_date(filename):
  144. match = re.search(r'(\d{8})', filename)
  145. if match:
  146. try:
  147. return datetime.strptime(match.group(1), "%Y%m%d")
  148. except ValueError:
  149. pass
  150. return datetime.min
  151. def _retention_count(archives, keep_n):
  152. if len(archives) <= keep_n:
  153. return []
  154. return archives[: len(archives) - keep_n]
  155. def _retention_daily(archives, days):
  156. cutoff = datetime.utcnow() - timedelta(days=days)
  157. to_delete = []
  158. seen_dates = set()
  159. for archive in reversed(archives):
  160. date = _extract_date(archive)
  161. if date < cutoff:
  162. to_delete.append(archive)
  163. continue
  164. date_key = date.date()
  165. if date_key in seen_dates:
  166. to_delete.append(archive)
  167. else:
  168. seen_dates.add(date_key)
  169. return to_delete
  170. def _retention_gfs(archives, config):
  171. """Politique Grandfather-Father-Son.
  172. config: {"daily": N, "weekly": M, "monthly": P}
  173. - Fils (daily) : conserve les N archives les plus récentes
  174. - Père (weekly) : conserve 1 archive par semaine sur M semaines
  175. - Grand-Père (monthly): conserve 1 archive par mois sur P mois
  176. Une archive peut satisfaire plusieurs catégories simultanément.
  177. """
  178. daily_keep = int(config.get("daily", 7))
  179. weekly_keep = int(config.get("weekly", 4))
  180. monthly_keep = int(config.get("monthly", 12))
  181. dated = []
  182. for archive in archives:
  183. d = _extract_date(archive)
  184. if d != datetime.min:
  185. dated.append((d, archive))
  186. if not dated:
  187. return []
  188. # Trier du plus récent au plus ancien
  189. dated.sort(key=lambda x: x[0], reverse=True)
  190. keepers = set()
  191. # Fils : N archives les plus récentes
  192. for _, archive in dated[:daily_keep]:
  193. keepers.add(archive)
  194. # Père : 1 archive par semaine (la plus récente de chaque semaine), M semaines
  195. seen_weeks = {}
  196. for d, archive in dated:
  197. wk = (d.isocalendar()[0], d.isocalendar()[1])
  198. if wk not in seen_weeks:
  199. seen_weeks[wk] = archive # premier = plus récent de la semaine
  200. for wk in sorted(seen_weeks, reverse=True)[:weekly_keep]:
  201. keepers.add(seen_weeks[wk])
  202. # Grand-Père : 1 archive par mois (la plus récente du mois), P mois
  203. seen_months = {}
  204. for d, archive in dated:
  205. mk = (d.year, d.month)
  206. if mk not in seen_months:
  207. seen_months[mk] = archive
  208. for mk in sorted(seen_months, reverse=True)[:monthly_keep]:
  209. keepers.add(seen_months[mk])
  210. return [archive for _, archive in dated if archive not in keepers]