Jelajahi Sumber

feat: Phase 2A — sauvegarde et restauration custom_dir

Backup:
- rsync sudo vers structure data/custom{source_path}/ dans tmpdir
- backup.csv (format YunoHost) + backup_info.json embarqués dans le tar
- .info.json externe pour listing webadmin YunoHost
- sudo tar pour créer l'archive (fichiers root-owned)

Restauration complète:
- Extraction + sudo rsync vers source_path
- Création utilisateur système (useradd --system, si inexistant)
- Application des permissions (chown -R, chmod -R)
- Activation/démarrage service systemd
- Exécution des commandes post-restauration

Infrastructure:
- sudoers : rsync, tar, rm tmpdir, mkdir, chown, chmod, useradd, systemctl
- Route /archives/<name>/restore (GET confirmation + POST async)
- Template restore_confirm.html avec récap actions + avertissement
- Formulaire custom_dir complet (source, exclusions, section restauration)
- Historique : colonne "Restaurer" sur les runs réussis
- retention.py : préfixe slugifié explicite pour custom_dir

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen 1 hari lalu
induk
melakukan
081b659453

+ 11 - 0
conf/sudoers

@@ -6,3 +6,14 @@ __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 *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/rsync *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/tar *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/rm -rf /tmp/backupmanager_*
+__APP__ ALL=(root) NOPASSWD: /usr/bin/mkdir -p *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/chown *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/chmod *
+__APP__ ALL=(root) NOPASSWD: /usr/sbin/useradd *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
+__APP__ ALL=(root) NOPASSWD: /usr/bin/systemctl enable *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/systemctl start *
+__APP__ ALL=(root) NOPASSWD: /usr/bin/systemctl restart *

+ 74 - 0
sources/app.py

@@ -80,6 +80,20 @@ def _inject_globals():
 
 # --- Helpers -----------------------------------------------------------------
 
+def _read_archive_info(archive_name):
+    backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+    archive_path = os.path.join(backup_dir, archive_name + ".tar")
+    try:
+        import tarfile as _tarfile
+        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 _get_ynh_apps():
     try:
         result = subprocess.run(
@@ -149,6 +163,34 @@ def job_history(job_id):
     return render_template("job_history.html", job=job, runs=runs)
 
 
+@app.route("/archives/<path:archive_name>/restore", methods=["GET", "POST"])
+def archive_restore(archive_name):
+    info = _read_archive_info(archive_name)
+    archive_type = info.get("type", "")
+
+    if request.method == "GET":
+        return render_template("restore_confirm.html", archive_name=archive_name, info=info)
+
+    def _do_restore():
+        with app.app_context():
+            try:
+                backup_dir = app.config["YUNOHOST_BACKUP_DIR"]
+                if archive_type == "custom_dir":
+                    from jobs.custom_dir import restore_custom_dir
+                    restore_custom_dir(archive_name, backup_dir)
+                else:
+                    raise NotImplementedError(
+                        f"Restauration non supportée pour le type '{archive_type}'."
+                    )
+            except Exception as exc:
+                app.logger.error(f"Restauration {archive_name} échouée : {exc}")
+
+    import threading
+    threading.Thread(target=_do_restore, daemon=True).start()
+    flash(f"Restauration de « {archive_name} » démarrée en arrière-plan.", "success")
+    return redirect(url_for("index"))
+
+
 @app.route("/jobs/<int:job_id>/toggle", methods=["POST"])
 def job_toggle(job_id):
     job = db.get_or_404(Job, job_id)
@@ -184,6 +226,38 @@ def _save_job(job):
             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}
+    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())
+        excludes = [e.strip() for e in f.get("excludes", "").splitlines() if e.strip()]
+        restore_cfg = {}
+        user_name = f.get("restore_user_name", "").strip()
+        if user_name:
+            restore_cfg["system_user"] = {
+                "name": user_name,
+                "home": f.get("restore_user_home", source_path).strip() or source_path,
+                "shell": f.get("restore_user_shell", "/bin/false").strip() or "/bin/false",
+            }
+        service_name = f.get("restore_service_name", "").strip()
+        if service_name:
+            restore_cfg["systemd_service"] = {
+                "name": service_name,
+                "service_file": f.get("restore_service_file", "").strip(),
+            }
+        owner = f.get("restore_perm_owner", "").strip()
+        mode = f.get("restore_perm_mode", "").strip()
+        if owner or mode:
+            restore_cfg["permissions"] = {}
+            if owner:
+                restore_cfg["permissions"]["owner"] = owner
+            if mode:
+                restore_cfg["permissions"]["mode"] = mode
+        post_cmds = [c.strip() for c in f.get("restore_post_cmds", "").splitlines() if c.strip()]
+        if post_cmds:
+            restore_cfg["post_restore_commands"] = post_cmds
+        cfg = {"source_path": source_path, "excludes": excludes, "restore": restore_cfg}
 
     if job is None:
         job = Job()

+ 274 - 2
sources/jobs/custom_dir.py

@@ -1,2 +1,274 @@
-# Phase 2 — Sauvegarde de répertoires personnalisés (tar + format compatible YunoHost)
-raise NotImplementedError("custom_dir — Phase 2")
+import csv
+import json
+import os
+import pwd
+import re
+import subprocess
+import tarfile
+import tempfile
+import time
+from datetime import datetime
+
+
+# ---------------------------------------------------------------------------
+# Backup
+# ---------------------------------------------------------------------------
+
+def backup_custom_dir(job, instance, backup_dir):
+    """Sauvegarde un répertoire arbitraire au format compatible YunoHost."""
+    cfg = json.loads(job.config_json or "{}")
+    source_path = cfg.get("source_path", "").rstrip("/")
+    excludes = cfg.get("excludes", [])
+
+    if not source_path:
+        raise ValueError("source_path manquant dans la configuration du job.")
+    if not os.path.isabs(source_path):
+        raise ValueError(f"source_path doit être un chemin absolu : {source_path}")
+
+    label = _slugify(job.name)
+    archive_name = f"{instance}_{label}_{datetime.utcnow().strftime('%Y%m%d')}"
+    archive_path = os.path.join(backup_dir, archive_name + ".tar")
+
+    if os.path.exists(archive_path):
+        raise RuntimeError(
+            f"L'archive {archive_name}.tar existe déjà. "
+            "Supprimez-la ou attendez le prochain cycle."
+        )
+
+    from flask import current_app
+    instance_url = current_app.config.get("INSTANCE_URL", "")
+
+    tmpdir = tempfile.mkdtemp(prefix="backupmanager_")
+    try:
+        # Répertoire de destination dans l'archive : data/custom{source_path}
+        dest_in_archive = os.path.join(tmpdir, "data", "custom" + source_path)
+        os.makedirs(dest_in_archive, exist_ok=True)
+
+        # Copie avec sudo rsync (accès root pour lire tous les fichiers)
+        rsync_cmd = ["sudo", "rsync", "-az", "--delete"]
+        for exc in excludes:
+            rsync_cmd += ["--exclude", exc]
+        rsync_cmd += [source_path + "/", dest_in_archive + "/"]
+
+        result = subprocess.run(rsync_cmd, capture_output=True, text=True, timeout=7200)
+        log = (result.stdout + result.stderr).strip()
+        if result.returncode != 0:
+            raise RuntimeError(f"rsync a échoué (code {result.returncode}) :\n{log}")
+
+        # backup.csv (requis YunoHost)
+        csv_path = os.path.join(tmpdir, "backup.csv")
+        with open(csv_path, "w", newline="") as f:
+            writer = csv.writer(f, delimiter=";")
+            writer.writerow(["source", "dest"])
+            writer.writerow([f"data/custom{source_path}", source_path])
+
+        # backup_info.json (métadonnées BackupManager)
+        info = {
+            "instance_name": instance,
+            "instance_url": instance_url,
+            "type": "custom_dir",
+            "source_path": source_path,
+            "job_name": job.name,
+            "created_at": datetime.utcnow().isoformat(),
+            "backupmanager_version": "1.0.0",
+            "restore": cfg.get("restore", {}),
+        }
+        info_path = os.path.join(tmpdir, "backup_info.json")
+        with open(info_path, "w") as f:
+            json.dump(info, f, indent=2)
+
+        # Création du .tar via sudo (pour lire les fichiers root-owned dans tmpdir)
+        result = subprocess.run(
+            ["sudo", "tar", "-cf", archive_path, "-C", tmpdir,
+             "backup.csv", "backup_info.json", "data"],
+            capture_output=True, text=True, timeout=600,
+        )
+        if result.returncode != 0:
+            raise RuntimeError(f"tar a échoué : {result.stderr.strip()}")
+
+        # Rendre l'archive accessible à l'app
+        subprocess.run(
+            ["sudo", "chown", f"{_get_current_user()}:", archive_path],
+            check=True,
+        )
+
+        # .info.json YunoHost (hors tar, pour listing webadmin)
+        size = os.path.getsize(archive_path)
+        ynh_info = {
+            "created_at": int(time.time()),
+            "description": f"BackupManager: custom_dir {source_path}",
+            "size": size,
+            "from_before_upgrade": False,
+            "apps": {},
+            "system": {},
+        }
+        with open(os.path.join(backup_dir, archive_name + ".info.json"), "w") as f:
+            json.dump(ynh_info, f, indent=2)
+
+    finally:
+        subprocess.run(["sudo", "rm", "-rf", tmpdir], check=False)
+
+    return archive_name, log or "rsync terminé sans sortie."
+
+
+# ---------------------------------------------------------------------------
+# Restore
+# ---------------------------------------------------------------------------
+
+def restore_custom_dir(archive_name, backup_dir):
+    """Restauration complète d'un custom_dir : fichiers + user + service + permissions."""
+    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)
+    source_path = info.get("source_path", "").rstrip("/")
+    restore_cfg = info.get("restore", {})
+    log_lines = []
+
+    tmpdir = tempfile.mkdtemp(prefix="backupmanager_restore_")
+    try:
+        # Extraction complète dans tmpdir
+        result = subprocess.run(
+            ["sudo", "tar", "-xf", archive_path, "-C", tmpdir],
+            capture_output=True, text=True, timeout=600,
+        )
+        if result.returncode != 0:
+            raise RuntimeError(f"Extraction échouée : {result.stderr.strip()}")
+        log_lines.append("Archive extraite.")
+
+        extracted_data = os.path.join(tmpdir, "data", "custom" + source_path)
+        if not os.path.isdir(extracted_data):
+            raise RuntimeError(
+                f"Chemin attendu absent dans l'archive : data/custom{source_path}"
+            )
+
+        # Créer le répertoire de destination si absent
+        subprocess.run(["sudo", "mkdir", "-p", source_path], check=True)
+
+        # Restauration des fichiers
+        result = subprocess.run(
+            ["sudo", "rsync", "-az", "--delete",
+             extracted_data + "/", source_path + "/"],
+            capture_output=True, text=True, timeout=7200,
+        )
+        if result.returncode != 0:
+            raise RuntimeError(f"rsync restore a échoué : {result.stderr.strip()}")
+        log_lines.append(f"Fichiers restaurés vers {source_path}.")
+
+        # User système
+        user_cfg = restore_cfg.get("system_user", {})
+        if user_cfg.get("name"):
+            _restore_system_user(user_cfg, log_lines)
+
+        # Permissions
+        perms_cfg = restore_cfg.get("permissions", {})
+        if perms_cfg:
+            _restore_permissions(source_path, perms_cfg, log_lines)
+
+        # Service systemd
+        service_cfg = restore_cfg.get("systemd_service", {})
+        if service_cfg.get("name"):
+            _restore_systemd_service(service_cfg, log_lines)
+
+        # Commandes post-restauration
+        post_cmds = restore_cfg.get("post_restore_commands", [])
+        for cmd in post_cmds:
+            _run_command(cmd, log_lines)
+
+    finally:
+        subprocess.run(["sudo", "rm", "-rf", tmpdir], check=False)
+
+    return "\n".join(log_lines)
+
+
+def _restore_system_user(user_cfg, log_lines):
+    name = user_cfg["name"]
+    home = user_cfg.get("home", "/opt/" + name)
+    shell = user_cfg.get("shell", "/bin/false")
+
+    try:
+        pwd.getpwnam(name)
+        log_lines.append(f"Utilisateur système '{name}' déjà existant.")
+    except KeyError:
+        subprocess.run(
+            ["sudo", "useradd",
+             "--system",
+             "--home-dir", home,
+             "--no-create-home",
+             "--shell", shell,
+             name],
+            check=True,
+        )
+        log_lines.append(f"Utilisateur système '{name}' créé.")
+
+
+def _restore_permissions(source_path, perms_cfg, log_lines):
+    owner = perms_cfg.get("owner")
+    mode = perms_cfg.get("mode")
+    if owner:
+        subprocess.run(["sudo", "chown", "-R", owner, source_path], check=True)
+        log_lines.append(f"Propriétaire défini : {owner}.")
+    if mode:
+        subprocess.run(["sudo", "chmod", "-R", mode, source_path], check=True)
+        log_lines.append(f"Permissions définies : {mode}.")
+
+
+def _restore_systemd_service(service_cfg, log_lines):
+    name = service_cfg["name"]
+    service_file = service_cfg.get("service_file", "")
+
+    if service_file and os.path.exists(service_file):
+        subprocess.run(["sudo", "systemctl", "daemon-reload"], check=False)
+        log_lines.append("systemctl daemon-reload effectué.")
+
+    subprocess.run(["sudo", "systemctl", "enable", name], check=False)
+    result = subprocess.run(
+        ["sudo", "systemctl", "start", name],
+        capture_output=True, text=True,
+    )
+    if result.returncode == 0:
+        log_lines.append(f"Service '{name}' activé et démarré.")
+    else:
+        log_lines.append(
+            f"Service '{name}' : démarrage échoué — {result.stderr.strip()}"
+        )
+
+
+def _run_command(cmd, log_lines):
+    result = subprocess.run(
+        cmd, shell=True, capture_output=True, text=True, timeout=120
+    )
+    out = (result.stdout + result.stderr).strip()
+    status = "✓" if result.returncode == 0 else "✗"
+    log_lines.append(f"{status} {cmd}" + (f"\n  {out}" if out else ""))
+
+
+# ---------------------------------------------------------------------------
+# Utilitaires
+# ---------------------------------------------------------------------------
+
+def _slugify(s):
+    return re.sub(r'[^a-z0-9]+', '-', s.lower().strip()).strip('-')
+
+
+def _get_current_user():
+    import getpass
+    return getpass.getuser()
+
+
+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 read_backup_info_from_dir(archive_name, backup_dir):
+    """Utilisé par app.py pour afficher les infos de restauration."""
+    archive_path = os.path.join(backup_dir, archive_name + ".tar")
+    return _read_backup_info(archive_path)

+ 3 - 0
sources/jobs/ynh_backup.py

@@ -31,6 +31,9 @@ def execute_job(job_id):
         elif job.type in ("mysql", "postgresql"):
             from jobs.db_dump import run_db_dump
             archive_name, log = run_db_dump(job, instance, backup_dir)
+        elif job.type == "custom_dir":
+            from jobs.custom_dir import backup_custom_dir
+            archive_name, log = backup_custom_dir(job, instance, backup_dir)
         else:
             raise ValueError(f"Type de job non géré : {job.type}")
 

+ 4 - 0
sources/retention.py

@@ -41,6 +41,10 @@ def _list_archives_for_job(job, backup_dir):
         cfg = json.loads(job.config_json or "{}")
         dbname = cfg.get("database", "")
         prefix = f"{instance}_{job.type}_{dbname}_"
+    elif job.type == "custom_dir":
+        import re
+        label = re.sub(r'[^a-z0-9]+', '-', job.name.lower().strip()).strip('-')
+        prefix = f"{instance}_{label}_"
     else:
         prefix = f"{instance}_{job.name.lower().replace(' ', '-')}_"
 

+ 100 - 1
sources/templates/job_form.html

@@ -32,7 +32,7 @@
                                 ('custom_dir','Répertoire custom (Phase 2)')] %}
             <option value="{{ val }}"
               {% if job and job.type == val %}selected{% endif %}
-              {% if val == 'custom_dir' %}disabled class="text-gray-400"{% endif %}>
+              >
               {{ label }}
             </option>
           {% endfor %}
@@ -104,6 +104,105 @@
           </p>
         </div>
       </div>
+
+      {# Type-specific config : custom_dir #}
+      {% set cd_cfg = (job.config_json | fromjson) if job and job.config_json and job.type == 'custom_dir' else {} %}
+      {% set cd_restore = cd_cfg.get('restore', {}) %}
+      <div id="cfg-custom_dir" class="type-cfg hidden space-y-4">
+
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Chemin à sauvegarder</label>
+          <input type="text" name="source_path"
+                 value="{{ cd_cfg.get('source_path', '') }}"
+                 placeholder="/opt/monapp"
+                 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">
+        </div>
+
+        <div>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Exclusions <span class="font-normal text-gray-400">(une par ligne)</span></label>
+          <textarea name="excludes" rows="3" placeholder="cache/&#10;logs/&#10;*.tmp"
+                    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">{{ cd_cfg.get('excludes', []) | join('\n') }}</textarea>
+        </div>
+
+        {# Section restauration (optionnel, collapsible) #}
+        <details {% if cd_restore %}open{% endif %} class="border border-gray-200 rounded-lg">
+          <summary class="px-4 py-3 text-sm font-medium text-gray-700 cursor-pointer hover:bg-gray-50 select-none">
+            Configuration de restauration <span class="text-gray-400 font-normal">(optionnel)</span>
+          </summary>
+          <div class="px-4 pb-4 pt-2 space-y-4 border-t border-gray-100">
+
+            <div>
+              <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Utilisateur système</p>
+              <div class="grid grid-cols-3 gap-3">
+                <div>
+                  <label class="block text-xs text-gray-600 mb-1">Nom</label>
+                  <input type="text" name="restore_user_name"
+                         value="{{ cd_restore.get('system_user', {}).get('name', '') }}"
+                         placeholder="monapp" class="w-full border border-gray-300 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-blue-500">
+                </div>
+                <div>
+                  <label class="block text-xs text-gray-600 mb-1">Home</label>
+                  <input type="text" name="restore_user_home"
+                         value="{{ cd_restore.get('system_user', {}).get('home', '') }}"
+                         placeholder="/opt/monapp" class="w-full border border-gray-300 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-blue-500">
+                </div>
+                <div>
+                  <label class="block text-xs text-gray-600 mb-1">Shell</label>
+                  <input type="text" name="restore_user_shell"
+                         value="{{ cd_restore.get('system_user', {}).get('shell', '/bin/false') }}"
+                         placeholder="/bin/false" class="w-full border border-gray-300 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-blue-500">
+                </div>
+              </div>
+            </div>
+
+            <div>
+              <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Service systemd</p>
+              <div class="grid grid-cols-2 gap-3">
+                <div>
+                  <label class="block text-xs text-gray-600 mb-1">Nom du service</label>
+                  <input type="text" name="restore_service_name"
+                         value="{{ cd_restore.get('systemd_service', {}).get('name', '') }}"
+                         placeholder="monapp" class="w-full border border-gray-300 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-blue-500">
+                </div>
+                <div>
+                  <label class="block text-xs text-gray-600 mb-1">Fichier .service</label>
+                  <input type="text" name="restore_service_file"
+                         value="{{ cd_restore.get('systemd_service', {}).get('service_file', '') }}"
+                         placeholder="/opt/monapp/monapp.service" class="w-full border border-gray-300 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-blue-500">
+                </div>
+              </div>
+            </div>
+
+            <div>
+              <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Permissions</p>
+              <div class="grid grid-cols-2 gap-3">
+                <div>
+                  <label class="block text-xs text-gray-600 mb-1">Propriétaire (user:group)</label>
+                  <input type="text" name="restore_perm_owner"
+                         value="{{ cd_restore.get('permissions', {}).get('owner', '') }}"
+                         placeholder="monapp:monapp" class="w-full border border-gray-300 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-blue-500">
+                </div>
+                <div>
+                  <label class="block text-xs text-gray-600 mb-1">Mode (chmod)</label>
+                  <input type="text" name="restore_perm_mode"
+                         value="{{ cd_restore.get('permissions', {}).get('mode', '') }}"
+                         placeholder="750" class="w-full border border-gray-300 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-blue-500">
+                </div>
+              </div>
+            </div>
+
+            <div>
+              <label class="block text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
+                Commandes post-restauration <span class="font-normal text-gray-400">(une par ligne)</span>
+              </label>
+              <textarea name="restore_post_cmds" rows="3"
+                        placeholder="systemctl restart monapp"
+                        class="w-full border border-gray-300 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-blue-500">{{ cd_restore.get('post_restore_commands', []) | join('\n') }}</textarea>
+            </div>
+
+          </div>
+        </details>
+      </div>
     </div>
 
     {# ── Planification ── #}

+ 11 - 0
sources/templates/job_history.html

@@ -33,6 +33,7 @@
             <th class="px-6 py-3 text-left font-medium">Archive</th>
             <th class="px-6 py-3 text-left font-medium">Taille</th>
             <th class="px-6 py-3 text-left font-medium">Log</th>
+            <th class="px-6 py-3 text-left font-medium">Action</th>
           </tr>
         </thead>
         <tbody class="divide-y divide-gray-100">
@@ -82,6 +83,16 @@
                   <span class="text-gray-300 text-xs">—</span>
                 {% endif %}
               </td>
+              <td class="px-6 py-3">
+                {% if run.status == 'success' and run.archive_name %}
+                  <a href="{{ url_for('archive_restore', archive_name=run.archive_name) }}"
+                     class="text-xs text-orange-600 hover:text-orange-800 hover:underline whitespace-nowrap">
+                    ↩ Restaurer
+                  </a>
+                {% else %}
+                  <span class="text-gray-300 text-xs">—</span>
+                {% endif %}
+              </td>
             </tr>
           {% endfor %}
         </tbody>

+ 105 - 0
sources/templates/restore_confirm.html

@@ -0,0 +1,105 @@
+{% extends "base.html" %}
+{% block title %}Restaurer — {{ archive_name }}{% endblock %}
+
+{% block content %}
+<div class="max-w-2xl">
+  <div class="mb-6">
+    <a href="{{ url_for('index') }}" class="text-gray-400 hover:text-gray-600 text-sm">← Dashboard</a>
+  </div>
+
+  <div class="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-5">
+    <div>
+      <h1 class="text-xl font-bold text-gray-900">Confirmer la restauration</h1>
+      <p class="text-sm text-gray-500 mt-1">Cette opération écrase les fichiers en place.</p>
+    </div>
+
+    <div class="bg-gray-50 rounded-lg p-4 space-y-2 text-sm">
+      <div class="flex justify-between">
+        <span class="text-gray-500">Archive</span>
+        <span class="font-mono font-medium text-gray-800">{{ archive_name }}</span>
+      </div>
+      {% if info %}
+        <div class="flex justify-between">
+          <span class="text-gray-500">Type</span>
+          <span class="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded font-mono">{{ info.get('type', '—') }}</span>
+        </div>
+        {% if info.get('source_path') %}
+        <div class="flex justify-between">
+          <span class="text-gray-500">Chemin</span>
+          <span class="font-mono text-gray-800">{{ info.get('source_path') }}</span>
+        </div>
+        {% endif %}
+        {% if info.get('database') %}
+        <div class="flex justify-between">
+          <span class="text-gray-500">Base de données</span>
+          <span class="font-mono text-gray-800">{{ info.get('database') }}</span>
+        </div>
+        {% endif %}
+        <div class="flex justify-between">
+          <span class="text-gray-500">Créée le</span>
+          <span class="text-gray-700">{{ info.get('created_at', '—') }}</span>
+        </div>
+        <div class="flex justify-between">
+          <span class="text-gray-500">Instance source</span>
+          <span class="text-gray-700">{{ info.get('instance_name', '—') }}</span>
+        </div>
+      {% else %}
+        <p class="text-gray-400 italic">Métadonnées indisponibles.</p>
+      {% endif %}
+    </div>
+
+    {% set restore = info.get('restore', {}) if info else {} %}
+    {% if restore %}
+    <div class="space-y-2">
+      <p class="text-sm font-semibold text-gray-700">Actions de restauration :</p>
+      <ul class="text-sm text-gray-600 space-y-1 pl-4">
+        {% if restore.get('system_user') %}
+          <li class="flex items-center gap-2">
+            <span class="text-green-500">✓</span>
+            Création utilisateur système <code class="bg-gray-100 px-1 rounded text-xs">{{ restore.system_user.name }}</code>
+            (si inexistant)
+          </li>
+        {% endif %}
+        {% if restore.get('permissions') %}
+          <li class="flex items-center gap-2">
+            <span class="text-green-500">✓</span>
+            Permissions :
+            {% if restore.permissions.get('owner') %}<code class="bg-gray-100 px-1 rounded text-xs">chown {{ restore.permissions.owner }}</code>{% endif %}
+            {% if restore.permissions.get('mode') %}<code class="bg-gray-100 px-1 rounded text-xs">chmod {{ restore.permissions.mode }}</code>{% endif %}
+          </li>
+        {% endif %}
+        {% if restore.get('systemd_service') %}
+          <li class="flex items-center gap-2">
+            <span class="text-green-500">✓</span>
+            Service systemd <code class="bg-gray-100 px-1 rounded text-xs">{{ restore.systemd_service.name }}</code>
+            activé et démarré
+          </li>
+        {% endif %}
+        {% for cmd in restore.get('post_restore_commands', []) %}
+          <li class="flex items-center gap-2">
+            <span class="text-blue-500">→</span>
+            <code class="bg-gray-100 px-1 rounded text-xs">{{ cmd }}</code>
+          </li>
+        {% endfor %}
+      </ul>
+    </div>
+    {% endif %}
+
+    <div class="bg-amber-50 border border-amber-200 rounded-lg px-4 py-3 text-sm text-amber-800">
+      ⚠ Les fichiers existants dans le chemin de destination seront <strong>écrasés</strong>.
+      Cette action ne peut pas être annulée.
+    </div>
+
+    <form method="post" class="flex gap-3">
+      <button type="submit"
+              class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded-lg font-medium text-sm transition">
+        Confirmer la restauration
+      </button>
+      <a href="{{ url_for('index') }}"
+         class="bg-white hover:bg-gray-50 text-gray-700 border border-gray-300 px-5 py-2 rounded-lg font-medium text-sm transition">
+        Annuler
+      </a>
+    </form>
+  </div>
+</div>
+{% endblock %}