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:
296
agent_v0/server_v1/agent_registry.py
Normal file
296
agent_v0/server_v1/agent_registry.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# 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')})"
|
||||||
|
)
|
||||||
@@ -30,6 +30,7 @@ from .replay_failure_logger import log_replay_failure
|
|||||||
from .replay_verifier import ReplayVerifier, VerificationResult
|
from .replay_verifier import ReplayVerifier, VerificationResult
|
||||||
from .replay_learner import ReplayLearner
|
from .replay_learner import ReplayLearner
|
||||||
from .audit_trail import AuditTrail, AuditEntry
|
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 .stream_processor import StreamProcessor, build_replay_from_raw_events, enrich_click_from_screenshot
|
||||||
from .worker_stream import StreamWorker
|
from .worker_stream import StreamWorker
|
||||||
from .execution_plan_runner import (
|
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))
|
processor = StreamProcessor(data_dir=str(LIVE_SESSIONS_DIR))
|
||||||
worker = StreamWorker(live_dir=str(LIVE_SESSIONS_DIR), processor=processor)
|
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)
|
# 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
|
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
|
# Thread de nettoyage périodique des replays terminés et sessions expirées
|
||||||
_cleanup_thread: Optional[threading.Thread] = None
|
_cleanup_thread: Optional[threading.Thread] = None
|
||||||
_cleanup_running = False
|
_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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
|
|||||||
333
tests/integration/test_agents_enroll_api.py
Normal file
333
tests/integration/test_agents_enroll_api.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
"""
|
||||||
|
Tests d'integration pour les endpoints /api/v1/agents/* (fleet management).
|
||||||
|
|
||||||
|
Couvre :
|
||||||
|
- POST /api/v1/agents/enroll (201, 409 duplicate, 401 sans token,
|
||||||
|
reenrollement apres uninstall)
|
||||||
|
- POST /api/v1/agents/uninstall (200, 404 inconnu)
|
||||||
|
- GET /api/v1/agents/fleet (listing actif / desinstalle)
|
||||||
|
|
||||||
|
Le module `agent_v0.server_v1.api_stream` applique un fail-closed a
|
||||||
|
l'import si RPA_API_TOKEN est absent : la fixture `_ensure_api_token`
|
||||||
|
garantit que l'env est defini AVANT tout import.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Racine du projet pour les imports locaux (meme pattern que les autres
|
||||||
|
# tests d'integration)
|
||||||
|
_ROOT = str(Path(__file__).resolve().parents[2])
|
||||||
|
if _ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
_TEST_API_TOKEN = "test_token_fleet_endpoints_0123456789abcdef"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def agents_client(monkeypatch, tmp_path):
|
||||||
|
"""Client FastAPI de test avec un AgentRegistry isole sur disque.
|
||||||
|
|
||||||
|
Remplace le `agent_registry` global par une instance pointant sur une
|
||||||
|
DB temporaire, pour ne pas polluer la vraie rpa_data.db du workspace.
|
||||||
|
"""
|
||||||
|
# Garantir que le module peut s'importer (RPA_API_TOKEN sinon sys.exit 1)
|
||||||
|
monkeypatch.setenv("RPA_API_TOKEN", _TEST_API_TOKEN)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"RPA_AGENTS_DB_PATH", str(tmp_path / "test_agents.db")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Import tardif apres config de l'env
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from agent_v0.server_v1 import api_stream
|
||||||
|
from agent_v0.server_v1.agent_registry import AgentRegistry
|
||||||
|
|
||||||
|
# Aligner le token attendu par le middleware Bearer avec notre token de test
|
||||||
|
monkeypatch.setattr(api_stream, "API_TOKEN", _TEST_API_TOKEN)
|
||||||
|
|
||||||
|
# Substituer le registre global par une instance dediee au test
|
||||||
|
original_registry = api_stream.agent_registry
|
||||||
|
test_registry = AgentRegistry(db_path=str(tmp_path / "test_agents.db"))
|
||||||
|
monkeypatch.setattr(api_stream, "agent_registry", test_registry)
|
||||||
|
|
||||||
|
client = TestClient(api_stream.app, raise_server_exceptions=False)
|
||||||
|
yield client, _TEST_API_TOKEN, test_registry
|
||||||
|
|
||||||
|
# Restauration
|
||||||
|
monkeypatch.setattr(api_stream, "agent_registry", original_registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/v1/agents/enroll
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_new_agent_returns_201(agents_client):
|
||||||
|
client, token, _ = agents_client
|
||||||
|
payload = {
|
||||||
|
"machine_id": "aivanov-jdoe-a3f2b718",
|
||||||
|
"user_name": "Jean Doe",
|
||||||
|
"user_email": "jdoe@aivanov.fr",
|
||||||
|
"user_id": "AIVA-001",
|
||||||
|
"hostname": "DESKTOP-ABC123",
|
||||||
|
"os_info": "Windows 11",
|
||||||
|
"version": "1.0.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/agents/enroll", json=payload, headers=_auth_headers(token)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "enrolled"
|
||||||
|
assert data["created"] is True
|
||||||
|
assert data["reactivated"] is False
|
||||||
|
assert data["machine_id"] == "aivanov-jdoe-a3f2b718"
|
||||||
|
# Phase 1 : token global renvoye pour confirmation
|
||||||
|
assert data["api_token"] == token
|
||||||
|
agent = data["agent"]
|
||||||
|
assert agent["user_name"] == "Jean Doe"
|
||||||
|
assert agent["hostname"] == "DESKTOP-ABC123"
|
||||||
|
assert agent["status"] == "active"
|
||||||
|
assert agent["enrolled_at"]
|
||||||
|
assert agent["uninstalled_at"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_duplicate_returns_409(agents_client):
|
||||||
|
client, token, _ = agents_client
|
||||||
|
payload = {
|
||||||
|
"machine_id": "dup-machine-001",
|
||||||
|
"user_name": "Alice",
|
||||||
|
"hostname": "PC-ALICE",
|
||||||
|
"version": "1.0.0",
|
||||||
|
}
|
||||||
|
first = client.post(
|
||||||
|
"/api/v1/agents/enroll", json=payload, headers=_auth_headers(token)
|
||||||
|
)
|
||||||
|
assert first.status_code == 201
|
||||||
|
|
||||||
|
# Reenrollement sur machine encore active -> 409
|
||||||
|
second = client.post(
|
||||||
|
"/api/v1/agents/enroll", json=payload, headers=_auth_headers(token)
|
||||||
|
)
|
||||||
|
assert second.status_code == 409, second.text
|
||||||
|
body = second.json()
|
||||||
|
# FastAPI enveloppe notre detail dans "detail"
|
||||||
|
detail = body["detail"]
|
||||||
|
assert detail["error"] == "already_enrolled"
|
||||||
|
assert detail["existing"]["machine_id"] == "dup-machine-001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_without_token_returns_401(agents_client):
|
||||||
|
client, _, _ = agents_client
|
||||||
|
payload = {"machine_id": "no-auth-001"}
|
||||||
|
resp = client.post("/api/v1/agents/enroll", json=payload)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_with_wrong_token_returns_401(agents_client):
|
||||||
|
client, _, _ = agents_client
|
||||||
|
payload = {"machine_id": "bad-token-001"}
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/agents/enroll",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": "Bearer WRONG_TOKEN"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_missing_machine_id_returns_422(agents_client):
|
||||||
|
"""Pydantic renvoie 422 si machine_id est absent (validation automatique)."""
|
||||||
|
client, token, _ = agents_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/agents/enroll", json={}, headers=_auth_headers(token)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_enroll_blank_machine_id_returns_400(agents_client):
|
||||||
|
"""Un machine_id vide (whitespace) est rejete avec un 400 explicite."""
|
||||||
|
client, token, _ = agents_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/agents/enroll",
|
||||||
|
json={"machine_id": " "},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/v1/agents/uninstall
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninstall_existing_returns_200_and_soft_deletes(agents_client):
|
||||||
|
client, token, registry = agents_client
|
||||||
|
|
||||||
|
# Preparer un agent actif
|
||||||
|
client.post(
|
||||||
|
"/api/v1/agents/enroll",
|
||||||
|
json={
|
||||||
|
"machine_id": "uninst-001",
|
||||||
|
"user_name": "Bob",
|
||||||
|
"hostname": "PC-BOB",
|
||||||
|
},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/agents/uninstall",
|
||||||
|
json={"machine_id": "uninst-001", "reason": "user_uninstall"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["status"] == "uninstalled"
|
||||||
|
assert data["machine_id"] == "uninst-001"
|
||||||
|
assert data["agent"]["status"] == "uninstalled"
|
||||||
|
assert data["agent"]["uninstall_reason"] == "user_uninstall"
|
||||||
|
assert data["agent"]["uninstalled_at"]
|
||||||
|
|
||||||
|
# Verifier en base : pas de suppression physique (soft delete)
|
||||||
|
row = registry.get("uninst-001")
|
||||||
|
assert row is not None
|
||||||
|
assert row["status"] == "uninstalled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninstall_unknown_returns_404(agents_client):
|
||||||
|
client, token, _ = agents_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/agents/uninstall",
|
||||||
|
json={"machine_id": "never-seen-001", "reason": "admin_revoke"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_uninstall_without_token_returns_401(agents_client):
|
||||||
|
client, _, _ = agents_client
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/agents/uninstall",
|
||||||
|
json={"machine_id": "anything"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reenrollement apres uninstall = reactivation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_reenroll_after_uninstall_reactivates(agents_client):
|
||||||
|
client, token, _ = agents_client
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/v1/agents/enroll",
|
||||||
|
json={
|
||||||
|
"machine_id": "reenroll-001",
|
||||||
|
"user_name": "Carol",
|
||||||
|
"hostname": "PC-CAROL",
|
||||||
|
"version": "1.0.0",
|
||||||
|
},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/api/v1/agents/uninstall",
|
||||||
|
json={"machine_id": "reenroll-001", "reason": "user_uninstall"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nouvelle installation -> reactivation OK (meme machine_id, maj des champs)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/agents/enroll",
|
||||||
|
json={
|
||||||
|
"machine_id": "reenroll-001",
|
||||||
|
"user_name": "Carol Durand",
|
||||||
|
"hostname": "PC-CAROL",
|
||||||
|
"version": "1.1.0",
|
||||||
|
},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["created"] is False
|
||||||
|
assert data["reactivated"] is True
|
||||||
|
agent = data["agent"]
|
||||||
|
assert agent["status"] == "active"
|
||||||
|
assert agent["uninstalled_at"] is None
|
||||||
|
assert agent["uninstall_reason"] is None
|
||||||
|
# Les champs ont bien ete mis a jour
|
||||||
|
assert agent["user_name"] == "Carol Durand"
|
||||||
|
assert agent["version"] == "1.1.0"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/v1/agents/fleet
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_lists_active_and_uninstalled(agents_client):
|
||||||
|
client, token, _ = agents_client
|
||||||
|
|
||||||
|
# 2 agents actifs + 1 desinstalle
|
||||||
|
for mid in ("fleet-a", "fleet-b"):
|
||||||
|
client.post(
|
||||||
|
"/api/v1/agents/enroll",
|
||||||
|
json={"machine_id": mid, "user_name": mid, "hostname": mid.upper()},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/v1/agents/enroll",
|
||||||
|
json={"machine_id": "fleet-c", "user_name": "Cleo"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
"/api/v1/agents/uninstall",
|
||||||
|
json={"machine_id": "fleet-c", "reason": "machine_retired"},
|
||||||
|
headers=_auth_headers(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/v1/agents/fleet", headers=_auth_headers(token))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total_active"] == 2
|
||||||
|
assert data["total_uninstalled"] == 1
|
||||||
|
|
||||||
|
active_ids = {a["machine_id"] for a in data["active"]}
|
||||||
|
assert active_ids == {"fleet-a", "fleet-b"}
|
||||||
|
|
||||||
|
uninstalled_ids = {a["machine_id"] for a in data["uninstalled"]}
|
||||||
|
assert uninstalled_ids == {"fleet-c"}
|
||||||
|
assert data["uninstalled"][0]["uninstall_reason"] == "machine_retired"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_empty(agents_client):
|
||||||
|
client, token, _ = agents_client
|
||||||
|
resp = client.get("/api/v1/agents/fleet", headers=_auth_headers(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data == {
|
||||||
|
"active": [],
|
||||||
|
"uninstalled": [],
|
||||||
|
"total_active": 0,
|
||||||
|
"total_uninstalled": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fleet_without_token_returns_401(agents_client):
|
||||||
|
client, _, _ = agents_client
|
||||||
|
resp = client.get("/api/v1/agents/fleet")
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -6,6 +6,7 @@ Sans GPU/modèles lourds (mocks pour ScreenAnalyzer et CLIP).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -457,6 +458,27 @@ class TestStreamProcessorListMethods:
|
|||||||
class TestAPIEndpoints:
|
class TestAPIEndpoints:
|
||||||
"""Tests pour les endpoints GET sessions et workflows."""
|
"""Tests pour les endpoints GET sessions et workflows."""
|
||||||
|
|
||||||
|
# Token de test fixe utilisé pour tous les tests d'API.
|
||||||
|
# Doit être défini AVANT le premier import de agent_v0.server_v1.api_stream
|
||||||
|
# car le module fail-closed (sys.exit 1) si RPA_API_TOKEN est absent.
|
||||||
|
_TEST_API_TOKEN = "test_token_for_api_endpoints_0123456789abcdef"
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _ensure_api_token(self, monkeypatch):
|
||||||
|
"""Garantit que RPA_API_TOKEN est défini avant l'import de api_stream.
|
||||||
|
|
||||||
|
Le module agent_v0.server_v1.api_stream applique un fail-closed P0-C
|
||||||
|
(sys.exit 1) à l'import si RPA_API_TOKEN est absent. On force donc
|
||||||
|
une valeur de test ici avant tout import lazy dans la fixture client.
|
||||||
|
"""
|
||||||
|
monkeypatch.setenv("RPA_API_TOKEN", self._TEST_API_TOKEN)
|
||||||
|
# Si api_stream est déjà chargé dans sys.modules avec un autre token
|
||||||
|
# (par ex. depuis un précédent test), on aligne sa valeur API_TOKEN
|
||||||
|
# pour que les requêtes Bearer du test passent l'auth.
|
||||||
|
api_stream_mod = sys.modules.get("agent_v0.server_v1.api_stream")
|
||||||
|
if api_stream_mod is not None:
|
||||||
|
monkeypatch.setattr(api_stream_mod, "API_TOKEN", self._TEST_API_TOKEN)
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(self, temp_dir):
|
def client(self, temp_dir):
|
||||||
"""Client de test FastAPI."""
|
"""Client de test FastAPI."""
|
||||||
|
|||||||
Reference in New Issue
Block a user