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:
Dom
2026-07-01 12:36:48 +02:00
parent 2a1b1ed80e
commit 61664c9a36
10 changed files with 879 additions and 7 deletions

View File

@@ -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=<canary>`
+ 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_<version>` **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=<nouvelle>`, `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 <v> 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=<nouvelle>` + 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/<machine_id>` (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)