jobs.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  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
  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. jobs = Job.query.order_by(Job.name).all()
  21. last_runs = {
  22. j.id: Run.query.filter_by(job_id=j.id).order_by(Run.started_at.desc()).first()
  23. for j in jobs
  24. }
  25. return render_template("dashboard_local.html", jobs=jobs, last_runs=last_runs)
  26. # --- CRUD Jobs ----------------------------------------------------------------
  27. @bp.route("/jobs/new", methods=["GET", "POST"])
  28. def job_new():
  29. if request.method == "POST":
  30. return _save_job(None)
  31. return render_template("job_form.html", job=None, ynh_apps=get_ynh_apps(),
  32. destinations=Destination.query.filter_by(enabled=True).all())
  33. @bp.route("/jobs/<int:job_id>/edit", methods=["GET", "POST"])
  34. def job_edit(job_id):
  35. job = db.get_or_404(Job, job_id)
  36. if request.method == "POST":
  37. return _save_job(job)
  38. return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(),
  39. destinations=Destination.query.filter_by(enabled=True).all())
  40. @bp.route("/jobs/<int:job_id>/delete", methods=["POST"])
  41. def job_delete(job_id):
  42. job = db.get_or_404(Job, job_id)
  43. from scheduler import remove_job
  44. remove_job(job.id)
  45. db.session.delete(job)
  46. db.session.commit()
  47. flash(f"Job « {job.name} » supprimé.", "success")
  48. return redirect(url_for("jobs.index"))
  49. @bp.route("/jobs/<int:job_id>/run", methods=["POST"])
  50. def job_run_now(job_id):
  51. job = db.get_or_404(Job, job_id)
  52. from scheduler import _execute_job
  53. app = current_app._get_current_object()
  54. threading.Thread(target=_execute_job, args=(job.id,), daemon=True).start()
  55. flash(f"Job « {job.name} » lancé manuellement.", "success")
  56. return redirect(url_for("jobs.index"))
  57. @bp.route("/jobs/<int:job_id>/toggle", methods=["POST"])
  58. def job_toggle(job_id):
  59. job = db.get_or_404(Job, job_id)
  60. from scheduler import schedule_job, remove_job
  61. job.enabled = not job.enabled
  62. job.updated_at = datetime.utcnow()
  63. db.session.commit()
  64. if job.enabled:
  65. schedule_job(job)
  66. flash(f"Job « {job.name} » activé.", "success")
  67. else:
  68. remove_job(job.id)
  69. flash(f"Job « {job.name} » désactivé.", "info")
  70. return redirect(url_for("jobs.index"))
  71. @bp.route("/jobs/<int:job_id>/history")
  72. def job_history(job_id):
  73. job = db.get_or_404(Job, job_id)
  74. runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(100).all()
  75. return render_template("job_history.html", job=job, runs=runs)
  76. # --- Restauration -------------------------------------------------------------
  77. @bp.route("/archives/<path:archive_name>/restore", methods=["GET", "POST"])
  78. def archive_restore(archive_name):
  79. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  80. info = read_archive_info(archive_name, backup_dir)
  81. if request.method == "GET":
  82. return render_template("restore_confirm.html", archive_name=archive_name, info=info)
  83. _start_restore(archive_name)
  84. flash(f"Restauration de « {archive_name} » démarrée en arrière-plan.", "success")
  85. return redirect(url_for("jobs.index"))
  86. def _start_restore(archive_name):
  87. """Crée un Run de restauration et lance le thread. Retourne (restore_run_id, archive_type)."""
  88. backup_dir = current_app.config["YUNOHOST_BACKUP_DIR"]
  89. info = read_archive_info(archive_name, backup_dir)
  90. archive_type = info.get("type", "")
  91. original_run = Run.query.filter_by(archive_name=archive_name).first()
  92. restore_run_id = None
  93. if original_run:
  94. restore_run = Run(
  95. job_id=original_run.job_id,
  96. started_at=datetime.utcnow(),
  97. status="running",
  98. archive_name=archive_name,
  99. log_text="[RESTAURATION en cours…]",
  100. )
  101. db.session.add(restore_run)
  102. db.session.commit()
  103. restore_run_id = restore_run.id
  104. app = current_app._get_current_object()
  105. threading.Thread(
  106. target=_do_restore_job,
  107. args=(app, archive_name, archive_type, restore_run_id),
  108. daemon=True,
  109. ).start()
  110. return restore_run_id, archive_type
  111. def _do_restore_job(app, archive_name, archive_type, restore_run_id):
  112. with app.app_context():
  113. run = db.session.get(Run, restore_run_id) if restore_run_id else None
  114. try:
  115. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  116. if archive_type == "custom_dir":
  117. from jobs.custom_dir import restore_custom_dir
  118. log = restore_custom_dir(archive_name, backup_dir)
  119. elif archive_type in ("mysql", "postgresql"):
  120. from jobs.db_dump import restore_db_dump
  121. log = restore_db_dump(archive_name, backup_dir)
  122. elif archive_type == "ynh_app":
  123. result = subprocess.run(
  124. ["sudo", "yunohost", "backup", "restore", archive_name,
  125. "--apps", "--force"],
  126. capture_output=True, text=True, timeout=3600,
  127. )
  128. log = (result.stdout + result.stderr).strip()
  129. if result.returncode != 0:
  130. raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
  131. elif archive_type == "ynh_system":
  132. result = subprocess.run(
  133. ["sudo", "yunohost", "backup", "restore", archive_name,
  134. "--system", "--force"],
  135. capture_output=True, text=True, timeout=3600,
  136. )
  137. log = (result.stdout + result.stderr).strip()
  138. if result.returncode != 0:
  139. raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
  140. else:
  141. raise NotImplementedError(
  142. f"Restauration non supportée pour le type '{archive_type}'."
  143. )
  144. if run:
  145. run.status = "success"
  146. run.finished_at = datetime.utcnow()
  147. run.log_text = f"[RESTAURATION]\n{log or 'OK'}"
  148. db.session.commit()
  149. except Exception as exc:
  150. app.logger.error(f"Restauration {archive_name} échouée : {exc}")
  151. if run:
  152. run.status = "error"
  153. run.finished_at = datetime.utcnow()
  154. run.log_text = f"[RESTAURATION]\n{exc}"
  155. db.session.commit()
  156. # --- Helper save job ----------------------------------------------------------
  157. def _save_job(job):
  158. f = request.form
  159. job_type = f.get("type", "")
  160. name = f.get("name", "").strip()
  161. if not name:
  162. flash("Le nom est requis.", "error")
  163. return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(),
  164. destinations=Destination.query.filter_by(enabled=True).all())
  165. cfg = {}
  166. if job_type == "ynh_app":
  167. cfg = {"app_id": f.get("app_id", ""), "core_only": f.get("core_only") == "1"}
  168. elif job_type == "ynh_system":
  169. cfg = {}
  170. elif job_type in ("mysql", "postgresql"):
  171. dbname = f.get("db_database", "").strip()
  172. if not dbname:
  173. flash("Le nom de la base de données est requis.", "error")
  174. return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(),
  175. destinations=Destination.query.filter_by(enabled=True).all())
  176. cfg = {"database": dbname}
  177. elif job_type == "custom_dir":
  178. source_path = f.get("source_path", "").strip().rstrip("/")
  179. if not source_path or not source_path.startswith("/"):
  180. flash("Le chemin source doit être un chemin absolu (ex: /opt/monapp).", "error")
  181. return render_template("job_form.html", job=job, ynh_apps=get_ynh_apps(),
  182. destinations=Destination.query.filter_by(enabled=True).all())
  183. excludes = [e.strip() for e in f.get("excludes", "").splitlines() if e.strip()]
  184. restore_cfg = {}
  185. user_name = f.get("restore_user_name", "").strip()
  186. if user_name:
  187. restore_cfg["system_user"] = {
  188. "name": user_name,
  189. "home": f.get("restore_user_home", source_path).strip() or source_path,
  190. "shell": f.get("restore_user_shell", "/bin/false").strip() or "/bin/false",
  191. }
  192. service_name = f.get("restore_service_name", "").strip()
  193. if service_name:
  194. restore_cfg["systemd_service"] = {
  195. "name": service_name,
  196. "service_file": f.get("restore_service_file", "").strip(),
  197. }
  198. owner = f.get("restore_perm_owner", "").strip()
  199. mode = f.get("restore_perm_mode", "").strip()
  200. if owner or mode:
  201. restore_cfg["permissions"] = {}
  202. if owner:
  203. restore_cfg["permissions"]["owner"] = owner
  204. if mode:
  205. restore_cfg["permissions"]["mode"] = mode
  206. post_cmds = [c.strip() for c in f.get("restore_post_cmds", "").splitlines() if c.strip()]
  207. if post_cmds:
  208. restore_cfg["post_restore_commands"] = post_cmds
  209. cfg = {"source_path": source_path, "excludes": excludes, "restore": restore_cfg}
  210. if job is None:
  211. job = Job()
  212. db.session.add(job)
  213. from scheduler import schedule_job, remove_job
  214. dest_id = f.get("destination_id", "").strip()
  215. job.name = name
  216. job.type = job_type
  217. job.config_json = json.dumps(cfg)
  218. job.cron_expr = f.get("cron_expr", "0 3 * * *").strip()
  219. job.retention_mode = f.get("retention_mode", "count")
  220. job.retention_value = int(f.get("retention_value", 7))
  221. job.enabled = f.get("enabled") == "1"
  222. job.core_only = cfg.get("core_only", False)
  223. job.destination_id = int(dest_id) if dest_id else None
  224. job.updated_at = datetime.utcnow()
  225. db.session.commit()
  226. if job.enabled:
  227. schedule_job(job)
  228. else:
  229. remove_job(job.id)
  230. flash(f"Job « {job.name} » enregistré.", "success")
  231. return redirect(url_for("jobs.index"))