From 61664c9a3629f11a9ac32e4b26953330438b64ca Mon Sep 17 00:00:00 2001 From: Dom Date: Wed, 1 Jul 2026 12:36:48 +0200 Subject: [PATCH] feat(update): scaffold MAJ silencieuse + canary par machine (DETTE-022, gated OFF, swap encore stub) Co-Authored-By: Claude Opus 4.8 (1M context) --- agent_v0/agent_v1/config.py | 10 + agent_v0/agent_v1/main.py | 71 +++++++ agent_v0/agent_v1/network/updater.py | 119 +++++++++++ agent_v0/server_v1/api_stream.py | 32 ++- agent_v0/server_v1/update_policy.py | 139 +++++++++++++ deploy/lea_package/config.txt | 9 + ...ESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md | 193 ++++++++++++++++++ .../integration/test_update_check_endpoint.py | 40 ++++ tests/unit/test_agent_v1_updater.py | 111 ++++++++++ tests/unit/test_update_policy_canary.py | 162 +++++++++++++++ 10 files changed, 879 insertions(+), 7 deletions(-) create mode 100644 agent_v0/server_v1/update_policy.py create mode 100644 docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md create mode 100644 tests/unit/test_update_policy_canary.py diff --git a/agent_v0/agent_v1/config.py b/agent_v0/agent_v1/config.py index 4d6070f5f..0c50b01e5 100644 --- a/agent_v0/agent_v1/config.py +++ b/agent_v0/agent_v1/config.py @@ -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 diff --git a/agent_v0/agent_v1/main.py b/agent_v0/agent_v1/main.py index 6a9a5c415..848e8def3 100644 --- a/agent_v0/agent_v1/main.py +++ b/agent_v0/agent_v1/main.py @@ -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 diff --git a/agent_v0/agent_v1/network/updater.py b/agent_v0/agent_v1/network/updater.py index eac9a78ac..80af1c63d 100644 --- a/agent_v0/agent_v1/network/updater.py +++ b/agent_v0/agent_v1/network/updater.py @@ -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. diff --git a/agent_v0/server_v1/api_stream.py b/agent_v0/server_v1/api_stream.py index afaeee5a8..0dd590f06 100644 --- a/agent_v0/server_v1/api_stream.py +++ b/agent_v0/server_v1/api_stream.py @@ -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, ) diff --git a/agent_v0/server_v1/update_policy.py b/agent_v0/server_v1/update_policy.py new file mode 100644 index 000000000..06b446f7f --- /dev/null +++ b/agent_v0/server_v1/update_policy.py @@ -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(), + ) diff --git a/deploy/lea_package/config.txt b/deploy/lea_package/config.txt index 8073f07aa..ab1b73481 100644 --- a/deploy/lea_package/config.txt +++ b/deploy/lea_package/config.txt @@ -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 diff --git a/docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md b/docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md new file mode 100644 index 000000000..b5ff3b7a8 --- /dev/null +++ b/docs/DESIGN_MAJ_SILENCIEUSE_CANARY_2026-07-01.md @@ -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=` ++ 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_` **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=`, `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 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=` + 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/` (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) diff --git a/tests/integration/test_update_check_endpoint.py b/tests/integration/test_update_check_endpoint.py index 9fe0279b3..ffd8c7f66 100644 --- a/tests/integration/test_update_check_endpoint.py +++ b/tests/integration/test_update_check_endpoint.py @@ -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 diff --git a/tests/unit/test_agent_v1_updater.py b/tests/unit/test_agent_v1_updater.py index 5de767636..bd3debf0d 100644 --- a/tests/unit/test_agent_v1_updater.py +++ b/tests/unit/test_agent_v1_updater.py @@ -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" diff --git a/tests/unit/test_update_policy_canary.py b/tests/unit/test_update_policy_canary.py new file mode 100644 index 000000000..bd5b998e2 --- /dev/null +++ b/tests/unit/test_update_policy_canary.py @@ -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"