app.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140
  1. import glob
  2. import hashlib
  3. import json
  4. import logging
  5. import math
  6. import os
  7. import shutil
  8. import subprocess
  9. import threading
  10. import uuid
  11. from datetime import datetime
  12. from flask import (
  13. Flask,
  14. flash,
  15. jsonify,
  16. redirect,
  17. render_template,
  18. request,
  19. url_for,
  20. )
  21. from werkzeug.middleware.proxy_fix import ProxyFix
  22. # --- Configuration -----------------------------------------------------------
  23. _config_path = os.environ.get(
  24. "BACKUPMANAGER_CONFIG",
  25. os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.py"),
  26. )
  27. app = Flask(__name__)
  28. app.config.from_pyfile(_config_path)
  29. app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + app.config["DB_PATH"]
  30. app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
  31. # Proxy headers Nginx → Flask (sous-chemin + HTTPS)
  32. app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
  33. # Filtre Jinja2 pour désérialiser du JSON dans les templates
  34. app.jinja_env.filters["fromjson"] = json.loads
  35. # Logging
  36. os.makedirs(os.path.dirname(app.config["LOG_PATH"]), exist_ok=True)
  37. logging.basicConfig(
  38. filename=app.config["LOG_PATH"],
  39. level=logging.INFO,
  40. format="%(asctime)s %(levelname)s %(message)s",
  41. )
  42. # --- Extensions --------------------------------------------------------------
  43. from db import db, Job, Run, Destination, Setting, RemoteInstance, RemoteRun, Upload
  44. db.init_app(app)
  45. from scheduler import init_scheduler, schedule_job, remove_job
  46. # --- Démarrage ---------------------------------------------------------------
  47. with app.app_context():
  48. db.create_all()
  49. init_scheduler(app)
  50. for _job in Job.query.filter_by(enabled=True).all():
  51. schedule_job(_job)
  52. # --- Auth API ----------------------------------------------------------------
  53. @app.before_request
  54. def _check_api_auth():
  55. if not request.path.startswith("/api/"):
  56. return
  57. if request.path == "/api/v1/health":
  58. return
  59. token = request.headers.get("X-BackupManager-Key", "")
  60. if token != app.config["API_TOKEN"]:
  61. return jsonify({"error": "Unauthorized"}), 401
  62. # --- Context processors ------------------------------------------------------
  63. @app.context_processor
  64. def _inject_globals():
  65. return {
  66. "instance_name": app.config.get("INSTANCE_NAME", ""),
  67. "now": datetime.utcnow(),
  68. }
  69. # --- Helpers -----------------------------------------------------------------
  70. def _read_archive_info(archive_name):
  71. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  72. archive_path = os.path.join(backup_dir, archive_name + ".tar")
  73. from jobs.utils import sudo_read_backup_info
  74. info = sudo_read_backup_info(archive_path)
  75. if not info.get("type"):
  76. # Archives YunoHost natives : déterminer le type depuis la table Run
  77. run = Run.query.filter_by(archive_name=archive_name).first()
  78. if run:
  79. job = db.session.get(Job, run.job_id)
  80. if job:
  81. info["type"] = job.type
  82. info["_from_run"] = True
  83. return info
  84. def _get_ynh_apps():
  85. try:
  86. result = subprocess.run(
  87. ["sudo", "yunohost", "app", "list", "--output-as", "json"],
  88. capture_output=True,
  89. text=True,
  90. timeout=15,
  91. )
  92. if result.returncode == 0:
  93. return json.loads(result.stdout).get("apps", [])
  94. except Exception:
  95. pass
  96. return []
  97. # --- Routes dashboard --------------------------------------------------------
  98. @app.route("/")
  99. def index():
  100. jobs = Job.query.order_by(Job.name).all()
  101. last_runs = {
  102. j.id: Run.query.filter_by(job_id=j.id).order_by(Run.started_at.desc()).first()
  103. for j in jobs
  104. }
  105. return render_template("dashboard_local.html", jobs=jobs, last_runs=last_runs)
  106. @app.route("/jobs/new", methods=["GET", "POST"])
  107. def job_new():
  108. if request.method == "POST":
  109. return _save_job(None)
  110. return render_template("job_form.html", job=None, ynh_apps=_get_ynh_apps(),
  111. destinations=Destination.query.filter_by(enabled=True).all())
  112. @app.route("/jobs/<int:job_id>/edit", methods=["GET", "POST"])
  113. def job_edit(job_id):
  114. job = db.get_or_404(Job, job_id)
  115. if request.method == "POST":
  116. return _save_job(job)
  117. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps(),
  118. destinations=Destination.query.filter_by(enabled=True).all())
  119. @app.route("/jobs/<int:job_id>/delete", methods=["POST"])
  120. def job_delete(job_id):
  121. job = db.get_or_404(Job, job_id)
  122. remove_job(job.id)
  123. db.session.delete(job)
  124. db.session.commit()
  125. flash(f"Job « {job.name} » supprimé.", "success")
  126. return redirect(url_for("index"))
  127. @app.route("/jobs/<int:job_id>/run", methods=["POST"])
  128. def job_run_now(job_id):
  129. job = db.get_or_404(Job, job_id)
  130. from scheduler import _execute_job
  131. import threading
  132. t = threading.Thread(target=_execute_job, args=(job.id,), daemon=True)
  133. t.start()
  134. flash(f"Job « {job.name} » lancé manuellement.", "success")
  135. return redirect(url_for("index"))
  136. @app.route("/jobs/<int:job_id>/history")
  137. def job_history(job_id):
  138. job = db.get_or_404(Job, job_id)
  139. runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(100).all()
  140. return render_template("job_history.html", job=job, runs=runs)
  141. def _do_restore_job(archive_name, archive_type, restore_run_id):
  142. """Exécute la restauration en arrière-plan et met à jour le Run."""
  143. with app.app_context():
  144. run = db.session.get(Run, restore_run_id) if restore_run_id else None
  145. try:
  146. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  147. if archive_type == "custom_dir":
  148. from jobs.custom_dir import restore_custom_dir
  149. log = restore_custom_dir(archive_name, backup_dir)
  150. elif archive_type in ("mysql", "postgresql"):
  151. from jobs.db_dump import restore_db_dump
  152. log = restore_db_dump(archive_name, backup_dir)
  153. elif archive_type == "ynh_app":
  154. result = subprocess.run(
  155. ["sudo", "yunohost", "backup", "restore", archive_name,
  156. "--apps", "--force"],
  157. capture_output=True, text=True, timeout=3600,
  158. )
  159. log = (result.stdout + result.stderr).strip()
  160. if result.returncode != 0:
  161. raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
  162. elif archive_type == "ynh_system":
  163. result = subprocess.run(
  164. ["sudo", "yunohost", "backup", "restore", archive_name,
  165. "--system", "--force"],
  166. capture_output=True, text=True, timeout=3600,
  167. )
  168. log = (result.stdout + result.stderr).strip()
  169. if result.returncode != 0:
  170. raise RuntimeError(f"yunohost backup restore a échoué :\n{log}")
  171. else:
  172. raise NotImplementedError(
  173. f"Restauration non supportée pour le type '{archive_type}'."
  174. )
  175. if run:
  176. run.status = "success"
  177. run.finished_at = datetime.utcnow()
  178. run.log_text = f"[RESTAURATION]\n{log or 'OK'}"
  179. db.session.commit()
  180. except Exception as exc:
  181. app.logger.error(f"Restauration {archive_name} échouée : {exc}")
  182. if run:
  183. run.status = "error"
  184. run.finished_at = datetime.utcnow()
  185. run.log_text = f"[RESTAURATION]\n{exc}"
  186. db.session.commit()
  187. def _start_restore(archive_name):
  188. """Crée un Run de restauration et lance le thread. Retourne (restore_run_id, archive_type)."""
  189. info = _read_archive_info(archive_name)
  190. archive_type = info.get("type", "")
  191. original_run = Run.query.filter_by(archive_name=archive_name).first()
  192. restore_run_id = None
  193. if original_run:
  194. restore_run = Run(
  195. job_id=original_run.job_id,
  196. started_at=datetime.utcnow(),
  197. status="running",
  198. archive_name=archive_name,
  199. log_text="[RESTAURATION en cours…]",
  200. )
  201. db.session.add(restore_run)
  202. db.session.commit()
  203. restore_run_id = restore_run.id
  204. threading.Thread(
  205. target=_do_restore_job,
  206. args=(archive_name, archive_type, restore_run_id),
  207. daemon=True,
  208. ).start()
  209. return restore_run_id, archive_type
  210. @app.route("/archives/<path:archive_name>/restore", methods=["GET", "POST"])
  211. def archive_restore(archive_name):
  212. info = _read_archive_info(archive_name)
  213. if request.method == "GET":
  214. return render_template("restore_confirm.html", archive_name=archive_name, info=info)
  215. _start_restore(archive_name)
  216. flash(f"Restauration de « {archive_name} » démarrée en arrière-plan.", "success")
  217. return redirect(url_for("index"))
  218. @app.route("/jobs/<int:job_id>/toggle", methods=["POST"])
  219. def job_toggle(job_id):
  220. job = db.get_or_404(Job, job_id)
  221. job.enabled = not job.enabled
  222. job.updated_at = datetime.utcnow()
  223. db.session.commit()
  224. if job.enabled:
  225. schedule_job(job)
  226. flash(f"Job « {job.name} » activé.", "success")
  227. else:
  228. remove_job(job.id)
  229. flash(f"Job « {job.name} » désactivé.", "info")
  230. return redirect(url_for("index"))
  231. def _save_job(job):
  232. f = request.form
  233. job_type = f.get("type", "")
  234. name = f.get("name", "").strip()
  235. if not name:
  236. flash("Le nom est requis.", "error")
  237. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps(),
  238. destinations=Destination.query.filter_by(enabled=True).all())
  239. cfg = {}
  240. if job_type == "ynh_app":
  241. cfg = {"app_id": f.get("app_id", ""), "core_only": f.get("core_only") == "1"}
  242. elif job_type == "ynh_system":
  243. cfg = {}
  244. elif job_type in ("mysql", "postgresql"):
  245. dbname = f.get("db_database", "").strip()
  246. if not dbname:
  247. flash("Le nom de la base de données est requis.", "error")
  248. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps(),
  249. destinations=Destination.query.filter_by(enabled=True).all())
  250. cfg = {"database": dbname}
  251. elif job_type == "custom_dir":
  252. source_path = f.get("source_path", "").strip().rstrip("/")
  253. if not source_path or not source_path.startswith("/"):
  254. flash("Le chemin source doit être un chemin absolu (ex: /opt/monapp).", "error")
  255. return render_template("job_form.html", job=job, ynh_apps=_get_ynh_apps(),
  256. destinations=Destination.query.filter_by(enabled=True).all())
  257. excludes = [e.strip() for e in f.get("excludes", "").splitlines() if e.strip()]
  258. restore_cfg = {}
  259. user_name = f.get("restore_user_name", "").strip()
  260. if user_name:
  261. restore_cfg["system_user"] = {
  262. "name": user_name,
  263. "home": f.get("restore_user_home", source_path).strip() or source_path,
  264. "shell": f.get("restore_user_shell", "/bin/false").strip() or "/bin/false",
  265. }
  266. service_name = f.get("restore_service_name", "").strip()
  267. if service_name:
  268. restore_cfg["systemd_service"] = {
  269. "name": service_name,
  270. "service_file": f.get("restore_service_file", "").strip(),
  271. }
  272. owner = f.get("restore_perm_owner", "").strip()
  273. mode = f.get("restore_perm_mode", "").strip()
  274. if owner or mode:
  275. restore_cfg["permissions"] = {}
  276. if owner:
  277. restore_cfg["permissions"]["owner"] = owner
  278. if mode:
  279. restore_cfg["permissions"]["mode"] = mode
  280. post_cmds = [c.strip() for c in f.get("restore_post_cmds", "").splitlines() if c.strip()]
  281. if post_cmds:
  282. restore_cfg["post_restore_commands"] = post_cmds
  283. cfg = {"source_path": source_path, "excludes": excludes, "restore": restore_cfg}
  284. if job is None:
  285. job = Job()
  286. db.session.add(job)
  287. dest_id = f.get("destination_id", "").strip()
  288. job.name = name
  289. job.type = job_type
  290. job.config_json = json.dumps(cfg)
  291. job.cron_expr = f.get("cron_expr", "0 3 * * *").strip()
  292. job.retention_mode = f.get("retention_mode", "count")
  293. job.retention_value = int(f.get("retention_value", 7))
  294. job.enabled = f.get("enabled") == "1"
  295. job.core_only = cfg.get("core_only", False)
  296. job.destination_id = int(dest_id) if dest_id else None
  297. job.updated_at = datetime.utcnow()
  298. db.session.commit()
  299. if job.enabled:
  300. schedule_job(job)
  301. else:
  302. remove_job(job.id)
  303. flash(f"Job « {job.name} » enregistré.", "success")
  304. return redirect(url_for("index"))
  305. # --- Destinations ------------------------------------------------------------
  306. @app.route("/destinations")
  307. def destinations_list():
  308. destinations = Destination.query.order_by(Destination.name).all()
  309. return render_template("destinations.html", destinations=destinations)
  310. @app.route("/destinations/new", methods=["GET", "POST"])
  311. def destination_new():
  312. if request.method == "POST":
  313. return _save_destination(None)
  314. return render_template("destination_form.html", dest=None)
  315. @app.route("/destinations/<int:dest_id>/edit", methods=["GET", "POST"])
  316. def destination_edit(dest_id):
  317. dest = db.get_or_404(Destination, dest_id)
  318. if request.method == "POST":
  319. return _save_destination(dest)
  320. pub_key = _get_pub_key(dest)
  321. return render_template("destination_form.html", dest=dest, pub_key=pub_key)
  322. @app.route("/destinations/<int:dest_id>/delete", methods=["POST"])
  323. def destination_delete(dest_id):
  324. dest = db.get_or_404(Destination, dest_id)
  325. db.session.delete(dest)
  326. db.session.commit()
  327. flash(f"Destination « {dest.name} » supprimée.", "success")
  328. return redirect(url_for("destinations_list"))
  329. @app.route("/destinations/<int:dest_id>/test", methods=["POST"])
  330. def destination_test(dest_id):
  331. dest = db.get_or_404(Destination, dest_id)
  332. from jobs.transfer import test_connection
  333. ok, msg = test_connection(dest, app.config["DATA_DIR"])
  334. flash(msg, "success" if ok else "error")
  335. return redirect(url_for("destinations_list"))
  336. @app.route("/archives/<path:archive_name>/transfer", methods=["POST"])
  337. def archive_transfer(archive_name):
  338. dest_id = request.form.get("destination_id", type=int)
  339. dest = db.get_or_404(Destination, dest_id)
  340. def _do_transfer():
  341. with app.app_context():
  342. try:
  343. from jobs.transfer import transfer_archive
  344. transfer_archive(archive_name, dest, app.config["YUNOHOST_BACKUP_DIR"],
  345. app.config["DATA_DIR"])
  346. app.logger.info(f"Transfert {archive_name} → {dest.remote_str} OK")
  347. except Exception as exc:
  348. app.logger.error(f"Transfert {archive_name} échoué : {exc}")
  349. import threading
  350. threading.Thread(target=_do_transfer, daemon=True).start()
  351. flash(f"Transfert de « {archive_name} » vers {dest.remote_str} démarré.", "success")
  352. return redirect(request.referrer or url_for("index"))
  353. def _save_destination(dest):
  354. f = request.form
  355. name = f.get("name", "").strip()
  356. host = f.get("host", "").strip()
  357. if not name or not host:
  358. flash("Nom et hôte sont requis.", "error")
  359. return render_template("destination_form.html", dest=dest)
  360. is_new = dest is None
  361. if is_new:
  362. dest = Destination()
  363. db.session.add(dest)
  364. dest.name = name
  365. dest.host = host
  366. dest.port = int(f.get("port", 22) or 22)
  367. dest.user = f.get("user", "root").strip() or "root"
  368. dest.remote_path = f.get("remote_path", "/home/yunohost.backup/archives").strip()
  369. dest.enabled = f.get("enabled") == "1"
  370. db.session.flush() # obtenir l'id si nouveau
  371. # Génération de la clé SSH si absente
  372. if not dest.key_name:
  373. from jobs.transfer import generate_key
  374. dest.key_name = generate_key(dest.name, app.config["DATA_DIR"])
  375. db.session.commit()
  376. flash(f"Destination « {dest.name} » enregistrée.", "success")
  377. return redirect(url_for("destination_edit", dest_id=dest.id))
  378. def _get_pub_key(dest):
  379. if not dest.key_name:
  380. return None
  381. from jobs.transfer import get_public_key
  382. return get_public_key(dest.key_name, app.config["DATA_DIR"])
  383. # --- Paramètres --------------------------------------------------------------
  384. _SETTING_KEYS = [
  385. "smtp_host", "smtp_port", "smtp_user", "smtp_password",
  386. "smtp_from", "smtp_to", "smtp_tls", "smtp_ssl",
  387. "notify_on_success", "notify_on_error",
  388. ]
  389. def _get_setting(key, default=""):
  390. s = Setting.query.filter_by(key=key).first()
  391. return s.value if s else default
  392. @app.route("/settings", methods=["GET", "POST"])
  393. def settings():
  394. if request.method == "POST":
  395. action = request.form.get("action")
  396. if action == "test_smtp":
  397. from notifications import send_test_email
  398. try:
  399. send_test_email(
  400. host=request.form.get("smtp_host", "").strip(),
  401. port=int(request.form.get("smtp_port", 587) or 587),
  402. user=request.form.get("smtp_user", "").strip(),
  403. password=request.form.get("smtp_password", ""),
  404. from_addr=request.form.get("smtp_from", "").strip(),
  405. to_addr=request.form.get("smtp_to", "").strip(),
  406. use_ssl=request.form.get("smtp_ssl") == "1",
  407. use_tls=request.form.get("smtp_tls") == "1",
  408. )
  409. flash("Email de test envoyé avec succès.", "success")
  410. except Exception as exc:
  411. flash(f"Échec du test SMTP : {exc}", "error")
  412. else:
  413. for key in _SETTING_KEYS:
  414. if key in ("smtp_tls", "smtp_ssl", "notify_on_success", "notify_on_error"):
  415. value = "1" if request.form.get(key) == "1" else "0"
  416. else:
  417. value = request.form.get(key, "").strip()
  418. s = Setting.query.filter_by(key=key).first()
  419. if s is None:
  420. s = Setting(key=key, value=value)
  421. db.session.add(s)
  422. else:
  423. s.value = value
  424. db.session.commit()
  425. flash("Paramètres enregistrés.", "success")
  426. return redirect(url_for("settings"))
  427. cfg = {k: _get_setting(k) for k in _SETTING_KEYS}
  428. cfg.setdefault("smtp_port", "587")
  429. cfg["smtp_tls"] = cfg.get("smtp_tls") or "1"
  430. cfg["smtp_ssl"] = cfg.get("smtp_ssl") or "0"
  431. cfg["notify_on_error"] = cfg.get("notify_on_error") or "1"
  432. api_token = app.config.get("API_TOKEN", "")
  433. instance_url = app.config.get("INSTANCE_URL", "")
  434. return render_template("settings.html", cfg=cfg, api_token=api_token,
  435. instance_url=instance_url)
  436. # --- Routes internes (usage formulaires) -------------------------------------
  437. @app.route("/internal/databases/<db_type>")
  438. def internal_databases(db_type):
  439. """Liste les bases de données disponibles pour le formulaire job."""
  440. databases = []
  441. try:
  442. if db_type == "mysql":
  443. result = subprocess.run(
  444. ["sudo", "mysql", "--skip-column-names", "-e", "SHOW DATABASES;"],
  445. capture_output=True, text=True, timeout=10,
  446. )
  447. if result.returncode == 0:
  448. exclude = {"information_schema", "performance_schema", "mysql", "sys"}
  449. databases = [d.strip() for d in result.stdout.splitlines()
  450. if d.strip() and d.strip() not in exclude]
  451. elif db_type == "postgresql":
  452. result = subprocess.run(
  453. ["sudo", "-u", "postgres", "psql", "-Atc",
  454. "SELECT datname FROM pg_database WHERE datistemplate = false;"],
  455. capture_output=True, text=True, timeout=10,
  456. )
  457. if result.returncode == 0:
  458. databases = [d.strip() for d in result.stdout.splitlines() if d.strip()]
  459. except Exception:
  460. pass
  461. return jsonify(databases)
  462. # --- API v1 ------------------------------------------------------------------
  463. @app.route("/api/v1/health")
  464. def api_health():
  465. return jsonify({"status": "ok", "instance": app.config.get("INSTANCE_NAME")})
  466. @app.route("/api/v1/jobs")
  467. def api_jobs():
  468. jobs = Job.query.all()
  469. return jsonify([
  470. {
  471. "id": j.id,
  472. "name": j.name,
  473. "type": j.type,
  474. "cron_expr": j.cron_expr,
  475. "enabled": j.enabled,
  476. "retention_mode": j.retention_mode,
  477. "retention_value": j.retention_value,
  478. }
  479. for j in jobs
  480. ])
  481. @app.route("/api/v1/jobs/<int:job_id>/runs")
  482. def api_job_runs(job_id):
  483. runs = Run.query.filter_by(job_id=job_id).order_by(Run.started_at.desc()).limit(50).all()
  484. return jsonify([
  485. {
  486. "id": r.id,
  487. "started_at": r.started_at.isoformat() if r.started_at else None,
  488. "finished_at": r.finished_at.isoformat() if r.finished_at else None,
  489. "status": r.status,
  490. "archive_name": r.archive_name,
  491. "size_bytes": r.size_bytes,
  492. }
  493. for r in runs
  494. ])
  495. @app.route("/api/v1/jobs/<int:job_id>/run", methods=["POST"])
  496. def api_job_run(job_id):
  497. job = db.get_or_404(Job, job_id)
  498. from scheduler import _execute_job
  499. import threading
  500. threading.Thread(target=_execute_job, args=(job.id,), daemon=True).start()
  501. return jsonify({"status": "triggered", "job_id": job_id})
  502. @app.route("/api/v1/archives")
  503. def api_archives():
  504. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  505. archives = []
  506. try:
  507. from jobs.utils import sudo_listdir, sudo_getsize, sudo_getmtime
  508. for fname in sorted(sudo_listdir(backup_dir)):
  509. if fname.endswith(".tar"):
  510. path = os.path.join(backup_dir, fname)
  511. archives.append({
  512. "name": fname[:-4],
  513. "size_bytes": sudo_getsize(path),
  514. "modified_at": datetime.utcfromtimestamp(sudo_getmtime(path)).isoformat(),
  515. })
  516. except OSError:
  517. pass
  518. return jsonify(archives)
  519. @app.route("/api/v1/archives/<name>", methods=["DELETE"])
  520. def api_archive_delete(name):
  521. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  522. from jobs.utils import sudo_exists
  523. for ext in (".tar", ".info.json"):
  524. path = os.path.join(backup_dir, name + ext)
  525. if sudo_exists(path):
  526. subprocess.run(["sudo", "rm", "-f", path], capture_output=True)
  527. return jsonify({"status": "deleted", "name": name})
  528. @app.route("/api/v1/archives/<name>/info")
  529. def api_archive_info(name):
  530. return jsonify(_read_archive_info(name))
  531. @app.route("/api/v1/archives/<name>/restore", methods=["POST"])
  532. def api_archive_restore(name):
  533. restore_run_id, _ = _start_restore(name)
  534. return jsonify({"status": "started", "run_id": restore_run_id})
  535. @app.route("/api/v1/archives/<name>/restore/status")
  536. def api_archive_restore_status(name):
  537. run = (Run.query
  538. .filter(Run.archive_name == name, Run.log_text.like("[RESTAURATION%"))
  539. .order_by(Run.started_at.desc())
  540. .first())
  541. if not run:
  542. return jsonify({"error": "Aucune restauration trouvée pour cette archive."}), 404
  543. return jsonify({
  544. "status": run.status,
  545. "log": run.log_text,
  546. "started_at": run.started_at.isoformat() if run.started_at else None,
  547. "finished_at": run.finished_at.isoformat() if run.finished_at else None,
  548. })
  549. @app.route("/api/v1/summary")
  550. def api_summary():
  551. jobs = Job.query.all()
  552. result = []
  553. for job in jobs:
  554. last_run = (Run.query.filter_by(job_id=job.id)
  555. .order_by(Run.started_at.desc()).first())
  556. result.append({
  557. "id": job.id,
  558. "name": job.name,
  559. "type": job.type,
  560. "cron_expr": job.cron_expr,
  561. "enabled": job.enabled,
  562. "last_run": {
  563. "id": last_run.id,
  564. "started_at": last_run.started_at.isoformat() if last_run.started_at else None,
  565. "status": last_run.status,
  566. "archive_name": last_run.archive_name,
  567. "size_bytes": last_run.size_bytes,
  568. } if last_run else None,
  569. })
  570. return jsonify({"instance": app.config.get("INSTANCE_NAME"), "jobs": result})
  571. # --- Upload chunked -----------------------------------------------------------
  572. @app.route("/api/v1/archives/upload/start", methods=["POST"])
  573. def api_upload_start():
  574. data = request.get_json(force=True) or {}
  575. filename = data.get("filename", "")
  576. total_size = int(data.get("total_size", 0))
  577. chunk_size = int(data.get("chunk_size", 50 * 1024 * 1024))
  578. chunks_total = int(data.get("chunks_total", math.ceil(total_size / chunk_size) if chunk_size else 1))
  579. checksum = data.get("checksum", "")
  580. if not filename:
  581. return jsonify({"error": "filename requis"}), 400
  582. upload_id = str(uuid.uuid4())
  583. upload = Upload(
  584. upload_id=upload_id,
  585. filename=filename,
  586. total_size=total_size,
  587. chunk_size=chunk_size,
  588. chunks_total=chunks_total,
  589. chunks_received=0,
  590. checksum=checksum,
  591. status="pending",
  592. )
  593. db.session.add(upload)
  594. db.session.commit()
  595. return jsonify({"upload_id": upload_id, "chunks_total": chunks_total})
  596. @app.route("/api/v1/archives/upload/<upload_id>/chunk/<int:n>", methods=["POST"])
  597. def api_upload_chunk(upload_id, n):
  598. upload = db.get_or_404(Upload, upload_id)
  599. if upload.status == "complete":
  600. return jsonify({"error": "upload déjà terminé"}), 400
  601. tmp_dir = os.path.join(app.config["DATA_DIR"], "uploads", upload_id)
  602. os.makedirs(tmp_dir, exist_ok=True)
  603. chunk_path = os.path.join(tmp_dir, f"chunk_{n:06d}")
  604. with open(chunk_path, "wb") as f:
  605. f.write(request.data)
  606. upload.chunks_received = (upload.chunks_received or 0) + 1
  607. upload.status = "in_progress"
  608. db.session.commit()
  609. return jsonify({"chunk": n, "received": upload.chunks_received})
  610. @app.route("/api/v1/archives/upload/<upload_id>/finish", methods=["POST"])
  611. def api_upload_finish(upload_id):
  612. upload = db.get_or_404(Upload, upload_id)
  613. tmp_dir = os.path.join(app.config["DATA_DIR"], "uploads", upload_id)
  614. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  615. chunk_files = sorted(glob.glob(os.path.join(tmp_dir, "chunk_*")))
  616. if not chunk_files:
  617. return jsonify({"error": "aucun chunk reçu"}), 400
  618. tmp_archive = os.path.join(tmp_dir, upload.filename)
  619. sha256 = hashlib.sha256()
  620. with open(tmp_archive, "wb") as out:
  621. for chunk_file in chunk_files:
  622. with open(chunk_file, "rb") as f:
  623. data = f.read()
  624. out.write(data)
  625. sha256.update(data)
  626. if upload.checksum and sha256.hexdigest() != upload.checksum:
  627. upload.status = "error"
  628. db.session.commit()
  629. shutil.rmtree(tmp_dir, ignore_errors=True)
  630. return jsonify({"error": "checksum invalide"}), 400
  631. dest_path = os.path.join(backup_dir, upload.filename)
  632. result = subprocess.run(
  633. ["sudo", "rsync", tmp_archive, dest_path],
  634. capture_output=True, text=True,
  635. )
  636. if result.returncode != 0:
  637. upload.status = "error"
  638. db.session.commit()
  639. shutil.rmtree(tmp_dir, ignore_errors=True)
  640. return jsonify({"error": result.stderr.strip()}), 500
  641. # .info.json optionnel transmis dans le body JSON
  642. data = request.get_json(silent=True) or {}
  643. info_json_str = data.get("info_json")
  644. if info_json_str:
  645. archive_base = upload.filename[:-4] if upload.filename.endswith(".tar") else upload.filename
  646. tmp_info = os.path.join(tmp_dir, archive_base + ".info.json")
  647. with open(tmp_info, "w") as f:
  648. f.write(info_json_str)
  649. subprocess.run(
  650. ["sudo", "rsync", tmp_info,
  651. os.path.join(backup_dir, archive_base + ".info.json")],
  652. capture_output=True,
  653. )
  654. shutil.rmtree(tmp_dir, ignore_errors=True)
  655. upload.status = "complete"
  656. db.session.commit()
  657. return jsonify({"status": "complete", "filename": upload.filename})
  658. @app.route("/api/v1/archives/<name>/info-json-download")
  659. def api_archive_info_json_download(name):
  660. """Téléchargement du .info.json via sudo rsync (pour pull inter-instances)."""
  661. from jobs.utils import sudo_exists
  662. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  663. info_path = os.path.join(backup_dir, name + ".info.json")
  664. if not sudo_exists(info_path):
  665. return jsonify({"error": "info.json introuvable"}), 404
  666. tmp_path = f"/tmp/backupmanager_dl_{name}.info.json"
  667. try:
  668. result = subprocess.run(["sudo", "rsync", info_path, tmp_path],
  669. capture_output=True, text=True)
  670. if result.returncode != 0:
  671. return jsonify({"error": result.stderr.strip()}), 500
  672. with open(tmp_path, "rb") as f:
  673. content = f.read()
  674. os.unlink(tmp_path)
  675. from flask import Response as _R
  676. return _R(content, mimetype="application/json")
  677. except Exception as exc:
  678. if os.path.exists(tmp_path):
  679. os.unlink(tmp_path)
  680. return jsonify({"error": str(exc)}), 500
  681. @app.route("/api/v1/archives/<name>/download")
  682. def api_archive_download(name):
  683. """Téléchargement d'une archive via sudo rsync vers /tmp (pour pull inter-instances)."""
  684. from flask import Response, stream_with_context
  685. from jobs.utils import sudo_exists
  686. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  687. archive_path = os.path.join(backup_dir, name + ".tar")
  688. if not sudo_exists(archive_path):
  689. return jsonify({"error": "archive introuvable"}), 404
  690. tmp_path = f"/tmp/backupmanager_dl_{name}.tar"
  691. try:
  692. result = subprocess.run(
  693. ["sudo", "rsync", archive_path, tmp_path],
  694. capture_output=True, text=True, timeout=3600,
  695. )
  696. if result.returncode != 0:
  697. return jsonify({"error": result.stderr.strip()}), 500
  698. def stream_and_cleanup():
  699. try:
  700. with open(tmp_path, "rb") as f:
  701. while True:
  702. chunk = f.read(1024 * 1024)
  703. if not chunk:
  704. break
  705. yield chunk
  706. finally:
  707. if os.path.exists(tmp_path):
  708. os.unlink(tmp_path)
  709. return Response(
  710. stream_with_context(stream_and_cleanup()),
  711. mimetype="application/octet-stream",
  712. headers={"Content-Disposition": f'attachment; filename="{name}.tar"'},
  713. )
  714. except Exception as exc:
  715. if os.path.exists(tmp_path):
  716. os.unlink(tmp_path)
  717. return jsonify({"error": str(exc)}), 500
  718. @app.route("/api/v1/archives/upload/<upload_id>", methods=["DELETE"])
  719. def api_upload_cancel(upload_id):
  720. upload = db.get_or_404(Upload, upload_id)
  721. tmp_dir = os.path.join(app.config["DATA_DIR"], "uploads", upload_id)
  722. shutil.rmtree(tmp_dir, ignore_errors=True)
  723. db.session.delete(upload)
  724. db.session.commit()
  725. return jsonify({"status": "cancelled"})
  726. # --- Instances distantes (3B) -------------------------------------------------
  727. @app.route("/remote-instances")
  728. def remote_instances_list():
  729. instances = RemoteInstance.query.order_by(RemoteInstance.name).all()
  730. return render_template("remote_instances.html", instances=instances)
  731. @app.route("/remote-instances/new", methods=["GET", "POST"])
  732. def remote_instance_new():
  733. if request.method == "POST":
  734. return _save_remote_instance(None)
  735. return render_template("remote_instance_form.html", inst=None)
  736. @app.route("/remote-instances/<int:inst_id>/edit", methods=["GET", "POST"])
  737. def remote_instance_edit(inst_id):
  738. inst = db.get_or_404(RemoteInstance, inst_id)
  739. if request.method == "POST":
  740. return _save_remote_instance(inst)
  741. return render_template("remote_instance_form.html", inst=inst)
  742. @app.route("/remote-instances/<int:inst_id>/delete", methods=["POST"])
  743. def remote_instance_delete(inst_id):
  744. inst = db.get_or_404(RemoteInstance, inst_id)
  745. db.session.delete(inst)
  746. db.session.commit()
  747. flash(f"Instance « {inst.name} » supprimée.", "success")
  748. return redirect(url_for("remote_instances_list"))
  749. @app.route("/remote-instances/<int:inst_id>/test", methods=["POST"])
  750. def remote_instance_test(inst_id):
  751. inst = db.get_or_404(RemoteInstance, inst_id)
  752. from federation.client import FederationClient
  753. try:
  754. data = FederationClient(inst).health()
  755. inst.status = "online"
  756. inst.last_seen = datetime.utcnow()
  757. db.session.commit()
  758. flash(f"Instance « {inst.name} » en ligne — {data.get('instance', '?')}.", "success")
  759. except Exception as exc:
  760. inst.status = "error"
  761. db.session.commit()
  762. flash(f"Connexion échouée vers « {inst.name} » : {exc}", "error")
  763. return redirect(url_for("remote_instances_list"))
  764. @app.route("/remote-instances/<int:inst_id>/sync", methods=["POST"])
  765. def remote_instance_sync(inst_id):
  766. inst = db.get_or_404(RemoteInstance, inst_id)
  767. from federation.client import sync_instance
  768. try:
  769. sync_instance(inst)
  770. flash(f"Instance « {inst.name} » synchronisée.", "success")
  771. except Exception as exc:
  772. flash(f"Synchronisation échouée pour « {inst.name} » : {exc}", "error")
  773. return redirect(url_for("remote_instances_list"))
  774. @app.route("/network")
  775. def dashboard_network():
  776. local_jobs = Job.query.order_by(Job.name).all()
  777. local_jobs_data = []
  778. for job in local_jobs:
  779. run = Run.query.filter_by(job_id=job.id).order_by(Run.started_at.desc()).first()
  780. local_jobs_data.append(_JobRow(
  781. job_id=job.id, name=job.name, type=job.type,
  782. last_run_at=run.started_at if run else None,
  783. last_status=run.status if run else None,
  784. last_archive_name=run.archive_name if run else None,
  785. last_size_bytes=run.size_bytes if run else None,
  786. ))
  787. instances = RemoteInstance.query.order_by(RemoteInstance.name).all()
  788. return render_template("dashboard_network.html",
  789. local_jobs_data=local_jobs_data,
  790. instances=instances,
  791. instances_for_push=instances)
  792. @app.route("/network/sync-all", methods=["POST"])
  793. def network_sync_all():
  794. from federation.client import sync_instance
  795. instances = RemoteInstance.query.all()
  796. errors = []
  797. for inst in instances:
  798. try:
  799. sync_instance(inst)
  800. except Exception as exc:
  801. errors.append(f"{inst.name}: {exc}")
  802. if errors:
  803. flash("Synchronisation partielle — " + " | ".join(errors), "error")
  804. else:
  805. flash(f"{len(instances)} instance(s) synchronisée(s).", "success")
  806. return redirect(url_for("dashboard_network"))
  807. @app.route("/remote-instances/<int:inst_id>/run-job/<int:job_id>", methods=["POST"])
  808. def remote_job_run(inst_id, job_id):
  809. inst = db.get_or_404(RemoteInstance, inst_id)
  810. from federation.client import FederationClient
  811. try:
  812. FederationClient(inst).run_job(job_id)
  813. flash(f"Job déclenché sur « {inst.name} ».", "success")
  814. except Exception as exc:
  815. flash(f"Impossible de lancer le job sur « {inst.name} » : {exc}", "error")
  816. return redirect(url_for("dashboard_network"))
  817. @app.route("/archives/<path:archive_name>/push/<int:inst_id>", methods=["POST"])
  818. def archive_push(archive_name, inst_id):
  819. inst = db.get_or_404(RemoteInstance, inst_id)
  820. threading.Thread(target=_do_push_archive, args=(archive_name, inst.id), daemon=True).start()
  821. flash(f"Envoi de « {archive_name} » vers « {inst.name} » démarré en arrière-plan.", "success")
  822. return redirect(request.referrer or url_for("index"))
  823. @app.route("/remote-instances/<int:inst_id>/pull-latest/<int:remote_job_id>", methods=["POST"])
  824. def archive_pull_latest(inst_id, remote_job_id):
  825. inst = db.get_or_404(RemoteInstance, inst_id)
  826. threading.Thread(target=_do_pull_latest, args=(inst.id, remote_job_id), daemon=True).start()
  827. flash(f"Rapatriement depuis « {inst.name} » démarré en arrière-plan.", "success")
  828. return redirect(url_for("dashboard_network"))
  829. def _do_push_archive(archive_name, inst_id):
  830. """Pousse une archive locale vers une instance distante via HTTP chunked."""
  831. import hashlib as _hashlib
  832. from federation.client import FederationClient
  833. from jobs.utils import sudo_exists
  834. with app.app_context():
  835. inst = db.session.get(RemoteInstance, inst_id)
  836. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  837. archive_path = os.path.join(backup_dir, archive_name + ".tar")
  838. tmp_path = None
  839. try:
  840. # Copie vers /tmp accessible par l'app
  841. tmp_path = f"/tmp/backupmanager_push_{archive_name}.tar"
  842. result = subprocess.run(
  843. ["sudo", "rsync", archive_path, tmp_path],
  844. capture_output=True, text=True,
  845. )
  846. if result.returncode != 0:
  847. raise RuntimeError(f"Copie locale échouée : {result.stderr.strip()}")
  848. total_size = os.path.getsize(tmp_path)
  849. sha256 = _hashlib.sha256()
  850. chunk_size = 50 * 1024 * 1024
  851. with open(tmp_path, "rb") as f:
  852. while True:
  853. data = f.read(65536)
  854. if not data:
  855. break
  856. sha256.update(data)
  857. checksum = sha256.hexdigest()
  858. client = FederationClient(inst)
  859. upload_info = client.upload_start(archive_name + ".tar", total_size, checksum, chunk_size)
  860. upload_id = upload_info["upload_id"]
  861. with open(tmp_path, "rb") as f:
  862. n = 0
  863. while True:
  864. data = f.read(chunk_size)
  865. if not data:
  866. break
  867. client.upload_chunk(upload_id, n, data)
  868. n += 1
  869. # Finish + transmettre le .info.json si présent
  870. info_json_content = None
  871. info_path = os.path.join(backup_dir, archive_name + ".info.json")
  872. if sudo_exists(info_path):
  873. r = subprocess.run(["sudo", "cat", info_path], capture_output=True)
  874. if r.returncode == 0:
  875. info_json_content = r.stdout.decode("utf-8", errors="replace")
  876. client.upload_finish_with_info(upload_id, info_json_content)
  877. app.logger.info(f"Push {archive_name} → {inst.name} OK")
  878. except Exception as exc:
  879. app.logger.error(f"Push {archive_name} → {inst.name} échoué : {exc}")
  880. finally:
  881. if tmp_path and os.path.exists(tmp_path):
  882. os.unlink(tmp_path)
  883. def _do_pull_latest(inst_id, remote_job_id):
  884. """Rapatrie la dernière archive d'un job distant (.tar + .info.json)."""
  885. from federation.client import FederationClient, sync_instance
  886. with app.app_context():
  887. inst = db.session.get(RemoteInstance, inst_id)
  888. backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
  889. try:
  890. client = FederationClient(inst)
  891. # Sync pour obtenir la dernière archive
  892. sync_instance(inst)
  893. db.session.refresh(inst)
  894. # Récupère le dernier run de ce job distant
  895. runs = client.get_job_runs(remote_job_id)
  896. if not runs:
  897. raise RuntimeError(f"Aucun run distant pour le job {remote_job_id}")
  898. archive_name = runs[0].get("archive_name")
  899. if not archive_name:
  900. raise RuntimeError("Le dernier run distant n'a pas d'archive.")
  901. # Télécharge le .tar
  902. archive_bytes = client.download_archive(archive_name)
  903. tmp_tar = f"/tmp/backupmanager_pull_{archive_name}.tar"
  904. with open(tmp_tar, "wb") as f:
  905. f.write(archive_bytes)
  906. subprocess.run(["sudo", "rsync", tmp_tar,
  907. os.path.join(backup_dir, archive_name + ".tar")], check=True)
  908. os.unlink(tmp_tar)
  909. # Télécharge le .info.json si disponible
  910. info_bytes = client.download_info_json(archive_name)
  911. if info_bytes:
  912. tmp_info = f"/tmp/backupmanager_pull_{archive_name}.info.json"
  913. with open(tmp_info, "wb") as f:
  914. f.write(info_bytes)
  915. subprocess.run(["sudo", "rsync", tmp_info,
  916. os.path.join(backup_dir, archive_name + ".info.json")],
  917. check=True)
  918. os.unlink(tmp_info)
  919. app.logger.info(f"Pull {archive_name} ← {inst.name} OK")
  920. except Exception as exc:
  921. app.logger.error(f"Pull ← {inst.name} échoué : {exc}")
  922. class _JobRow:
  923. """DTO pour le dashboard réseau (local et distant)."""
  924. def __init__(self, job_id, name, type, last_run_at, last_status,
  925. last_archive_name, last_size_bytes):
  926. self.job_id = job_id
  927. self.name = name
  928. self.type = type
  929. self.last_run_at = last_run_at
  930. self.last_status = last_status
  931. self.last_archive_name = last_archive_name
  932. self.last_size_bytes = last_size_bytes
  933. @property
  934. def size_human(self):
  935. from db import _size_human
  936. return _size_human(self.last_size_bytes)
  937. def _save_remote_instance(inst):
  938. f = request.form
  939. name = f.get("name", "").strip()
  940. url = f.get("url", "").strip().rstrip("/")
  941. api_key = f.get("api_key", "").strip()
  942. if not name or not url or not api_key:
  943. flash("Nom, URL et token API sont requis.", "error")
  944. return render_template("remote_instance_form.html", inst=inst)
  945. if inst is None:
  946. inst = RemoteInstance()
  947. db.session.add(inst)
  948. inst.name = name
  949. inst.url = url
  950. inst.api_key = api_key
  951. db.session.commit()
  952. flash(f"Instance « {inst.name} » enregistrée.", "success")
  953. return redirect(url_for("remote_instances_list"))