retention.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. import os
  2. import re
  3. from datetime import datetime, timedelta
  4. def apply_retention(job, new_archive_name, backup_dir):
  5. """Applique la politique de rétention après une sauvegarde réussie."""
  6. import json as _json
  7. archives = _list_archives_for_job(job, backup_dir)
  8. if job.retention_mode == "count":
  9. to_delete = _retention_count(archives, job.retention_value)
  10. elif job.retention_mode == "daily":
  11. to_delete = _retention_daily(archives, job.retention_value)
  12. elif job.retention_mode == "gfs":
  13. cfg = _json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
  14. to_delete = _retention_gfs(archives, cfg)
  15. else:
  16. return []
  17. from jobs.utils import sudo_rm
  18. deleted = []
  19. failed = []
  20. for archive_filename in to_delete:
  21. base = os.path.splitext(archive_filename)[0]
  22. tar_path = os.path.join(backup_dir, base + ".tar")
  23. if sudo_rm(tar_path):
  24. deleted.append(archive_filename)
  25. sudo_rm(os.path.join(backup_dir, base + ".info.json"))
  26. else:
  27. failed.append(archive_filename)
  28. return deleted, failed
  29. def _job_archive_prefix(job, instance_name):
  30. """Retourne le préfixe des archives pour ce job (ex: jerry_nextcloud_)."""
  31. if job.type == "ynh_app":
  32. import json
  33. cfg = json.loads(job.config_json or "{}")
  34. return f"{instance_name}_{cfg.get('app_id', '')}_"
  35. elif job.type == "ynh_system":
  36. return f"{instance_name}_system_"
  37. elif job.type in ("mysql", "postgresql"):
  38. import json
  39. cfg = json.loads(job.config_json or "{}")
  40. return f"{instance_name}_{job.type}_{cfg.get('database', '')}_"
  41. elif job.type == "custom_dir":
  42. label = re.sub(r'[^a-z0-9]+', '-', job.name.lower().strip()).strip('-')
  43. return f"{instance_name}_{label}_"
  44. else:
  45. return f"{instance_name}_{job.name.lower().replace(' ', '-')}_"
  46. def _list_archives_for_job(job, backup_dir):
  47. """Liste les archives correspondant à ce job, triées par date (plus ancienne en premier)."""
  48. from flask import current_app
  49. instance = current_app.config["INSTANCE_NAME"]
  50. prefix = _job_archive_prefix(job, instance)
  51. from jobs.utils import sudo_listdir
  52. archives = [
  53. fname for fname in sudo_listdir(backup_dir)
  54. if fname.startswith(prefix) and fname.endswith(".tar")
  55. ]
  56. archives.sort(key=_extract_date)
  57. return archives
  58. def apply_remote_retention(job, client):
  59. """Applique la rétention sur l'instance distante après un push.
  60. Filtre les archives par le même préfixe que le job local et applique
  61. la même politique (count/daily). Ne touche pas aux archives des autres jobs.
  62. """
  63. from flask import current_app
  64. instance = current_app.config["INSTANCE_NAME"]
  65. prefix = _job_archive_prefix(job, instance)
  66. try:
  67. remote_archives = client.get_archives()
  68. except Exception:
  69. return []
  70. matching = sorted(
  71. [a["name"] + ".tar" for a in remote_archives if a["name"].startswith(prefix)],
  72. key=_extract_date,
  73. )
  74. if job.retention_mode == "count":
  75. to_delete = _retention_count(matching, job.retention_value)
  76. elif job.retention_mode == "daily":
  77. to_delete = _retention_daily(matching, job.retention_value)
  78. elif job.retention_mode == "gfs":
  79. import json as _json
  80. cfg = _json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
  81. to_delete = _retention_gfs(matching, cfg)
  82. else:
  83. return []
  84. deleted = []
  85. for archive_filename in to_delete:
  86. base = os.path.splitext(archive_filename)[0]
  87. try:
  88. client.delete_archive(base)
  89. deleted.append(base)
  90. except Exception:
  91. pass
  92. return deleted
  93. def _extract_date(filename):
  94. match = re.search(r'(\d{8})', filename)
  95. if match:
  96. try:
  97. return datetime.strptime(match.group(1), "%Y%m%d")
  98. except ValueError:
  99. pass
  100. return datetime.min
  101. def _retention_count(archives, keep_n):
  102. if len(archives) <= keep_n:
  103. return []
  104. return archives[: len(archives) - keep_n]
  105. def _retention_daily(archives, days):
  106. cutoff = datetime.utcnow() - timedelta(days=days)
  107. to_delete = []
  108. seen_dates = set()
  109. for archive in reversed(archives):
  110. date = _extract_date(archive)
  111. if date < cutoff:
  112. to_delete.append(archive)
  113. continue
  114. date_key = date.date()
  115. if date_key in seen_dates:
  116. to_delete.append(archive)
  117. else:
  118. seen_dates.add(date_key)
  119. return to_delete
  120. def _retention_gfs(archives, config):
  121. """Politique Grandfather-Father-Son.
  122. config: {"daily": N, "weekly": M, "monthly": P}
  123. - Fils (daily) : conserve les N archives les plus récentes
  124. - Père (weekly) : conserve 1 archive par semaine sur M semaines
  125. - Grand-Père (monthly): conserve 1 archive par mois sur P mois
  126. Une archive peut satisfaire plusieurs catégories simultanément.
  127. """
  128. daily_keep = int(config.get("daily", 7))
  129. weekly_keep = int(config.get("weekly", 4))
  130. monthly_keep = int(config.get("monthly", 12))
  131. dated = []
  132. for archive in archives:
  133. d = _extract_date(archive)
  134. if d != datetime.min:
  135. dated.append((d, archive))
  136. if not dated:
  137. return []
  138. # Trier du plus récent au plus ancien
  139. dated.sort(key=lambda x: x[0], reverse=True)
  140. keepers = set()
  141. # Fils : N archives les plus récentes
  142. for _, archive in dated[:daily_keep]:
  143. keepers.add(archive)
  144. # Père : 1 archive par semaine (la plus récente de chaque semaine), M semaines
  145. seen_weeks = {}
  146. for d, archive in dated:
  147. wk = (d.isocalendar()[0], d.isocalendar()[1])
  148. if wk not in seen_weeks:
  149. seen_weeks[wk] = archive # premier = plus récent de la semaine
  150. for wk in sorted(seen_weeks, reverse=True)[:weekly_keep]:
  151. keepers.add(seen_weeks[wk])
  152. # Grand-Père : 1 archive par mois (la plus récente du mois), P mois
  153. seen_months = {}
  154. for d, archive in dated:
  155. mk = (d.year, d.month)
  156. if mk not in seen_months:
  157. seen_months[mk] = archive
  158. for mk in sorted(seen_months, reverse=True)[:monthly_keep]:
  159. keepers.add(seen_months[mk])
  160. return [archive for _, archive in dated if archive not in keepers]