فهرست منبع

feat: option par job pour ne pas conserver l'archive en local après transfert

Ajoute Job.keep_local_archive : quand désactivé, l'archive locale est
supprimée uniquement si toutes les destinations configurées ont reçu
le transfert avec succès (sinon conservée par sécurité).
Cedric Hansen 3 روز پیش
والد
کامیت
99cc714cae

+ 1 - 0
sources/blueprints/jobs.py

@@ -450,6 +450,7 @@ def _save_job(job):
     job.retention_gfs_config = gfs_config_json
     job.enabled = f.get("enabled") == "1"
     job.core_only = cfg.get("core_only", False)
+    job.keep_local_archive = f.get("keep_local_archive") == "1"
     job.job_destinations = [
         JobDestination(dest_type="ssh", dest_id=int(t[5:]))
         if t.startswith("dest:") else

+ 2 - 0
sources/blueprints/settings.py

@@ -98,6 +98,7 @@ def export_config():
             "retention_gfs_config": j.retention_gfs_config,
             "enabled": j.enabled,
             "core_only": j.core_only,
+            "keep_local_archive": j.keep_local_archive,
             "destination_names": dest_names,
             "remote_instance_names": inst_names,
         })
@@ -221,6 +222,7 @@ def import_config():
         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.keep_local_archive = bool(j_data.get("keep_local_archive", True))
         job.updated_at = datetime.utcnow()
 
         # Compat ancien format (destination_name / remote_instance_name) et nouveau (listes)

+ 1 - 0
sources/db.py

@@ -50,6 +50,7 @@ class Job(db.Model):
     retention_gfs_config = db.Column(db.Text, nullable=True)  # JSON {"daily":N,"weekly":M,"monthly":P}
     enabled = db.Column(db.Boolean, default=True)
     core_only = db.Column(db.Boolean, default=False)
+    keep_local_archive = db.Column(db.Boolean, default=True, nullable=False)
     created_at = db.Column(db.DateTime, default=datetime.utcnow)
     updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
 

+ 1 - 0
sources/init_db.py

@@ -27,6 +27,7 @@ if os.path.exists(db_path):
     migrations = [
         ("destination_id",    "ALTER TABLE jobs ADD COLUMN destination_id INTEGER REFERENCES destinations(id)"),
         ("remote_instance_id","ALTER TABLE jobs ADD COLUMN remote_instance_id INTEGER REFERENCES remote_instances(id)"),
+        ("keep_local_archive","ALTER TABLE jobs ADD COLUMN keep_local_archive BOOLEAN NOT NULL DEFAULT 1"),
     ]
     for col, sql in migrations:
         if col not in existing_cols:

+ 11 - 0
sources/jobs/ynh_backup.py

@@ -96,6 +96,17 @@ def execute_job(job_id):
                     transfer_errors += 1
                 db.session.commit()
 
+        if not job.keep_local_archive:
+            if not job.job_destinations:
+                run.log_text += "\n\nArchive locale conservée : aucune destination configurée."
+            elif transfer_errors:
+                run.log_text += "\n\nArchive locale conservée : au moins un transfert a échoué."
+            else:
+                from jobs.utils import sudo_rm_archive
+                sudo_rm_archive(archive_name, backup_dir)
+                run.log_text += "\n\nArchive locale supprimée (transfert réussi vers toutes les destinations)."
+            db.session.commit()
+
         run.status = "warning" if (transfer_errors or backup_warning) else "success"
 
     except Exception as exc:

+ 10 - 0
sources/templates/job_form.html

@@ -360,6 +360,16 @@
           <a href="{{ url_for('cfg.settings') }}?tab=instances" class="text-blue-600 hover:underline">Ajouter une instance fédérée →</a>
         </p>
       {% endif %}
+      <div class="flex items-center gap-2 pt-2 border-t border-gray-100 mt-2">
+        <input type="checkbox" name="keep_local_archive" value="1" id="keep_local_archive"
+               {% if not job or job.keep_local_archive %}checked{% endif %}
+               class="rounded border-gray-300 text-blue-600">
+        <label for="keep_local_archive" class="text-sm text-gray-700">Conserver l'archive en local après transfert</label>
+      </div>
+      <p class="text-xs text-gray-400">
+        Si décoché, l'archive locale est supprimée dès que le transfert a réussi vers <strong>toutes</strong> les destinations ci-dessus.
+        Conservée en cas d'échec de transfert ou si aucune destination n'est configurée.
+      </p>
     </div>
 
     {# ── Options ── #}

+ 102 - 0
sources/tests/test_keep_local_archive.py

@@ -0,0 +1,102 @@
+"""Tests pour l'option 'conserver l'archive en local' (job.keep_local_archive)."""
+import pytest
+
+
+def _make_job(app, keep_local_archive, with_destination):
+    from db import db, Job, JobDestination, Destination
+
+    dest = None
+    if with_destination:
+        dest = Destination(name="d1", host="h", port=22, user="u",
+                            remote_path="/backups", key_name="k", enabled=True)
+        db.session.add(dest)
+        db.session.flush()
+
+    job = Job(name="j1", type="custom_dir",
+              config_json='{"source_path": "/opt/app", "excludes": [], "restore": {}}',
+              cron_expr="", retention_mode="count", retention_value=5,
+              enabled=True, keep_local_archive=keep_local_archive)
+    db.session.add(job)
+    db.session.flush()
+    if dest:
+        db.session.add(JobDestination(job_id=job.id, dest_type="ssh", dest_id=dest.id))
+    db.session.commit()
+    return job.id
+
+
+@pytest.fixture(autouse=True)
+def _stub_side_effects(monkeypatch):
+    """Neutralise tout ce qui touche au disque/réseau réel."""
+    import jobs.custom_dir
+    import jobs.utils
+    import retention
+    import notifications
+
+    monkeypatch.setattr(jobs.custom_dir, "backup_custom_dir",
+                         lambda job, instance, backup_dir: ("archive_20260101", "log", False))
+    monkeypatch.setattr(jobs.utils, "sudo_getsize", lambda path: 123)
+    monkeypatch.setattr(retention, "apply_retention", lambda job, name, backup_dir: ([], []))
+    monkeypatch.setattr(retention, "apply_ssh_retention", lambda job, dest, data_dir: [])
+    monkeypatch.setattr(notifications, "send_job_notification", lambda run, job: None)
+
+
+class TestKeepLocalArchive:
+    def test_conserve_si_aucune_destination(self, app, monkeypatch):
+        import jobs.utils
+        called = []
+        monkeypatch.setattr(jobs.utils, "sudo_rm_archive", lambda *a, **k: called.append(a))
+
+        with app.app_context():
+            from jobs.ynh_backup import execute_job
+            from db import db, Run
+            job_id = _make_job(app, keep_local_archive=False, with_destination=False)
+            execute_job(job_id)
+            run = Run.query.filter_by(job_id=job_id).first()
+            assert not called
+            assert "aucune destination configurée" in run.log_text
+
+    def test_supprime_si_transfert_reussi(self, app, monkeypatch):
+        import jobs.utils
+        import jobs.transfer
+        called = []
+        monkeypatch.setattr(jobs.transfer, "transfer_archive", lambda *a, **k: "ok")
+        monkeypatch.setattr(jobs.utils, "sudo_rm_archive", lambda *a, **k: called.append(a))
+
+        with app.app_context():
+            from jobs.ynh_backup import execute_job
+            from db import db, Run
+            job_id = _make_job(app, keep_local_archive=False, with_destination=True)
+            execute_job(job_id)
+            run = Run.query.filter_by(job_id=job_id).first()
+            assert called == [("archive_20260101", app.config["YUNOHOST_BACKUP_DIR"])]
+            assert "Archive locale supprimée" in run.log_text
+
+    def test_conserve_si_transfert_echoue(self, app, monkeypatch):
+        import jobs.utils
+        import jobs.transfer
+        called = []
+        monkeypatch.setattr(jobs.transfer, "transfer_archive",
+                             lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
+        monkeypatch.setattr(jobs.utils, "sudo_rm_archive", lambda *a, **k: called.append(a))
+
+        with app.app_context():
+            from jobs.ynh_backup import execute_job
+            from db import db, Run
+            job_id = _make_job(app, keep_local_archive=False, with_destination=True)
+            execute_job(job_id)
+            run = Run.query.filter_by(job_id=job_id).first()
+            assert not called
+            assert "au moins un transfert a échoué" in run.log_text
+
+    def test_conserve_par_defaut(self, app, monkeypatch):
+        import jobs.utils
+        import jobs.transfer
+        called = []
+        monkeypatch.setattr(jobs.transfer, "transfer_archive", lambda *a, **k: "ok")
+        monkeypatch.setattr(jobs.utils, "sudo_rm_archive", lambda *a, **k: called.append(a))
+
+        with app.app_context():
+            from jobs.ynh_backup import execute_job
+            job_id = _make_job(app, keep_local_archive=True, with_destination=True)
+            execute_job(job_id)
+            assert not called