ソースを参照

feat: export/import configuration (jobs, destinations, instances, SMTP)

Deux routes GET /settings/export-config et POST /settings/import-config.
L'import est idempotent : crée ou met à jour par nom. Les clés SSH ne sont
pas exportées (note dans l'UI).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cedric Hansen 1 ヶ月 前
コミット
8c9afc9dd9
2 ファイル変更221 行追加0 行削除
  1. 183 0
      sources/blueprints/settings.py
  2. 38 0
      sources/templates/settings.html

+ 183 - 0
sources/blueprints/settings.py

@@ -1,7 +1,10 @@
+import json
 import subprocess
+from datetime import datetime
 
 from flask import (
     Blueprint,
+    Response,
     current_app,
     flash,
     jsonify,
@@ -80,6 +83,186 @@ def settings():
                            instances=instances)
 
 
+@bp.route("/settings/export-config")
+def export_config():
+    from db import Job, Destination, RemoteInstance
+
+    jobs_data = []
+    for j in Job.query.order_by(Job.name).all():
+        dest_name = j.destination.name if j.destination_id and j.destination else None
+        inst_name = j.remote_instance.name if j.remote_instance_id and j.remote_instance else None
+        jobs_data.append({
+            "name": j.name,
+            "type": j.type,
+            "config_json": j.config_json,
+            "cron_expr": j.cron_expr,
+            "retention_mode": j.retention_mode,
+            "retention_value": j.retention_value,
+            "retention_gfs_config": j.retention_gfs_config,
+            "enabled": j.enabled,
+            "core_only": j.core_only,
+            "destination_name": dest_name,
+            "remote_instance_name": inst_name,
+        })
+
+    dest_data = []
+    for d in Destination.query.order_by(Destination.name).all():
+        dest_data.append({
+            "name": d.name,
+            "host": d.host,
+            "port": d.port,
+            "user": d.user,
+            "remote_path": d.remote_path,
+            "key_name": d.key_name,
+            "enabled": d.enabled,
+        })
+
+    inst_data = []
+    for i in RemoteInstance.query.order_by(RemoteInstance.name).all():
+        inst_data.append({
+            "name": i.name,
+            "url": i.url,
+            "api_key": i.api_key,
+        })
+
+    settings_data = {k: _get_setting(k) for k in _SETTING_KEYS}
+
+    payload = {
+        "version": 1,
+        "exported_at": datetime.utcnow().isoformat(),
+        "instance_name": current_app.config.get("INSTANCE_NAME", ""),
+        "jobs": jobs_data,
+        "destinations": dest_data,
+        "remote_instances": inst_data,
+        "settings": settings_data,
+    }
+
+    filename = f"backupmanager_config_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
+    return Response(
+        json.dumps(payload, ensure_ascii=False, indent=2),
+        mimetype="application/json",
+        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+    )
+
+
+@bp.route("/settings/import-config", methods=["POST"])
+def import_config():
+    from db import Job, Destination, RemoteInstance
+    from scheduler import schedule_job, remove_job
+
+    f = request.files.get("config_file")
+    if not f or not f.filename:
+        flash("Aucun fichier sélectionné.", "error")
+        return redirect(url_for("cfg.settings") + "?tab=config")
+
+    try:
+        payload = json.loads(f.read().decode("utf-8"))
+    except Exception:
+        flash("Fichier invalide — JSON attendu.", "error")
+        return redirect(url_for("cfg.settings") + "?tab=config")
+
+    if payload.get("version") != 1:
+        flash("Format de fichier non reconnu (version != 1).", "error")
+        return redirect(url_for("cfg.settings") + "?tab=config")
+
+    counts = {"destinations": 0, "instances": 0, "jobs": 0, "settings": 0}
+
+    # --- Destinations ---
+    for d_data in payload.get("destinations", []):
+        name = d_data.get("name", "").strip()
+        if not name:
+            continue
+        dest = Destination.query.filter_by(name=name).first()
+        if dest is None:
+            dest = Destination()
+            db.session.add(dest)
+        dest.name = name
+        dest.host = d_data.get("host", "")
+        dest.port = int(d_data.get("port", 22))
+        dest.user = d_data.get("user", "root")
+        dest.remote_path = d_data.get("remote_path", "")
+        dest.key_name = d_data.get("key_name") or None
+        dest.enabled = bool(d_data.get("enabled", True))
+        counts["destinations"] += 1
+    db.session.flush()
+
+    # --- Instances distantes ---
+    for i_data in payload.get("remote_instances", []):
+        name = i_data.get("name", "").strip()
+        if not name:
+            continue
+        inst = RemoteInstance.query.filter_by(name=name).first()
+        if inst is None:
+            inst = RemoteInstance()
+            db.session.add(inst)
+        inst.name = name
+        inst.url = i_data.get("url", "").rstrip("/")
+        inst.api_key = i_data.get("api_key", "")
+        counts["instances"] += 1
+    db.session.flush()
+
+    # --- Jobs ---
+    dest_by_name = {d.name: d for d in Destination.query.all()}
+    inst_by_name = {i.name: i for i in RemoteInstance.query.all()}
+
+    for j_data in payload.get("jobs", []):
+        name = j_data.get("name", "").strip()
+        if not name:
+            continue
+        job = Job.query.filter_by(name=name).first()
+        if job is None:
+            job = Job()
+            db.session.add(job)
+        else:
+            remove_job(job.id)
+
+        job.name = name
+        job.type = j_data.get("type", "ynh_app")
+        job.config_json = j_data.get("config_json") or "{}"
+        job.cron_expr = j_data.get("cron_expr") or ""
+        job.retention_mode = j_data.get("retention_mode", "count")
+        job.retention_value = int(j_data.get("retention_value", 2))
+        job.retention_gfs_config = j_data.get("retention_gfs_config")
+        job.enabled = bool(j_data.get("enabled", True))
+        job.core_only = bool(j_data.get("core_only", False))
+        job.updated_at = datetime.utcnow()
+
+        dest_name = j_data.get("destination_name")
+        inst_name = j_data.get("remote_instance_name")
+        job.destination_id = dest_by_name[dest_name].id if dest_name and dest_name in dest_by_name else None
+        job.remote_instance_id = inst_by_name[inst_name].id if inst_name and inst_name in inst_by_name else None
+
+        counts["jobs"] += 1
+    db.session.flush()
+
+    # --- Paramètres SMTP ---
+    for key, value in payload.get("settings", {}).items():
+        if key not in _SETTING_KEYS:
+            continue
+        s = Setting.query.filter_by(key=key).first()
+        if s is None:
+            s = Setting(key=key, value=value)
+            db.session.add(s)
+        else:
+            s.value = value
+        counts["settings"] += 1
+
+    db.session.commit()
+
+    # Replanifier les jobs actifs
+    for job in Job.query.filter_by(enabled=True).all():
+        schedule_job(job)
+
+    flash(
+        f"Import réussi — {counts['jobs']} job(s), "
+        f"{counts['destinations']} destination(s), "
+        f"{counts['instances']} instance(s), "
+        f"{counts['settings']} paramètre(s).",
+        "success",
+    )
+    return redirect(url_for("cfg.settings") + "?tab=config")
+
+
 @bp.route("/internal/databases/<db_type>")
 def internal_databases(db_type):
     """Liste les bases de données disponibles pour le formulaire job."""

+ 38 - 0
sources/templates/settings.html

@@ -261,6 +261,44 @@
     </div>
 
   </form>
+
+  {# ── Export / Import ── #}
+  <div class="bg-white rounded-xl border border-gray-200 p-6 space-y-5 mt-6">
+    <h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wide">Export / Import de configuration</h2>
+
+    <div>
+      <p class="text-sm text-gray-600 mb-3">
+        Exporte tous les jobs, destinations, instances distantes et paramètres SMTP dans un fichier JSON.
+      </p>
+      <p class="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-3">
+        Les fichiers de clés SSH ne sont <strong>pas inclus</strong> dans l'export — ils devront être restaurés manuellement dans <code class="font-mono">data_dir/keys/</code>.
+      </p>
+      <a href="{{ url_for('cfg.export_config') }}"
+         class="btn-secondary btn-md inline-block">
+        Télécharger la configuration
+      </a>
+    </div>
+
+    <hr class="border-gray-100">
+
+    <div>
+      <p class="text-sm text-gray-600 mb-3">
+        Importe un fichier exporté par BackupManager. Les jobs et destinations existants portant le même nom seront mis à jour.
+      </p>
+      <form method="post" enctype="multipart/form-data"
+            action="{{ url_for('cfg.import_config') }}"
+            class="flex items-center gap-3 flex-wrap">
+        <input type="file" name="config_file" accept=".json"
+               required
+               class="text-sm text-gray-600 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border file:border-gray-300 file:bg-white file:text-sm file:font-medium file:text-gray-700 hover:file:bg-gray-50">
+        <button type="submit" class="btn-primary btn-md"
+                onclick="return confirm('Importer cette configuration ? Les jobs et destinations portant le même nom seront mis à jour.')">
+          Importer
+        </button>
+      </form>
+    </div>
+  </div>
+
 </div>{# /pane-config #}
 
 <script>