Просмотр исходного кода

fix: pull — dernière archive via job_id + .info.json + cleanup runs bloqués toutes les heures

Cédric Hansen 3 часов назад
Родитель
Сommit
3dbff1a364
4 измененных файлов с 113 добавлено и 31 удалено
  1. 64 15
      sources/app.py
  2. 16 0
      sources/federation/client.py
  3. 25 0
      sources/scheduler.py
  4. 8 16
      sources/templates/dashboard_network.html

+ 64 - 15
sources/app.py

@@ -785,6 +785,31 @@ def api_upload_finish(upload_id):
     return jsonify({"status": "complete", "filename": upload.filename})
 
 
+@app.route("/api/v1/archives/<name>/info-json-download")
+def api_archive_info_json_download(name):
+    """Téléchargement du .info.json via sudo rsync (pour pull inter-instances)."""
+    from jobs.utils import sudo_exists
+    backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+    info_path = os.path.join(backup_dir, name + ".info.json")
+    if not sudo_exists(info_path):
+        return jsonify({"error": "info.json introuvable"}), 404
+    tmp_path = f"/tmp/backupmanager_dl_{name}.info.json"
+    try:
+        result = subprocess.run(["sudo", "rsync", info_path, tmp_path],
+                                capture_output=True, text=True)
+        if result.returncode != 0:
+            return jsonify({"error": result.stderr.strip()}), 500
+        with open(tmp_path, "rb") as f:
+            content = f.read()
+        os.unlink(tmp_path)
+        from flask import Response as _R
+        return _R(content, mimetype="application/json")
+    except Exception as exc:
+        if os.path.exists(tmp_path):
+            os.unlink(tmp_path)
+        return jsonify({"error": str(exc)}), 500
+
+
 @app.route("/api/v1/archives/<name>/download")
 def api_archive_download(name):
     """Téléchargement d'une archive via sudo rsync vers /tmp (pour pull inter-instances)."""
@@ -956,11 +981,11 @@ def archive_push(archive_name, inst_id):
     return redirect(request.referrer or url_for("index"))
 
 
-@app.route("/remote-instances/<int:inst_id>/pull/<path:archive_name>", methods=["POST"])
-def archive_pull(inst_id, archive_name):
+@app.route("/remote-instances/<int:inst_id>/pull-latest/<int:remote_job_id>", methods=["POST"])
+def archive_pull_latest(inst_id, remote_job_id):
     inst = db.get_or_404(RemoteInstance, inst_id)
-    threading.Thread(target=_do_pull_archive, args=(archive_name, inst.id), daemon=True).start()
-    flash(f"Rapatriement de « {archive_name} » depuis « {inst.name} » démarré.", "success")
+    threading.Thread(target=_do_pull_latest, args=(inst.id, remote_job_id), daemon=True).start()
+    flash(f"Rapatriement depuis « {inst.name} » démarré en arrière-plan.", "success")
     return redirect(url_for("dashboard_network"))
 
 
@@ -1028,27 +1053,51 @@ def _do_push_archive(archive_name, inst_id):
                 os.unlink(tmp_path)
 
 
-def _do_pull_archive(archive_name, inst_id):
-    """Rapatrie une archive depuis une instance distante via HTTP chunked."""
-    import hashlib as _hashlib
-    from federation.client import FederationClient
+def _do_pull_latest(inst_id, remote_job_id):
+    """Rapatrie la dernière archive d'un job distant (.tar + .info.json)."""
+    from federation.client import FederationClient, sync_instance
 
     with app.app_context():
         inst = db.session.get(RemoteInstance, inst_id)
         backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
         try:
             client = FederationClient(inst)
-            # Télécharge l'archive chunk par chunk
+
+            # Sync pour obtenir la dernière archive
+            sync_instance(inst)
+            db.session.refresh(inst)
+
+            # Récupère le dernier run de ce job distant
+            runs = client.get_job_runs(remote_job_id)
+            if not runs:
+                raise RuntimeError(f"Aucun run distant pour le job {remote_job_id}")
+            archive_name = runs[0].get("archive_name")
+            if not archive_name:
+                raise RuntimeError("Le dernier run distant n'a pas d'archive.")
+
+            # Télécharge le .tar
             archive_bytes = client.download_archive(archive_name)
-            tmp_path = f"/tmp/backupmanager_pull_{archive_name}.tar"
-            with open(tmp_path, "wb") as f:
+            tmp_tar = f"/tmp/backupmanager_pull_{archive_name}.tar"
+            with open(tmp_tar, "wb") as f:
                 f.write(archive_bytes)
-            dest = os.path.join(backup_dir, archive_name + ".tar")
-            subprocess.run(["sudo", "rsync", tmp_path, dest], check=True)
-            os.unlink(tmp_path)
+            subprocess.run(["sudo", "rsync", tmp_tar,
+                            os.path.join(backup_dir, archive_name + ".tar")], check=True)
+            os.unlink(tmp_tar)
+
+            # Télécharge le .info.json si disponible
+            info_bytes = client.download_info_json(archive_name)
+            if info_bytes:
+                tmp_info = f"/tmp/backupmanager_pull_{archive_name}.info.json"
+                with open(tmp_info, "wb") as f:
+                    f.write(info_bytes)
+                subprocess.run(["sudo", "rsync", tmp_info,
+                                os.path.join(backup_dir, archive_name + ".info.json")],
+                               check=True)
+                os.unlink(tmp_info)
+
             app.logger.info(f"Pull {archive_name} ← {inst.name} OK")
         except Exception as exc:
-            app.logger.error(f"Pull {archive_name} ← {inst.name} échoué : {exc}")
+            app.logger.error(f"Pull ← {inst.name} échoué : {exc}")
 
 
 class _JobRow:

+ 16 - 0
sources/federation/client.py

@@ -89,6 +89,22 @@ class FederationClient:
         r.raise_for_status()
         return r.json()
 
+    def download_info_json(self, archive_name):
+        """Télécharge le .info.json d'une archive distante. Retourne None si absent."""
+        try:
+            r = requests.get(
+                f"{self.base}/api/v1/archives/{archive_name}/info-json-download",
+                headers=self.headers,
+                timeout=30,
+                verify=True,
+            )
+            if r.status_code == 404:
+                return None
+            r.raise_for_status()
+            return r.content
+        except Exception:
+            return None
+
     def download_archive(self, archive_name):
         """Télécharge une archive distante en mémoire (archives de taille raisonnable)."""
         r = requests.get(

+ 25 - 0
sources/scheduler.py

@@ -14,6 +14,31 @@ def init_scheduler(flask_app):
     _flask_app = flask_app
     if not scheduler.running:
         scheduler.start()
+    # Nettoyage des runs bloqués à "running" (app redémarrée pendant un backup)
+    scheduler.add_job(
+        func=_cleanup_stuck_runs,
+        trigger="interval",
+        hours=1,
+        id="cleanup_stuck_runs",
+        replace_existing=True,
+    )
+
+
+def _cleanup_stuck_runs():
+    from datetime import timedelta
+    with _flask_app.app_context():
+        from db import db, Run
+        cutoff = __import__("datetime").datetime.utcnow() - timedelta(hours=6)
+        stuck = Run.query.filter(
+            Run.status == "running",
+            Run.started_at < cutoff,
+        ).all()
+        for run in stuck:
+            run.status = "error"
+            run.log_text = (run.log_text or "") + "\n[timeout] Run marqué en erreur par le nettoyage automatique."
+            run.finished_at = __import__("datetime").datetime.utcnow()
+        if stuck:
+            db.session.commit()
 
 
 def _execute_job(job_id):

+ 8 - 16
sources/templates/dashboard_network.html

@@ -125,23 +125,15 @@
                 </button>
               </form>
               {% endif %}
-              {% if inst_id and row.last_archive_name and instances_for_push %}
-              <div class="relative group">
-                <button type="button"
-                        class="text-gray-400 hover:text-gray-700 text-xs px-2 py-1 rounded hover:bg-gray-100 transition">
-                  Récupérer ↓
+              {% if inst_id and row.job_id %}
+              <form method="post"
+                    action="{{ url_for('archive_pull_latest', inst_id=inst_id, remote_job_id=row.job_id) }}"
+                    onsubmit="return confirm('Rapatrier la dernière archive de « {{ row.name }} » depuis {{ inst_name }} ?')">
+                <button type="submit"
+                  class="text-gray-400 hover:text-gray-700 text-xs px-2 py-1 rounded hover:bg-gray-100 transition">
+                  ← Rapatrier
                 </button>
-                <div class="hidden group-hover:block absolute right-0 top-6 bg-white border border-gray-200 rounded-lg shadow-lg z-10 min-w-max">
-                  <p class="text-xs text-gray-400 px-3 pt-2 pb-1">Tirer vers cette instance</p>
-                  <form method="post"
-                        action="{{ url_for('archive_pull', inst_id=inst_id, archive_name=row.last_archive_name) }}">
-                    <button type="submit"
-                            class="block w-full text-left text-xs px-3 py-2 hover:bg-gray-50">
-                      ← Rapatrier l'archive
-                    </button>
-                  </form>
-                </div>
-              </div>
+              </form>
               {% endif %}
             </div>
           </td>