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>
139 lines
4.9 KiB
Python
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),
|
|
}
|