Files
rpa_vision_v3/agent_v0/server_v1/update_check.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

139 lines
4.9 KiB
Python

# agent_v0/server_v1/update_check.py
"""Logique PURE de décision de mise à jour du client Léa (DETTE-022 v2).
But : centraliser, SANS dépendance FastAPI, le cœur testable de la MAJ
silencieuse :
- `parse_version()` (R3) : parse une version semver en tuple d'entiers, pour
une comparaison correcte ("1.0.2" < "1.0.10" — le piège lexicographique
classique). Tolérant : préfixe « v », espaces, et format invalide → fallback
`(0,)` (la plus basse) SANS jamais lever.
- `decide_update()` (R2) : compare la version courante à la dernière dispo,
choisit l'`update_type` (`code-only` par défaut, ~500 Ko / `full` ~33 Mo
rare) et construit la réponse
`{update_available, latest_version, update_type, url}`.
Ce module est volontairement IMPORTABLE seul (aucun import lourd, pas de
`api_stream`) pour être testé sans démarrer le serveur (DETTE-013). Le
branchement HTTP (endpoint gated) vit dans `api_stream.py`.
⚠️ Cette brique ne fait QUE décider. Le swap réel des fichiers, l'édition de
Lea.bat et le redémarrage sont HORS de ce module (réservé révision humaine).
Branche feat/push-log-dgx.
"""
from __future__ import annotations
from typing import Optional, Tuple
# Niveaux de livraison valides (R2). `code-only` par défaut = 99 % des MAJ.
VALID_UPDATE_TYPES = ("code-only", "full")
DEFAULT_UPDATE_TYPE = "code-only"
# Fallback de version « la plus basse » pour une chaîne illisible : ainsi une
# version valide est toujours > à une version invalide, et une *latest* illisible
# ne déclenche jamais de MAJ douteuse.
_FALLBACK_VERSION: Tuple[int, ...] = (0,)
def parse_version(v) -> Tuple[int, ...]:
"""Parse une version semver en tuple d'entiers (R3).
"1.0.2" → (1, 0, 2), "1.0.10" → (1, 0, 10), "v1.2.3" → (1, 2, 3).
Tolérant et SANS exception : préfixe « v/V » et espaces tolérés ; tout
format non numérique (vide, None, "abc", "1.x.3") retombe sur `(0,)`.
Stratégie : `packaging.version` si présent (déjà dans le venv via
setuptools/pip), sinon parse manuel. Aucune nouvelle dépendance.
"""
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:
# packaging absent (python-embed minimal) OU version non-PEP440.
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` est strictement plus récent que `baseline` (semver)."""
return parse_version(candidate) > parse_version(baseline)
def _normalize_update_type(update_type: Optional[str]) -> str:
"""Normalise l'update_type sur un niveau valide (défaut code-only)."""
if update_type in VALID_UPDATE_TYPES:
return update_type
return DEFAULT_UPDATE_TYPE
def build_download_url(
machine_id: Optional[str],
version: str,
update_type: str,
) -> str:
"""Construit l'URL de téléchargement RELATIVE (R2, 2 niveaux).
Forme alignée sur les endpoints fleet existants :
/api/fleet/download/<machine_id>?type=<update_type>&version=<version>
On garde une URL relative : le client la résout contre son SERVER_BASE.
`machine_id` absent → segment « default » (rétrocompatible).
"""
mid = (machine_id or "default").strip() or "default"
return f"/api/fleet/download/{mid}?type={update_type}&version={version}"
def decide_update(
current_version: str,
latest_version: str,
update_type: Optional[str] = None,
machine_id: Optional[str] = None,
) -> dict:
"""Décision PURE de mise à jour (R2 + R3).
Compare `current_version` à `latest_version` en semver. Si la dernière est
strictement plus récente, construit une réponse d'update ; sinon réponse
« à jour ». Aucune exception : versions illisibles → pas de MAJ (prudence).
Returns:
{
"update_available": bool,
"latest_version": str,
"update_type": "code-only" | "full" | None, # None si pas de MAJ
"url": str | None, # None si pas de MAJ
}
"""
no_update = {
"update_available": False,
"latest_version": latest_version,
"update_type": None,
"url": None,
}
# latest illisible → on ne propose RIEN (pas de MAJ douteuse).
if parse_version(latest_version) == _FALLBACK_VERSION:
return no_update
if not is_newer(latest_version, current_version):
return no_update
chosen_type = _normalize_update_type(update_type)
return {
"update_available": True,
"latest_version": latest_version,
"update_type": chosen_type,
"url": build_download_url(machine_id, latest_version, chosen_type),
}