overview.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. import json
  2. import os
  3. import shlex
  4. import subprocess
  5. import threading
  6. from concurrent.futures import ThreadPoolExecutor
  7. from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
  8. from db import db, Job, Destination, RemoteInstance, _size_human
  9. from helpers import get_ynh_apps
  10. bp = Blueprint("overview", __name__)
  11. # ---------------------------------------------------------------------------
  12. # Helpers
  13. # ---------------------------------------------------------------------------
  14. def _retention_label(job):
  15. if job.retention_mode == "count":
  16. return f"{job.retention_value} dernières"
  17. if job.retention_mode == "daily":
  18. return f"1/j sur {job.retention_value} j"
  19. if job.retention_mode == "gfs":
  20. cfg = json.loads(job.retention_gfs_config or "{}") if job.retention_gfs_config else {}
  21. d = cfg.get("daily", 7)
  22. w = cfg.get("weekly", 4)
  23. m = cfg.get("monthly", 12)
  24. return f"GFS {d}j/{w}s/{m}m"
  25. return "—"
  26. def _job_detail(job, ynh_app_labels=None):
  27. """Détail affiché à côté du nom du job pour distinguer des jobs homonymes
  28. (ex: instances multiples d'une même app YunoHost, toutes nommées pareil)."""
  29. if job.type not in ("ynh_app", "custom_dir", "mysql", "postgresql"):
  30. return ""
  31. cfg = json.loads(job.config_json or "{}")
  32. if job.type == "ynh_app":
  33. app_id = cfg.get("app_id", "")
  34. label = (ynh_app_labels or {}).get(app_id)
  35. return f"{label} ({app_id})" if label and label != app_id else app_id
  36. if job.type == "custom_dir":
  37. return cfg.get("source_path", "")
  38. return cfg.get("database", "")
  39. def _group_archives(archive_list, jobs, instance, ynh_app_labels=None):
  40. """
  41. archive_list : [{"name": "jerry_nextcloud_20260716", "size_bytes": N}]
  42. Retourne (groups, orphaned) où chaque group est associé à un job.
  43. """
  44. from retention import _job_archive_prefix, _matches_job_prefix, _extract_date
  45. used = set()
  46. groups = []
  47. for job in jobs:
  48. prefix = _job_archive_prefix(job, instance)
  49. matching = [a for a in archive_list if _matches_job_prefix(a["name"], prefix)]
  50. matching.sort(key=lambda a: _extract_date(a["name"] + ".tar"))
  51. for a in matching:
  52. used.add(a["name"])
  53. d = _extract_date(a["name"] + ".tar")
  54. a["date_str"] = d.strftime("%d %b %Y") if d.year > 1 else "—"
  55. a["size_human"] = _size_human(a["size_bytes"])
  56. groups.append({
  57. "job": job,
  58. "retention_label": _retention_label(job),
  59. "detail": _job_detail(job, ynh_app_labels),
  60. "archives": matching,
  61. })
  62. orphaned = []
  63. for a in archive_list:
  64. if a["name"] not in used:
  65. from retention import _extract_date
  66. d = _extract_date(a["name"] + ".tar")
  67. a["date_str"] = d.strftime("%d %b %Y") if d.year > 1 else "—"
  68. a["size_human"] = _size_human(a["size_bytes"])
  69. orphaned.append(a)
  70. return groups, orphaned
  71. def _collect_local(backup_dir):
  72. from jobs.utils import batch_list_archives
  73. stats = batch_list_archives(backup_dir)
  74. return [{"name": name, "size_bytes": info["size_bytes"]} for name, info in stats.items()]
  75. def _ssh_base(destination, data_dir):
  76. key_path = os.path.join(data_dir, "keys", destination.key_name)
  77. return [
  78. "ssh", "-i", key_path,
  79. "-p", str(destination.port),
  80. "-o", "StrictHostKeyChecking=accept-new",
  81. "-o", "BatchMode=yes",
  82. "-o", "ConnectTimeout=15",
  83. f"{destination.user}@{destination.host}",
  84. ]
  85. def _collect_ssh(destination, data_dir):
  86. remote_dir = shlex.quote(destination.remote_path)
  87. # find -printf n'est pas disponible sur tous les systèmes (BusyBox, BSD…)
  88. # On utilise cd + find + stat avec fallback BSD pour la portabilité
  89. remote_cmd = (
  90. f"cd {remote_dir} && find . -maxdepth 1 -name '*.tar' -type f 2>/dev/null"
  91. " | sed 's|^\\./||'"
  92. " | while IFS= read -r f; do"
  93. " s=$(stat -c%s \"$f\" 2>/dev/null || stat -f%z \"$f\" 2>/dev/null || echo 0);"
  94. " printf '%s\\t%s\\n' \"$f\" \"$s\";"
  95. " done; true"
  96. )
  97. result = subprocess.run(
  98. _ssh_base(destination, data_dir) + [remote_cmd],
  99. capture_output=True, text=True, timeout=20,
  100. )
  101. if result.returncode != 0:
  102. raise RuntimeError(result.stderr.strip() or "Connexion SSH échouée")
  103. archives = []
  104. for line in result.stdout.splitlines():
  105. parts = line.strip().split("\t")
  106. if len(parts) == 2 and parts[0].endswith(".tar"):
  107. try:
  108. archives.append({"name": parts[0][:-4], "size_bytes": int(parts[1])})
  109. except ValueError:
  110. pass
  111. return archives
  112. def _collect_instance(instance):
  113. from federation.client import FederationClient
  114. raw = FederationClient(instance).get_archives()
  115. return [{"name": a["name"], "size_bytes": a.get("size_bytes", 0)} for a in raw]
  116. def _fetch_ssh_file(destination, data_dir, remote_filename):
  117. remote_path = shlex.quote(f"{destination.remote_path}/{remote_filename}")
  118. result = subprocess.run(
  119. _ssh_base(destination, data_dir) + [f"cat {remote_path}"],
  120. capture_output=True, timeout=3600,
  121. )
  122. if result.returncode != 0:
  123. raise RuntimeError(result.stderr.decode(errors="replace").strip() or "Connexion SSH échouée")
  124. return result.stdout
  125. def _do_pull(app, loc_type, archive_name, dest_id=None, instance_id=None):
  126. """Rapatrie une archive nommée (SSH ou instance) vers le backup_dir local."""
  127. with app.app_context():
  128. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  129. data_dir = app.config["DATA_DIR"]
  130. try:
  131. if loc_type == "ssh":
  132. dest = db.session.get(Destination, dest_id)
  133. label = dest.host
  134. tar_bytes = _fetch_ssh_file(dest, data_dir, archive_name + ".tar")
  135. try:
  136. info_bytes = _fetch_ssh_file(dest, data_dir, archive_name + ".info.json")
  137. except Exception:
  138. info_bytes = None
  139. elif loc_type == "instance":
  140. inst = db.session.get(RemoteInstance, instance_id)
  141. label = inst.name
  142. from federation.client import FederationClient
  143. client = FederationClient(inst)
  144. tar_bytes = client.download_archive(archive_name)
  145. info_bytes = client.download_info_json(archive_name)
  146. else:
  147. return
  148. tmp_tar = f"/tmp/backupmanager_pull_{archive_name}.tar"
  149. with open(tmp_tar, "wb") as f:
  150. f.write(tar_bytes)
  151. subprocess.run(["sudo", "rsync", tmp_tar,
  152. os.path.join(backup_dir, archive_name + ".tar")], check=True)
  153. os.unlink(tmp_tar)
  154. if info_bytes:
  155. tmp_info = f"/tmp/backupmanager_pull_{archive_name}.info.json"
  156. with open(tmp_info, "wb") as f:
  157. f.write(info_bytes)
  158. subprocess.run(["sudo", "rsync", tmp_info,
  159. os.path.join(backup_dir, archive_name + ".info.json")], check=True)
  160. os.unlink(tmp_info)
  161. app.logger.info(f"Pull {archive_name} ← {label} OK")
  162. except Exception as exc:
  163. app.logger.error(f"Pull {archive_name} échoué : {exc}")
  164. # ---------------------------------------------------------------------------
  165. # Routes
  166. # ---------------------------------------------------------------------------
  167. @bp.route("/overview")
  168. def overview():
  169. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  170. data_dir = current_app.config["DATA_DIR"]
  171. instance = current_app.config["INSTANCE_NAME"]
  172. jobs = Job.query.order_by(Job.name).all()
  173. ynh_app_labels = {a["id"]: a.get("label") or a.get("name", "") for a in get_ynh_apps()}
  174. # Construire la liste des tâches de collecte dans l'ordre d'affichage souhaité
  175. dests = Destination.query.filter_by(enabled=True).order_by(Destination.name).all()
  176. insts = RemoteInstance.query.order_by(RemoteInstance.name).all()
  177. tasks = [
  178. ({"type": "local", "label": "Local"},
  179. lambda: _collect_local(backup_dir)),
  180. ]
  181. for dest in dests:
  182. tasks.append((
  183. {"type": "ssh", "label": f"SSH → {dest.remote_str}", "dest_id": dest.id},
  184. lambda d=dest: _collect_ssh(d, data_dir),
  185. ))
  186. for inst in insts:
  187. tasks.append((
  188. {"type": "instance", "label": inst.name,
  189. "instance_id": inst.id, "instance_url": inst.url},
  190. lambda i=inst: _collect_instance(i),
  191. ))
  192. # Collecter toutes les destinations en parallèle pour éviter que les timeouts
  193. # d'une destination lente ne bloquent les autres
  194. def _run_task(args):
  195. meta, fn = args
  196. try:
  197. return meta, fn(), None
  198. except Exception as exc:
  199. return meta, None, str(exc)
  200. with ThreadPoolExecutor(max_workers=min(len(tasks), 10)) as pool:
  201. raw_results = list(pool.map(_run_task, tasks))
  202. locations = []
  203. for meta, archives, error in raw_results:
  204. if error:
  205. locations.append({**meta, "error": error, "groups": [], "orphaned": []})
  206. else:
  207. groups, orphaned = _group_archives(archives, jobs, instance, ynh_app_labels)
  208. locations.append({**meta, "error": None, "groups": groups, "orphaned": orphaned})
  209. # --- Vue par job : pivot de locations → jobs ---
  210. jobs_view = []
  211. for job in jobs:
  212. allowed = {(jd.dest_type, jd.dest_id) for jd in job.job_destinations}
  213. jlocs = []
  214. for loc in locations:
  215. if loc["type"] == "ssh" and ("ssh", loc.get("dest_id")) not in allowed:
  216. continue
  217. if loc["type"] == "instance" and ("instance", loc.get("instance_id")) not in allowed:
  218. continue
  219. group = next((g for g in loc.get("groups", []) if g["job"].id == job.id), None)
  220. jlocs.append({
  221. "type": loc["type"],
  222. "label": loc["label"],
  223. "dest_id": loc.get("dest_id"),
  224. "instance_id": loc.get("instance_id"),
  225. "error": loc["error"],
  226. "archives": group["archives"] if group else [],
  227. })
  228. jobs_view.append({
  229. "job": job,
  230. "retention_label": _retention_label(job),
  231. "detail": _job_detail(job, ynh_app_labels),
  232. "locations": jlocs,
  233. })
  234. return render_template("archives_overview.html", locations=locations, jobs_view=jobs_view)
  235. @bp.route("/overview/delete", methods=["POST"])
  236. def overview_delete():
  237. name = request.form["name"]
  238. loc_type = request.form["loc_type"]
  239. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  240. data_dir = current_app.config["DATA_DIR"]
  241. try:
  242. if loc_type == "local":
  243. from jobs.utils import sudo_rm_archive
  244. sudo_rm_archive(name, backup_dir)
  245. flash(f"Archive « {name} » supprimée localement.", "success")
  246. elif loc_type == "ssh":
  247. dest = db.get_or_404(Destination, int(request.form["dest_id"]))
  248. tar_q = shlex.quote(f"{dest.remote_path}/{name}.tar")
  249. info_q = shlex.quote(f"{dest.remote_path}/{name}.info.json")
  250. r = subprocess.run(
  251. _ssh_base(dest, data_dir) + [f"rm -f {tar_q} {info_q}"],
  252. capture_output=True, timeout=20,
  253. )
  254. if r.returncode != 0:
  255. raise RuntimeError(r.stderr.decode().strip())
  256. flash(f"Archive « {name} » supprimée sur {dest.host}.", "success")
  257. elif loc_type == "instance":
  258. inst = db.get_or_404(RemoteInstance, int(request.form["instance_id"]))
  259. from federation.client import FederationClient
  260. FederationClient(inst).delete_archive(name)
  261. flash(f"Archive « {name} » supprimée sur {inst.name}.", "success")
  262. except Exception as exc:
  263. flash(f"Erreur lors de la suppression : {exc}", "error")
  264. return redirect(url_for("overview.overview"))
  265. @bp.route("/overview/pull", methods=["POST"])
  266. def overview_pull():
  267. name = request.form["name"]
  268. loc_type = request.form["loc_type"]
  269. dest_id = request.form.get("dest_id", type=int)
  270. instance_id = request.form.get("instance_id", type=int)
  271. app = current_app._get_current_object()
  272. threading.Thread(target=_do_pull, args=(app, loc_type, name, dest_id, instance_id), daemon=True).start()
  273. flash(f"Rapatriement de « {name} » démarré en arrière-plan.", "success")
  274. return redirect(url_for("overview.overview"))
  275. @bp.route("/overview/retention", methods=["POST"])
  276. def overview_retention():
  277. job_id = int(request.form["job_id"])
  278. loc_type = request.form["loc_type"]
  279. job = db.get_or_404(Job, job_id)
  280. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  281. data_dir = current_app.config["DATA_DIR"]
  282. try:
  283. if loc_type == "local":
  284. from retention import apply_retention
  285. deleted, failed = apply_retention(job, "", backup_dir)
  286. msg = f"Rétention locale — {len(deleted)} archive(s) supprimée(s)"
  287. if failed:
  288. msg += f", {len(failed)} échec(s) : {', '.join(failed)}"
  289. flash(msg, "success" if not failed else "warning")
  290. elif loc_type == "ssh":
  291. dest = db.get_or_404(Destination, int(request.form["dest_id"]))
  292. from retention import apply_ssh_retention
  293. deleted = apply_ssh_retention(job, dest, data_dir)
  294. flash(
  295. f"Rétention SSH — {len(deleted)} archive(s) supprimée(s) sur {dest.host}.",
  296. "success",
  297. )
  298. elif loc_type == "instance":
  299. inst = db.get_or_404(RemoteInstance, int(request.form["instance_id"]))
  300. from federation.client import FederationClient
  301. from retention import apply_remote_retention
  302. deleted = apply_remote_retention(job, FederationClient(inst))
  303. flash(
  304. f"Rétention distante — {len(deleted)} archive(s) supprimée(s) sur {inst.name}.",
  305. "success",
  306. )
  307. except Exception as exc:
  308. flash(f"Erreur lors de la rétention : {exc}", "error")
  309. return redirect(url_for("overview.overview"))