feat(update): swap atomique + rollback (Lea.bat) + confirmation boot (main.py)

Implémente le SWAP réel de la MAJ silencieuse (DETTE-022), remplace les stubs :
- updater.apply_update : ARME le swap (extrait le ZIP -> agent_v1_new/ +
  marqueur UPDATE_READY, garde-fou zip-slip). N'écrase JAMAIS le vivant.
- updater.write_boot_ok_marker : désarme le rollback (retire PENDING_BOOT).
- Lea.bat (template + embed généré par configure_embed.ps1) : swap ATOMIQUE
  par renames (agent_v1 -> agent_v1_prev backup ; agent_v1_new -> agent_v1)
  + rollback auto si PENDING_BOOT persiste (boot précédent non confirmé).
- main.py : confirme le boot après 90 s de liveness locale OU quit propre
  (évite un faux rollback ; RPA_BOOT_CONFIRM_DELAY_S surchargeable pour les tests).

Testable (Python) : 45 tests verts. Le swap OS (renames Lea.bat) + le câblage
main.py seront validés par le test Win 11 (step 0 pré-canary, dont le rollback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-07-01 14:10:34 +02:00
parent 5d235e49f1
commit a210e5ee32
5 changed files with 313 additions and 80 deletions

View File

@@ -168,6 +168,22 @@ class AgentV1:
target=self._auto_update_loop, daemon=True, name="lea-auto-update" target=self._auto_update_loop, daemon=True, name="lea-auto-update"
).start() ).start()
# MAJ silencieuse — confirmation de boot post-swap. Si Lea.bat vient
# d'appliquer une MAJ (marqueur PENDING_BOOT), on désarme le rollback
# après ~90 s de tourne STABLE (liveness LOCALE, indépendante du DGX).
# Un quit propre avant 90 s confirme aussi (cf. main()). Seul un vrai
# crash laisse PENDING_BOOT → rollback au prochain lancement.
if _pending_boot_marker_exists():
def _boot_confirm():
import os as _os
import time as _time
_time.sleep(float(_os.environ.get("RPA_BOOT_CONFIRM_DELAY_S", "90")))
if self.running:
_confirm_boot_ok()
threading.Thread(
target=_boot_confirm, daemon=True, name="lea-boot-confirm"
).start()
# Mini-serveur HTTP pour captures a la demande (port 5006) # Mini-serveur HTTP pour captures a la demande (port 5006)
self._capture_server = CaptureServer() self._capture_server = CaptureServer()
self._capture_server.start() self._capture_server.start()
@@ -718,6 +734,31 @@ def _agent_should_live(agent) -> bool:
return True return True
def _pending_boot_marker_exists() -> bool:
"""True si Lea.bat a posé PENDING_BOOT (boot post-MAJ à valider)."""
try:
from .network.updater import _resolve_app_dir
return (_resolve_app_dir(None) / "PENDING_BOOT").exists()
except Exception:
return False
def _confirm_boot_ok() -> None:
"""Confirme un boot post-MAJ : écrit boot_ok + retire PENDING_BOOT.
Désarme le rollback de Lea.bat. No-op si pas de PENDING_BOOT (boot normal).
Best-effort — ne doit jamais casser l'arrêt/la vie de Léa.
"""
try:
if not _pending_boot_marker_exists():
return
from .network import updater
updater.write_boot_ok_marker(AGENT_VERSION)
logger.info("[MAJ] Boot confirmé (v%s) — rollback désarmé", AGENT_VERSION)
except Exception as e: # noqa: BLE001
logger.debug("confirm_boot_ok: %s", e)
def main(): def main():
from .ui.session_watchdog import InteractiveSessionWatchdog from .ui.session_watchdog import InteractiveSessionWatchdog
@@ -738,6 +779,10 @@ def main():
try: try:
watchdog.run() watchdog.run()
# Sortie normale du watchdog = quit propre (tray / session) → le boot
# était sain : on confirme (couvre un quit AVANT les 90 s, évite un faux
# rollback). No-op si ce n'est pas un boot post-MAJ.
_confirm_boot_ok()
except KeyboardInterrupt: except KeyboardInterrupt:
logger.info("[MAIN] Interruption clavier — arret propre") logger.info("[MAIN] Interruption clavier — arret propre")
except Exception: except Exception:

View File

@@ -18,12 +18,11 @@ Ce module ne contient que les parties PURES / testables, sans réseau réel :
les fichiers vivants. Retourne un plan d'application. les fichiers vivants. Retourne un plan d'application.
- `auto_update_enabled()` : lit le flag (défaut OFF). - `auto_update_enabled()` : lit le flag (défaut OFF).
⚠️⚠️ PARTIES DANGEREUSES — RÉSERVÉES RÉVISION HUMAINE ⚠️⚠️ ⚠️ SWAP — répartition claire des responsabilités :
Le remplacement réel des fichiers (`apply_update`), l'écriture du marker `apply_update` / `write_boot_ok_marker` ci-dessous ne font que l'ARMEMENT côté
rollback (`write_boot_ok_marker`), l'édition de `Lea.bat` et le redémarrage Python (extraction vers `agent_v1_new/` + marqueurs) — ils n'écrasent JAMAIS un
ne sont PAS implémentés ici : ce sont des STUBS no-op explicites. Un agent ne fichier vivant. Le remplacement ATOMIQUE (renames), le redémarrage et le
doit pas écrire de code qui écrase des binaires vivants ou relance un process rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (revu ligne à ligne).
sans supervision. Les points d'extension sont marqués `# TODO swap supervisé`.
Pattern d'import / résilience aligné sur `log_shipper.py` (même branche). Pattern d'import / résilience aligné sur `log_shipper.py` (même branche).
@@ -33,8 +32,10 @@ Branche feat/push-log-dgx.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import logging import logging
import os import os
import shutil
from pathlib import Path from pathlib import Path
from typing import Callable, Optional, Tuple from typing import Callable, Optional, Tuple
@@ -296,6 +297,7 @@ def run_update_cycle(
staging_dir, staging_dir,
checker: Optional[Callable[[str, str], object]] = None, checker: Optional[Callable[[str, str], object]] = None,
downloader: Optional[Callable[[str], bytes]] = None, downloader: Optional[Callable[[str], bytes]] = None,
app_dir=None,
) -> dict: ) -> dict:
"""Un cycle complet de MAJ silencieuse — GATED, best-effort, SANS swap. """Un cycle complet de MAJ silencieuse — GATED, best-effort, SANS swap.
@@ -306,9 +308,10 @@ def run_update_cycle(
3. `should_update(...)` → plan (double garde semver, jamais de downgrade). 3. `should_update(...)` → plan (double garde semver, jamais de downgrade).
4. `download_update(...)` → ZIP dans le STAGING + vérif SHA256. Ne touche 4. `download_update(...)` → ZIP dans le STAGING + vérif SHA256. Ne touche
JAMAIS les fichiers vivants. JAMAIS les fichiers vivants.
5. Le swap réel N'EST PAS FAIT ici : `apply_update` reste un stub no-op 5. `apply_update` ARME le swap (extraction `agent_v1_new/` + marqueur
(réservé révision humaine + Lea.bat hors-process). Le résultat porte UPDATE_READY) mais NE swappe PAS : le remplacement atomique + le
`applied: False`. redémarrage sont faits par Lea.bat au prochain démarrage. `applied`
reste False tant que Léa n'a pas redémarré sur la nouvelle version.
Jamais d'exception ne remonte (ne doit JAMAIS casser Léa). Retourne un dict Jamais d'exception ne remonte (ne doit JAMAIS casser Léa). Retourne un dict
d'état pour le diagnostic / le log : d'état pour le diagnostic / le log :
@@ -342,76 +345,137 @@ def run_update_cycle(
"error": staged.get("error"), "error": staged.get("error"),
} }
# ⚠️ Le swap réel est réservé révision humaine : on APPELLE le stub (no-op) # Armement du swap : extraction du ZIP vers agent_v1_new\ + marqueur
# pour matérialiser le point d'extension, mais rien n'est écrasé/redémarré. # UPDATE_READY. Le swap ATOMIQUE (renames) et le redémarrage sont faits
applied = apply_update(staged) # HORS-PROCESS par Lea.bat au prochain démarrage — JAMAIS depuis ici
# (on n'écrase pas les fichiers d'un Léa en cours d'exécution).
armed = apply_update(staged, app_dir=app_dir)
return { return {
"status": "staged", "status": "armed" if armed.get("armed") else "arm_failed",
"applied": bool(applied.get("applied", False)), "applied": False, # le swap effectif est fait par Lea.bat, pas ici
"armed": bool(armed.get("armed", False)),
"target_version": staged.get("target_version"), "target_version": staged.get("target_version"),
"update_type": staged.get("update_type"), "update_type": staged.get("update_type"),
"staged_zip": staged.get("staged_zip"), "staged_zip": staged.get("staged_zip"),
"sha256_verified": staged.get("sha256_verified", False), "sha256_verified": staged.get("sha256_verified", False),
"apply_reason": applied.get("reason"), "marker": armed.get("marker"),
"error": armed.get("error"),
} }
# =========================================================================== # ===========================================================================
# ⚠️ ZONE DANGEREUSE — STUBS RÉSERVÉS RÉVISION HUMAINE (NE PAS IMPLÉMENTER # SWAP — côté Python : ARMEMENT SEULEMENT (extraction + marqueurs).
# PAR UN AGENT). Points d'extension explicites, no-op pour l'instant. # Le remplacement ATOMIQUE des fichiers vivants + le redémarrage + le
# rollback sont faits HORS-PROCESS par `Lea.bat` au démarrage (renames).
# Python n'écrase JAMAIS les fichiers d'un Léa en cours d'exécution.
# =========================================================================== # ===========================================================================
def apply_update(prepared: dict) -> dict: def _resolve_app_dir(app_dir) -> Path:
"""STUB — application réelle de l'update (swap des fichiers). """Répertoire d'install (contient `agent_v1/`, `run_agent_v1.py`, `Lea.bat`).
Réservé révision humaine : remplacer des fichiers vivants du client et INJECTABLE (tests : tmp_path). Défaut = parent du package agent_v1.
déclencher un swap est trop risqué pour être généré par un agent. La """
mécanique cible (design v2) est : if app_dir is not None:
return Path(app_dir)
try:
from ..config import BASE_DIR # BASE_DIR = dossier du package agent_v1
return Path(BASE_DIR).parent
except Exception:
return Path(__file__).resolve().parent.parent.parent
- code-only : extraire `agent_v1\\` + `lea_ui\\` + `run_agent_v1.py` +
`config.py` du ZIP staging, poser un marker `UPDATE_READY`
(`update_type=code-only`) ; le swap effectif est fait par `Lea.bat`
au prochain démarrage (xcopy ciblé).
- full : poser `UPDATE_READY` (`update_type=full`) ; `Lea.bat` fait le
backup complet `Lea_prev\\` puis le swap complet.
# TODO swap supervisé : extraction ZIP + écriture marker UPDATE_READY. def apply_update(prepared: dict, app_dir=None) -> dict:
# NE PAS écraser les fichiers vivants depuis Python — c'est Lea.bat qui """ARME le swap : extrait le ZIP staging vers `agent_v1_new/` + marqueur.
# swappe hors-process. Édition de Lea.bat + restart = hors périmètre agent.
NE swappe PAS et NE redémarre PAS (c'est le rôle de `Lea.bat`). Écrit
uniquement à côté des fichiers vivants (dossier neuf + marqueur), donc
l'opération est sûre même sur un Léa en cours d'exécution.
1. Extrait `prepared["staged_zip"]` → `<app_dir>/agent_v1_new/`
(nettoyé au préalable ; garde-fou zip-slip).
2. Écrit `<app_dir>/UPDATE_READY` (JSON : version, type, chemins) que
`Lea.bat` lira au prochain démarrage pour faire le swap atomique.
Best-effort : aucune exception ne remonte (ne doit jamais casser Léa).
Returns: Returns:
{applied: False, reason: "réservé révision humaine (swap supervisé)"} succès : {armed: True, applied: False, target_version, update_type,
marker, extracted_to}
échec : {armed: False, applied: False, error}
""" """
logger.info( if not isinstance(prepared, dict):
"apply_update appelé mais NON implémenté (stub réservé révision humaine) : %r", return {"armed": False, "applied": False, "error": "prepared invalide"}
prepared.get("target_version") if isinstance(prepared, dict) else prepared, staged_zip = prepared.get("staged_zip")
) target_version = prepared.get("target_version", "unknown")
return { update_type = _normalize_update_type(prepared.get("update_type"))
"applied": False, try:
"reason": "réservé révision humaine — swap supervisé (Lea.bat), hors périmètre agent", root = _resolve_app_dir(app_dir)
} zip_path = Path(staged_zip) if staged_zip else None
if zip_path is None or not zip_path.is_file():
return {"armed": False, "applied": False, "error": "ZIP staging introuvable"}
new_dir = root / "agent_v1_new"
if new_dir.exists():
shutil.rmtree(new_dir, ignore_errors=True) # nettoie un staging partiel
new_dir.mkdir(parents=True, exist_ok=True)
import zipfile
new_root = new_dir.resolve()
with zipfile.ZipFile(zip_path) as zf:
for name in zf.namelist(): # garde-fou zip-slip (chemins ../)
dest = (new_dir / name).resolve()
if not str(dest).startswith(str(new_root)):
shutil.rmtree(new_dir, ignore_errors=True)
return {"armed": False, "applied": False,
"error": f"zip-slip refusé : {name}"}
zf.extractall(new_dir)
marker = root / "UPDATE_READY"
marker.write_text(json.dumps({
"target_version": target_version,
"update_type": update_type,
"extracted_to": str(new_dir),
"staged_zip": str(zip_path),
}), encoding="utf-8")
logger.info(
"Update ARMÉ : %s (%s) → %s ; swap au prochain démarrage (Lea.bat)",
target_version, update_type, new_dir,
)
return {"armed": True, "applied": False, "target_version": target_version,
"update_type": update_type, "marker": str(marker),
"extracted_to": str(new_dir)}
except Exception as e: # noqa: BLE001
logger.warning("apply_update (armement) a échoué : %s", e)
return {"armed": False, "applied": False, "error": f"arm_failed: {e}"}
def write_boot_ok_marker(version: str) -> dict: def write_boot_ok_marker(version: str, app_dir=None) -> dict:
"""STUB — écriture du marker rollback `boot_ok_{version}` (R1). """Confirme un boot sain : écrit `boot_ok_{version}` + désarme le rollback.
Réservé révision humaine : le marker pilote le rollback de Lea.bat au Appelé par `main.py` après ~90 s de tourne STABLE (liveness LOCALE,
prochain marrage. Sa sémantique (health-check ~60s heartbeat DGX + indépendante du DGX — évite un faux rollback quand le réseau est coupé).
session active AVANT écriture) doit être validée à la main pour éviter un Retirer `PENDING_BOOT*` dit à `Lea.bat` que la nouvelle version a démarré
faux rollback (cas DGX down ≠ Léa N+1 buguée — cf. design R1, cas edge 3). correctement (sinon, au prochain lancement, Lea.bat rollback vers la
version précédente).
# TODO swap supervisé : écrire `%LOCALAPPDATA%\\Lea\\boot_ok_{version}` Best-effort : aucune exception ne remonte.
# après ~60s de heartbeat DGX sain + session active (main.py startup).
Returns:
{written: False, reason: "..."}
""" """
logger.info( try:
"write_boot_ok_marker appelé mais NON implémenté (stub R1) : version=%s", root = _resolve_app_dir(app_dir)
version, marker = root / f"boot_ok_{version}"
) marker.write_text("ok", encoding="utf-8")
return { cleared = []
"written": False, for p in root.glob("PENDING_BOOT*"):
"reason": "réservé révision humaine — marker rollback (health-check), hors périmètre agent", try:
} p.unlink()
cleared.append(p.name)
except OSError:
pass
logger.info("boot_ok écrit (%s) ; PENDING_BOOT retiré : %s",
version, cleared or "aucun")
return {"written": True, "marker": str(marker), "cleared_pending": cleared}
except Exception as e: # noqa: BLE001
logger.warning("write_boot_ok_marker a échoué : %s", e)
return {"written": False, "error": str(e)}

View File

@@ -76,6 +76,29 @@ if exist "lea_agent.lock" (
timeout /t 2 >nul timeout /t 2 >nul
) )
:: MAJ SILENCIEUSE swap atomique + rollback (renames uniquement)
if exist "PENDING_BOOT" (
echo [MAJ] Boot precedent non confirme : retour a la version precedente.
if exist "agent_v1_prev" (
if exist "agent_v1_echec" rmdir /s /q "agent_v1_echec" >nul 2>&1
if exist "agent_v1" move "agent_v1" "agent_v1_echec" >nul 2>&1
move "agent_v1_prev" "agent_v1" >nul 2>&1
)
del /f /q "PENDING_BOOT" >nul 2>&1
) else if exist "UPDATE_READY" (
if exist "agent_v1_new" (
echo [MAJ] Application de la mise a jour...
if exist "agent_v1" (
if exist "agent_v1_prev" rmdir /s /q "agent_v1_prev" >nul 2>&1
move "agent_v1" "agent_v1_prev" >nul 2>&1
)
move "agent_v1_new" "agent_v1" >nul 2>&1
move "UPDATE_READY" "PENDING_BOOT" >nul 2>&1
) else (
del /f /q "UPDATE_READY" >nul 2>&1
)
)
if exist "config.txt" ( if exist "config.txt" (
for /f "usebackq eol=# tokens=1,* delims==" %%a in ("config.txt") do ( for /f "usebackq eol=# tokens=1,* delims==" %%a in ("config.txt") do (
if not "%%a"=="" if not "%%b"=="" set "%%a=%%b" if not "%%a"=="" if not "%%b"=="" set "%%a=%%b"

View File

@@ -20,6 +20,35 @@ if exist "lea_agent.lock" (
timeout /t 2 >nul timeout /t 2 >nul
) )
:: ---------------------------------------------------------------
:: MAJ SILENCIEUSE — swap atomique + rollback (hors-process)
:: L'ancienne instance est fermee ci-dessus : agent_v1\ est libre.
:: Renames uniquement (quasi-atomiques), jamais d'ecrasement fichier par fichier.
:: ---------------------------------------------------------------
if exist "PENDING_BOOT" (
:: Le boot precedent n'a JAMAIS confirme (crash) -> ROLLBACK version precedente
echo [MAJ] Boot precedent non confirme : retour a la version precedente.
if exist "agent_v1_prev" (
if exist "agent_v1_echec" rmdir /s /q "agent_v1_echec" >nul 2>&1
if exist "agent_v1" move "agent_v1" "agent_v1_echec" >nul 2>&1
move "agent_v1_prev" "agent_v1" >nul 2>&1
)
del /f /q "PENDING_BOOT" >nul 2>&1
) else if exist "UPDATE_READY" (
:: Une MAJ est armee (agent_v1_new pret) -> SWAP
if exist "agent_v1_new" (
echo [MAJ] Application de la mise a jour...
if exist "agent_v1" (
if exist "agent_v1_prev" rmdir /s /q "agent_v1_prev" >nul 2>&1
move "agent_v1" "agent_v1_prev" >nul 2>&1
)
move "agent_v1_new" "agent_v1" >nul 2>&1
move "UPDATE_READY" "PENDING_BOOT" >nul 2>&1
) else (
del /f /q "UPDATE_READY" >nul 2>&1
)
)
:: --------------------------------------------------------------- :: ---------------------------------------------------------------
:: Verifier que l'installation a ete faite :: Verifier que l'installation a ete faite
:: --------------------------------------------------------------- :: ---------------------------------------------------------------

View File

@@ -206,23 +206,88 @@ class TestDownloadUpdate:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Stubs réservés à la révision humaine — DOIVENT être no-op explicites # apply_update — ARMEMENT du swap (extraction agent_v1_new + marqueur).
# NE swappe PAS et NE touche PAS les fichiers vivants (Lea.bat le fait au boot).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestDangerousPartsAreStubs: def _make_zip(path, entries):
def test_apply_update_est_un_stub_non_implemente(self, mod, tmp_path): """Fabrique un ZIP {nom: contenu} pour les tests."""
# Le swap réel est réservé révision humaine : le stub NE TOUCHE RIEN import zipfile
# et signale qu'il n'est pas implémenté. with zipfile.ZipFile(path, "w") as zf:
result = mod.apply_update( for name, content in entries.items():
{"target_version": "1.0.2", "update_type": "code-only", zf.writestr(name, content)
"staged_zip": str(tmp_path / "x.zip")} return path
)
assert result["applied"] is False
assert "human" in result["reason"].lower() or "supervis" in result["reason"].lower()
def test_write_boot_ok_marker_est_un_stub(self, mod):
result = mod.write_boot_ok_marker("1.0.2") class TestApplyUpdateArm:
assert result["written"] is False def test_arme_extrait_et_pose_marqueur(self, mod, tmp_path):
app = tmp_path / "app"; app.mkdir()
z = _make_zip(tmp_path / "u.zip", {"main.py": "v2", "sub/x.py": "y"})
res = mod.apply_update(
{"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)},
app_dir=app,
)
assert res["armed"] is True and res["applied"] is False
new_dir = app / "agent_v1_new"
assert (new_dir / "main.py").read_text() == "v2"
assert (new_dir / "sub" / "x.py").read_text() == "y"
import json as _j
data = _j.loads((app / "UPDATE_READY").read_text())
assert data["target_version"] == "1.0.2"
assert data["update_type"] == "code-only"
def test_ne_touche_pas_le_agent_v1_vivant(self, mod, tmp_path):
app = tmp_path / "app"; (app / "agent_v1").mkdir(parents=True)
live = app / "agent_v1" / "sentinelle.txt"
live.write_text("VERSION_VIVANTE")
z = _make_zip(tmp_path / "u.zip", {"main.py": "v2"})
mod.apply_update(
{"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)},
app_dir=app,
)
assert live.read_text() == "VERSION_VIVANTE" # swap différé à Lea.bat
def test_zip_introuvable_pas_de_crash_ni_marqueur(self, mod, tmp_path):
app = tmp_path / "app"; app.mkdir()
res = mod.apply_update(
{"target_version": "1.0.2", "update_type": "code-only",
"staged_zip": str(tmp_path / "absent.zip")},
app_dir=app,
)
assert res["armed"] is False and "error" in res
assert not (app / "UPDATE_READY").exists()
def test_relance_nettoie_agent_v1_new_precedent(self, mod, tmp_path):
app = tmp_path / "app"; app.mkdir()
stale = app / "agent_v1_new"; stale.mkdir()
(stale / "vieux.txt").write_text("obsolete")
z = _make_zip(tmp_path / "u.zip", {"main.py": "v2"})
mod.apply_update(
{"target_version": "1.0.3", "update_type": "code-only", "staged_zip": str(z)},
app_dir=app,
)
assert not (app / "agent_v1_new" / "vieux.txt").exists()
assert (app / "agent_v1_new" / "main.py").read_text() == "v2"
def test_zip_slip_refuse(self, mod, tmp_path):
app = tmp_path / "app"; app.mkdir()
z = _make_zip(tmp_path / "evil.zip", {"../evil.py": "pwn"})
res = mod.apply_update(
{"target_version": "1.0.2", "update_type": "code-only", "staged_zip": str(z)},
app_dir=app,
)
assert res["armed"] is False
assert not (app / "evil.py").exists()
class TestWriteBootOkMarker:
def test_ecrit_boot_ok_et_desarme_pending(self, mod, tmp_path):
app = tmp_path / "app"; app.mkdir()
(app / "PENDING_BOOT_1.0.2").write_text("x")
res = mod.write_boot_ok_marker("1.0.2", app_dir=app)
assert res["written"] is True
assert (app / "boot_ok_1.0.2").exists()
assert not (app / "PENDING_BOOT_1.0.2").exists()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -272,12 +337,18 @@ class TestRunUpdateCycle:
assert result["status"] == "up_to_date" assert result["status"] == "up_to_date"
assert list(tmp_path.glob("*.zip")) == [] assert list(tmp_path.glob("*.zip")) == []
def test_maj_dispo_telecharge_en_staging_mais_ne_swappe_pas( def test_maj_dispo_arme_le_swap_mais_ne_swappe_pas(
self, mod, tmp_path, monkeypatch self, mod, tmp_path, monkeypatch
): ):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true") monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
payload = b"PK\x03\x04 fake" # payload = un VRAI ZIP (le download le stage, apply_update l'extrait)
import io, zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("main.py", "code v1.0.2")
payload = buf.getvalue()
sha = hashlib.sha256(payload).hexdigest() sha = hashlib.sha256(payload).hexdigest()
app = tmp_path / "app"; app.mkdir()
result = mod.run_update_cycle( result = mod.run_update_cycle(
local_version="1.0.1", local_version="1.0.1",
@@ -291,15 +362,16 @@ class TestRunUpdateCycle:
"sha256": sha, "sha256": sha,
}), }),
downloader=lambda u: payload, downloader=lambda u: payload,
app_dir=app,
) )
# Téléchargé + vérifié + STAGÉ, mais PAS appliqué (swap = stub humain). # Téléchargé + vérifié + ARMÉ (agent_v1_new + UPDATE_READY), mais PAS
assert result["status"] == "staged" # swappé : le remplacement atomique est fait par Lea.bat au reboot.
assert result["status"] == "armed"
assert result["target_version"] == "1.0.2" assert result["target_version"] == "1.0.2"
assert result["sha256_verified"] is True assert result["sha256_verified"] is True
staged = Path(result["staged_zip"])
assert staged.exists() and staged.parent == tmp_path
# Le swap est explicitement NON fait (réservé révision humaine).
assert result["applied"] is False assert result["applied"] is False
assert (app / "UPDATE_READY").exists()
assert (app / "agent_v1_new" / "main.py").read_text() == "code v1.0.2"
def test_sha256_mismatch_ne_stage_pas(self, mod, tmp_path, monkeypatch): def test_sha256_mismatch_ne_stage_pas(self, mod, tmp_path, monkeypatch):
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true") monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")