Files
rpa_vision_v3/agent_v0/server_v1/agent_registry.py
Dom b808e48b1f 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>
2026-04-15 09:07:19 +02:00

297 lines
11 KiB
Python

# 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')})"
)