app.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. import json
  2. import logging
  3. import os
  4. import subprocess
  5. from datetime import datetime
  6. from flask import (
  7. Flask,
  8. flash,
  9. jsonify,
  10. redirect,
  11. render_template,
  12. request,
  13. url_for,
  14. )
  15. from werkzeug.middleware.proxy_fix import ProxyFix
  16. # --- Configuration -----------------------------------------------------------
  17. _config_path = os.environ.get(
  18. "BACKUPMANAGER_CONFIG",
  19. os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.py"),
  20. )
  21. app = Flask(__name__)
  22. app.config.from_pyfile(_config_path)
  23. app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + app.config["DB_PATH"]
  24. app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
  25. # Proxy headers Nginx → Flask (sous-chemin + HTTPS)
  26. app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
  27. # Filtre Jinja2 pour désérialiser du JSON dans les templates
  28. app.jinja_env.filters["fromjson"] = json.loads
  29. # Logging
  30. os.makedirs(os.path.dirname(app.config["LOG_PATH"]), exist_ok=True)
  31. logging.basicConfig(
  32. filename=app.config["LOG_PATH"],
  33. level=logging.INFO,
  34. format="%(asctime)s %(levelname)s %(message)s",
  35. )
  36. # --- Extensions --------------------------------------------------------------
  37. from db import db, Job, Run
  38. db.init_app(app)
  39. from scheduler import init_scheduler, schedule_job, remove_job
  40. # --- Démarrage ---------------------------------------------------------------
  41. with app.app_context():
  42. db.create_all()
  43. init_scheduler(app)
  44. for _job in Job.query.filter_by(enabled=True).all():
  45. schedule_job(_job)
  46. # --- Auth API ----------------------------------------------------------------
  47. @app.before_request
  48. def _check_api_auth():
  49. if not request.path.startswith("/api/"):
  50. return
  51. if request.path == "/api/v1/health":
  52. return
  53. token = request.headers.get("X-BackupManager-Key", "")
  54. if token != app.config["API_TOKEN"]:
  55. return jsonify({"error": "Unauthorized"}), 401
  56. # --- Context processors ------------------------------------------------------
  57. @app.context_processor
  58. def _inject_globals():
  59. return {
  60. "instance_name": app.config.get("INSTANCE_NAME", ""),
  61. "now": datetime.utcnow(),
  62. }
  63. # --- Helpers -----------------------------------------------------------------
  64. def _get_ynh_apps():
  65. try:
  66. result = subprocess.run(
  67. ["sudo", "yunohost", "app", "list", "--output-as", "json"],
  68. capture_output=True,
  69. text=True,
  70. timeout=15,
  71. )
  72. if result.returncode == 0:
  73. return json.loads(result.stdout).get("apps", [])
  74. except Exception:
  75. pass
  76. return []
  77. # --- Routes dashboard --------------------------------------------------------
  78. @app.route("/")
  79. def index():
  80. jobs = Job.query.order_by(Job.name).all()
  81. last_runs = {
  82. j.id: Run.query.filter_by(job_id=j.id).order_by(Run.started_at.desc()).first()
  83. for j in jobs
  84. }
  85. return render_template("dashboard_local.html", jobs=jobs, last_runs=last_runs)
  86. @app.route("/jobs/new", methods=["GET", "POST"])
  87. def job_new():
  88. if request.method == "POST":
  89. return _save_job(None)
  90. return render_template("job_form.html", job=None, ynh_apps=_get_ynh_apps())
  91. @app.route("/jobs/<int:job_id>/edit", methods=["GET", "POST"])
  92. def job_edit(job_id):
  93. job = db.get_or_404(Job, job_id)
  94. if request.method == "POST":
  95. return _save_job(job)
  96. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps())
  97. @app.route("/jobs/<int:job_id>/delete", methods=["POST"])
  98. def job_delete(job_id):
  99. job = db.get_or_404(Job, job_id)
  100. remove_job(job.id)
  101. db.session.delete(job)
  102. db.session.commit()
  103. flash(f"Job « {job.name} » supprimé.", "success")
  104. return redirect(url_for("index"))
  105. @app.route("/jobs/<int:job_id>/run", methods=["POST"])
  106. def job_run_now(job_id):
  107. job = db.get_or_404(Job, job_id)
  108. from scheduler import _execute_job
  109. import threading
  110. t = threading.Thread(target=_execute_job, args=(job.id,), daemon=True)
  111. t.start()
  112. flash(f"Job « {job.name} » lancé manuellement.", "success")
  113. return redirect(url_for("index"))
  114. @app.route("/jobs/<int:job_id>/history")
  115. def job_history(job_id):
  116. job = db.get_or_404(Job, job_id)
  117. runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(100).all()
  118. return render_template("job_history.html", job=job, runs=runs)
  119. @app.route("/jobs/<int:job_id>/toggle", methods=["POST"])
  120. def job_toggle(job_id):
  121. job = db.get_or_404(Job, job_id)
  122. job.enabled = not job.enabled
  123. job.updated_at = datetime.utcnow()
  124. db.session.commit()
  125. if job.enabled:
  126. schedule_job(job)
  127. flash(f"Job « {job.name} » activé.", "success")
  128. else:
  129. remove_job(job.id)
  130. flash(f"Job « {job.name} » désactivé.", "info")
  131. return redirect(url_for("index"))
  132. def _save_job(job):
  133. f = request.form
  134. job_type = f.get("type", "")
  135. name = f.get("name", "").strip()
  136. if not name:
  137. flash("Le nom est requis.", "error")
  138. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps())
  139. cfg = {}
  140. if job_type == "ynh_app":
  141. cfg = {"app_id": f.get("app_id", ""), "core_only": f.get("core_only") == "1"}
  142. elif job_type == "ynh_system":
  143. cfg = {}
  144. elif job_type in ("mysql", "postgresql"):
  145. dbname = f.get("db_database", "").strip()
  146. if not dbname:
  147. flash("Le nom de la base de données est requis.", "error")
  148. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps())
  149. cfg = {"database": dbname}
  150. if job is None:
  151. job = Job()
  152. db.session.add(job)
  153. job.name = name
  154. job.type = job_type
  155. job.config_json = json.dumps(cfg)
  156. job.cron_expr = f.get("cron_expr", "0 3 * * *").strip()
  157. job.retention_mode = f.get("retention_mode", "count")
  158. job.retention_value = int(f.get("retention_value", 7))
  159. job.enabled = f.get("enabled") == "1"
  160. job.core_only = cfg.get("core_only", False)
  161. job.updated_at = datetime.utcnow()
  162. db.session.commit()
  163. if job.enabled:
  164. schedule_job(job)
  165. else:
  166. remove_job(job.id)
  167. flash(f"Job « {job.name} » enregistré.", "success")
  168. return redirect(url_for("index"))
  169. # --- API v1 ------------------------------------------------------------------
  170. @app.route("/api/v1/health")
  171. def api_health():
  172. return jsonify({"status": "ok", "instance": app.config.get("INSTANCE_NAME")})
  173. @app.route("/api/v1/jobs")
  174. def api_jobs():
  175. jobs = Job.query.all()
  176. return jsonify([
  177. {
  178. "id": j.id,
  179. "name": j.name,
  180. "type": j.type,
  181. "cron_expr": j.cron_expr,
  182. "enabled": j.enabled,
  183. "retention_mode": j.retention_mode,
  184. "retention_value": j.retention_value,
  185. }
  186. for j in jobs
  187. ])
  188. @app.route("/api/v1/jobs/<int:job_id>/runs")
  189. def api_job_runs(job_id):
  190. runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(50).all()
  191. return jsonify([
  192. {
  193. "id": r.id,
  194. "started_at": r.started_at.isoformat() if r.started_at else None,
  195. "finished_at": r.finished_at.isoformat() if r.finished_at else None,
  196. "status": r.status,
  197. "archive_name": r.archive_name,
  198. "size_bytes": r.size_bytes,
  199. }
  200. for r in runs
  201. ])
  202. @app.route("/api/v1/jobs/<int:job_id>/run", methods=["POST"])
  203. def api_job_run(job_id):
  204. job = db.get_or_404(Job, job_id)
  205. from scheduler import _execute_job
  206. import threading
  207. threading.Thread(target=_execute_job, args=(job.id,), daemon=True).start()
  208. return jsonify({"status": "triggered", "job_id": job_id})
  209. @app.route("/api/v1/archives")
  210. def api_archives():
  211. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  212. archives = []
  213. try:
  214. for fname in sorted(os.listdir(backup_dir)):
  215. if fname.endswith(".tar"):
  216. path = os.path.join(backup_dir, fname)
  217. archives.append({
  218. "name": fname[:-4],
  219. "size_bytes": os.path.getsize(path),
  220. "modified_at": datetime.utcfromtimestamp(os.path.getmtime(path)).isoformat(),
  221. })
  222. except OSError:
  223. pass
  224. return jsonify(archives)
  225. @app.route("/api/v1/archives/<name>", methods=["DELETE"])
  226. def api_archive_delete(name):
  227. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  228. for ext in (".tar", ".info.json"):
  229. path = os.path.join(backup_dir, name + ext)
  230. if os.path.exists(path):
  231. os.remove(path)
  232. return jsonify({"status": "deleted", "name": name})