140 lines
5.6 KiB
Python
140 lines
5.6 KiB
Python
# 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(),
|
|
)
|