Explorar o código

feat: archive unique, labels rétention, liste BDD MySQL/PostgreSQL

- utils.py: unique_archive_name() — suffixe _2/_3 si date du jour déjà prise
- ynh_backup.py, db_dump.py, custom_dir.py: utilise unique_archive_name
  (supprime _abort_if_exists, plus d'erreur si run 2× le même jour)
- app.py: route /internal/databases/<type> pour lister les BDD
- job_form.html: select peuplé par JS fetch pour MySQL/PostgreSQL,
  labels rétention clarifiés + texte d'aide contextuel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Cédric Hansen hai 1 día
pai
achega
55a6f3dd8b

+ 29 - 0
sources/app.py

@@ -454,6 +454,35 @@ def settings():
     return render_template("settings.html", cfg=cfg)
 
 
+# --- Routes internes (usage formulaires) -------------------------------------
+
+@app.route("/internal/databases/<db_type>")
+def internal_databases(db_type):
+    """Liste les bases de données disponibles pour le formulaire job."""
+    databases = []
+    try:
+        if db_type == "mysql":
+            result = subprocess.run(
+                ["sudo", "mysql", "--skip-column-names", "-e", "SHOW DATABASES;"],
+                capture_output=True, text=True, timeout=10,
+            )
+            if result.returncode == 0:
+                exclude = {"information_schema", "performance_schema", "mysql", "sys"}
+                databases = [d.strip() for d in result.stdout.splitlines()
+                             if d.strip() and d.strip() not in exclude]
+        elif db_type == "postgresql":
+            result = subprocess.run(
+                ["sudo", "-u", "postgres", "psql", "-Atc",
+                 "SELECT datname FROM pg_database WHERE datistemplate = false;"],
+                capture_output=True, text=True, timeout=10,
+            )
+            if result.returncode == 0:
+                databases = [d.strip() for d in result.stdout.splitlines() if d.strip()]
+    except Exception:
+        pass
+    return jsonify(databases)
+
+
 # --- API v1 ------------------------------------------------------------------
 
 @app.route("/api/v1/health")

+ 3 - 8
sources/jobs/custom_dir.py

@@ -25,17 +25,12 @@ def backup_custom_dir(job, instance, backup_dir):
     if not os.path.isabs(source_path):
         raise ValueError(f"source_path doit être un chemin absolu : {source_path}")
 
+    from jobs.utils import unique_archive_name
     label = _slugify(job.name)
-    archive_name = f"{instance}_{label}_{datetime.utcnow().strftime('%Y%m%d')}"
+    base_name = f"{instance}_{label}_{datetime.utcnow().strftime('%Y%m%d')}"
+    archive_name = unique_archive_name(base_name, backup_dir)
     archive_path = os.path.join(backup_dir, archive_name + ".tar")
 
-    from jobs.utils import sudo_exists
-    if sudo_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", "")
 

+ 5 - 16
sources/jobs/db_dump.py

@@ -27,8 +27,7 @@ def _run_mysql(job, instance, backup_dir):
     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)
+    archive_name = _archive_name(instance, "mysql", dbname, backup_dir)
 
     with tempfile.TemporaryDirectory() as tmpdir:
         dump_path = os.path.join(tmpdir, f"{dbname}.sql")
@@ -67,8 +66,7 @@ def _run_postgresql(job, instance, backup_dir):
     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)
+    archive_name = _archive_name(instance, "postgresql", dbname, backup_dir)
 
     with tempfile.TemporaryDirectory() as tmpdir:
         dump_path = os.path.join(tmpdir, f"{dbname}.sql")
@@ -98,19 +96,10 @@ def _run_postgresql(job, instance, backup_dir):
 # Helpers partagés
 # ---------------------------------------------------------------------------
 
-def _archive_name(instance, db_type, dbname):
+def _archive_name(instance, db_type, dbname, backup_dir):
+    from jobs.utils import unique_archive_name
     date_str = datetime.utcnow().strftime("%Y%m%d")
-    return f"{instance}_{db_type}_{dbname}_{date_str}"
-
-
-def _abort_if_exists(archive_name, backup_dir):
-    from jobs.utils import sudo_exists
-    path = os.path.join(backup_dir, archive_name + ".tar")
-    if sudo_exists(path):
-        raise RuntimeError(
-            f"L'archive {archive_name}.tar existe déjà. "
-            "Supprimez-la ou attendez le prochain cycle."
-        )
+    return unique_archive_name(f"{instance}_{db_type}_{dbname}_{date_str}", backup_dir)
 
 
 # ---------------------------------------------------------------------------

+ 10 - 0
sources/jobs/utils.py

@@ -36,6 +36,16 @@ def sudo_getmtime(path):
         return 0.0
 
 
+def unique_archive_name(base_name, backup_dir):
+    """Retourne un nom d'archive unique (ajoute _2, _3… si le nom du jour est déjà pris)."""
+    name = base_name
+    i = 2
+    while sudo_exists(os.path.join(backup_dir, name + ".tar")):
+        name = f"{base_name}_{i}"
+        i += 1
+    return name
+
+
 def sudo_exists(path):
     """Vérifie l'existence d'un fichier via sudo si non accessible directement."""
     if os.path.exists(path):

+ 5 - 14
sources/jobs/ynh_backup.py

@@ -80,9 +80,10 @@ def execute_job(job_id):
         pass
 
 
-def _archive_name(instance, label):
+def _archive_name(instance, label, backup_dir):
+    from jobs.utils import unique_archive_name
     date_str = datetime.utcnow().strftime("%Y%m%d")
-    return f"{instance}_{label}_{date_str}"
+    return unique_archive_name(f"{instance}_{label}_{date_str}", backup_dir)
 
 
 def _run_ynh_app(job, instance, backup_dir):
@@ -90,8 +91,7 @@ def _run_ynh_app(job, instance, backup_dir):
     app_id = cfg.get("app_id", "")
     core_only = cfg.get("core_only", job.core_only)
 
-    archive = _archive_name(instance, app_id)
-    _abort_if_exists(archive, backup_dir)
+    archive = _archive_name(instance, app_id, backup_dir)
 
     cmd = ["sudo", "yunohost", "backup", "create", "--apps", app_id, "--name", archive]
     if core_only:
@@ -107,8 +107,7 @@ def _run_ynh_app(job, instance, backup_dir):
 
 
 def _run_ynh_system(job, instance, backup_dir):
-    archive = _archive_name(instance, "system")
-    _abort_if_exists(archive, backup_dir)
+    archive = _archive_name(instance, "system", backup_dir)
 
     cmd = ["sudo", "yunohost", "backup", "create", "--system", "--name", archive]
     result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
@@ -120,11 +119,3 @@ def _run_ynh_system(job, instance, backup_dir):
     return archive, log
 
 
-def _abort_if_exists(archive_name, backup_dir):
-    from jobs.utils import sudo_exists
-    path = os.path.join(backup_dir, archive_name + ".tar")
-    if sudo_exists(path):
-        raise RuntimeError(
-            f"L'archive {archive_name}.tar existe déjà. "
-            "Supprimez-la manuellement ou attendez le prochain cycle."
-        )

+ 78 - 16
sources/templates/job_form.html

@@ -80,13 +80,18 @@
       {% 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">
+          <label class="block text-sm font-medium text-gray-700 mb-1">Base de données</label>
+          <select id="db-select-mysql" name="db_database"
+                  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">
+            {% set cur_db = db_cfg.get('database', '') if job and job.type == 'mysql' else '' %}
+            {% if cur_db %}
+              <option value="{{ cur_db }}" selected>{{ cur_db }}</option>
+            {% else %}
+              <option value="">Chargement…</option>
+            {% endif %}
+          </select>
           <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.
+            Dump via <code class="bg-gray-100 px-1 rounded">sudo mysqldump</code> — aucun mot de passe requis.
           </p>
         </div>
       </div>
@@ -94,13 +99,18 @@
       {# 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">
+          <label class="block text-sm font-medium text-gray-700 mb-1">Base de données</label>
+          <select id="db-select-postgresql" name="db_database"
+                  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">
+            {% set cur_db = db_cfg.get('database', '') if job and job.type == 'postgresql' else '' %}
+            {% if cur_db %}
+              <option value="{{ cur_db }}" selected>{{ cur_db }}</option>
+            {% else %}
+              <option value="">Chargement…</option>
+            {% endif %}
+          </select>
           <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.
+            Dump via <code class="bg-gray-100 px-1 rounded">sudo -u postgres pg_dump</code> — aucun mot de passe requis.
           </p>
         </div>
       </div>
@@ -230,9 +240,9 @@
           <label class="block text-sm font-medium text-gray-700 mb-1">Mode</label>
           <select name="retention_mode"
                   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 [('count','Count — N dernières archives'),
-                                  ('daily','Daily — 1 par jour sur N jours'),
-                                  ('gfs','GFS — Grand/Père/Fils (Phase 4)')] %}
+            {% for val, label in [('count','Garder les N dernières archives'),
+                                  ('daily','1 archive par jour sur N jours glissants'),
+                                  ('gfs','Grand-Père-Fils (non disponible)')] %}
               <option value="{{ val }}"
                 {% if job and job.retention_mode == val %}selected{% endif %}
                 {% if val == 'gfs' %}disabled class="text-gray-400"{% endif %}>
@@ -242,12 +252,13 @@
           </select>
         </div>
         <div>
-          <label class="block text-sm font-medium text-gray-700 mb-1">Valeur</label>
+          <label class="block text-sm font-medium text-gray-700 mb-1">Valeur (N)</label>
           <input type="number" name="retention_value" min="1" max="365" required
                  value="{{ job.retention_value if job else 7 }}"
                  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">
         </div>
       </div>
+      <p id="retention-help" class="text-xs text-gray-400"></p>
     </div>
 
     {# ── Destination ── #}
@@ -296,13 +307,64 @@
 </div>
 
 <script>
+  const retentionHelp = {
+    count: "Ex : N=7 → conserve les 7 dernières archives, supprime les plus anciennes.",
+    daily: "Ex : N=30 → conserve 1 archive par jour sur les 30 derniers jours. Les doublons du même jour sont supprimés.",
+    gfs:   "Mode non encore disponible."
+  };
+
+  const dbCache = {};
+
+  function loadDatabases(dbType) {
+    const sel = document.getElementById('db-select-' + dbType);
+    if (!sel) return;
+    if (dbCache[dbType]) {
+      populateSelect(sel, dbCache[dbType]);
+      return;
+    }
+    fetch("{{ url_for('internal_databases', db_type='__TYPE__') }}".replace('__TYPE__', dbType))
+      .then(r => r.json())
+      .then(dbs => {
+        dbCache[dbType] = dbs;
+        populateSelect(sel, dbs);
+      })
+      .catch(() => {
+        sel.innerHTML = '<option value="">Impossible de charger les bases</option>';
+      });
+  }
+
+  function populateSelect(sel, dbs) {
+    const current = sel.querySelector('option[selected]')?.value || '';
+    sel.innerHTML = '';
+    if (!dbs.length) {
+      sel.innerHTML = '<option value="">Aucune base trouvée</option>';
+      return;
+    }
+    dbs.forEach(db => {
+      const opt = document.createElement('option');
+      opt.value = db;
+      opt.textContent = db;
+      if (db === current) opt.selected = true;
+      sel.appendChild(opt);
+    });
+  }
+
   function showTypeConfig() {
     document.querySelectorAll('.type-cfg').forEach(el => el.classList.add('hidden'));
     const type = document.getElementById('job-type').value;
     const el = document.getElementById('cfg-' + type);
     if (el) el.classList.remove('hidden');
+    if (type === 'mysql' || type === 'postgresql') loadDatabases(type);
   }
+
+  function updateRetentionHelp() {
+    const mode = document.querySelector('[name=retention_mode]').value;
+    document.getElementById('retention-help').textContent = retentionHelp[mode] || '';
+  }
+
   document.getElementById('job-type').addEventListener('change', showTypeConfig);
+  document.querySelector('[name=retention_mode]').addEventListener('change', updateRetentionHelp);
   showTypeConfig();
+  updateRetentionHelp();
 </script>
 {% endblock %}