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>
This commit is contained in:
@@ -7830,6 +7830,63 @@ async def lea_screen_analyze(payload: _Phase25ScreenRequest, request: Request):
|
||||
return payload_out
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# DETTE-022 v2 — GET /api/v1/agents/update/check (MAJ silencieuse client Léa)
|
||||
# Flag OFF par défaut (RPA_AUTO_UPDATE_SERVER_ENABLED). Best-effort, additif :
|
||||
# expose la DÉCISION d'update (logique PURE dans update_check.py, testée hors
|
||||
# serveur — DETTE-013). NE FAIT PAS le swap (réservé révision humaine côté
|
||||
# client + Lea.bat).
|
||||
# =========================================================================
|
||||
from .update_check import decide_update as _decide_update # noqa: E402
|
||||
|
||||
|
||||
def _auto_update_server_enabled() -> bool:
|
||||
"""Flag d'activation serveur — lu à chaque appel (faciliter les tests)."""
|
||||
return os.environ.get("RPA_AUTO_UPDATE_SERVER_ENABLED", "").lower() in (
|
||||
"1", "true", "yes", "on",
|
||||
)
|
||||
|
||||
|
||||
def _latest_agent_version() -> str:
|
||||
"""Dernière version d'agent disponible côté serveur.
|
||||
|
||||
Source de vérité minimale (POC) : variable d'environnement
|
||||
RPA_AGENT_LATEST_VERSION. Permet de piloter la fleet sans rebuild. Une
|
||||
évolution future pourra la lire d'un manifeste/DB (cf. design).
|
||||
"""
|
||||
return os.environ.get("RPA_AGENT_LATEST_VERSION", "1.0.1")
|
||||
|
||||
|
||||
@app.get("/api/v1/agents/update/check")
|
||||
async def check_agent_update(
|
||||
current_version: str,
|
||||
machine_id: Optional[str] = None,
|
||||
update_type: Optional[str] = None,
|
||||
):
|
||||
"""Indiquer au client Léa si une MAJ est disponible (DETTE-022 v2).
|
||||
|
||||
Réponse : {update_available, latest_version, update_type, url}.
|
||||
|
||||
GATED : si RPA_AUTO_UPDATE_SERVER_ENABLED n'est pas positionné → 503
|
||||
(aucun effet sur le pipeline existant — anti-régression). Auth Bearer
|
||||
requise (dépendance globale `_verify_token`).
|
||||
"""
|
||||
if not _auto_update_server_enabled():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
"MAJ auto désactivée (flag RPA_AUTO_UPDATE_SERVER_ENABLED). "
|
||||
"DETTE-022 : endpoint exposé mais OFF par défaut."
|
||||
),
|
||||
)
|
||||
return _decide_update(
|
||||
current_version=current_version,
|
||||
latest_version=_latest_agent_version(),
|
||||
update_type=update_type,
|
||||
machine_id=machine_id,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
|
||||
138
agent_v0/server_v1/update_check.py
Normal file
138
agent_v0/server_v1/update_check.py
Normal file
@@ -0,0 +1,138 @@
|
||||
# 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),
|
||||
}
|
||||
Reference in New Issue
Block a user