Pārlūkot izejas kodu

feat: Phase 2B — dumps MySQL et PostgreSQL

- jobs/db_dump.py : mysqldump (root via sudo) + pg_dump (postgres via sudo)
- Archive au format YunoHost : .tar avec db/{name}.sql + backup_info.json
- .info.json externe généré pour listing webadmin YunoHost
- Dispatch mysql/postgresql dans execute_job (ynh_backup.py)
- Préfixes retention corrects pour mysql/postgresql
- sudoers : ajout mysqldump (root) et pg_dump (postgres)
- Formulaire : mysql/postgresql activés, champ db_database dynamique
- _save_job : validation et config pour les types DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen 1 dienu atpakaļ
vecāks
revīzija
181dd5adb9

+ 2 - 0
conf/sudoers

@@ -4,3 +4,5 @@ __APP__ ALL=(root) NOPASSWD: /usr/bin/yunohost backup delete *
 __APP__ ALL=(root) NOPASSWD: /usr/bin/yunohost backup restore *
 __APP__ ALL=(root) NOPASSWD: /usr/bin/yunohost app list *
 __APP__ ALL=(root) NOPASSWD: /usr/bin/yunohost tools version
+__APP__ ALL=(root) NOPASSWD: /usr/bin/mysqldump *
+__APP__ ALL=(postgres) NOPASSWD: /usr/bin/pg_dump *

+ 6 - 0
sources/app.py

@@ -178,6 +178,12 @@ def _save_job(job):
         cfg = {"app_id": f.get("app_id", ""), "core_only": f.get("core_only") == "1"}
     elif job_type == "ynh_system":
         cfg = {}
+    elif job_type in ("mysql", "postgresql"):
+        dbname = f.get("db_database", "").strip()
+        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())
+        cfg = {"database": dbname}
 
     if job is None:
         job = Job()

+ 151 - 2
sources/jobs/db_dump.py

@@ -1,2 +1,151 @@
-# Phase 2 — Sauvegarde MySQL / PostgreSQL (mysqldump / pg_dump)
-raise NotImplementedError("db_dump — Phase 2")
+import json
+import os
+import subprocess
+import tarfile
+import tempfile
+import time
+from datetime import datetime
+
+from db import db, Job, Run
+
+
+def run_db_dump(job, instance, backup_dir):
+    """Point d'entrée commun mysql et postgresql."""
+    if job.type == "mysql":
+        return _run_mysql(job, instance, backup_dir)
+    elif job.type == "postgresql":
+        return _run_postgresql(job, instance, backup_dir)
+    raise ValueError(f"Type inconnu pour db_dump : {job.type}")
+
+
+# ---------------------------------------------------------------------------
+# MySQL
+# ---------------------------------------------------------------------------
+
+def _run_mysql(job, instance, backup_dir):
+    from flask import current_app
+    cfg = json.loads(job.config_json or "{}")
+    dbname = cfg.get("database", "")
+    if not dbname:
+        raise ValueError("Nom de base de données manquant dans la configuration du job.")
+
+    archive_name = _archive_name(instance, "mysql", dbname)
+    _abort_if_exists(archive_name, backup_dir)
+
+    with tempfile.TemporaryDirectory() as tmpdir:
+        dump_path = os.path.join(tmpdir, f"{dbname}.sql")
+
+        result = subprocess.run(
+            [
+                "sudo", "mysqldump",
+                "--single-transaction",
+                "--routines",
+                "--triggers",
+                "--result-file", dump_path,
+                dbname,
+            ],
+            capture_output=True,
+            text=True,
+            timeout=7200,
+        )
+        log = (result.stdout + result.stderr).strip()
+        if result.returncode != 0:
+            raise RuntimeError(f"mysqldump a échoué (code {result.returncode}) :\n{log}")
+
+        _write_tar(tmpdir, dump_path, dbname, archive_name, backup_dir, job,
+                   instance, current_app.config.get("INSTANCE_URL", ""))
+
+    return archive_name, log or "mysqldump terminé sans sortie."
+
+
+# ---------------------------------------------------------------------------
+# PostgreSQL
+# ---------------------------------------------------------------------------
+
+def _run_postgresql(job, instance, backup_dir):
+    from flask import current_app
+    cfg = json.loads(job.config_json or "{}")
+    dbname = cfg.get("database", "")
+    if not dbname:
+        raise ValueError("Nom de base de données manquant dans la configuration du job.")
+
+    archive_name = _archive_name(instance, "postgresql", dbname)
+    _abort_if_exists(archive_name, backup_dir)
+
+    with tempfile.TemporaryDirectory() as tmpdir:
+        dump_path = os.path.join(tmpdir, f"{dbname}.sql")
+
+        # pg_dump doit tourner en tant qu'utilisateur postgres
+        result = subprocess.run(
+            ["sudo", "-u", "postgres", "pg_dump", "--format=plain", dbname],
+            capture_output=True,
+            timeout=7200,
+        )
+        if result.returncode != 0:
+            log = result.stderr.decode("utf-8", errors="replace").strip()
+            raise RuntimeError(f"pg_dump a échoué (code {result.returncode}) :\n{log}")
+
+        with open(dump_path, "wb") as f:
+            f.write(result.stdout)
+
+        log = result.stderr.decode("utf-8", errors="replace").strip()
+
+        _write_tar(tmpdir, dump_path, dbname, archive_name, backup_dir, job,
+                   instance, current_app.config.get("INSTANCE_URL", ""))
+
+    return archive_name, log or "pg_dump terminé sans sortie."
+
+
+# ---------------------------------------------------------------------------
+# Helpers partagés
+# ---------------------------------------------------------------------------
+
+def _archive_name(instance, db_type, dbname):
+    date_str = datetime.utcnow().strftime("%Y%m%d")
+    return f"{instance}_{db_type}_{dbname}_{date_str}"
+
+
+def _abort_if_exists(archive_name, backup_dir):
+    path = os.path.join(backup_dir, archive_name + ".tar")
+    if os.path.exists(path):
+        raise RuntimeError(
+            f"L'archive {archive_name}.tar existe déjà. "
+            "Supprimez-la ou attendez le prochain cycle."
+        )
+
+
+def _write_tar(tmpdir, dump_path, dbname, archive_name, backup_dir, job, instance, instance_url):
+    """Crée le .tar dans backup_dir et le .info.json YunoHost à côté."""
+    archive_path = os.path.join(backup_dir, archive_name + ".tar")
+
+    # backup_info.json embarqué dans le tar (métadonnées BackupManager)
+    import json as _json
+    info = {
+        "instance_name": instance,
+        "instance_url": instance_url,
+        "type": job.type,
+        "database": dbname,
+        "created_at": datetime.utcnow().isoformat(),
+        "backupmanager_version": "1.0.0",
+    }
+    info_path = os.path.join(tmpdir, "backup_info.json")
+    with open(info_path, "w") as f:
+        _json.dump(info, f, indent=2)
+
+    with tarfile.open(archive_path, "w") as tar:
+        tar.add(dump_path, arcname=f"db/{dbname}.sql")
+        tar.add(info_path, arcname="backup_info.json")
+
+    # .info.json YunoHost (hors tar, requis pour listing webadmin)
+    size = os.path.getsize(archive_path)
+    ynh_info = {
+        "created_at": int(time.time()),
+        "description": f"BackupManager: {job.type} {dbname}",
+        "size": size,
+        "from_before_upgrade": False,
+        "apps": {},
+        "system": {},
+    }
+    ynh_info_path = os.path.join(backup_dir, archive_name + ".info.json")
+    with open(ynh_info_path, "w") as f:
+        _json.dump(ynh_info, f, indent=2)

+ 4 - 1
sources/jobs/ynh_backup.py

@@ -28,8 +28,11 @@ def execute_job(job_id):
             archive_name, log = _run_ynh_app(job, instance, backup_dir)
         elif job.type == "ynh_system":
             archive_name, log = _run_ynh_system(job, instance, backup_dir)
+        elif job.type in ("mysql", "postgresql"):
+            from jobs.db_dump import run_db_dump
+            archive_name, log = run_db_dump(job, instance, backup_dir)
         else:
-            raise ValueError(f"Type de job non géré dans ce module : {job.type}")
+            raise ValueError(f"Type de job non géré : {job.type}")
 
         archive_path = os.path.join(backup_dir, archive_name + ".tar")
         size_bytes = os.path.getsize(archive_path) if os.path.exists(archive_path) else None

+ 5 - 0
sources/retention.py

@@ -36,6 +36,11 @@ def _list_archives_for_job(job, backup_dir):
         prefix = f"{instance}_{app_id}_"
     elif job.type == "ynh_system":
         prefix = f"{instance}_system_"
+    elif job.type in ("mysql", "postgresql"):
+        import json
+        cfg = json.loads(job.config_json or "{}")
+        dbname = cfg.get("database", "")
+        prefix = f"{instance}_{job.type}_{dbname}_"
     else:
         prefix = f"{instance}_{job.name.lower().replace(' ', '-')}_"
 

+ 32 - 3
sources/templates/job_form.html

@@ -28,11 +28,11 @@
         <select name="type" id="job-type"
                 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">
           {% for val, label in [('ynh_app','Application YunoHost'), ('ynh_system','Système YunoHost'),
-                                ('custom_dir','Répertoire custom (Phase 2)'), ('mysql','MySQL (Phase 2)'),
-                                ('postgresql','PostgreSQL (Phase 2)')] %}
+                                ('mysql','MySQL'), ('postgresql','PostgreSQL'),
+                                ('custom_dir','Répertoire custom (Phase 2)')] %}
             <option value="{{ val }}"
               {% if job and job.type == val %}selected{% endif %}
-              {% if val in ('custom_dir','mysql','postgresql') %}disabled class="text-gray-400"{% endif %}>
+              {% if val == 'custom_dir' %}disabled class="text-gray-400"{% endif %}>
               {{ label }}
             </option>
           {% endfor %}
@@ -75,6 +75,35 @@
           Aucun paramètre supplémentaire.
         </p>
       </div>
+
+      {# Type-specific config : mysql #}
+      {% set db_cfg = (job.config_json | fromjson) if job and job.config_json else {} %}
+      <div id="cfg-mysql" class="type-cfg hidden space-y-3">
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Nom de la base de données</label>
+          <input type="text" name="db_database"
+                 value="{{ db_cfg.get('database', '') if job and job.type == 'mysql' else '' }}"
+                 placeholder="ex: nextcloud"
+                 class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500">
+          <p class="text-xs text-gray-400 mt-1">
+            Le dump est exécuté avec <code class="bg-gray-100 px-1 rounded">sudo mysqldump</code> — aucun mot de passe requis.
+          </p>
+        </div>
+      </div>
+
+      {# Type-specific config : postgresql #}
+      <div id="cfg-postgresql" class="type-cfg hidden space-y-3">
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Nom de la base de données</label>
+          <input type="text" name="db_database"
+                 value="{{ db_cfg.get('database', '') if job and job.type == 'postgresql' else '' }}"
+                 placeholder="ex: gitea"
+                 class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500">
+          <p class="text-xs text-gray-400 mt-1">
+            Le dump est exécuté avec <code class="bg-gray-100 px-1 rounded">sudo -u postgres pg_dump</code> — aucun mot de passe requis.
+          </p>
+        </div>
+      </div>
     </div>
 
     {# ── Planification ── #}