Jelajahi Sumber

fix: éviter qu'un job n'absorbe les archives d'un job homonyme (instances multiples)

Le matching de préfixe (startswith) capturait aussi les archives des
instances multiples d'une app YunoHost (ex: "redirect_" matchait
"redirect__2_..."), mélangeant leurs archives dans la vue globale et
risquant de supprimer les archives d'un autre job via la rétention.
Ajoute _matches_job_prefix() qui ancre le match sur la date, et affiche
le détail du job (app_id/source_path/database) à côté du nom pour
distinguer les jobs homonymes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cedric Hansen 1 hari lalu
induk
melakukan
0b86206cda

+ 1 - 1
manifest.toml

@@ -4,7 +4,7 @@ name = "Backup Manager"
 description.en = "Centralized backup manager for YunoHost"
 description.fr = "Gestionnaire de sauvegardes centralisé pour YunoHost"
 
-version = "1.4~ynh1"
+version = "1.5~ynh1"
 
 maintainers = []
 

+ 17 - 2
sources/blueprints/overview.py

@@ -29,18 +29,31 @@ def _retention_label(job):
     return "—"
 
 
+def _job_detail(job):
+    """Détail affiché à côté du nom du job pour distinguer des jobs homonymes
+    (ex: instances multiples d'une même app YunoHost, toutes nommées pareil)."""
+    if job.type not in ("ynh_app", "custom_dir", "mysql", "postgresql"):
+        return ""
+    cfg = json.loads(job.config_json or "{}")
+    if job.type == "ynh_app":
+        return cfg.get("app_id", "")
+    if job.type == "custom_dir":
+        return cfg.get("source_path", "")
+    return cfg.get("database", "")
+
+
 def _group_archives(archive_list, jobs, instance):
     """
     archive_list : [{"name": "jerry_nextcloud_20260716", "size_bytes": N}]
     Retourne (groups, orphaned) où chaque group est associé à un job.
     """
-    from retention import _job_archive_prefix, _extract_date
+    from retention import _job_archive_prefix, _matches_job_prefix, _extract_date
 
     used = set()
     groups = []
     for job in jobs:
         prefix = _job_archive_prefix(job, instance)
-        matching = [a for a in archive_list if a["name"].startswith(prefix)]
+        matching = [a for a in archive_list if _matches_job_prefix(a["name"], prefix)]
         matching.sort(key=lambda a: _extract_date(a["name"] + ".tar"))
         for a in matching:
             used.add(a["name"])
@@ -50,6 +63,7 @@ def _group_archives(archive_list, jobs, instance):
         groups.append({
             "job": job,
             "retention_label": _retention_label(job),
+            "detail": _job_detail(job),
             "archives": matching,
         })
 
@@ -191,6 +205,7 @@ def overview():
         jobs_view.append({
             "job": job,
             "retention_label": _retention_label(job),
+            "detail": _job_detail(job),
             "locations": jlocs,
         })
 

+ 11 - 3
sources/retention.py

@@ -52,6 +52,14 @@ def _job_archive_prefix(job, instance_name):
         return f"{instance_name}_{job.name.lower().replace(' ', '-')}_"
 
 
+def _matches_job_prefix(name, prefix):
+    """True si `name` appartient bien à ce job (et pas à un job dont le préfixe
+    n'est qu'un préfixe plus court, ex. "redirect_" vs "redirect__2_" pour les
+    instances multiples d'une app YunoHost). Le préfixe est toujours suivi de
+    la date (8 chiffres AAAAMMJJ)."""
+    return name.startswith(prefix) and name[len(prefix):len(prefix) + 8].isdigit()
+
+
 def _list_archives_for_job(job, backup_dir):
     """Liste les archives correspondant à ce job, triées par date (plus ancienne en premier)."""
     from flask import current_app
@@ -61,7 +69,7 @@ def _list_archives_for_job(job, backup_dir):
     from jobs.utils import sudo_listdir
     archives = [
         fname for fname in sudo_listdir(backup_dir)
-        if fname.startswith(prefix) and fname.endswith(".tar")
+        if _matches_job_prefix(fname, prefix) and fname.endswith(".tar")
     ]
     archives.sort(key=_extract_date)
     return archives
@@ -99,7 +107,7 @@ def apply_ssh_retention(job, destination, data_dir):
 
     remote_archives = sorted(
         [f.strip() for f in result.stdout.splitlines()
-         if f.strip().startswith(prefix) and f.strip().endswith(".tar")],
+         if _matches_job_prefix(f.strip(), prefix) and f.strip().endswith(".tar")],
         key=_extract_date,
     )
 
@@ -143,7 +151,7 @@ def apply_remote_retention(job, client):
         return []
 
     matching = sorted(
-        [a["name"] + ".tar" for a in remote_archives if a["name"].startswith(prefix)],
+        [a["name"] + ".tar" for a in remote_archives if _matches_job_prefix(a["name"], prefix)],
         key=_extract_date,
     )
 

+ 6 - 0
sources/templates/archives_overview.html

@@ -40,6 +40,9 @@
       <!-- En-tête du job -->
       <div class="px-6 py-4 bg-gray-50 border-b border-gray-200 flex items-center gap-3 flex-wrap">
         <span class="font-semibold text-gray-800">{{ item.job.name }}</span>
+        {% if item.detail %}
+          <span class="font-mono text-xs text-gray-400">{{ item.detail }}</span>
+        {% endif %}
         <span class="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">{{ item.job.type }}</span>
         <span class="text-xs bg-green-50 text-green-700 border border-green-200 px-2 py-0.5 rounded-full">
           rétention : {{ item.retention_label }}
@@ -196,6 +199,9 @@
             <div class="flex items-center justify-between mb-3">
               <div class="flex items-center gap-2">
                 <span class="font-medium text-gray-800">{{ group.job.name }}</span>
+                {% if group.detail %}
+                  <span class="font-mono text-xs text-gray-400">{{ group.detail }}</span>
+                {% endif %}
                 <span class="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full">{{ group.job.type }}</span>
                 <span class="text-xs bg-green-50 text-green-700 border border-green-200 px-2 py-0.5 rounded-full">
                   rétention : {{ group.retention_label }}

+ 23 - 0
sources/tests/test_retention.py

@@ -168,3 +168,26 @@ class TestRetentionGFS:
         # Avec 3 archives, tout doit être gardé
         archives = ["a_20260509.tar", "a_20260510.tar", "a_20260511.tar"]
         assert _retention_gfs(archives, {}) == []
+
+
+# ---------------------------------------------------------------------------
+# _matches_job_prefix
+# ---------------------------------------------------------------------------
+
+class TestMatchesJobPrefix:
+    def test_prefixe_exact(self):
+        from retention import _matches_job_prefix
+        assert _matches_job_prefix("nimesys_redirect_20260808.tar", "nimesys_redirect_")
+
+    def test_ne_capture_pas_une_instance_yunohost_multiple(self):
+        # "redirect_" ne doit pas matcher les archives de "redirect__2" (autre job)
+        from retention import _matches_job_prefix
+        assert not _matches_job_prefix("nimesys_redirect__2_20260808.tar", "nimesys_redirect_")
+
+    def test_instance_multiple_matche_son_propre_prefixe(self):
+        from retention import _matches_job_prefix
+        assert _matches_job_prefix("nimesys_redirect__2_20260808.tar", "nimesys_redirect__2_")
+
+    def test_prefixe_absent(self):
+        from retention import _matches_job_prefix
+        assert not _matches_job_prefix("nimesys_other_20260808.tar", "nimesys_redirect_")

+ 1 - 1
v3/apps.json

@@ -24,7 +24,7 @@
         "packaging_format": 2,
         "id": "backupmanager",
         "name": "Backup Manager",
-        "version": "1.4~ynh1",
+        "version": "1.5~ynh1",
         "description": {
           "en": "Centralized backup manager for YunoHost",
           "fr": "Gestionnaire de sauvegardes centralisé pour YunoHost"