Parcourir la source

feat: restauration MySQL et PostgreSQL

- restore_db_dump() : dispatch mysql/postgresql depuis l'archive
- MySQL : DROP/CREATE DATABASE + mysql < dump.sql (sudo root)
- PostgreSQL : pg_terminate_backend + dropdb + createdb + psql (sudo postgres)
- Extraction du dump directement depuis le .tar sans décompression totale
- sudoers : ajout mysql, psql, createdb, dropdb
- app.py : dispatch restore vers db_dump pour mysql/postgresql
- CDC : restauration DB cochée

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen il y a 1 jour
Parent
commit
4d772b5180
4 fichiers modifiés avec 123 ajouts et 1 suppressions
  1. 4 0
      conf/sudoers
  2. 1 1
      doc/CDC_backupmanager_ynh.md
  3. 3 0
      sources/app.py
  4. 115 0
      sources/jobs/db_dump.py

+ 4 - 0
conf/sudoers

@@ -5,7 +5,11 @@ __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=(root) NOPASSWD: /usr/bin/mysql *
 __APP__ ALL=(postgres) NOPASSWD: /usr/bin/pg_dump *
+__APP__ ALL=(postgres) NOPASSWD: /usr/bin/psql *
+__APP__ ALL=(postgres) NOPASSWD: /usr/bin/createdb *
+__APP__ ALL=(postgres) NOPASSWD: /usr/bin/dropdb *
 __APP__ ALL=(root) NOPASSWD: /usr/bin/rsync *
 __APP__ ALL=(root) NOPASSWD: /usr/bin/tar *
 __APP__ ALL=(root) NOPASSWD: /usr/bin/rm -rf /tmp/backupmanager_*

+ 1 - 1
doc/CDC_backupmanager_ynh.md

@@ -358,7 +358,7 @@ rsync -az -e "ssh -i $key -p $port" archive.tar archive.info.json user@host:/hom
 - [x] Jobs custom_dir (exclusions + format YNH compatible)
 - [x] Jobs mysql et postgresql
 - [x] Restauration complète custom_dir
-- [ ] Restauration complète DB (mysql + postgresql)
+- [x] Restauration complète DB (mysql + postgresql)
 - [ ] Destinations rsync SSH / SFTP
 - [ ] Notifications email
 

+ 3 - 0
sources/app.py

@@ -178,6 +178,9 @@ def archive_restore(archive_name):
                 if archive_type == "custom_dir":
                     from jobs.custom_dir import restore_custom_dir
                     restore_custom_dir(archive_name, backup_dir)
+                elif archive_type in ("mysql", "postgresql"):
+                    from jobs.db_dump import restore_db_dump
+                    restore_db_dump(archive_name, backup_dir)
                 else:
                     raise NotImplementedError(
                         f"Restauration non supportée pour le type '{archive_type}'."

+ 115 - 0
sources/jobs/db_dump.py

@@ -114,6 +114,121 @@ def _abort_if_exists(archive_name, backup_dir):
         )
 
 
+# ---------------------------------------------------------------------------
+# Restore
+# ---------------------------------------------------------------------------
+
+def restore_db_dump(archive_name, backup_dir):
+    """Restauration d'une base MySQL ou PostgreSQL depuis une archive BackupManager."""
+    archive_path = os.path.join(backup_dir, archive_name + ".tar")
+    if not os.path.exists(archive_path):
+        raise FileNotFoundError(f"Archive introuvable : {archive_path}")
+
+    info = _read_backup_info(archive_path)
+    db_type = info.get("type")
+    dbname = info.get("database", "")
+
+    if not dbname:
+        raise ValueError("Nom de base de données introuvable dans backup_info.json.")
+
+    with tempfile.TemporaryDirectory() as tmpdir:
+        dump_path = os.path.join(tmpdir, f"{dbname}.sql")
+
+        # Extraction du dump depuis l'archive
+        with tarfile.open(archive_path) as tar:
+            member = tar.getmember(f"db/{dbname}.sql")
+            with tar.extractfile(member) as src, open(dump_path, "wb") as dst:
+                dst.write(src.read())
+
+        if db_type == "mysql":
+            return _restore_mysql(dbname, dump_path)
+        elif db_type == "postgresql":
+            return _restore_postgresql(dbname, dump_path)
+        else:
+            raise ValueError(f"Type de base inconnu dans l'archive : {db_type}")
+
+
+def _restore_mysql(dbname, dump_path):
+    log_lines = []
+
+    # Suppression + recréation propre de la base
+    result = subprocess.run(
+        ["sudo", "mysql", "-e",
+         f"DROP DATABASE IF EXISTS `{dbname}`; CREATE DATABASE `{dbname}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"],
+        capture_output=True, text=True, timeout=60,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(f"Impossible de recréer la base MySQL '{dbname}' :\n{result.stderr.strip()}")
+    log_lines.append(f"Base MySQL '{dbname}' recréée.")
+
+    # Restauration du dump
+    with open(dump_path, "rb") as f:
+        result = subprocess.run(
+            ["sudo", "mysql", dbname],
+            stdin=f,
+            capture_output=True,
+            timeout=7200,
+        )
+    log = result.stderr.decode("utf-8", errors="replace").strip()
+    if result.returncode != 0:
+        raise RuntimeError(f"mysql restore a échoué (code {result.returncode}) :\n{log}")
+
+    log_lines.append(f"Dump restauré dans '{dbname}'.")
+    if log:
+        log_lines.append(log)
+    return "\n".join(log_lines)
+
+
+def _restore_postgresql(dbname, dump_path):
+    log_lines = []
+
+    # Terminer les connexions actives puis drop + recreate
+    subprocess.run(
+        ["sudo", "-u", "postgres", "psql", "-c",
+         f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{dbname}' AND pid <> pg_backend_pid();"],
+        capture_output=True, timeout=30,
+    )
+    subprocess.run(
+        ["sudo", "-u", "postgres", "dropdb", "--if-exists", dbname],
+        capture_output=True, timeout=60,
+    )
+    result = subprocess.run(
+        ["sudo", "-u", "postgres", "createdb", dbname],
+        capture_output=True, text=True, timeout=60,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(f"Impossible de recréer la base PostgreSQL '{dbname}' :\n{result.stderr.strip()}")
+    log_lines.append(f"Base PostgreSQL '{dbname}' recréée.")
+
+    # Restauration du dump
+    with open(dump_path, "rb") as f:
+        result = subprocess.run(
+            ["sudo", "-u", "postgres", "psql", "-d", dbname, "-v", "ON_ERROR_STOP=1"],
+            stdin=f,
+            capture_output=True,
+            timeout=7200,
+        )
+    log = result.stderr.decode("utf-8", errors="replace").strip()
+    if result.returncode != 0:
+        raise RuntimeError(f"psql restore a échoué (code {result.returncode}) :\n{log}")
+
+    log_lines.append(f"Dump restauré dans '{dbname}'.")
+    if log:
+        log_lines.append(log)
+    return "\n".join(log_lines)
+
+
+def _read_backup_info(archive_path):
+    try:
+        with tarfile.open(archive_path) as tar:
+            member = tar.extractfile("backup_info.json")
+            if member:
+                return json.loads(member.read())
+    except Exception:
+        pass
+    return {}
+
+
 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")