retention.py 8.9 KB

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