527 lines
16 KiB
Python
527 lines
16 KiB
Python
"""
|
|
Routes API pour la gestion des tâches.
|
|
"""
|
|
|
|
import uuid
|
|
import asyncio
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.core.constants import ACTION_PLAYBOOK_MAP, ACTION_DISPLAY_NAMES
|
|
from app.core.dependencies import get_db, verify_api_key
|
|
from app.crud.task import TaskRepository
|
|
from app.crud.log import LogRepository
|
|
from app.schemas.task_api import TaskRequest
|
|
from app.services import ws_manager, db, ansible_service, notification_service
|
|
from app.services.task_log_service import TaskLogService
|
|
from app.models.database import async_session_maker
|
|
from app.schemas.task_api import Task
|
|
from app.schemas.common import LogEntry
|
|
from time import perf_counter
|
|
|
|
router = APIRouter()
|
|
|
|
# Instance du service de logs de tâches
|
|
task_log_service = TaskLogService(settings.tasks_logs_dir)
|
|
|
|
# Dictionnaire des tâches en cours d'exécution
|
|
running_task_handles = {}
|
|
|
|
|
|
@router.get("")
|
|
async def get_tasks(
|
|
limit: int = 100,
|
|
offset: int = 0,
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
db_session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Récupère la liste des tâches."""
|
|
repo = TaskRepository(db_session)
|
|
tasks = await repo.list(limit=limit, offset=offset)
|
|
return [
|
|
{
|
|
"id": t.id,
|
|
"name": t.action,
|
|
"host": t.target,
|
|
"status": t.status,
|
|
"progress": 100 if t.status == "completed" else (50 if t.status == "running" else 0),
|
|
"start_time": t.started_at,
|
|
"end_time": t.completed_at,
|
|
"duration": None,
|
|
"output": t.result_data.get("output") if t.result_data else None,
|
|
"error": t.error_message,
|
|
}
|
|
for t in tasks
|
|
]
|
|
|
|
|
|
@router.get("/running")
|
|
async def get_running_tasks(
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
db_session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Récupère les tâches en cours d'exécution."""
|
|
repo = TaskRepository(db_session)
|
|
tasks = await repo.list(limit=100, offset=0)
|
|
running_tasks = [t for t in tasks if t.status in ("running", "pending")]
|
|
return {
|
|
"tasks": [
|
|
{
|
|
"id": t.id,
|
|
"name": t.action,
|
|
"host": t.target,
|
|
"status": t.status,
|
|
"progress": 50 if t.status == "running" else 0,
|
|
"start_time": t.started_at,
|
|
"end_time": t.completed_at,
|
|
}
|
|
for t in running_tasks
|
|
],
|
|
"count": len(running_tasks)
|
|
}
|
|
|
|
|
|
@router.get("/logs")
|
|
async def get_task_logs(
|
|
status: Optional[str] = None,
|
|
year: Optional[str] = None,
|
|
month: Optional[str] = None,
|
|
day: Optional[str] = None,
|
|
hour_start: Optional[str] = None,
|
|
hour_end: Optional[str] = None,
|
|
target: Optional[str] = None,
|
|
category: Optional[str] = None,
|
|
source_type: Optional[str] = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
api_key_valid: bool = Depends(verify_api_key)
|
|
):
|
|
"""Récupère les logs de tâches depuis les fichiers markdown."""
|
|
logs, total_count = task_log_service.get_task_logs(
|
|
year=year,
|
|
month=month,
|
|
day=day,
|
|
status=status,
|
|
target=target,
|
|
category=category,
|
|
source_type=source_type,
|
|
hour_start=hour_start,
|
|
hour_end=hour_end,
|
|
limit=limit,
|
|
offset=offset
|
|
)
|
|
return {
|
|
"logs": [log.dict() for log in logs],
|
|
"count": len(logs),
|
|
"total_count": total_count,
|
|
"has_more": offset + len(logs) < total_count,
|
|
"filters": {
|
|
"status": status,
|
|
"year": year,
|
|
"month": month,
|
|
"day": day,
|
|
"hour_start": hour_start,
|
|
"hour_end": hour_end,
|
|
"target": target,
|
|
"source_type": source_type
|
|
},
|
|
"pagination": {
|
|
"limit": limit,
|
|
"offset": offset
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/logs/dates")
|
|
async def get_task_logs_dates(api_key_valid: bool = Depends(verify_api_key)):
|
|
"""Récupère les dates disponibles pour le filtrage."""
|
|
return task_log_service.get_available_dates()
|
|
|
|
|
|
@router.get("/logs/stats")
|
|
async def get_task_logs_stats(api_key_valid: bool = Depends(verify_api_key)):
|
|
"""Récupère les statistiques des logs de tâches."""
|
|
return task_log_service.get_stats()
|
|
|
|
|
|
@router.get("/logs/{log_id}")
|
|
async def get_task_log_content(log_id: str, api_key_valid: bool = Depends(verify_api_key)):
|
|
"""Récupère le contenu d'un log de tâche spécifique."""
|
|
logs, _ = task_log_service.get_task_logs(limit=0)
|
|
log = next((l for l in logs if l.id == log_id), None)
|
|
|
|
if not log:
|
|
raise HTTPException(status_code=404, detail="Log non trouvé")
|
|
|
|
try:
|
|
content = Path(log.path).read_text(encoding='utf-8')
|
|
return {
|
|
"log": log.dict(),
|
|
"content": content
|
|
}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Erreur lecture du fichier: {str(e)}")
|
|
|
|
|
|
@router.delete("/logs/{log_id}")
|
|
async def delete_task_log(log_id: str, api_key_valid: bool = Depends(verify_api_key)):
|
|
"""Supprime un fichier de log de tâche."""
|
|
logs, _ = task_log_service.get_task_logs(limit=0)
|
|
log = next((l for l in logs if l.id == log_id), None)
|
|
|
|
if not log:
|
|
raise HTTPException(status_code=404, detail="Log non trouvé")
|
|
|
|
try:
|
|
log_path = Path(log.path)
|
|
if log_path.exists():
|
|
log_path.unlink()
|
|
# Le TaskLogService met en cache l'index des fichiers pendant ~60s.
|
|
# Invalider immédiatement pour que la suppression soit visible instantanément.
|
|
task_log_service.invalidate_index()
|
|
|
|
await ws_manager.broadcast({
|
|
"type": "task_log_deleted",
|
|
"data": {"id": log_id}
|
|
})
|
|
return {"message": "Log supprimé", "id": log_id}
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"Erreur suppression: {str(e)}")
|
|
|
|
|
|
@router.post("/{task_id}/cancel")
|
|
async def cancel_task(
|
|
task_id: str,
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
db_session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Annule une tâche en cours d'exécution."""
|
|
repo = TaskRepository(db_session)
|
|
task = await repo.get(task_id)
|
|
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Tâche non trouvée")
|
|
|
|
if task.status not in ("running", "pending"):
|
|
raise HTTPException(status_code=400, detail=f"La tâche n'est pas en cours (statut: {task.status})")
|
|
|
|
# Marquer comme annulée
|
|
if task_id in running_task_handles:
|
|
running_task_handles[task_id]["cancelled"] = True
|
|
|
|
async_task = running_task_handles[task_id].get("asyncio_task")
|
|
if async_task and not async_task.done():
|
|
async_task.cancel()
|
|
|
|
process = running_task_handles[task_id].get("process")
|
|
if process:
|
|
try:
|
|
process.terminate()
|
|
await asyncio.sleep(0.5)
|
|
if process.returncode is None:
|
|
process.kill()
|
|
except Exception:
|
|
pass
|
|
|
|
del running_task_handles[task_id]
|
|
|
|
await repo.update(
|
|
task,
|
|
status="cancelled",
|
|
completed_at=datetime.now(timezone.utc),
|
|
error_message="Tâche annulée par l'utilisateur"
|
|
)
|
|
await db_session.commit()
|
|
|
|
# Log
|
|
log_repo = LogRepository(db_session)
|
|
await log_repo.create(
|
|
level="WARNING",
|
|
message=f"Tâche '{task.action}' annulée manuellement",
|
|
source="task",
|
|
task_id=task_id,
|
|
)
|
|
await db_session.commit()
|
|
|
|
await ws_manager.broadcast({
|
|
"type": "task_cancelled",
|
|
"data": {
|
|
"id": task_id,
|
|
"status": "cancelled",
|
|
"message": "Tâche annulée par l'utilisateur"
|
|
}
|
|
})
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Tâche {task_id} annulée avec succès",
|
|
"task_id": task_id
|
|
}
|
|
|
|
|
|
@router.get("/{task_id}")
|
|
async def get_task(
|
|
task_id: str,
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
db_session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Récupère une tâche spécifique."""
|
|
repo = TaskRepository(db_session)
|
|
task = await repo.get(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Tâche non trouvée")
|
|
return {
|
|
"id": task.id,
|
|
"name": task.action,
|
|
"host": task.target,
|
|
"status": task.status,
|
|
"progress": 100 if task.status == "completed" else (50 if task.status == "running" else 0),
|
|
"start_time": task.started_at,
|
|
"end_time": task.completed_at,
|
|
"duration": None,
|
|
"output": task.result_data.get("output") if task.result_data else None,
|
|
"error": task.error_message,
|
|
}
|
|
|
|
|
|
@router.delete("/{task_id}")
|
|
async def delete_task(
|
|
task_id: str,
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
db_session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Supprime une tâche."""
|
|
repo = TaskRepository(db_session)
|
|
task = await repo.get(task_id)
|
|
if not task:
|
|
raise HTTPException(status_code=404, detail="Tâche non trouvée")
|
|
|
|
await db_session.delete(task)
|
|
await db_session.commit()
|
|
|
|
await ws_manager.broadcast({
|
|
"type": "task_deleted",
|
|
"data": {"id": task_id}
|
|
})
|
|
|
|
return {"message": "Tâche supprimée avec succès"}
|
|
|
|
|
|
@router.post("")
|
|
async def create_task(
|
|
task_request: TaskRequest,
|
|
api_key_valid: bool = Depends(verify_api_key),
|
|
db_session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Crée une nouvelle tâche et exécute le playbook correspondant."""
|
|
repo = TaskRepository(db_session)
|
|
task_id = uuid.uuid4().hex
|
|
target = task_request.host or task_request.group or "all"
|
|
playbook = ACTION_PLAYBOOK_MAP.get(task_request.action)
|
|
|
|
task_obj = await repo.create(
|
|
id=task_id,
|
|
action=task_request.action,
|
|
target=target,
|
|
playbook=playbook,
|
|
status="running",
|
|
)
|
|
await repo.update(task_obj, started_at=datetime.now(timezone.utc))
|
|
await db_session.commit()
|
|
|
|
task_name = ACTION_DISPLAY_NAMES.get(task_request.action, f"Tâche {task_request.action}")
|
|
|
|
response_data = {
|
|
"id": task_obj.id,
|
|
"name": task_name,
|
|
"host": target,
|
|
"status": "running",
|
|
"progress": 0,
|
|
"start_time": task_obj.started_at,
|
|
"end_time": None,
|
|
"duration": None,
|
|
"output": None,
|
|
"error": None,
|
|
}
|
|
|
|
await ws_manager.broadcast({
|
|
"type": "task_created",
|
|
"data": response_data
|
|
})
|
|
|
|
# Exécuter le playbook en arrière-plan
|
|
if playbook:
|
|
asyncio.create_task(_execute_task_playbook(
|
|
task_id=task_id,
|
|
task_name=task_name,
|
|
playbook=playbook,
|
|
target=target,
|
|
extra_vars=task_request.extra_vars,
|
|
check_mode=task_request.dry_run if hasattr(task_request, 'dry_run') else False
|
|
))
|
|
|
|
return response_data
|
|
|
|
|
|
async def _execute_task_playbook(
|
|
task_id: str,
|
|
task_name: str,
|
|
playbook: str,
|
|
target: str,
|
|
extra_vars: dict = None,
|
|
check_mode: bool = False
|
|
):
|
|
"""Exécute un playbook Ansible en arrière-plan et met à jour le statut."""
|
|
start_time = perf_counter()
|
|
|
|
# Créer une tâche en mémoire pour le service de logs
|
|
mem_task = Task(
|
|
id=task_id,
|
|
name=task_name,
|
|
host=target,
|
|
status="running",
|
|
progress=10,
|
|
start_time=datetime.now(timezone.utc)
|
|
)
|
|
|
|
# Notifier la progression
|
|
await ws_manager.broadcast({
|
|
"type": "task_progress",
|
|
"data": {"id": task_id, "progress": 10, "message": "Démarrage du playbook..."}
|
|
})
|
|
|
|
try:
|
|
# Exécuter le playbook
|
|
result = await ansible_service.execute_playbook(
|
|
playbook=playbook,
|
|
target=target,
|
|
extra_vars=extra_vars,
|
|
check_mode=check_mode,
|
|
verbose=True
|
|
)
|
|
|
|
execution_time = perf_counter() - start_time
|
|
success = result.get("success", False)
|
|
|
|
# Mettre à jour la tâche en mémoire
|
|
mem_task.status = "completed" if success else "failed"
|
|
mem_task.progress = 100
|
|
mem_task.end_time = datetime.now(timezone.utc)
|
|
mem_task.duration = f"{execution_time:.1f}s"
|
|
mem_task.output = result.get("stdout", "")
|
|
mem_task.error = result.get("stderr", "") if not success else None
|
|
|
|
# Mettre à jour en BD
|
|
async with async_session_maker() as session:
|
|
repo = TaskRepository(session)
|
|
db_task = await repo.get(task_id)
|
|
if db_task:
|
|
await repo.update(
|
|
db_task,
|
|
status=mem_task.status,
|
|
completed_at=mem_task.end_time,
|
|
error_message=mem_task.error,
|
|
result_data={"output": result.get("stdout", "")[:5000]}
|
|
)
|
|
await session.commit()
|
|
|
|
# Sauvegarder le log markdown
|
|
try:
|
|
log_path = task_log_service.save_task_log(
|
|
task=mem_task,
|
|
output=result.get("stdout", ""),
|
|
error=result.get("stderr", "")
|
|
)
|
|
created_log = task_log_service.index_log_file(log_path)
|
|
if created_log:
|
|
await ws_manager.broadcast({
|
|
"type": "task_log_created",
|
|
"data": created_log.dict()
|
|
})
|
|
except Exception as log_error:
|
|
print(f"Erreur sauvegarde log markdown: {log_error}")
|
|
|
|
# Notifier la fin via WebSocket
|
|
await ws_manager.broadcast({
|
|
"type": "task_completed",
|
|
"data": {
|
|
"id": task_id,
|
|
"status": mem_task.status,
|
|
"progress": 100,
|
|
"duration": mem_task.duration,
|
|
"success": success
|
|
}
|
|
})
|
|
|
|
# Notification ntfy
|
|
if success:
|
|
await notification_service.notify_task_completed(
|
|
task_name=task_name,
|
|
target=target,
|
|
duration=mem_task.duration
|
|
)
|
|
else:
|
|
await notification_service.notify_task_failed(
|
|
task_name=task_name,
|
|
target=target,
|
|
error=result.get("stderr", "Erreur inconnue")[:200]
|
|
)
|
|
|
|
except Exception as e:
|
|
execution_time = perf_counter() - start_time
|
|
error_msg = str(e)
|
|
|
|
mem_task.status = "failed"
|
|
mem_task.end_time = datetime.now(timezone.utc)
|
|
mem_task.duration = f"{execution_time:.1f}s"
|
|
mem_task.error = error_msg
|
|
|
|
# Mettre à jour en BD
|
|
try:
|
|
async with async_session_maker() as session:
|
|
repo = TaskRepository(session)
|
|
db_task = await repo.get(task_id)
|
|
if db_task:
|
|
await repo.update(
|
|
db_task,
|
|
status="failed",
|
|
completed_at=mem_task.end_time,
|
|
error_message=error_msg
|
|
)
|
|
await session.commit()
|
|
except Exception as db_error:
|
|
print(f"Erreur mise à jour BD: {db_error}")
|
|
|
|
# Sauvegarder le log markdown
|
|
try:
|
|
log_path = task_log_service.save_task_log(task=mem_task, error=error_msg)
|
|
created_log = task_log_service.index_log_file(log_path)
|
|
if created_log:
|
|
await ws_manager.broadcast({
|
|
"type": "task_log_created",
|
|
"data": created_log.dict()
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
# Notifier l'échec
|
|
await ws_manager.broadcast({
|
|
"type": "task_failed",
|
|
"data": {
|
|
"id": task_id,
|
|
"status": "failed",
|
|
"error": error_msg
|
|
}
|
|
})
|
|
|
|
await notification_service.notify_task_failed(
|
|
task_name=task_name,
|
|
target=target,
|
|
error=error_msg[:200]
|
|
)
|