jobs.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. import json
  2. import subprocess
  3. import threading
  4. from datetime import datetime
  5. from flask import (
  6. Blueprint,
  7. current_app,
  8. flash,
  9. redirect,
  10. render_template,
  11. request,
  12. url_for,
  13. )
  14. from db import db, Job, Run, Destination, RemoteInstance, JobDestination
  15. from helpers import read_archive_info, get_ynh_apps
  16. bp = Blueprint("jobs", __name__)
  17. # --- Dashboard local ----------------------------------------------------------
  18. @bp.route("/")
  19. def index():
  20. from db import RemoteInstance
  21. jobs = Job.query.order_by(Job.name).all()
  22. last_runs = {
  23. j.id: Run.query.filter_by(job_id=j.id).order_by(Run.started_at.desc()).first()
  24. for j in jobs
  25. }
  26. instances = RemoteInstance.query.order_by(RemoteInstance.name).all()
  27. ynh_app_labels = {a["id"]: a.get("label", "") for a in get_ynh_apps()}
  28. return render_template("dashboard_local.html", jobs=jobs, last_runs=last_runs,
  29. instances=instances, ynh_app_labels=ynh_app_labels)
  30. # --- CRUD Jobs ----------------------------------------------------------------
  31. def _used_app_ids(exclude_job_id=None):
  32. """Retourne les app_id déjà couverts par un job ynh_app existant."""
  33. q = Job.query.filter_by(type="ynh_app")
  34. if exclude_job_id:
  35. q = q.filter(Job.id != exclude_job_id)
  36. return {json.loads(j.config_json).get("app_id") for j in q.all() if j.config_json}
  37. @bp.route("/jobs/new", methods=["GET", "POST"])
  38. def job_new():
  39. if request.method == "POST":
  40. return _save_job(None)
  41. return render_template("job_form.html", job=None,
  42. ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids()),
  43. destinations=Destination.query.filter_by(enabled=True).all(),
  44. remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
  45. @bp.route("/jobs/<int:job_id>/edit", methods=["GET", "POST"])
  46. def job_edit(job_id):
  47. job = db.get_or_404(Job, job_id)
  48. if request.method == "POST":
  49. return _save_job(job)
  50. return render_template("job_form.html", job=job,
  51. ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids(exclude_job_id=job_id)),
  52. destinations=Destination.query.filter_by(enabled=True).all(),
  53. remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
  54. @bp.route("/jobs/<int:job_id>/delete", methods=["POST"])
  55. def job_delete(job_id):
  56. job = db.get_or_404(Job, job_id)
  57. from scheduler import remove_job
  58. remove_job(job.id)
  59. db.session.delete(job)
  60. db.session.commit()
  61. flash(f"Job « {job.name} » supprimé.", "success")
  62. return redirect(url_for("jobs.index"))
  63. @bp.route("/jobs/<int:job_id>/run", methods=["POST"])
  64. def job_run_now(job_id):
  65. job = db.get_or_404(Job, job_id)
  66. from scheduler import _execute_job
  67. app = current_app._get_current_object()
  68. threading.Thread(target=_execute_job, args=(job.id,), daemon=True).start()
  69. flash(f"Job « {job.name} » lancé manuellement.", "success")
  70. return redirect(url_for("jobs.index"))
  71. @bp.route("/jobs/bulk", methods=["POST"])
  72. def jobs_bulk():
  73. action = request.form.get("action")
  74. job_ids = [int(jid) for jid in request.form.getlist("job_ids") if jid.isdigit()]
  75. if not job_ids:
  76. return redirect(url_for("jobs.index"))
  77. from scheduler import schedule_job, remove_job, _execute_job
  78. if action == "run":
  79. for jid in job_ids:
  80. job = db.session.get(Job, jid)
  81. if job:
  82. threading.Thread(target=_execute_job, args=(jid,), daemon=True).start()
  83. flash(f"{len(job_ids)} job(s) lancé(s) en arrière-plan.", "info")
  84. elif action == "enable":
  85. for jid in job_ids:
  86. job = db.session.get(Job, jid)
  87. if job:
  88. job.enabled = True
  89. job.updated_at = datetime.utcnow()
  90. schedule_job(job)
  91. db.session.commit()
  92. flash(f"{len(job_ids)} job(s) activé(s).", "success")
  93. elif action == "disable":
  94. for jid in job_ids:
  95. job = db.session.get(Job, jid)
  96. if job:
  97. job.enabled = False
  98. job.updated_at = datetime.utcnow()
  99. remove_job(jid)
  100. db.session.commit()
  101. flash(f"{len(job_ids)} job(s) désactivé(s).", "info")
  102. elif action == "delete":
  103. names = []
  104. for jid in job_ids:
  105. job = db.session.get(Job, jid)
  106. if job:
  107. names.append(job.name)
  108. remove_job(jid)
  109. db.session.delete(job)
  110. db.session.commit()
  111. flash(f"{len(names)} job(s) supprimé(s).", "success")
  112. return redirect(url_for("jobs.index"))
  113. @bp.route("/jobs/<int:job_id>/toggle", methods=["POST"])
  114. def job_toggle(job_id):
  115. job = db.get_or_404(Job, job_id)
  116. from scheduler import schedule_job, remove_job
  117. job.enabled = not job.enabled
  118. job.updated_at = datetime.utcnow()
  119. db.session.commit()
  120. if job.enabled:
  121. schedule_job(job)
  122. flash(f"Job « {job.name} » activé.", "success")
  123. else:
  124. remove_job(job.id)
  125. flash(f"Job « {job.name} » désactivé.", "info")
  126. return redirect(url_for("jobs.index"))
  127. @bp.route("/jobs/<int:job_id>/history")
  128. def job_history(job_id):
  129. job = db.get_or_404(Job, job_id)
  130. runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(100).all()
  131. ynh_app_labels = {a["id"]: a.get("label", "") for a in get_ynh_apps()} if job.type == "ynh_app" else {}
  132. return render_template("job_history.html", job=job, runs=runs, ynh_app_labels=ynh_app_labels)
  133. # --- Navigateur d'archives ----------------------------------------------------
  134. @bp.route("/archives")
  135. def archives():
  136. from jobs.utils import batch_list_archives
  137. from db import _size_human, RemoteInstance
  138. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  139. # UN seul appel sudo find pour toutes les tailles + mtimes
  140. file_stats = batch_list_archives(backup_dir)
  141. sorted_names = sorted(file_stats, key=lambda n: file_stats[n]["mtime"], reverse=True)
  142. # Pré-charger runs et jobs en une passe DB (pas de subprocess)
  143. runs_by_archive = {}
  144. for run in Run.query.order_by(Run.started_at.desc()).all():
  145. if run.archive_name and run.archive_name not in runs_by_archive:
  146. runs_by_archive[run.archive_name] = run
  147. jobs_by_id = {j.id: j for j in Job.query.all()}
  148. ynh_app_labels = {a["id"]: a.get("label", "") for a in get_ynh_apps()}
  149. items = []
  150. for name in sorted_names:
  151. size_bytes = file_stats[name]["size_bytes"] or None
  152. run = runs_by_archive.get(name)
  153. job = jobs_by_id.get(run.job_id) if run else None
  154. app_id = None
  155. app_label = None
  156. if job and job.type == "ynh_app" and job.config_json:
  157. try:
  158. app_id = json.loads(job.config_json).get("app_id")
  159. if app_id:
  160. app_label = ynh_app_labels.get(app_id, "")
  161. except Exception:
  162. pass
  163. items.append({
  164. "name": name,
  165. "type": job.type if job else "",
  166. "job_name": job.name if job else "—",
  167. "job_id": job.id if job else None,
  168. "app_id": app_id,
  169. "app_label": app_label,
  170. "last_status": run.status if run else None,
  171. "run_at": run.started_at if run else None,
  172. "size_bytes": size_bytes,
  173. "size_human": _size_human(size_bytes) if size_bytes else "—",
  174. })
  175. instances = RemoteInstance.query.order_by(RemoteInstance.name).all()
  176. return render_template("archives.html", items=items, instances=instances)
  177. @bp.route("/archives/<path:archive_name>/download")
  178. def archive_download(archive_name):
  179. import os, subprocess
  180. from flask import Response, stream_with_context
  181. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  182. archive_path = os.path.join(backup_dir, archive_name + ".tar")
  183. tmp_path = f"/tmp/backupmanager_webdl_{archive_name}.tar"
  184. try:
  185. r = subprocess.run(["sudo", "rsync", archive_path, tmp_path],
  186. capture_output=True, text=True, timeout=3600)
  187. if r.returncode != 0:
  188. flash(f"Téléchargement impossible : {r.stderr.strip()}", "error")
  189. return redirect(url_for("jobs.archives"))
  190. def _stream():
  191. try:
  192. with open(tmp_path, "rb") as f:
  193. while True:
  194. chunk = f.read(1024 * 1024)
  195. if not chunk:
  196. break
  197. yield chunk
  198. finally:
  199. subprocess.run(["sudo", "rm", "-rf", tmp_path], capture_output=True)
  200. return Response(
  201. stream_with_context(_stream()),
  202. mimetype="application/octet-stream",
  203. headers={"Content-Disposition": f'attachment; filename="{archive_name}.tar"'},
  204. )
  205. except Exception as exc:
  206. subprocess.run(["sudo", "rm", "-f", tmp_path], capture_output=True)
  207. flash(f"Erreur : {exc}", "error")
  208. return redirect(url_for("jobs.archives"))
  209. @bp.route("/archives/<path:archive_name>/delete", methods=["POST"])
  210. def archive_delete(archive_name):
  211. from jobs.utils import sudo_rm_archive
  212. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  213. sudo_rm_archive(archive_name, backup_dir)
  214. flash(f"Archive « {archive_name} » supprimée.", "success")
  215. return redirect(url_for("jobs.archives"))
  216. # --- Restauration -------------------------------------------------------------
  217. @bp.route("/archives/<path:archive_name>/restore", methods=["GET", "POST"])
  218. def archive_restore(archive_name):
  219. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  220. info = read_archive_info(archive_name, backup_dir)
  221. if request.method == "GET":
  222. return render_template("restore_confirm.html", archive_name=archive_name, info=info)
  223. _start_restore(archive_name)
  224. flash(f"Restauration de « {archive_name} » démarrée en arrière-plan.", "success")
  225. return redirect(url_for("jobs.index"))
  226. def _start_restore(archive_name):
  227. """Crée un Run de restauration et lance le thread. Retourne (restore_run_id, archive_type)."""
  228. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  229. info = read_archive_info(archive_name, backup_dir)
  230. archive_type = info.get("type", "")
  231. original_run = Run.query.filter_by(archive_name=archive_name).first()
  232. restore_run_id = None
  233. if original_run:
  234. restore_run = Run(
  235. job_id=original_run.job_id,
  236. started_at=datetime.utcnow(),
  237. status="running",
  238. archive_name=archive_name,
  239. log_text="[RESTAURATION en cours…]",
  240. )
  241. db.session.add(restore_run)
  242. db.session.commit()
  243. restore_run_id = restore_run.id
  244. app = current_app._get_current_object()
  245. threading.Thread(
  246. target=_do_restore_job,
  247. args=(app, archive_name, archive_type, restore_run_id),
  248. daemon=True,
  249. ).start()
  250. return restore_run_id, archive_type
  251. def _do_restore_job(app, archive_name, archive_type, restore_run_id):
  252. with app.app_context():
  253. run = db.session.get(Run, restore_run_id) if restore_run_id else None
  254. try:
  255. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  256. if archive_type == "custom_dir":
  257. from jobs.custom_dir import restore_custom_dir
  258. log = restore_custom_dir(archive_name, backup_dir)
  259. elif archive_type in ("mysql", "postgresql"):
  260. from jobs.db_dump import restore_db_dump
  261. log = restore_db_dump(archive_name, backup_dir)
  262. elif archive_type == "ynh_app":
  263. result = subprocess.run(
  264. ["sudo", "yunohost", "backup", "restore", archive_name,
  265. "--apps", "--force"],
  266. capture_output=True, text=True, timeout=3600,
  267. )
  268. log = (result.stdout + result.stderr).strip()
  269. if result.returncode != 0:
  270. raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
  271. elif archive_type == "ynh_system":
  272. result = subprocess.run(
  273. ["sudo", "yunohost", "backup", "restore", archive_name,
  274. "--system", "--force"],
  275. capture_output=True, text=True, timeout=3600,
  276. )
  277. log = (result.stdout + result.stderr).strip()
  278. if result.returncode != 0:
  279. raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
  280. else:
  281. raise NotImplementedError(
  282. f"Restauration non supportée pour le type '{archive_type}'."
  283. )
  284. if run:
  285. run.status = "success"
  286. run.finished_at = datetime.utcnow()
  287. run.log_text = f"[RESTAURATION]\n{log or 'OK'}"
  288. db.session.commit()
  289. except Exception as exc:
  290. app.logger.error(f"Restauration {archive_name} échouée : {exc}")
  291. if run:
  292. run.status = "error"
  293. run.finished_at = datetime.utcnow()
  294. run.log_text = f"[RESTAURATION]\n{exc}"
  295. db.session.commit()
  296. # --- Helper save job ----------------------------------------------------------
  297. def _save_job(job):
  298. f = request.form
  299. job_type = f.get("type", "")
  300. name = f.get("name", "").strip()
  301. if not name:
  302. flash("Le nom est requis.", "error")
  303. return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids(exclude_job_id=job.id if job else None)),
  304. destinations=Destination.query.filter_by(enabled=True).all(),
  305. remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
  306. cfg = {}
  307. if job_type == "ynh_app":
  308. cfg = {"app_id": f.get("app_id", ""), "core_only": f.get("core_only") == "1"}
  309. elif job_type == "ynh_system":
  310. all_hooks = {"conf_ynh_settings", "conf_ynh_firewall", "conf_ssowat", "conf_nginx",
  311. "conf_ynh_certs", "conf_ynh_domain", "conf_ynh_user", "data_home", "data_mail"}
  312. selected = [h for h in f.getlist("system_hooks") if h in all_hooks]
  313. if not selected:
  314. flash("Sélectionnez au moins un hook système.", "error")
  315. # Pour un nouveau job, on crée un objet temporaire (non persisté) pour
  316. # que le template puisse pré-remplir le nom et le type.
  317. job_for_form = job or Job(name=name, type=job_type,
  318. cron_expr=(f.get("cron_expr") or "").strip())
  319. return render_template("job_form.html", job=job_for_form,
  320. ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids(exclude_job_id=job.id if job else None)),
  321. destinations=Destination.query.filter_by(enabled=True).all(),
  322. remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
  323. cfg = {"hooks": [] if set(selected) >= all_hooks else sorted(selected)}
  324. elif job_type in ("mysql", "postgresql"):
  325. dbname = f.get("db_database", "").strip()
  326. if not dbname:
  327. flash("Le nom de la base de données est requis.", "error")
  328. return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids(exclude_job_id=job.id if job else None)),
  329. destinations=Destination.query.filter_by(enabled=True).all(),
  330. remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
  331. cfg = {"database": dbname}
  332. elif job_type == "custom_dir":
  333. source_path = f.get("source_path", "").strip().rstrip("/")
  334. if not source_path or not source_path.startswith("/"):
  335. flash("Le chemin source doit être un chemin absolu (ex: /opt/monapp).", "error")
  336. return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids(exclude_job_id=job.id if job else None)),
  337. destinations=Destination.query.filter_by(enabled=True).all(),
  338. remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
  339. excludes = [e.strip() for e in f.get("excludes", "").splitlines() if e.strip()]
  340. restore_cfg = {}
  341. user_name = f.get("restore_user_name", "").strip()
  342. if user_name:
  343. restore_cfg["system_user"] = {
  344. "name": user_name,
  345. "home": f.get("restore_user_home", source_path).strip() or source_path,
  346. "shell": f.get("restore_user_shell", "/bin/false").strip() or "/bin/false",
  347. }
  348. service_name = f.get("restore_service_name", "").strip()
  349. if service_name:
  350. restore_cfg["systemd_service"] = {
  351. "name": service_name,
  352. "service_file": f.get("restore_service_file", "").strip(),
  353. }
  354. owner = f.get("restore_perm_owner", "").strip()
  355. mode = f.get("restore_perm_mode", "").strip()
  356. if owner or mode:
  357. restore_cfg["permissions"] = {}
  358. if owner:
  359. restore_cfg["permissions"]["owner"] = owner
  360. if mode:
  361. restore_cfg["permissions"]["mode"] = mode
  362. post_cmds = [c.strip() for c in f.get("restore_post_cmds", "").splitlines() if c.strip()]
  363. if post_cmds:
  364. restore_cfg["post_restore_commands"] = post_cmds
  365. cfg = {"source_path": source_path, "excludes": excludes, "restore": restore_cfg}
  366. if job is None:
  367. job = Job()
  368. db.session.add(job)
  369. from scheduler import schedule_job, remove_job
  370. transfer_targets = f.getlist("transfer_targets")
  371. retention_mode = f.get("retention_mode", "count")
  372. if retention_mode == "gfs":
  373. gfs_cfg = {
  374. "daily": max(1, int(f.get("gfs_daily", 7))),
  375. "weekly": max(1, int(f.get("gfs_weekly", 4))),
  376. "monthly": max(1, int(f.get("gfs_monthly", 12))),
  377. }
  378. retention_value = 0
  379. gfs_config_json = json.dumps(gfs_cfg)
  380. else:
  381. gfs_config_json = None
  382. retention_value = int(f.get("retention_value", 2))
  383. job.name = name
  384. job.type = job_type
  385. job.config_json = json.dumps(cfg)
  386. cron_raw = (f.get("cron_expr") or "").strip()
  387. job.cron_expr = cron_raw # "" = manuel (NOT NULL compatible avec le schéma existant)
  388. job.retention_mode = retention_mode
  389. job.retention_value = retention_value
  390. job.retention_gfs_config = gfs_config_json
  391. job.enabled = f.get("enabled") == "1"
  392. job.core_only = cfg.get("core_only", False)
  393. job.job_destinations = [
  394. JobDestination(dest_type="ssh", dest_id=int(t[5:]))
  395. if t.startswith("dest:") else
  396. JobDestination(dest_type="instance", dest_id=int(t[5:]))
  397. for t in transfer_targets
  398. if (t.startswith("dest:") or t.startswith("inst:")) and t[5:].isdigit()
  399. ]
  400. job.updated_at = datetime.utcnow()
  401. db.session.commit()
  402. if job.enabled:
  403. schedule_job(job)
  404. else:
  405. remove_job(job.id)
  406. flash(f"Job « {job.name} » enregistré.", "success")
  407. return redirect(url_for("jobs.index"))