Files
rpa_vision_v3/agent_v0/agent_v1/network/updater.py
Dom 813b33b47e feat(update): DETTE-022 — noyau MAJ silencieuse client Léa (gated, swap en stub)
Logique PURE testée : parse_version semver (R3), decide_update code-only/full (R2),
should_update client (double garde anti-downgrade), download_update (staging only +
SHA256, downloader injectable). Endpoint GET /api/v1/agents/update/check gated
(RPA_AUTO_UPDATE_SERVER_ENABLED). Flags client+serveur OFF par défaut.
Swap fichiers / Lea.bat / restart = STUBS no-op réservés révision humaine.
34 tests TDD. refs DETTE-022

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:21:35 +02:00

299 lines
12 KiB
Python

# agent_v1/network/updater.py
"""NOYAU client de la mise à jour silencieuse de Léa (DETTE-022 v2).
GATED — flag `RPA_AUTO_UPDATE_ENABLED` (défaut OFF). Tant qu'il est OFF,
rien ne se déclenche : l'intégration de ce module au runtime (boucle de poll
de `main.py`) ne fait aucune MAJ.
Ce module ne contient que les parties PURES / testables, sans réseau réel :
- `parse_version` / `is_newer` (R3) : self-contained (le bundle client
n'embarque PAS `server_v1` — duplication assumée, même algorithme).
- `should_update(local_version, server_response)` : décide « faut-il
updater ? quelle version/type ? » à partir de la réponse serveur. Double
garde semver côté client (jamais de downgrade) = défense en profondeur.
- `download_update(plan, staging_dir, downloader)` : télécharge le ZIP via un
`downloader` callable INJECTABLE (aucun réseau réel en test), vérifie le
SHA256, écrit le ZIP dans le **staging** (`Lea_next\\`-like) — JAMAIS dans
les fichiers vivants. Retourne un plan d'application.
- `auto_update_enabled()` : lit le flag (défaut OFF).
⚠️⚠️ PARTIES DANGEREUSES — RÉSERVÉES RÉVISION HUMAINE ⚠️⚠️
Le remplacement réel des fichiers (`apply_update`), l'écriture du marker
rollback (`write_boot_ok_marker`), l'édition de `Lea.bat` et le redémarrage
ne sont PAS implémentés ici : ce sont des STUBS no-op explicites. Un agent ne
doit pas écrire de code qui écrase des binaires vivants ou relance un process
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).
Branche feat/push-log-dgx.
"""
from __future__ import annotations
import hashlib
import logging
import os
from pathlib import Path
from typing import Callable, Optional, Tuple
logger = logging.getLogger(__name__)
# Niveaux de livraison (R2). `code-only` par défaut = 99 % des MAJ (~500 Ko).
VALID_UPDATE_TYPES = ("code-only", "full")
DEFAULT_UPDATE_TYPE = "code-only"
_FALLBACK_VERSION: Tuple[int, ...] = (0,)
# ---------------------------------------------------------------------------
# Flag d'activation — OFF par défaut (lu à chaque appel pour faciliter tests)
# ---------------------------------------------------------------------------
def auto_update_enabled() -> bool:
"""True si la MAJ auto client est activée (flag RPA_AUTO_UPDATE_ENABLED).
Défaut PRUDENT = OFF. On l'active poste par poste via config.txt / variable
d'environnement, sans rebuild de l'installateur (même esprit que
LOG_SHIP_ENABLED).
"""
return os.environ.get("RPA_AUTO_UPDATE_ENABLED", "false").lower() in (
"true", "1", "yes", "on",
)
# ---------------------------------------------------------------------------
# R3 — parse_version self-contained (le bundle client n'a pas server_v1)
# ---------------------------------------------------------------------------
def parse_version(v) -> Tuple[int, ...]:
"""Parse une version semver en tuple d'entiers. Voir server_v1/update_check.
"1.0.2" → (1, 0, 2) ; "1.0.10" → (1, 0, 10) ; "v1.2.3" → (1, 2, 3).
Tolérant et SANS exception : invalide → fallback `(0,)`.
"""
if not isinstance(v, str):
return _FALLBACK_VERSION
s = v.strip().lstrip("vV").strip()
if not s:
return _FALLBACK_VERSION
try:
from packaging.version import Version
return tuple(Version(s).release)
except Exception:
pass
try:
return tuple(int(x) for x in s.split("."))
except (ValueError, AttributeError):
return _FALLBACK_VERSION
def is_newer(candidate: str, baseline: str) -> bool:
"""True si `candidate` strictement plus récent que `baseline` (semver)."""
return parse_version(candidate) > parse_version(baseline)
def _normalize_update_type(update_type) -> str:
if update_type in VALID_UPDATE_TYPES:
return update_type
return DEFAULT_UPDATE_TYPE
# ---------------------------------------------------------------------------
# Décision client : faut-il updater ?
# ---------------------------------------------------------------------------
def should_update(local_version: str, server_response) -> Optional[dict]:
"""Décide à partir de la réponse serveur s'il faut updater.
Args:
local_version : version courante du client (config.AGENT_VERSION).
server_response : dict renvoyé par l'endpoint serveur
{update_available, latest_version, update_type, url, [sha256]}.
Returns:
Un PLAN d'update `{target_version, update_type, url, sha256}` si une MAJ
valide est à faire, sinon None.
Défense en profondeur : même si `update_available` est True, le client
REVÉRIFIE en semver (`is_newer`) — il ne descend JAMAIS vers une version
<= locale. Tolérant : réponse malformée → None (jamais d'exception).
"""
if not isinstance(server_response, dict):
return None
if not server_response.get("update_available"):
return None
target = server_response.get("latest_version")
url = server_response.get("url")
if not target or not url:
return None
# Double garde semver : pas de downgrade, pas d'égalité.
if not is_newer(target, local_version):
return None
return {
"target_version": target,
"update_type": _normalize_update_type(server_response.get("update_type")),
"url": url,
"sha256": server_response.get("sha256"),
}
# ---------------------------------------------------------------------------
# Téléchargement — downloader INJECTABLE, SHA256, staging only
# ---------------------------------------------------------------------------
def _default_downloader(url: str) -> bytes:
"""Téléchargement réel du ZIP (best-effort, pattern streamer/log_shipper).
Résout l'URL relative contre SERVER_BASE, ajoute le Bearer si présent.
INJECTABLE : remplacé par un fake en test (aucun réseau réel).
"""
import requests # import tardif (absent de certains envs de test)
full_url = url
headers = {}
try:
from ..config import SERVER_BASE, API_TOKEN
if url.startswith("/"):
full_url = f"{SERVER_BASE}{url}"
if API_TOKEN:
headers["Authorization"] = f"Bearer {API_TOKEN}"
except Exception:
# Hors package (test isolé) : on utilise l'URL telle quelle.
pass
resp = requests.get(full_url, headers=headers, timeout=30, stream=False)
resp.raise_for_status()
return resp.content
def download_update(
plan: dict,
staging_dir,
downloader: Optional[Callable[[str], bytes]] = None,
) -> dict:
"""Télécharge le ZIP d'update dans le staging et vérifie son intégrité.
NE TOUCHE PAS aux fichiers vivants : écrit uniquement dans `staging_dir`
(équivalent de `Lea_next\\`). L'application réelle (swap) est un stub
réservé révision humaine (voir `apply_update`).
Args:
plan : sortie de `should_update` (target_version, update_type, url, sha256).
staging_dir : dossier de staging (créé si absent).
downloader : callable `(url) -> bytes` INJECTABLE (défaut = HTTP réel).
Returns:
Succès : {ok: True, staged_zip: str, update_type, target_version,
sha256_verified: bool}
Échec : {ok: False, error: str}
Best-effort : aucune exception ne remonte ; un échec laisse le staging propre
(pas de ZIP corrompu).
"""
dl = downloader if downloader is not None else _default_downloader
staging = Path(staging_dir)
try:
data = dl(plan["url"])
except Exception as e:
logger.warning("Téléchargement update échoué : %s", e)
return {"ok": False, "error": f"download_failed: {e}"}
expected_sha = (plan.get("sha256") or "").strip().lower()
sha256_verified = False
if expected_sha:
actual = hashlib.sha256(data).hexdigest()
if actual != expected_sha:
logger.warning(
"SHA256 mismatch update (attendu=%s, obtenu=%s) — rejeté",
expected_sha, actual,
)
return {"ok": False, "error": "sha256 mismatch — ZIP rejeté"}
sha256_verified = True
else:
# Best-effort : pas de SHA fourni → on accepte mais on le signale.
logger.info("Pas de SHA256 fourni pour l'update — intégrité non vérifiée")
try:
staging.mkdir(parents=True, exist_ok=True)
target_version = plan.get("target_version", "unknown")
staged_zip = staging / f"lea_update_{target_version}.zip"
staged_zip.write_bytes(data)
except Exception as e:
logger.warning("Écriture ZIP staging échouée : %s", e)
return {"ok": False, "error": f"staging_write_failed: {e}"}
return {
"ok": True,
"staged_zip": str(staged_zip),
"update_type": _normalize_update_type(plan.get("update_type")),
"target_version": plan.get("target_version"),
"sha256_verified": sha256_verified,
}
# ===========================================================================
# ⚠️ ZONE DANGEREUSE — STUBS RÉSERVÉS RÉVISION HUMAINE (NE PAS IMPLÉMENTER
# PAR UN AGENT). Points d'extension explicites, no-op pour l'instant.
# ===========================================================================
def apply_update(prepared: dict) -> dict:
"""STUB — application réelle de l'update (swap des fichiers).
Réservé révision humaine : remplacer des fichiers vivants du client et
déclencher un swap est trop risqué pour être généré par un agent. La
mécanique cible (design v2) est :
- 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.
# NE PAS écraser les fichiers vivants depuis Python — c'est Lea.bat qui
# swappe hors-process. Édition de Lea.bat + restart = hors périmètre agent.
Returns:
{applied: False, reason: "réservé révision humaine (swap supervisé)"}
"""
logger.info(
"apply_update appelé mais NON implémenté (stub réservé révision humaine) : %r",
prepared.get("target_version") if isinstance(prepared, dict) else prepared,
)
return {
"applied": False,
"reason": "réservé révision humaine — swap supervisé (Lea.bat), hors périmètre agent",
}
def write_boot_ok_marker(version: str) -> dict:
"""STUB — écriture du marker rollback `boot_ok_{version}` (R1).
Réservé révision humaine : le marker pilote le rollback de Lea.bat au
prochain démarrage. Sa sémantique (health-check ~60s heartbeat DGX +
session active AVANT écriture) doit être validée à la main pour éviter un
faux rollback (cas DGX down ≠ Léa N+1 buguée — cf. design R1, cas edge 3).
# TODO swap supervisé : écrire `%LOCALAPPDATA%\\Lea\\boot_ok_{version}`
# après ~60s de heartbeat DGX sain + session active (main.py startup).
Returns:
{written: False, reason: "..."}
"""
logger.info(
"write_boot_ok_marker appelé mais NON implémenté (stub R1) : version=%s",
version,
)
return {
"written": False,
"reason": "réservé révision humaine — marker rollback (health-check), hors périmètre agent",
}