# agent_v0/server_v1/agent_registry.py """ Registre des agents Lea enrolles sur le parc. Alimente par les endpoints /api/v1/agents/enroll et /api/v1/agents/uninstall que l'installeur Inno Setup (`deploy/installer/Lea.iss`) appelle a l'installation et a la desinstallation sur chaque poste collaborateur. Stockage : SQLite simple, cohabite avec rpa_data.db dans data/databases/. Aucune dependance GPU/LLM — ce module doit rester leger (juste sqlite3 + stdlib) pour pouvoir etre importe par le serveur HTTP. Schema de la table `enrolled_agents` : id INTEGER PK AUTOINCREMENT machine_id TEXT UNIQUE NOT NULL — identifiant genere par l'installeur user_name TEXT — nom affichage collaborateur user_email TEXT user_id TEXT — identifiant metier (ex: AIVA-001) hostname TEXT os_info TEXT version TEXT — version du client Lea status TEXT DEFAULT 'active' — 'active' | 'uninstalled' enrolled_at TEXT NOT NULL — ISO 8601 UTC last_seen_at TEXT — ISO 8601 UTC (heartbeat / stream) uninstalled_at TEXT uninstall_reason TEXT """ from __future__ import annotations import logging import sqlite3 import threading from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) # Verrou global : SQLite tolere plusieurs threads mais on serialise # les ecritures pour eviter les races sur _init_db + upserts concurrents. _DB_LOCK = threading.Lock() def _utc_now_iso() -> str: """Horodatage ISO 8601 UTC (compatible toutes les autres tables).""" return datetime.now(timezone.utc).isoformat() class AgentRegistry: """Gestion CRUD des agents enrolles (SQLite).""" def __init__(self, db_path: str | Path = "data/databases/rpa_data.db"): self.db_path = Path(db_path) self.db_path.parent.mkdir(parents=True, exist_ok=True) self._init_db() # ------------------------------------------------------------------ # Infra SQLite # ------------------------------------------------------------------ def _connect(self) -> sqlite3.Connection: # check_same_thread=False : on protege nous-memes via _DB_LOCK, # indispensable car FastAPI appelle les endpoints sur threads # differents (thread pool). conn = sqlite3.connect(str(self.db_path), check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") return conn def _init_db(self) -> None: """Cree la table et ses index si absents (idempotent).""" with _DB_LOCK, self._connect() as conn: conn.execute( """ CREATE TABLE IF NOT EXISTS enrolled_agents ( id INTEGER PRIMARY KEY AUTOINCREMENT, machine_id TEXT NOT NULL UNIQUE, user_name TEXT, user_email TEXT, user_id TEXT, hostname TEXT, os_info TEXT, version TEXT, status TEXT NOT NULL DEFAULT 'active', enrolled_at TEXT NOT NULL, last_seen_at TEXT, uninstalled_at TEXT, uninstall_reason TEXT ) """ ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_enrolled_agents_status " "ON enrolled_agents(status)" ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_enrolled_agents_machine " "ON enrolled_agents(machine_id)" ) # ------------------------------------------------------------------ # Lecture # ------------------------------------------------------------------ def get(self, machine_id: str) -> Optional[Dict[str, Any]]: """Recupere un agent par machine_id (ou None).""" with _DB_LOCK, self._connect() as conn: row = conn.execute( "SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,), ).fetchone() return dict(row) if row else None def list_by_status(self, status: str) -> List[Dict[str, Any]]: """Liste les agents par statut ('active' | 'uninstalled').""" with _DB_LOCK, self._connect() as conn: rows = conn.execute( "SELECT * FROM enrolled_agents WHERE status = ? " "ORDER BY enrolled_at DESC", (status,), ).fetchall() return [dict(r) for r in rows] def count_by_status(self, status: str) -> int: with _DB_LOCK, self._connect() as conn: row = conn.execute( "SELECT COUNT(*) AS n FROM enrolled_agents WHERE status = ?", (status,), ).fetchone() return int(row["n"]) if row else 0 # ------------------------------------------------------------------ # Ecriture # ------------------------------------------------------------------ def enroll( self, *, machine_id: str, user_name: str | None = None, user_email: str | None = None, user_id: str | None = None, hostname: str | None = None, os_info: str | None = None, version: str | None = None, allow_reactivate: bool = True, ) -> Dict[str, Any]: """Enregistre un nouvel agent ou reactive un agent desinstalle. Returns: dict avec clefs {"created": bool, "reactivated": bool, "agent": row} Raises: ValueError: si machine_id est vide. AgentAlreadyEnrolledError: si deja actif (status=active). """ if not machine_id or not machine_id.strip(): raise ValueError("machine_id est obligatoire") machine_id = machine_id.strip() now = _utc_now_iso() with _DB_LOCK, self._connect() as conn: existing = conn.execute( "SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,), ).fetchone() if existing is not None: if existing["status"] == "active": # Deja enrolle et actif -> conflit explicit raise AgentAlreadyEnrolledError(dict(existing)) # Agent desinstalle : reactivation si autorise (defaut) if not allow_reactivate: raise AgentAlreadyEnrolledError(dict(existing)) conn.execute( """ UPDATE enrolled_agents SET user_name = COALESCE(?, user_name), user_email = COALESCE(?, user_email), user_id = COALESCE(?, user_id), hostname = COALESCE(?, hostname), os_info = COALESCE(?, os_info), version = COALESCE(?, version), status = 'active', enrolled_at = ?, last_seen_at = ?, uninstalled_at = NULL, uninstall_reason = NULL WHERE machine_id = ? """, ( user_name, user_email, user_id, hostname, os_info, version, now, now, machine_id, ), ) conn.commit() row = conn.execute( "SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,), ).fetchone() return {"created": False, "reactivated": True, "agent": dict(row)} # Nouvelle inscription conn.execute( """ INSERT INTO enrolled_agents ( machine_id, user_name, user_email, user_id, hostname, os_info, version, status, enrolled_at, last_seen_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) """, ( machine_id, user_name, user_email, user_id, hostname, os_info, version, now, now, ), ) conn.commit() row = conn.execute( "SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,), ).fetchone() return {"created": True, "reactivated": False, "agent": dict(row)} def uninstall( self, *, machine_id: str, reason: str | None = None, ) -> Optional[Dict[str, Any]]: """Marque un agent comme desinstalle (soft delete). Returns: Le row mis a jour, ou None si l'agent n'existe pas. """ if not machine_id or not machine_id.strip(): raise ValueError("machine_id est obligatoire") machine_id = machine_id.strip() now = _utc_now_iso() with _DB_LOCK, self._connect() as conn: existing = conn.execute( "SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,), ).fetchone() if existing is None: return None conn.execute( """ UPDATE enrolled_agents SET status = 'uninstalled', uninstalled_at = ?, uninstall_reason = ? WHERE machine_id = ? """, (now, reason, machine_id), ) conn.commit() row = conn.execute( "SELECT * FROM enrolled_agents WHERE machine_id = ?", (machine_id,), ).fetchone() return dict(row) def touch_last_seen(self, machine_id: str) -> None: """Met a jour last_seen_at (appel depuis le stream / heartbeat). Silencieux si l'agent est inconnu (evite les erreurs sur vieux clients). """ if not machine_id: return now = _utc_now_iso() with _DB_LOCK, self._connect() as conn: conn.execute( "UPDATE enrolled_agents SET last_seen_at = ? WHERE machine_id = ?", (now, machine_id), ) conn.commit() class AgentAlreadyEnrolledError(Exception): """Levee si on tente d'enrouler une machine deja active.""" def __init__(self, existing_row: Dict[str, Any]): self.existing = existing_row super().__init__( f"machine_id={existing_row.get('machine_id')} deja enrole " f"(status={existing_row.get('status')})" )