# 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(), )