Files
rpa_vision_v3/tests/unit/test_update_policy_canary.py

163 lines
6.5 KiB
Python

"""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"