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:
@@ -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