feat(fleet): endpoints /agents/enroll|uninstall|fleet + SQLite

Endpoints REST pour le fleet management (utilisés par installeur Inno Setup) :
  POST /api/v1/agents/enroll    -> 201 {status, machine_id, api_token, agent}
  POST /api/v1/agents/uninstall -> 200 {status, machine_id, agent}
  GET  /api/v1/agents/fleet     -> 200 {active, uninstalled, totals}

Tous protégés par Bearer token (conforme _PUBLIC_PATHS existant).

Nouveau module agent_v0/server_v1/agent_registry.py :
  - Classe AgentRegistry (sqlite3 stdlib, WAL, thread-safe via Lock)
  - CRUD + soft-delete (uninstall = status="uninstalled", historique préservé)
  - Table enrolled_agents créée via IF NOT EXISTS (pas de migration nécessaire)
  - Ré-enrollment après uninstall = réactivation auto (allow_reactivate=True)
  - Chemin DB configurable via RPA_AGENTS_DB_PATH (défaut data/databases/rpa_data.db)

Fix fixture test_stream_processor : autouse RPA_API_TOKEN dans
TestAPIEndpoints pour éviter SystemExit P0-C au module load.

13 tests intégration (enroll/uninstall/fleet + auth + edge cases).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-04-15 09:07:19 +02:00
parent 78ee962918
commit b808e48b1f
4 changed files with 825 additions and 0 deletions

View File

@@ -30,6 +30,7 @@ from .replay_failure_logger import log_replay_failure
from .replay_verifier import ReplayVerifier, VerificationResult
from .replay_learner import ReplayLearner
from .audit_trail import AuditTrail, AuditEntry
from .agent_registry import AgentRegistry, AgentAlreadyEnrolledError
from .stream_processor import StreamProcessor, build_replay_from_raw_events, enrich_click_from_screenshot
from .worker_stream import StreamWorker
from .execution_plan_runner import (
@@ -340,6 +341,14 @@ REPLAY_LOCK_FILE = _DATA_DIR / "_replay_active.lock"
processor = StreamProcessor(data_dir=str(LIVE_SESSIONS_DIR))
worker = StreamWorker(live_dir=str(LIVE_SESSIONS_DIR), processor=processor)
# Registre des postes Lea enroles (table enrolled_agents dans rpa_data.db)
# Emplacement configurable via RPA_AGENTS_DB_PATH pour les tests.
_AGENTS_DB_PATH = os.environ.get(
"RPA_AGENTS_DB_PATH",
str(ROOT_DIR / "data" / "databases" / "rpa_data.db"),
)
agent_registry = AgentRegistry(db_path=_AGENTS_DB_PATH)
# =========================================================================
# Flush garanti à l'arrêt — signal handler + atexit (ceinture et bretelles)
@@ -563,6 +572,28 @@ class ErrorCallbackConfig(BaseModel):
callback_url: str # URL à appeler en cas d'erreur non-récupérable
# -------------------------------------------------------------------------
# Agent Fleet — enrollment / desinstallation
# Consommes par l'installeur Lea.iss (voir deploy/installer/)
# -------------------------------------------------------------------------
class AgentEnrollRequest(BaseModel):
"""Enregistrement d'un nouveau poste lors de l'installation Lea."""
machine_id: str
user_name: Optional[str] = None
user_email: Optional[str] = None
user_id: Optional[str] = None
hostname: Optional[str] = None
os_info: Optional[str] = None
version: Optional[str] = None
class AgentUninstallRequest(BaseModel):
"""Notification de desinstallation d'un poste."""
machine_id: str
# reason = user_uninstall | admin_revoke | machine_retired (libre)
reason: Optional[str] = None
# Thread de nettoyage périodique des replays terminés et sessions expirées
_cleanup_thread: Optional[threading.Thread] = None
_cleanup_running = False
@@ -4694,6 +4725,149 @@ async def list_chat_sessions():
}
# =========================================================================
# Fleet management — enrollment des postes collaborateurs
# Consommes par deploy/installer/Lea.iss et deploy/installer/uninstall_lea.ps1
# =========================================================================
def _agent_row_public(row: Dict[str, Any]) -> Dict[str, Any]:
"""Projette un row de la table enrolled_agents pour l'API publique.
On ne renvoie PAS l'id SQL interne : machine_id est l'identifiant public.
"""
return {
"machine_id": row.get("machine_id"),
"user_name": row.get("user_name"),
"user_email": row.get("user_email"),
"user_id": row.get("user_id"),
"hostname": row.get("hostname"),
"os_info": row.get("os_info"),
"version": row.get("version"),
"status": row.get("status"),
"enrolled_at": row.get("enrolled_at"),
"last_seen_at": row.get("last_seen_at"),
"uninstalled_at": row.get("uninstalled_at"),
"uninstall_reason": row.get("uninstall_reason"),
}
@app.post("/api/v1/agents/enroll", status_code=201)
async def agents_enroll(request: AgentEnrollRequest):
"""Enregistre un nouveau poste collaborateur (appele par l'installeur).
Comportement :
- machine_id unique et obligatoire.
- Si deja enrole et actif -> 409 Conflict (avec infos de l'enrollement existant).
- Si deja enrole mais desinstalle -> reactive automatiquement (return 201 + reactivated=True).
- Token Bearer global obligatoire (un seul token partage entre tous les postes).
Une phase 2 pourra emettre un token par poste si besoin.
"""
machine_id = (request.machine_id or "").strip()
if not machine_id:
raise HTTPException(status_code=400, detail="machine_id est obligatoire")
try:
result = agent_registry.enroll(
machine_id=machine_id,
user_name=request.user_name,
user_email=request.user_email,
user_id=request.user_id,
hostname=request.hostname,
os_info=request.os_info,
version=request.version,
)
except AgentAlreadyEnrolledError as exc:
existing = _agent_row_public(exc.existing)
logger.warning(
f"[FLEET] Tentative de reenrollement machine_id={machine_id} "
f"(deja actif depuis {existing.get('enrolled_at')})"
)
raise HTTPException(
status_code=409,
detail={
"error": "already_enrolled",
"message": "machine_id deja enrole et actif",
"existing": existing,
},
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
agent = _agent_row_public(result["agent"])
event_kind = "reactivated" if result["reactivated"] else "created"
logger.info(
f"[FLEET] Agent enrole ({event_kind}) : machine_id={machine_id} "
f"user={request.user_name!r} hostname={request.hostname!r} "
f"version={request.version!r}"
)
return {
"status": "enrolled",
"created": result["created"],
"reactivated": result["reactivated"],
"machine_id": machine_id,
# Phase 1 : on renvoie le token global pour que le client puisse
# verifier qu'il est bien aligne avec le serveur. Phase 2 pourra
# emettre un token par poste (issued_token != API_TOKEN global).
"api_token": API_TOKEN,
"agent": agent,
}
@app.post("/api/v1/agents/uninstall")
async def agents_uninstall(request: AgentUninstallRequest):
"""Marque un poste comme desinstalle (soft delete, garde l'historique).
Appele par deploy/installer/uninstall_lea.ps1 en best-effort. Si le
machine_id est inconnu -> 404 (le client l'ignore silencieusement).
"""
machine_id = (request.machine_id or "").strip()
if not machine_id:
raise HTTPException(status_code=400, detail="machine_id est obligatoire")
reason = (request.reason or "").strip() or None
try:
row = agent_registry.uninstall(machine_id=machine_id, reason=reason)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if row is None:
logger.warning(
f"[FLEET] Desinstallation d'un machine_id inconnu : {machine_id}"
)
raise HTTPException(
status_code=404,
detail=f"machine_id={machine_id} introuvable dans le registre",
)
logger.info(
f"[FLEET] Agent desinstalle : machine_id={machine_id} reason={reason!r}"
)
return {
"status": "uninstalled",
"machine_id": machine_id,
"agent": _agent_row_public(row),
}
@app.get("/api/v1/agents/fleet")
async def agents_fleet():
"""Liste les agents enroles, separes par statut (active / uninstalled).
Futur dashboard fleet : synthese des postes deployes + ceux disparus.
"""
active_rows = agent_registry.list_by_status("active")
uninstalled_rows = agent_registry.list_by_status("uninstalled")
return {
"active": [_agent_row_public(r) for r in active_rows],
"uninstalled": [_agent_row_public(r) for r in uninstalled_rows],
"total_active": len(active_rows),
"total_uninstalled": len(uninstalled_rows),
}
if __name__ == "__main__":
import uvicorn