homelab_automation/app/routes/builtin_playbooks.py
Bruno Charest 05087aa380
Some checks failed
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Replace manual upsert logic with SQLite native upsert in Docker CRUD repositories, enhance Ansible backup playbook with better error handling and file permissions, add favicon endpoint, and improve playbook editor UI with syntax highlighting, lint integration, quality badges, and enhanced code editing features
2025-12-17 15:36:49 -05:00

291 lines
10 KiB
Python

"""
Routes API pour les builtin playbooks (collecte métriques).
"""
import asyncio
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import OperationalError
from app.core.config import settings
from app.core.dependencies import get_db, verify_api_key
from app.crud.host import HostRepository
from app.crud.host_metrics import HostMetricsRepository
from app.models.database import async_session_maker
from app.services import ansible_service, ws_manager
from app.services.builtin_playbooks import (
BuiltinPlaybookService,
BUILTIN_PLAYBOOKS,
get_builtin_playbook_service,
init_builtin_playbook_service,
)
router = APIRouter()
logger = logging.getLogger("homelab.metrics")
async def _sync_hosts_from_inventory(db_session: AsyncSession) -> int:
repo = HostRepository(db_session)
inventory_hosts = ansible_service.get_hosts_from_inventory()
created_or_updated = 0
for inv_host in inventory_hosts:
existing = await repo.get_by_name(inv_host.name)
ip_address = inv_host.ansible_host or inv_host.name
ansible_group = getattr(inv_host, "group", None)
inv_groups = getattr(inv_host, "groups", None) or []
docker_enabled = "role_docker" in inv_groups
if existing:
await repo.update(
existing,
ip_address=ip_address,
ansible_group=ansible_group,
docker_enabled=docker_enabled or existing.docker_enabled,
)
created_or_updated += 1
else:
created = await repo.create(
id=uuid.uuid4().hex,
name=inv_host.name,
ip_address=ip_address,
ansible_group=ansible_group,
status="unknown",
reachable=False,
last_seen=None,
)
if docker_enabled:
await repo.update(created, docker_enabled=True)
created_or_updated += 1
if created_or_updated:
await db_session.commit()
return created_or_updated
def _get_service() -> BuiltinPlaybookService:
"""Récupère ou initialise le service builtin playbooks."""
try:
return get_builtin_playbook_service()
except RuntimeError:
return init_builtin_playbook_service(settings.ansible_dir, ansible_service)
@router.get("")
async def list_builtin_playbooks(api_key_valid: bool = Depends(verify_api_key)):
"""Liste tous les builtin playbooks disponibles."""
return [pb.model_dump() for pb in BUILTIN_PLAYBOOKS.values()]
@router.get("/{builtin_id}")
async def get_builtin_playbook(
builtin_id: str,
api_key_valid: bool = Depends(verify_api_key)
):
"""Récupère les détails d'un builtin playbook."""
if builtin_id not in BUILTIN_PLAYBOOKS:
raise HTTPException(status_code=404, detail=f"Builtin playbook '{builtin_id}' non trouvé")
return BUILTIN_PLAYBOOKS[builtin_id].model_dump()
@router.post("/execute")
async def execute_builtin_playbook(
builtin_id: str,
target: str,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db)
):
"""Exécute un builtin playbook sur une cible."""
if builtin_id not in BUILTIN_PLAYBOOKS:
raise HTTPException(status_code=404, detail=f"Builtin playbook '{builtin_id}' non trouvé")
service = _get_service()
result = await service.execute_builtin(builtin_id, target)
# Si collecte de métriques, sauvegarder en BD
if BUILTIN_PLAYBOOKS[builtin_id].collect_metrics and result.get("success"):
metrics_repo = HostMetricsRepository(db_session)
host_repo = HostRepository(db_session)
for hostname, metrics_data in result.get("parsed_metrics", {}).items():
# Trouver l'host_id
host = await host_repo.get_by_name(hostname)
if host:
metrics_create = service.create_metrics_from_parsed(
host_id=host.id,
parsed_data=metrics_data,
builtin_id=builtin_id,
execution_time_ms=result.get("execution_time_ms", 0)
)
await metrics_repo.create(**metrics_create.model_dump())
await db_session.commit()
await ws_manager.broadcast({
"type": "builtin_executed",
"data": {
"builtin_id": builtin_id,
"target": target,
"success": result.get("success", False)
}
})
return result
@router.post("/execute-background")
async def execute_builtin_playbook_background(
builtin_id: str,
target: str,
background_tasks: BackgroundTasks,
api_key_valid: bool = Depends(verify_api_key)
):
"""Exécute un builtin playbook en arrière-plan."""
if builtin_id not in BUILTIN_PLAYBOOKS:
raise HTTPException(status_code=404, detail=f"Builtin playbook '{builtin_id}' non trouvé")
async def run_in_background():
service = _get_service()
result = await service.execute_builtin(builtin_id, target)
await ws_manager.broadcast({
"type": "builtin_executed",
"data": {
"builtin_id": builtin_id,
"target": target,
"success": result.get("success", False)
}
})
# Lancer en arrière-plan via la boucle asyncio active (retour immédiat)
asyncio.create_task(run_in_background())
return {
"message": f"Builtin playbook '{builtin_id}' planifié pour exécution sur {target}",
"builtin_id": builtin_id,
"target": target
}
@router.post("/collect-all")
async def collect_all_metrics(
background_tasks: BackgroundTasks,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db)
):
"""Collecte les métriques de tous les hôtes."""
host_repo = HostRepository(db_session)
try:
hosts = await host_repo.list(limit=1000)
except OperationalError as e:
raise HTTPException(
status_code=500,
detail=f"Base de données non initialisée/migrations manquantes (hosts): {str(e)}",
)
if not hosts:
await _sync_hosts_from_inventory(db_session)
hosts = await host_repo.list(limit=1000)
if not hosts:
return {"success": True, "message": "Aucun hôte trouvé", "hosts_count": 0}
async def collect_for_all():
service = _get_service()
results = []
async with async_session_maker() as session:
host_repo_bg = HostRepository(session)
metrics_repo = HostMetricsRepository(session)
hosts_bg = await host_repo_bg.list(limit=1000)
inventory_hosts = ansible_service.get_hosts_from_inventory()
inventory_names = {h.name for h in (inventory_hosts or []) if getattr(h, "name", None)}
skipped = 0
if inventory_names:
filtered = [h for h in hosts_bg if h.name in inventory_names]
skipped = len(hosts_bg) - len(filtered)
hosts_bg = filtered
for host in hosts_bg:
try:
result = await service.execute_builtin("collect_system_info", host.name)
ok = bool(result.get("success", False))
stderr = (result.get("stderr") or "").strip()
stdout = (result.get("stdout") or "").strip()
return_code = result.get("return_code")
error_detail = None
if not ok:
# Keep payload small but actionable
if stderr:
error_detail = stderr[:400]
elif stdout:
error_detail = stdout[:400]
else:
error_detail = result.get("error") or "Unknown error"
logger.warning(
"[METRICS] Collection failed for host=%s return_code=%s error=%s",
host.name,
return_code,
error_detail,
)
if ok:
for hostname, metrics_data in result.get("parsed_metrics", {}).items():
target_host = await host_repo_bg.get_by_name(hostname)
if not target_host:
continue
metrics_create = service.create_metrics_from_parsed(
host_id=target_host.id,
parsed_data=metrics_data,
builtin_id="collect_system_info",
execution_time_ms=result.get("execution_time_ms", 0),
)
await metrics_repo.create(**metrics_create.model_dump())
await session.commit()
results.append({
"host": host.name,
"success": ok,
"return_code": return_code,
"error": error_detail,
})
except Exception as e:
await session.rollback()
logger.exception("[METRICS] Unhandled exception during collection for host=%s", host.name)
results.append({
"host": host.name,
"success": False,
"error": str(e)
})
await ws_manager.broadcast({
"type": "metrics_collection_complete",
"data": {
"total": len(results),
"success": sum(1 for r in results if r.get("success")),
"failed": sum(1 for r in results if not r.get("success")),
"skipped": skipped,
"results": results,
}
})
# Lancer en arrière-plan via la boucle asyncio active (retour immédiat)
asyncio.create_task(collect_for_all())
return {
"success": True,
"message": f"Collecte des métriques lancée pour {len(hosts)} hôte(s)",
"hosts_count": len(hosts)
}