feat(update): scaffold MAJ silencieuse + canary par machine (DETTE-022, gated OFF, swap encore stub)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -103,6 +103,16 @@ LOG_SHIP_INTERVAL_S = float(os.environ.get("RPA_LOG_SHIP_INTERVAL_S", "30"))
|
||||
AUTO_UPDATE_ENABLED = os.environ.get("RPA_AUTO_UPDATE_ENABLED", "false").lower() in (
|
||||
"true", "1", "yes", "on",
|
||||
)
|
||||
# Intervalle entre deux interrogations serveur pour une MAJ (secondes).
|
||||
# Défaut 1 h : une MAJ n'est jamais urgente ; on interroge peu pour ne pas
|
||||
# charger le réseau clinique. Le check ne fait de toute façon aucun swap.
|
||||
AUTO_UPDATE_INTERVAL_S = float(os.environ.get("RPA_AUTO_UPDATE_INTERVAL_S", "3600"))
|
||||
# Dossier de STAGING des ZIP d'update (jamais les fichiers vivants). Équivalent
|
||||
# de `Lea_next\\`. Sous LOCALAPPDATA en prod Windows, sinon à côté de l'agent.
|
||||
AUTO_UPDATE_STAGING_DIR = os.environ.get(
|
||||
"RPA_AUTO_UPDATE_STAGING_DIR",
|
||||
str(BASE_DIR / "_update_staging"),
|
||||
)
|
||||
|
||||
# Monitoring
|
||||
PERF_MONITOR_INTERVAL_S = 30
|
||||
|
||||
@@ -18,6 +18,7 @@ from .config import (
|
||||
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS, LOG_FILE,
|
||||
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME, API_TOKEN, MAX_SESSION_DURATION_S,
|
||||
STREAMING_ENDPOINT, LOG_SHIP_ENABLED, LOG_SHIP_INTERVAL_S,
|
||||
AUTO_UPDATE_ENABLED, AUTO_UPDATE_INTERVAL_S, AUTO_UPDATE_STAGING_DIR,
|
||||
)
|
||||
from .core.captor import EventCaptorV1
|
||||
from .core.executor import ActionExecutorV1
|
||||
@@ -158,6 +159,15 @@ class AgentV1:
|
||||
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
|
||||
threading.Thread(target=self._background_heartbeat_loop, daemon=True).start()
|
||||
|
||||
# DETTE-022 v2 : MAJ silencieuse — boucle de check GATED (défaut OFF).
|
||||
# Interroge le serveur (canary-aware) et télécharge en STAGING ; le swap
|
||||
# réel reste réservé révision humaine (updater.apply_update = stub no-op).
|
||||
# Activable poste par poste via RPA_AUTO_UPDATE_ENABLED, sans rebuild.
|
||||
if AUTO_UPDATE_ENABLED:
|
||||
threading.Thread(
|
||||
target=self._auto_update_loop, daemon=True, name="lea-auto-update"
|
||||
).start()
|
||||
|
||||
# Mini-serveur HTTP pour captures a la demande (port 5006)
|
||||
self._capture_server = CaptureServer()
|
||||
self._capture_server.start()
|
||||
@@ -441,6 +451,67 @@ class AgentV1:
|
||||
logger.debug(f"[HEARTBEAT] Erreur: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
def _auto_update_loop(self):
|
||||
"""DETTE-022 v2 — boucle de MAJ silencieuse GATED (défaut OFF).
|
||||
|
||||
Interroge périodiquement le serveur (endpoint canary-aware), et si une
|
||||
MAJ est proposée pour CE poste, la télécharge dans le STAGING après
|
||||
vérif SHA256. Le swap réel N'EST PAS fait ici : `updater.run_update_cycle`
|
||||
s'arrête au staging (apply_update = stub réservé révision humaine + swap
|
||||
hors-process par Lea.bat au prochain démarrage).
|
||||
|
||||
SÉCURITÉ — « au bon moment » : on NE stage PAS pendant un enregistrement
|
||||
ou un replay actif (self.session_id / self._replay_active), pour ne pas
|
||||
perturber le travail utilisateur ni consommer du réseau au mauvais
|
||||
moment. Best-effort : aucune exception ne remonte (ne casse jamais Léa).
|
||||
"""
|
||||
try:
|
||||
from .network.updater import run_update_cycle
|
||||
except Exception as e:
|
||||
logger.warning("[UPDATE] Module updater indisponible : %s", e)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"[UPDATE] Boucle MAJ silencieuse démarrée (intervalle=%.0fs, "
|
||||
"version=%s) — check seul, swap réservé révision humaine",
|
||||
AUTO_UPDATE_INTERVAL_S, AGENT_VERSION,
|
||||
)
|
||||
|
||||
while self.running:
|
||||
# Découpe l'attente pour réagir vite à l'arrêt.
|
||||
waited = 0.0
|
||||
step = 1.0
|
||||
while self.running and waited < AUTO_UPDATE_INTERVAL_S:
|
||||
time.sleep(step)
|
||||
waited += step
|
||||
if not self.running:
|
||||
break
|
||||
|
||||
# « Au bon moment » : jamais en plein travail (enregistrement/replay).
|
||||
if self.session_id or getattr(self, "_replay_active", False):
|
||||
logger.debug("[UPDATE] Report du check (session/replay active)")
|
||||
continue
|
||||
|
||||
try:
|
||||
result = run_update_cycle(
|
||||
local_version=AGENT_VERSION,
|
||||
machine_id=self.machine_id,
|
||||
staging_dir=AUTO_UPDATE_STAGING_DIR,
|
||||
)
|
||||
status = result.get("status")
|
||||
if status == "staged":
|
||||
logger.info(
|
||||
"[UPDATE] MAJ %s téléchargée en staging (SHA256=%s) — "
|
||||
"swap réservé révision humaine, non appliqué",
|
||||
result.get("target_version"),
|
||||
result.get("sha256_verified"),
|
||||
)
|
||||
elif status not in ("up_to_date", "disabled"):
|
||||
logger.debug("[UPDATE] Cycle: %s", result)
|
||||
except Exception as e:
|
||||
# run_update_cycle est déjà best-effort ; double filet ici.
|
||||
logger.debug("[UPDATE] Erreur boucle MAJ : %s", e)
|
||||
|
||||
def stop_session(self):
|
||||
# Sauvegarder le session_id avant de l'annuler (pour les logs)
|
||||
ended_session_id = self.session_id
|
||||
|
||||
@@ -238,6 +238,125 @@ def download_update(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interrogation serveur — checker INJECTABLE (GET /agents/update/check)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _default_update_checker(local_version: str, machine_id: str):
|
||||
"""Interroge le serveur : y a-t-il une MAJ ? (best-effort, INJECTABLE).
|
||||
|
||||
GET SERVER_URL/agents/update/check?current_version=..&machine_id=..
|
||||
(endpoint gated côté serveur — 503 si RPA_AUTO_UPDATE_SERVER_ENABLED OFF,
|
||||
auquel cas on renvoie None : pas de MAJ). Bearer si présent. Pattern aligné
|
||||
sur `log_shipper._default_sender`. INJECTABLE : remplacé par un fake en test.
|
||||
|
||||
Returns:
|
||||
Le dict réponse serveur (`should_update` sait le lire), ou None si
|
||||
indisponible / gated / erreur (jamais d'exception ne remonte).
|
||||
"""
|
||||
try:
|
||||
import requests # import tardif
|
||||
|
||||
headers = {}
|
||||
try:
|
||||
from ..config import SERVER_URL, API_TOKEN
|
||||
|
||||
base = SERVER_URL
|
||||
if API_TOKEN:
|
||||
headers["Authorization"] = f"Bearer {API_TOKEN}"
|
||||
except Exception:
|
||||
base = ""
|
||||
url = f"{base}/agents/update/check"
|
||||
resp = requests.get(
|
||||
url,
|
||||
params={"current_version": local_version, "machine_id": machine_id},
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
allow_redirects=False,
|
||||
)
|
||||
# 503 = endpoint gated OFF côté serveur → pas de MAJ (silencieux).
|
||||
if resp.status_code == 503:
|
||||
return None
|
||||
if not resp.ok:
|
||||
logger.debug("update/check HTTP %s", resp.status_code)
|
||||
return None
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.debug("update/check indisponible : %s", e)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestrateur GATED — check → décide → download (staging) → stub apply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run_update_cycle(
|
||||
local_version: str,
|
||||
machine_id: str,
|
||||
staging_dir,
|
||||
checker: Optional[Callable[[str, str], object]] = None,
|
||||
downloader: Optional[Callable[[str], bytes]] = None,
|
||||
) -> dict:
|
||||
"""Un cycle complet de MAJ silencieuse — GATED, best-effort, SANS swap.
|
||||
|
||||
Enchaîne :
|
||||
1. GATE `auto_update_enabled()` (RPA_AUTO_UPDATE_ENABLED, défaut OFF) —
|
||||
si OFF, ne fait STRICTEMENT rien (aucun appel réseau).
|
||||
2. `checker(local_version, machine_id)` → réponse serveur (canary-aware).
|
||||
3. `should_update(...)` → plan (double garde semver, jamais de downgrade).
|
||||
4. `download_update(...)` → ZIP dans le STAGING + vérif SHA256. Ne touche
|
||||
JAMAIS les fichiers vivants.
|
||||
5. Le swap réel N'EST PAS FAIT ici : `apply_update` reste un stub no-op
|
||||
(réservé révision humaine + Lea.bat hors-process). Le résultat porte
|
||||
`applied: False`.
|
||||
|
||||
Jamais d'exception ne remonte (ne doit JAMAIS casser Léa). Retourne un dict
|
||||
d'état pour le diagnostic / le log :
|
||||
status ∈ {disabled, check_failed, up_to_date, download_failed, staged}
|
||||
|
||||
Args:
|
||||
checker : callable `(local_version, machine_id) -> dict|None`
|
||||
INJECTABLE (défaut = HTTP réel vers l'endpoint gated).
|
||||
downloader : callable `(url) -> bytes` INJECTABLE (défaut = HTTP réel).
|
||||
"""
|
||||
if not auto_update_enabled():
|
||||
return {"status": "disabled", "applied": False}
|
||||
|
||||
chk = checker if checker is not None else _default_update_checker
|
||||
|
||||
try:
|
||||
server_response = chk(local_version, machine_id)
|
||||
except Exception as e:
|
||||
logger.warning("update check a levé : %s", e)
|
||||
return {"status": "check_failed", "applied": False, "error": str(e)}
|
||||
|
||||
plan = should_update(local_version, server_response)
|
||||
if plan is None:
|
||||
return {"status": "up_to_date", "applied": False}
|
||||
|
||||
staged = download_update(plan, staging_dir, downloader=downloader)
|
||||
if not staged.get("ok"):
|
||||
return {
|
||||
"status": "download_failed",
|
||||
"applied": False,
|
||||
"error": staged.get("error"),
|
||||
}
|
||||
|
||||
# ⚠️ Le swap réel est réservé révision humaine : on APPELLE le stub (no-op)
|
||||
# pour matérialiser le point d'extension, mais rien n'est écrasé/redémarré.
|
||||
applied = apply_update(staged)
|
||||
|
||||
return {
|
||||
"status": "staged",
|
||||
"applied": bool(applied.get("applied", False)),
|
||||
"target_version": staged.get("target_version"),
|
||||
"update_type": staged.get("update_type"),
|
||||
"staged_zip": staged.get("staged_zip"),
|
||||
"sha256_verified": staged.get("sha256_verified", False),
|
||||
"apply_reason": applied.get("reason"),
|
||||
}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ⚠️ 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.
|
||||
|
||||
@@ -7848,6 +7848,9 @@ async def lea_screen_analyze(payload: _Phase25ScreenRequest, request: Request):
|
||||
# client + Lea.bat).
|
||||
# =========================================================================
|
||||
from .update_check import decide_update as _decide_update # noqa: E402
|
||||
from .update_policy import ( # noqa: E402
|
||||
resolve_target_version_from_env as _resolve_target_version_from_env,
|
||||
)
|
||||
|
||||
|
||||
def _auto_update_server_enabled() -> bool:
|
||||
@@ -7857,14 +7860,25 @@ def _auto_update_server_enabled() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _latest_agent_version() -> str:
|
||||
"""Dernière version d'agent disponible côté serveur.
|
||||
def _latest_agent_version(machine_id: Optional[str] = None) -> str:
|
||||
"""Version d'agent cible POUR CE POSTE (canary-aware, DETTE-022 v2).
|
||||
|
||||
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).
|
||||
⭐ SÉCURITÉ flotte ⭐ — la version servie est résolue PAR MACHINE via la
|
||||
politique canary (`update_policy.resolve_target_version_from_env`) : un
|
||||
poste canary (Émilie `lea-4zbgwxty`) reçoit la nouvelle version en premier ;
|
||||
tous les autres restent sur le floor stable. Piloté 100 % par env, sans
|
||||
rebuild :
|
||||
RPA_AGENT_STABLE_VERSION (défaut 1.0.1) — servi à toute la flotte.
|
||||
RPA_AGENT_CANARY_VERSION — servi AUX SEULS postes canary.
|
||||
RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary.
|
||||
|
||||
Rétrocompat : si `RPA_AGENT_LATEST_VERSION` (ancienne var globale) est
|
||||
positionnée, elle prime — évite toute régression d'un déploiement existant.
|
||||
"""
|
||||
return os.environ.get("RPA_AGENT_LATEST_VERSION", "1.0.1")
|
||||
legacy = os.environ.get("RPA_AGENT_LATEST_VERSION")
|
||||
if legacy:
|
||||
return legacy
|
||||
return _resolve_target_version_from_env(machine_id)
|
||||
|
||||
|
||||
@app.get("/api/v1/agents/update/check")
|
||||
@@ -7877,6 +7891,10 @@ async def check_agent_update(
|
||||
|
||||
Réponse : {update_available, latest_version, update_type, url}.
|
||||
|
||||
La version cible est résolue PAR MACHINE (canary) : voir
|
||||
`_latest_agent_version`. Un poste hors canary ne se voit JAMAIS proposer la
|
||||
version canary (blast radius borné à la liste canary).
|
||||
|
||||
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`).
|
||||
@@ -7891,7 +7909,7 @@ async def check_agent_update(
|
||||
)
|
||||
return _decide_update(
|
||||
current_version=current_version,
|
||||
latest_version=_latest_agent_version(),
|
||||
latest_version=_latest_agent_version(machine_id),
|
||||
update_type=update_type,
|
||||
machine_id=machine_id,
|
||||
)
|
||||
|
||||
139
agent_v0/server_v1/update_policy.py
Normal file
139
agent_v0/server_v1/update_policy.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# agent_v0/server_v1/update_policy.py
|
||||
"""Politique de déploiement CANARY de la MAJ silencieuse Léa (DETTE-022 v2).
|
||||
|
||||
⭐ Brique de SÉCURITÉ centrale ⭐ — 10+ postes cliniques live (Wallerstein).
|
||||
|
||||
Une MAJ ratée peut briquer toute la flotte. La règle non négociable : on ne
|
||||
pousse JAMAIS une nouvelle version sur tous les postes d'un coup. On la déploie
|
||||
d'abord sur UN poste (canary = Émilie `lea-4zbgwxty`), on vérifie, puis on
|
||||
élargit. Ce module résout, PAR MACHINE, la version cible :
|
||||
|
||||
- poste dans la liste canary → `canary_version` (la nouvelle) ;
|
||||
- tous les autres postes → `stable_version` (le floor, inchangé).
|
||||
|
||||
Piloté 100 % par variables d'environnement (config serveur, sans rebuild) :
|
||||
RPA_AGENT_STABLE_VERSION — version servie à toute la flotte (défaut floor).
|
||||
RPA_AGENT_CANARY_VERSION — version servie AUX SEULS postes canary (optionnel).
|
||||
RPA_AGENT_CANARY_MACHINES — allow-list CSV des machine_id canary.
|
||||
|
||||
Promotion = quand le canary est validé, on met RPA_AGENT_STABLE_VERSION à la
|
||||
version canary (toute la flotte suit) et on vide RPA_AGENT_CANARY_MACHINES.
|
||||
Rollback canary = on remet RPA_AGENT_CANARY_VERSION à l'ancienne / on vide la
|
||||
liste : le prochain check ne proposera plus la MAJ (le swap réel côté client
|
||||
reste réservé révision humaine — cf. updater.py).
|
||||
|
||||
Module PUR (aucun import FastAPI, aucune IO) → importable et testable seul
|
||||
(DETTE-013). Le branchement HTTP vit dans api_stream.py.
|
||||
|
||||
Branche feat/push-log-dgx.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional, Set
|
||||
|
||||
# Réutilise le comparateur semver de la décision (même module serveur, pas de
|
||||
# duplication) : "1.0.2" < "1.0.10" correctement, tolérant aux formats invalides.
|
||||
try: # import relatif quand chargé comme package
|
||||
from .update_check import is_newer
|
||||
except Exception: # chargé par chemin (tests importlib) : import du voisin
|
||||
import importlib.util as _ilu
|
||||
from pathlib import Path as _Path
|
||||
|
||||
_uc_path = _Path(__file__).resolve().parent / "update_check.py"
|
||||
_spec = _ilu.spec_from_file_location("_rpa_update_check_for_policy", _uc_path)
|
||||
_uc = _ilu.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_uc)
|
||||
is_newer = _uc.is_newer
|
||||
|
||||
|
||||
# Séparateurs tolérés dans l'allow-list canary (CSV, espaces, point-virgule).
|
||||
_CANARY_SEPARATORS = (",", ";")
|
||||
|
||||
|
||||
def parse_canary_machines(raw: Optional[str]) -> Set[str]:
|
||||
"""Parse l'allow-list canary en un ensemble de machine_id.
|
||||
|
||||
Tolérant : virgule / point-virgule / espace comme séparateurs, entrées
|
||||
vides ignorées. `None` ou chaîne vide → ensemble vide (aucun canary).
|
||||
"""
|
||||
if not raw or not isinstance(raw, str):
|
||||
return set()
|
||||
normalized = raw
|
||||
for sep in _CANARY_SEPARATORS:
|
||||
normalized = normalized.replace(sep, " ")
|
||||
return {tok for tok in (t.strip() for t in normalized.split()) if tok}
|
||||
|
||||
|
||||
def resolve_target_version(
|
||||
machine_id: Optional[str],
|
||||
stable_version: str,
|
||||
canary_version: Optional[str],
|
||||
canary_machines: Set[str],
|
||||
) -> str:
|
||||
"""Résout la version cible POUR CE POSTE (cœur canary — sécurité).
|
||||
|
||||
Règles (toutes prudentes par défaut) :
|
||||
1. Poste HORS liste canary → `stable_version` (jamais la nouvelle).
|
||||
2. machine_id absent / liste vide / pas de canary_version → `stable_version`.
|
||||
3. Poste DANS la liste canary ET `canary_version` fournie ET STRICTEMENT
|
||||
plus récente que stable → `canary_version`.
|
||||
4. Garde-fou : si `canary_version` <= `stable_version` (config douteuse,
|
||||
ex. downgrade), on sert quand même `stable_version` (jamais de recul).
|
||||
|
||||
Ne lève jamais. Une version illisible retombe naturellement sur le stable
|
||||
via le comparateur semver tolérant.
|
||||
"""
|
||||
# Cas 1/2 : hors canary → stable.
|
||||
if not machine_id or machine_id not in canary_machines:
|
||||
return stable_version
|
||||
if not canary_version:
|
||||
return stable_version
|
||||
|
||||
# Cas 4 : garde-fou anti-recul — le canary doit être STRICTEMENT plus récent.
|
||||
if not is_newer(canary_version, stable_version):
|
||||
return stable_version
|
||||
|
||||
# Cas 3 : poste canary → nouvelle version.
|
||||
return canary_version
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lecture de la politique depuis l'environnement (pilotage sans rebuild).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Défaut historique aligné sur AGENT_VERSION client (config.py) et sur le
|
||||
# fallback de _latest_agent_version().
|
||||
_DEFAULT_STABLE_VERSION = "1.0.1"
|
||||
|
||||
|
||||
def stable_version_from_env() -> str:
|
||||
"""Version servie à toute la flotte (floor). Défaut = 1.0.1."""
|
||||
return os.environ.get("RPA_AGENT_STABLE_VERSION", _DEFAULT_STABLE_VERSION)
|
||||
|
||||
|
||||
def canary_version_from_env() -> Optional[str]:
|
||||
"""Version canary (nouvelle), servie aux seuls postes canary. Optionnel."""
|
||||
val = os.environ.get("RPA_AGENT_CANARY_VERSION", "").strip()
|
||||
return val or None
|
||||
|
||||
|
||||
def canary_machines_from_env() -> Set[str]:
|
||||
"""Allow-list canary (machine_id) depuis RPA_AGENT_CANARY_MACHINES."""
|
||||
return parse_canary_machines(os.environ.get("RPA_AGENT_CANARY_MACHINES", ""))
|
||||
|
||||
|
||||
def resolve_target_version_from_env(machine_id: Optional[str]) -> str:
|
||||
"""Raccourci : résout la version cible pour `machine_id` d'après l'env.
|
||||
|
||||
C'est le point d'entrée que l'endpoint serveur appelle. Il isole toute la
|
||||
lecture d'environnement ici (testable en injectant les paramètres via
|
||||
`resolve_target_version`).
|
||||
"""
|
||||
return resolve_target_version(
|
||||
machine_id=machine_id,
|
||||
stable_version=stable_version_from_env(),
|
||||
canary_version=canary_version_from_env(),
|
||||
canary_machines=canary_machines_from_env(),
|
||||
)
|
||||
@@ -38,3 +38,12 @@ RPA_USER_LABEL=CONFIGURE_ME
|
||||
# --- Parametres avances (ne pas modifier sauf indication) ---
|
||||
RPA_BLUR_SENSITIVE=false
|
||||
RPA_LOG_RETENTION_DAYS=180
|
||||
|
||||
# --- MAJ silencieuse (DETTE-022 v2) — DESACTIVEE par defaut ---
|
||||
# Deploiement CANARY : on active d'ABORD ce flag sur le SEUL poste pilote
|
||||
# (Emilie), on verifie, puis on elargit. Le poste interroge le serveur et
|
||||
# telecharge la MAJ en staging ; le remplacement reel des fichiers reste manuel
|
||||
# / supervise (reserve revision humaine). Decommenter pour activer ce poste :
|
||||
# RPA_AUTO_UPDATE_ENABLED=true
|
||||
# Intervalle d'interrogation serveur en secondes (defaut 3600 = 1h) :
|
||||
# RPA_AUTO_UPDATE_INTERVAL_S=3600
|
||||
|
||||
193
docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md
Normal file
193
docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# DESIGN — MAJ silencieuse du client Léa + déploiement CANARY (DETTE-022 v2)
|
||||
|
||||
Date : 2026-07-01
|
||||
Branche : `feat/push-log-dgx`
|
||||
Statut : **premier draft fonctionnel — GATED OFF partout, aucun swap réel, revue supervisée Dom requise avant toute activation**
|
||||
|
||||
> ⚠️ RIEN N'A ÉTÉ DÉPLOYÉ. Aucun SSH poste, aucune action fleet. Ce document +
|
||||
> le code de la branche sont un livrable de conception/implémentation pour revue.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problème
|
||||
|
||||
Pousser des correctifs au client Léa sur ~19 postes cliniques live (Wallerstein)
|
||||
**sans** patch manuel DSI et **sans** déranger les TIM en plein travail. Contrainte
|
||||
absolue : une MAJ ratée peut **briquer toute la flotte**. Le mécanisme doit donc
|
||||
être **conservateur** : canary lent + rollback béton plutôt que rapide et risqué.
|
||||
|
||||
## 2. État de départ (stub commit `813b33b47`) — ce qui existait déjà
|
||||
|
||||
Le noyau était plus avancé qu'un simple squelette. Déjà présent et **testé (vert)** :
|
||||
|
||||
| Brique | Fichier | Rôle |
|
||||
|---|---|---|
|
||||
| Décision serveur PURE | `agent_v0/server_v1/update_check.py` | `parse_version`/`is_newer` (semver correct : `1.0.2 < 1.0.10`), `decide_update()`, `build_download_url()` |
|
||||
| Endpoint serveur gated | `agent_v0/server_v1/api_stream.py:7843+` | `GET /api/v1/agents/update/check` — **503 si `RPA_AUTO_UPDATE_SERVER_ENABLED` OFF**, Bearer requis |
|
||||
| Noyau client PUR | `agent_v0/agent_v1/network/updater.py` | `auto_update_enabled()` (flag `RPA_AUTO_UPDATE_ENABLED`, défaut OFF), `should_update()` (double garde anti-downgrade), `download_update()` (staging + SHA256, ne touche jamais les fichiers vivants) |
|
||||
| **Stubs dangereux (no-op)** | `updater.py:246+` | `apply_update()` / `write_boot_ok_marker()` — **réservés révision humaine** (swap fichiers, édition `Lea.bat`, restart) |
|
||||
| Version agent | `agent_v0/agent_v1/config.py:30` | `AGENT_VERSION = os.environ.get("RPA_AGENT_VERSION", "1.0.1")` (amorcé `105ade959`) |
|
||||
| Tests | `tests/unit/test_update_check_server.py`, `tests/unit/test_agent_v1_updater.py`, `tests/integration/test_update_check_endpoint.py` | R2/R3 verts |
|
||||
|
||||
### Ce qui MANQUAIT (comblé par ce draft)
|
||||
|
||||
1. **Aucune logique canary** : `decide_update` recevait `machine_id` mais l'ignorait pour choisir la version. La version cible était une seule var globale `RPA_AGENT_LATEST_VERSION` → une MAJ partait sur **toute** la flotte d'un coup. **C'est le trou de sécurité n°1.**
|
||||
2. **Le noyau client n'était pas wiré** : `updater.py` n'était appelé nulle part. `main.py` ne l'importait pas. Aucun caller HTTP de `/agents/update/check`.
|
||||
3. **Pas d'orchestrateur** reliant check → décide → download (staging) côté client.
|
||||
|
||||
## 3. Fleet / versioning existant (réutilisé, pas réinventé)
|
||||
|
||||
- Registre SQLite `enrolled_agents` (`agent_v0/server_v1/agent_registry.py:105`) : colonne `version` + `last_seen_at` par `machine_id`. Le dashboard Fleet (`web_dashboard/templates/index.html:2247`) affiche déjà la version par poste.
|
||||
- **Limite connue** : `version` n'est écrite qu'à l'`enroll` (installateur), pas rafraîchie par le heartbeat runtime. Le serveur connaît donc la version *installée*, pas forcément la *version vive*. → **inventaire de version = amélioration future** (voir §8), non bloquante pour le canary (le canary est piloté par une allow-list de `machine_id`, pas par l'inventaire).
|
||||
|
||||
## 4. Design retenu (et pourquoi)
|
||||
|
||||
Aligné sur l'état de l'art self-update desktop 2025 (canary / blue-green / A-B swap + watchdog rollback + intégrité + version) — sources en fin de doc.
|
||||
|
||||
### 4.1 CANARY côté serveur — la keystone de sécurité (IMPLÉMENTÉ)
|
||||
|
||||
Nouveau module PUR `agent_v0/server_v1/update_policy.py`. Il résout la version cible
|
||||
**PAR MACHINE** :
|
||||
|
||||
- poste dans l'allow-list canary → `canary_version` (la nouvelle) ;
|
||||
- tous les autres postes → `stable_version` (le floor, inchangé).
|
||||
|
||||
Piloté 100 % par **variables d'environnement serveur** (aucun rebuild, aucune
|
||||
DSI) :
|
||||
|
||||
```
|
||||
RPA_AGENT_STABLE_VERSION # version servie à TOUTE la flotte (défaut 1.0.1)
|
||||
RPA_AGENT_CANARY_VERSION # version servie AUX SEULS postes canary (optionnel)
|
||||
RPA_AGENT_CANARY_MACHINES # allow-list CSV des machine_id canary
|
||||
```
|
||||
|
||||
Garde-fous du résolveur (tous prudents par défaut) :
|
||||
- machine_id absent / liste vide / pas de `canary_version` → **stable** ;
|
||||
- `canary_version` doit être **strictement plus récente** que `stable` (sinon on sert stable — jamais de recul) ;
|
||||
- ne lève jamais ; version illisible → retombe sur stable via le comparateur semver tolérant.
|
||||
|
||||
Wiring : `_latest_agent_version(machine_id)` dans `api_stream.py` appelle
|
||||
`resolve_target_version_from_env(machine_id)`. **Rétrocompat** : si l'ancienne
|
||||
`RPA_AGENT_LATEST_VERSION` est positionnée, elle prime (pas de régression d'un
|
||||
déploiement existant).
|
||||
|
||||
**Effet** : la 1.0.2 ne peut PAS fuiter hors de la liste canary. Blast radius =
|
||||
la liste. On démarre la liste = `lea-4zbgwxty` (Émilie) seul.
|
||||
|
||||
**Promotion** = quand le canary est validé : `RPA_AGENT_STABLE_VERSION=<canary>`
|
||||
+ vider `RPA_AGENT_CANARY_MACHINES` → toute la flotte suit.
|
||||
**Rollback canary** = vider `RPA_AGENT_CANARY_MACHINES` / remettre l'ancienne
|
||||
`RPA_AGENT_CANARY_VERSION` → le prochain check ne propose plus rien.
|
||||
|
||||
### 4.2 Orchestrateur client (IMPLÉMENTÉ, GATED, sans swap)
|
||||
|
||||
`updater.run_update_cycle(local_version, machine_id, staging_dir, checker?, downloader?)` :
|
||||
|
||||
1. **GATE** `auto_update_enabled()` (`RPA_AUTO_UPDATE_ENABLED`, défaut OFF) — si OFF, ne fait **strictement rien**, aucun appel réseau ;
|
||||
2. `checker(...)` → réponse serveur (défaut = `_default_update_checker` : GET vers l'endpoint gated, Bearer, 503→None, jamais d'exception) ;
|
||||
3. `should_update(...)` → plan (double garde semver anti-downgrade) ;
|
||||
4. `download_update(...)` → ZIP en **staging** + vérif **SHA256** (fichiers vivants jamais touchés) ;
|
||||
5. `apply_update(staged)` = **stub no-op** → résultat `applied: False`. **Le swap réel n'est PAS fait par du code d'agent.**
|
||||
|
||||
Statuts retournés (diagnostic/log) : `disabled | check_failed | up_to_date | download_failed | staged`. Best-effort total : aucune exception ne remonte (ne casse jamais Léa).
|
||||
|
||||
### 4.3 Wiring runtime (IMPLÉMENTÉ, GATED)
|
||||
|
||||
`main.py` : thread daemon `_auto_update_loop`, démarré **uniquement si**
|
||||
`AUTO_UPDATE_ENABLED`, à côté des boucles permanentes existantes (même pattern
|
||||
que le log shipper). Sécurité « **au bon moment** » : on ne stage PAS pendant un
|
||||
enregistrement (`self.session_id`) ou un replay actif (`self._replay_active`) —
|
||||
pas de perturbation du travail TIM. Intervalle `RPA_AUTO_UPDATE_INTERVAL_S`
|
||||
(défaut **3600 s / 1 h** : une MAJ n'est jamais urgente).
|
||||
|
||||
### 4.4 Intégrité + version
|
||||
|
||||
- **Intégrité** : SHA256 vérifié dans `download_update` (déjà présent) ; mismatch → rejet + staging propre.
|
||||
- **Version** : `AGENT_VERSION` envoyée à chaque check (`current_version`) ; le serveur choisit la cible par machine.
|
||||
- **Signature (à ajouter, §8)** : SHA256 seul protège de la corruption, pas de l'usurpation. Recommandation : signer le manifeste (le SHA256 vient d'un canal authentifié — l'endpoint Bearer — donc chaîne acceptable pour le POC ; signature détachée = durcissement futur).
|
||||
|
||||
### 4.5 Swap atomique + rollback (SPEC — réservé révision humaine, PAS codé par agent)
|
||||
|
||||
Le swap réel reste dans les stubs `apply_update` / `write_boot_ok_marker` et
|
||||
dans `Lea.bat`. **Un agent ne doit pas écrire de code qui écrase des binaires
|
||||
vivants ni relance un process.** Spec cible pour la revue humaine :
|
||||
|
||||
- **A-B / staging** : le ZIP est extrait dans `Lea_next\`. Au **prochain démarrage**, `Lea.bat` (hors-process) : backup `Lea\`→`Lea_prev\`, swap `Lea_next\`→`Lea\`, lance la nouvelle version.
|
||||
- **Watchdog rollback** : la nouvelle version doit écrire un marker `boot_ok_<version>` **après** ~60 s de heartbeat DGX sain + session OK. Si `Lea.bat` ne trouve pas le marker au démarrage suivant (crash au boot), il restaure `Lea_prev\` automatiquement. Cible « rollback latency » < 90 s (état de l'art).
|
||||
- **Cas edge** (documenté dans les stubs) : DGX down ≠ Léa N+1 buguée — le health-check doit distinguer les deux pour éviter un faux rollback.
|
||||
|
||||
## 5. Fichiers touchés (cette branche)
|
||||
|
||||
**Ajouts**
|
||||
- `agent_v0/server_v1/update_policy.py` — canary PUR (résolveur par machine + lecture env).
|
||||
- `tests/unit/test_update_policy_canary.py` — TDD canary (résolveur + env).
|
||||
|
||||
**Modifs**
|
||||
- `agent_v0/server_v1/api_stream.py` — `_latest_agent_version(machine_id)` canary-aware (rétrocompat legacy) + docstring endpoint.
|
||||
- `agent_v0/agent_v1/network/updater.py` — `_default_update_checker()` + `run_update_cycle()` (orchestrateur gated, sans swap).
|
||||
- `agent_v0/agent_v1/config.py` — `AUTO_UPDATE_INTERVAL_S`, `AUTO_UPDATE_STAGING_DIR`.
|
||||
- `agent_v0/agent_v1/main.py` — thread `_auto_update_loop` gated + import config.
|
||||
- `tests/unit/test_agent_v1_updater.py` — TDD `run_update_cycle` (gate off, up-to-date, staged, sha mismatch, checker raise).
|
||||
- `tests/integration/test_update_check_endpoint.py` — TDD canary HTTP (poste canary vs hors-canary).
|
||||
- `deploy/lea_package/config.txt` — flags client MAJ documentés (commentés, OFF).
|
||||
|
||||
**Intacts (réservés révision humaine)** : `updater.apply_update`, `updater.write_boot_ok_marker`, `Lea.bat`.
|
||||
|
||||
## 6. Matrice des flags (tout OFF par défaut)
|
||||
|
||||
| Flag | Côté | Défaut | Effet |
|
||||
|---|---|---|---|
|
||||
| `RPA_AUTO_UPDATE_SERVER_ENABLED` | serveur | OFF (503) | active l'endpoint de décision |
|
||||
| `RPA_AGENT_STABLE_VERSION` | serveur | `1.0.1` | version floor de toute la flotte |
|
||||
| `RPA_AGENT_CANARY_VERSION` | serveur | — | nouvelle version, postes canary seulement |
|
||||
| `RPA_AGENT_CANARY_MACHINES` | serveur | — | allow-list CSV canary |
|
||||
| `RPA_AGENT_LATEST_VERSION` (legacy) | serveur | — | si set, prime sur le canary (rétrocompat) |
|
||||
| `RPA_AUTO_UPDATE_ENABLED` | client | OFF | active la boucle de check + staging |
|
||||
| `RPA_AUTO_UPDATE_INTERVAL_S` | client | `3600` | intervalle de check |
|
||||
|
||||
## 7. Plan de déploiement CANARY (étapes + critères GO / ROLLBACK)
|
||||
|
||||
> Prérequis avant TOUTE étape : la mécanique de **swap réel** (§4.5) doit avoir
|
||||
> été implémentée et revue par un humain. Tant qu'elle est en stub, ce plan ne
|
||||
> fait que **stager** un ZIP (aucun poste ne change réellement de version) — ce
|
||||
> qui est déjà utile pour valider la chaîne check/download/intégrité à vide.
|
||||
|
||||
**Étape 0 — Serveur seul (aucun poste touché)**
|
||||
- Action : `RPA_AUTO_UPDATE_SERVER_ENABLED=true`, `RPA_AGENT_STABLE_VERSION=1.0.1`, PAS de canary encore.
|
||||
- GO si : `GET /agents/update/check` répond 200 pour un `machine_id` quelconque avec `update_available:false`. Aucun poste n'a la MAJ activée côté client.
|
||||
- ROLLBACK : repasser le flag serveur OFF.
|
||||
|
||||
**Étape 1 — Canary Émilie, staging seul**
|
||||
- Action serveur : `RPA_AGENT_CANARY_VERSION=<nouvelle>`, `RPA_AGENT_CANARY_MACHINES=lea-4zbgwxty`.
|
||||
- Action poste Émilie (config.txt) : `RPA_AUTO_UPDATE_ENABLED=true`.
|
||||
- GO si : dans les logs d'Émilie (remontés par le push-log DGX), `[UPDATE] MAJ <v> téléchargée en staging (SHA256=True)`, ZIP présent dans le staging, `applied:False`, Léa continue de tourner normalement (session/replay non perturbés). Vérifier qu'AUCUN autre poste ne reçoit `update_available:true`.
|
||||
- ROLLBACK : vider `RPA_AGENT_CANARY_MACHINES` (le check ne propose plus rien). Aucun impact : rien n'avait été appliqué.
|
||||
|
||||
**Étape 2 — Canary Émilie, swap réel (après implémentation humaine du §4.5)**
|
||||
- GO si : après redémarrage, Émilie tourne la nouvelle version (`AGENT_VERSION` remontée), marker `boot_ok` écrit, heartbeat DGX sain > 24 h, zéro régression fonctionnelle (enregistrement + replay OK).
|
||||
- ROLLBACK : automatique par watchdog `Lea.bat` si pas de `boot_ok` au boot ; manuel = restaurer `Lea_prev\` + vider la liste canary.
|
||||
|
||||
**Étape 3 — Élargissement progressif (rings)**
|
||||
- Ajouter 2-3 postes à `RPA_AGENT_CANARY_MACHINES`, attendre 48 h par palier.
|
||||
- GO/ROLLBACK : mêmes critères qu'étape 2, par palier.
|
||||
|
||||
**Étape 4 — Promotion générale**
|
||||
- `RPA_AGENT_STABLE_VERSION=<nouvelle>` + vider `RPA_AGENT_CANARY_MACHINES`.
|
||||
- Toute la flotte converge au rythme de son intervalle de check.
|
||||
- ROLLBACK flotte : remettre `RPA_AGENT_STABLE_VERSION` à l'ancienne (les postes ne redescendent pas seuls — le swap-down reste une opération supervisée ; les nouveaux checks ne proposeront plus la MAJ).
|
||||
|
||||
## 8. Améliorations futures (hors périmètre de ce draft)
|
||||
|
||||
1. **Swap réel + watchdog rollback** (§4.5) — la brique manquante n°1, révision humaine.
|
||||
2. **Inventaire de version vive** : rafraîchir `enrolled_agents.version` au heartbeat (le serveur saurait exactement quelle version tourne où — utile pour piloter le canary depuis le dashboard).
|
||||
3. **Signature détachée** du manifeste (durcissement au-delà du SHA256 sur canal Bearer).
|
||||
4. **Endpoint de download versionné** : aujourd'hui `/api/fleet/download/<machine_id>` (dashboard) sert l'installateur complet et **ignore `?type=&version=`** ; il faudra qu'il serve le vrai payload `code-only` incrémental attendu par le contrat d'URL.
|
||||
5. **Auto-report du résultat de swap** (succès/rollback) au serveur pour un tableau de bord canary.
|
||||
|
||||
## 9. Sources (état de l'art self-update desktop / canary 2025)
|
||||
|
||||
- [Rollback Strategies for Enterprise: 2025 Best Practices — sparkco.ai](https://sparkco.ai/blog/rollback-strategies-for-enterprise-2025-best-practices)
|
||||
- [Canary Deployment with Auto-Rollback for AI Agents — antigravitylab.net](https://antigravitylab.net/en/articles/agents/antigravity-ai-agent-canary-deployment-burn-rate-slo)
|
||||
- [awesome-agentic-patterns — canary rollout & automatic rollback](https://github.com/nibzard/awesome-agentic-patterns/blob/main/patterns/canary-rollout-and-automatic-rollback-for-agent-policy-changes.md)
|
||||
- [What is Canary Testing — aqua-cloud.io](https://aqua-cloud.io/canary-testing/)
|
||||
- [Rollback Automation Best Practices for CI/CD — hokstadconsulting.com](https://hokstadconsulting.com/blog/rollback-automation-best-practices-for-ci-cd)
|
||||
@@ -83,3 +83,43 @@ class TestUpdateCheckEndpointEnabled:
|
||||
"/api/v1/agents/update/check?current_version=1.0.1",
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestUpdateCheckCanary:
|
||||
"""Canary : seul le poste canary se voit proposer la nouvelle version.
|
||||
|
||||
On n'utilise PAS RPA_AGENT_LATEST_VERSION (var legacy globale) : on pilote
|
||||
la version cible via la politique canary (stable + canary + allow-list).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_canary(self, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_SERVER_ENABLED", "true")
|
||||
# Legacy OFF pour que la politique canary pilote la décision.
|
||||
monkeypatch.delenv("RPA_AGENT_LATEST_VERSION", raising=False)
|
||||
monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.1")
|
||||
monkeypatch.setenv("RPA_AGENT_CANARY_VERSION", "1.0.2")
|
||||
monkeypatch.setenv("RPA_AGENT_CANARY_MACHINES", "lea-4zbgwxty")
|
||||
|
||||
def test_poste_canary_recoit_la_nouvelle_version(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check"
|
||||
"?current_version=1.0.1&machine_id=lea-4zbgwxty",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["update_available"] is True
|
||||
assert body["latest_version"] == "1.0.2"
|
||||
|
||||
def test_poste_hors_canary_reste_a_jour_sur_stable(self, client):
|
||||
# Poste NON canary, déjà en 1.0.1 = stable → pas de MAJ (blast radius
|
||||
# borné : la 1.0.2 ne fuite pas hors de la liste canary).
|
||||
resp = client.get(
|
||||
"/api/v1/agents/update/check"
|
||||
"?current_version=1.0.1&machine_id=un-autre-poste",
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["update_available"] is False
|
||||
|
||||
@@ -223,3 +223,114 @@ class TestDangerousPartsAreStubs:
|
||||
def test_write_boot_ok_marker_est_un_stub(self, mod):
|
||||
result = mod.write_boot_ok_marker("1.0.2")
|
||||
assert result["written"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_update_cycle — orchestrateur GATED (check → décide → stage → stub apply)
|
||||
# AUCUN réseau réel, AUCUN swap réel : checker/downloader INJECTABLES, le swap
|
||||
# reste un stub no-op (réservé révision humaine).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunUpdateCycle:
|
||||
def _checker(self, response):
|
||||
"""Fabrique un checker injectable qui renvoie `response`."""
|
||||
def _c(local_version, machine_id):
|
||||
return response
|
||||
return _c
|
||||
|
||||
def test_gate_off_ne_fait_rien(self, mod, tmp_path, monkeypatch):
|
||||
# Flag OFF (défaut) : le cycle ne doit RIEN faire (pas d'appel réseau).
|
||||
monkeypatch.delenv("RPA_AUTO_UPDATE_ENABLED", raising=False)
|
||||
called = {"n": 0}
|
||||
|
||||
def _checker(local_version, machine_id):
|
||||
called["n"] += 1
|
||||
return {"update_available": True, "latest_version": "9.9.9",
|
||||
"url": "http://x", "sha256": None}
|
||||
|
||||
result = mod.run_update_cycle(
|
||||
local_version="1.0.1",
|
||||
machine_id="pc-1",
|
||||
staging_dir=tmp_path,
|
||||
checker=_checker,
|
||||
downloader=lambda u: b"x",
|
||||
)
|
||||
assert result["status"] == "disabled"
|
||||
assert called["n"] == 0 # aucun appel réseau quand OFF
|
||||
|
||||
def test_a_jour_ne_stage_rien(self, mod, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
|
||||
result = mod.run_update_cycle(
|
||||
local_version="1.0.1",
|
||||
machine_id="pc-1",
|
||||
staging_dir=tmp_path,
|
||||
checker=self._checker(
|
||||
{"update_available": False, "latest_version": "1.0.1"}
|
||||
),
|
||||
downloader=lambda u: b"should-not-be-called",
|
||||
)
|
||||
assert result["status"] == "up_to_date"
|
||||
assert list(tmp_path.glob("*.zip")) == []
|
||||
|
||||
def test_maj_dispo_telecharge_en_staging_mais_ne_swappe_pas(
|
||||
self, mod, tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
|
||||
payload = b"PK\x03\x04 fake"
|
||||
sha = hashlib.sha256(payload).hexdigest()
|
||||
|
||||
result = mod.run_update_cycle(
|
||||
local_version="1.0.1",
|
||||
machine_id="pc-1",
|
||||
staging_dir=tmp_path,
|
||||
checker=self._checker({
|
||||
"update_available": True,
|
||||
"latest_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://srv/dl?version=1.0.2",
|
||||
"sha256": sha,
|
||||
}),
|
||||
downloader=lambda u: payload,
|
||||
)
|
||||
# Téléchargé + vérifié + STAGÉ, mais PAS appliqué (swap = stub humain).
|
||||
assert result["status"] == "staged"
|
||||
assert result["target_version"] == "1.0.2"
|
||||
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
|
||||
|
||||
def test_sha256_mismatch_ne_stage_pas(self, mod, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
|
||||
result = mod.run_update_cycle(
|
||||
local_version="1.0.1",
|
||||
machine_id="pc-1",
|
||||
staging_dir=tmp_path,
|
||||
checker=self._checker({
|
||||
"update_available": True,
|
||||
"latest_version": "1.0.2",
|
||||
"update_type": "code-only",
|
||||
"url": "http://x",
|
||||
"sha256": "0" * 64,
|
||||
}),
|
||||
downloader=lambda u: b"corrupted",
|
||||
)
|
||||
assert result["status"] == "download_failed"
|
||||
assert list(tmp_path.glob("*.zip")) == []
|
||||
|
||||
def test_checker_qui_leve_pas_de_crash(self, mod, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AUTO_UPDATE_ENABLED", "true")
|
||||
|
||||
def _boom(local_version, machine_id):
|
||||
raise RuntimeError("serveur down / 503")
|
||||
|
||||
result = mod.run_update_cycle(
|
||||
local_version="1.0.1",
|
||||
machine_id="pc-1",
|
||||
staging_dir=tmp_path,
|
||||
checker=_boom,
|
||||
downloader=lambda u: b"x",
|
||||
)
|
||||
# Best-effort : jamais d'exception ne remonte (ne casse pas Léa).
|
||||
assert result["status"] == "check_failed"
|
||||
|
||||
162
tests/unit/test_update_policy_canary.py
Normal file
162
tests/unit/test_update_policy_canary.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""TDD — DETTE-022 v2 : CANARY server-side pour la MAJ silencieuse Léa.
|
||||
|
||||
Périmètre testé ICI = logique PURE de la POLITIQUE de déploiement canary,
|
||||
testable sans démarrer le serveur (DETTE-013 : on N'IMPORTE PAS `api_stream`
|
||||
— on charge `update_policy.py` par chemin, comme test_update_check_server).
|
||||
|
||||
Objectif SÉCURITÉ (10+ postes cliniques live) : une MAJ ne doit JAMAIS
|
||||
partir sur toute la flotte d'un coup. Le canary résout la version cible
|
||||
*par machine* :
|
||||
|
||||
- un poste dans la liste canary reçoit la version `canary` (Émilie d'abord) ;
|
||||
- tous les autres restent sur la version `stable` (floor) tant que le canary
|
||||
n'est pas promu.
|
||||
|
||||
`resolve_target_version(machine_id, ...)` est la brique PURE ; `decide_update`
|
||||
côté serveur l'appelle pour choisir la version cible avant de comparer.
|
||||
|
||||
Le NOYAU dangereux (swap fichiers / Lea.bat / restart) reste HORS périmètre.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_MOD_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "agent_v0" / "server_v1" / "update_policy.py"
|
||||
)
|
||||
|
||||
|
||||
def _load_module():
|
||||
spec = importlib.util.spec_from_file_location("rpa_update_policy", _MOD_PATH)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mod():
|
||||
return _load_module()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_canary_machines — liste d'allow-list (CSV / espaces tolérés)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParseCanaryMachines:
|
||||
def test_liste_csv(self, mod):
|
||||
assert mod.parse_canary_machines("lea-4zbgwxty") == {"lea-4zbgwxty"}
|
||||
assert mod.parse_canary_machines("a,b,c") == {"a", "b", "c"}
|
||||
|
||||
def test_espaces_et_vides_toleres(self, mod):
|
||||
assert mod.parse_canary_machines(" a , b , ") == {"a", "b"}
|
||||
assert mod.parse_canary_machines("") == set()
|
||||
assert mod.parse_canary_machines(None) == set()
|
||||
|
||||
def test_supporte_separateurs_espace_et_point_virgule(self, mod):
|
||||
# Tolérant : virgule, point-virgule, espace comme séparateurs.
|
||||
assert mod.parse_canary_machines("a; b c") == {"a", "b", "c"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_target_version — LE cœur canary (sécurité)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResolveTargetVersion:
|
||||
def test_machine_canary_recoit_version_canary(self, mod):
|
||||
# Émilie (canary) reçoit la nouvelle version en premier.
|
||||
target = mod.resolve_target_version(
|
||||
machine_id="lea-4zbgwxty",
|
||||
stable_version="1.0.1",
|
||||
canary_version="1.0.2",
|
||||
canary_machines={"lea-4zbgwxty"},
|
||||
)
|
||||
assert target == "1.0.2"
|
||||
|
||||
def test_machine_hors_canary_reste_sur_stable(self, mod):
|
||||
# Tous les autres postes restent sur la version stable (floor).
|
||||
target = mod.resolve_target_version(
|
||||
machine_id="lea-autre-poste",
|
||||
stable_version="1.0.1",
|
||||
canary_version="1.0.2",
|
||||
canary_machines={"lea-4zbgwxty"},
|
||||
)
|
||||
assert target == "1.0.1"
|
||||
|
||||
def test_pas_de_canary_configure_tout_le_monde_stable(self, mod):
|
||||
# Aucun canary défini → personne ne monte (défaut ultra-prudent).
|
||||
target = mod.resolve_target_version(
|
||||
machine_id="lea-4zbgwxty",
|
||||
stable_version="1.0.1",
|
||||
canary_version="1.0.2",
|
||||
canary_machines=set(),
|
||||
)
|
||||
assert target == "1.0.1"
|
||||
|
||||
def test_canary_version_absente_retombe_sur_stable(self, mod):
|
||||
# Si canary_version n'est pas fournie, même un poste canary reste stable.
|
||||
target = mod.resolve_target_version(
|
||||
machine_id="lea-4zbgwxty",
|
||||
stable_version="1.0.1",
|
||||
canary_version=None,
|
||||
canary_machines={"lea-4zbgwxty"},
|
||||
)
|
||||
assert target == "1.0.1"
|
||||
|
||||
def test_machine_id_none_reste_stable(self, mod):
|
||||
# machine_id inconnu / non fourni → jamais canary (prudence).
|
||||
target = mod.resolve_target_version(
|
||||
machine_id=None,
|
||||
stable_version="1.0.1",
|
||||
canary_version="1.0.2",
|
||||
canary_machines={"lea-4zbgwxty"},
|
||||
)
|
||||
assert target == "1.0.1"
|
||||
|
||||
def test_canary_ne_downgrade_jamais_en_dessous_de_stable(self, mod):
|
||||
# GARDE-FOU : si le canary_version est PLUS ANCIEN que stable (erreur
|
||||
# de config), on NE descend PAS le poste canary — on sert stable.
|
||||
target = mod.resolve_target_version(
|
||||
machine_id="lea-4zbgwxty",
|
||||
stable_version="1.0.5",
|
||||
canary_version="1.0.2", # plus ancien → config douteuse
|
||||
canary_machines={"lea-4zbgwxty"},
|
||||
)
|
||||
assert target == "1.0.5"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lecture depuis l'environnement (pilotage sans rebuild) — défauts prudents
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEnvPolicy:
|
||||
def test_defauts_prudents_aucune_maj(self, mod, monkeypatch):
|
||||
# Aucune var positionnée → stable par défaut, pas de canary.
|
||||
for var in (
|
||||
"RPA_AGENT_STABLE_VERSION",
|
||||
"RPA_AGENT_CANARY_VERSION",
|
||||
"RPA_AGENT_CANARY_MACHINES",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
assert mod.stable_version_from_env() == "1.0.1"
|
||||
assert mod.canary_version_from_env() is None
|
||||
assert mod.canary_machines_from_env() == set()
|
||||
# Un poste quelconque reste sur stable.
|
||||
assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.1"
|
||||
|
||||
def test_canary_actif_via_env_seul_le_poste_canary_monte(self, mod, monkeypatch):
|
||||
monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.1")
|
||||
monkeypatch.setenv("RPA_AGENT_CANARY_VERSION", "1.0.2")
|
||||
monkeypatch.setenv("RPA_AGENT_CANARY_MACHINES", "lea-4zbgwxty")
|
||||
assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.2"
|
||||
assert mod.resolve_target_version_from_env("autre-poste") == "1.0.1"
|
||||
|
||||
def test_promotion_toute_la_flotte_suit(self, mod, monkeypatch):
|
||||
# Promotion : on met stable = version canary, on vide la liste canary.
|
||||
monkeypatch.setenv("RPA_AGENT_STABLE_VERSION", "1.0.2")
|
||||
monkeypatch.delenv("RPA_AGENT_CANARY_VERSION", raising=False)
|
||||
monkeypatch.delenv("RPA_AGENT_CANARY_MACHINES", raising=False)
|
||||
assert mod.resolve_target_version_from_env("autre-poste") == "1.0.2"
|
||||
assert mod.resolve_target_version_from_env("lea-4zbgwxty") == "1.0.2"
|
||||
Reference in New Issue
Block a user