Forráskód Böngészése

feat: transfert post-backup vers instance fédérée (HTTP chunked)

- Ajout colonne remote_instance_id sur Job (DB + migration init_db.py)
- Formulaire job : select avec optgroups SSH/rsync et Instances fédérées
- push_archive_to_instance() dans jobs/transfer.py (réutilisable)
- ynh_backup.py : branche elif remote_instance_id → push HTTP chunked auto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen 1 hónapja
szülő
commit
ec50730c34

+ 21 - 8
sources/blueprints/jobs.py

@@ -13,7 +13,7 @@ from flask import (
     url_for,
 )
 
-from db import db, Job, Run, Destination
+from db import db, Job, Run, Destination, RemoteInstance
 from helpers import read_archive_info, get_ynh_apps
 
 bp = Blueprint("jobs", __name__)
@@ -50,7 +50,8 @@ def job_new():
         return _save_job(None)
     return render_template("job_form.html", job=None,
                            ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids()),
-                           destinations=Destination.query.filter_by(enabled=True).all())
+                           destinations=Destination.query.filter_by(enabled=True).all(),
+                           remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
 
 
 @bp.route("/jobs/<int:job_id>/edit", methods=["GET", "POST"])
@@ -60,7 +61,8 @@ def job_edit(job_id):
         return _save_job(job)
     return render_template("job_form.html", job=job,
                            ynh_apps=get_ynh_apps(exclude_app_ids=_used_app_ids(exclude_job_id=job_id)),
-                           destinations=Destination.query.filter_by(enabled=True).all())
+                           destinations=Destination.query.filter_by(enabled=True).all(),
+                           remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
 
 
 @bp.route("/jobs/<int:job_id>/delete", methods=["POST"])
@@ -342,7 +344,8 @@ def _save_job(job):
     if not name:
         flash("Le nom est requis.", "error")
         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)),
-                               destinations=Destination.query.filter_by(enabled=True).all())
+                               destinations=Destination.query.filter_by(enabled=True).all(),
+                               remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
 
     cfg = {}
     if job_type == "ynh_app":
@@ -354,14 +357,16 @@ def _save_job(job):
         if not dbname:
             flash("Le nom de la base de données est requis.", "error")
             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)),
-                                   destinations=Destination.query.filter_by(enabled=True).all())
+                                   destinations=Destination.query.filter_by(enabled=True).all(),
+                                   remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
         cfg = {"database": dbname}
     elif job_type == "custom_dir":
         source_path = f.get("source_path", "").strip().rstrip("/")
         if not source_path or not source_path.startswith("/"):
             flash("Le chemin source doit être un chemin absolu (ex: /opt/monapp).", "error")
             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)),
-                                   destinations=Destination.query.filter_by(enabled=True).all())
+                                   destinations=Destination.query.filter_by(enabled=True).all(),
+                                   remote_instances=RemoteInstance.query.order_by(RemoteInstance.name).all())
         excludes = [e.strip() for e in f.get("excludes", "").splitlines() if e.strip()]
         restore_cfg = {}
         user_name = f.get("restore_user_name", "").strip()
@@ -395,7 +400,7 @@ def _save_job(job):
         db.session.add(job)
 
     from scheduler import schedule_job, remove_job
-    dest_id = f.get("destination_id", "").strip()
+    transfer_target = f.get("transfer_target", "").strip()
     job.name = name
     job.type = job_type
     job.config_json = json.dumps(cfg)
@@ -405,7 +410,15 @@ def _save_job(job):
     job.retention_value = int(f.get("retention_value", 7))
     job.enabled = f.get("enabled") == "1"
     job.core_only = cfg.get("core_only", False)
-    job.destination_id = int(dest_id) if dest_id else None
+    if transfer_target.startswith("dest:"):
+        job.destination_id = int(transfer_target[5:])
+        job.remote_instance_id = None
+    elif transfer_target.startswith("inst:"):
+        job.destination_id = None
+        job.remote_instance_id = int(transfer_target[5:])
+    else:
+        job.destination_id = None
+        job.remote_instance_id = None
     job.updated_at = datetime.utcnow()
     db.session.commit()
 

+ 1 - 0
sources/db.py

@@ -52,6 +52,7 @@ class Job(db.Model):
     enabled = db.Column(db.Boolean, default=True)
     core_only = db.Column(db.Boolean, default=False)
     destination_id = db.Column(db.Integer, db.ForeignKey("destinations.id"), nullable=True)
+    remote_instance_id = db.Column(db.Integer, db.ForeignKey("remote_instances.id"), nullable=True)
     created_at = db.Column(db.DateTime, default=datetime.utcnow)
     updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
 

+ 6 - 0
sources/init_db.py

@@ -21,5 +21,11 @@ with app.app_context():
             ))
             conn.commit()
             print("Migration : colonne destination_id ajoutée à jobs.")
+        if "remote_instance_id" not in existing:
+            conn.execute(text(
+                "ALTER TABLE jobs ADD COLUMN remote_instance_id INTEGER REFERENCES remote_instances(id)"
+            ))
+            conn.commit()
+            print("Migration : colonne remote_instance_id ajoutée à jobs.")
 
     print("Base de données initialisée.")

+ 59 - 0
sources/jobs/transfer.py

@@ -98,5 +98,64 @@ def test_connection(destination, data_dir):
     return False, result.stderr.strip() or f"Connexion échouée (code {result.returncode})."
 
 
+def push_archive_to_instance(archive_name, instance, backup_dir):
+    """Pousse .tar + .info.json vers une instance fédérée via HTTP chunked. Retourne un log texte."""
+    import hashlib
+    from federation.client import FederationClient
+    from jobs.utils import sudo_exists
+
+    archive_path = os.path.join(backup_dir, archive_name + ".tar")
+    tmp_tar = f"/tmp/backupmanager_push_{archive_name}.tar"
+    tmp_info = f"/tmp/backupmanager_push_{archive_name}.info.json"
+
+    try:
+        result = subprocess.run(["sudo", "rsync", archive_path, tmp_tar],
+                                capture_output=True, text=True)
+        if result.returncode != 0:
+            raise RuntimeError(f"Copie locale échouée : {result.stderr.strip()}")
+
+        total_size = os.path.getsize(tmp_tar)
+        sha256 = hashlib.sha256()
+        chunk_size = 50 * 1024 * 1024
+        with open(tmp_tar, "rb") as f:
+            while True:
+                data = f.read(65536)
+                if not data:
+                    break
+                sha256.update(data)
+        checksum = sha256.hexdigest()
+
+        client = FederationClient(instance)
+        upload_info = client.upload_start(archive_name + ".tar", total_size, checksum, chunk_size)
+        upload_id = upload_info["upload_id"]
+
+        with open(tmp_tar, "rb") as f:
+            n = 0
+            while True:
+                data = f.read(chunk_size)
+                if not data:
+                    break
+                client.upload_chunk(upload_id, n, data)
+                n += 1
+
+        info_json_content = None
+        info_path = os.path.join(backup_dir, archive_name + ".info.json")
+        if sudo_exists(info_path):
+            r = subprocess.run(["sudo", "rsync", info_path, tmp_info], capture_output=True)
+            if r.returncode == 0:
+                try:
+                    with open(tmp_info, "r", encoding="utf-8", errors="replace") as fh:
+                        info_json_content = fh.read()
+                finally:
+                    subprocess.run(["sudo", "rm", "-rf", tmp_info], capture_output=True)
+
+        client.upload_finish_with_info(upload_id, info_json_content)
+        return f"Transfert HTTP chunked vers {instance.name} ({instance.url}) — {total_size // (1024*1024)} Mo en {n} chunks."
+
+    finally:
+        if os.path.exists(tmp_tar):
+            os.unlink(tmp_tar)
+
+
 def _slugify(s):
     return re.sub(r'[^a-z0-9]+', '-', s.lower().strip()).strip('-')

+ 11 - 2
sources/jobs/ynh_backup.py

@@ -51,10 +51,9 @@ def execute_job(job_id):
         if deleted:
             run.log_text += f"\n\nRétention : {len(deleted)} archive(s) supprimée(s) : {', '.join(deleted)}"
 
-        # Transfert automatique vers la destination configurée
+        # Transfert automatique post-backup
         if job.destination_id:
             from db import Destination
-            from flask import current_app
             dest = db.session.get(Destination, job.destination_id)
             if dest and dest.enabled:
                 data_dir = current_app.config["DATA_DIR"]
@@ -64,6 +63,16 @@ def execute_job(job_id):
                     run.log_text += f"\n\nTransfert → {dest.remote_str} :\n{transfer_log}"
                 except Exception as transfer_exc:
                     run.log_text += f"\n\n⚠ Transfert échoué vers {dest.remote_str} :\n{transfer_exc}"
+        elif job.remote_instance_id:
+            from db import RemoteInstance
+            inst = db.session.get(RemoteInstance, job.remote_instance_id)
+            if inst:
+                try:
+                    from jobs.transfer import push_archive_to_instance
+                    transfer_log = push_archive_to_instance(archive_name, inst, backup_dir)
+                    run.log_text += f"\n\nTransfert HTTP → {inst.name} :\n{transfer_log}"
+                except Exception as transfer_exc:
+                    run.log_text += f"\n\n⚠ Transfert HTTP échoué vers {inst.name} :\n{transfer_exc}"
 
     except Exception as exc:
         run.status = "error"

+ 26 - 10
sources/templates/job_form.html

@@ -271,20 +271,36 @@
     {# ── Destination ── #}
     <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-3">
       <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Transfert après sauvegarde</h2>
-      <select name="destination_id"
+      <select name="transfer_target"
               class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
-        <option value="">Aucune — stockage local uniquement</option>
-        {% for d in destinations %}
-          <option value="{{ d.id }}"
-                  {% if job and job.destination_id == d.id %}selected{% endif %}>
-            {{ d.name }} — {{ d.remote_str }}
-          </option>
-        {% endfor %}
+        <option value="">Aucun — stockage local uniquement</option>
+        {% if destinations %}
+          <optgroup label="SSH / rsync">
+            {% for d in destinations %}
+              <option value="dest:{{ d.id }}"
+                      {% if job and job.destination_id == d.id %}selected{% endif %}>
+                {{ d.name }} — {{ d.remote_str }}
+              </option>
+            {% endfor %}
+          </optgroup>
+        {% endif %}
+        {% if remote_instances %}
+          <optgroup label="Instance fédérée (HTTP chunked)">
+            {% for inst in remote_instances %}
+              <option value="inst:{{ inst.id }}"
+                      {% if job and job.remote_instance_id == inst.id %}selected{% endif %}>
+                {{ inst.name }} — {{ inst.url }}
+              </option>
+            {% endfor %}
+          </optgroup>
+        {% endif %}
       </select>
-      {% if not destinations %}
+      {% if not destinations and not remote_instances %}
         <p class="text-xs text-gray-400">
           Aucune destination configurée.
-          <a href="{{ url_for('dest.destination_new') }}" class="text-blue-600 hover:underline">En créer une →</a>
+          <a href="{{ url_for('dest.destination_new') }}" class="text-blue-600 hover:underline">Créer une destination SSH →</a>
+          &nbsp;|&nbsp;
+          <a href="{{ url_for('cfg.settings') }}?tab=instances" class="text-blue-600 hover:underline">Ajouter une instance fédérée →</a>
         </p>
       {% endif %}
     </div>