555 lines
18 KiB
Python

"""
Routes API pour la gestion des schedules.
"""
import json
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, verify_api_key
from app.crud.schedule import ScheduleRepository
from app.crud.schedule_run import ScheduleRunRepository
from app.crud.log import LogRepository
from app.schemas.schedule_api import (
Schedule,
ScheduleCreateRequest,
ScheduleUpdateRequest,
)
from app.services import ansible_service, ws_manager, scheduler_service
router = APIRouter()
@router.get("")
async def get_schedules(
enabled: Optional[bool] = None,
playbook: Optional[str] = None,
tag: Optional[str] = None,
limit: int = 100,
offset: int = 0,
api_key_valid: bool = Depends(verify_api_key),
):
"""Liste tous les schedules."""
schedules = scheduler_service.get_all_schedules(
enabled=enabled,
playbook=playbook,
tag=tag,
)
paginated = schedules[offset:offset + limit]
results = []
for s in paginated:
rec = s.recurrence
results.append({
"id": s.id,
"name": s.name,
"playbook": s.playbook,
"target": s.target,
"schedule_type": s.schedule_type,
"recurrence": rec.model_dump() if rec else None,
"enabled": s.enabled,
"notification_type": getattr(s, 'notification_type', 'all'),
"tags": s.tags,
"next_run_at": s.next_run_at,
"last_run_at": s.last_run_at,
"last_status": s.last_status,
"run_count": s.run_count,
"success_count": s.success_count,
"failure_count": s.failure_count,
"created_at": s.created_at,
"updated_at": s.updated_at,
})
return {"schedules": results, "count": len(schedules)}
@router.get("/stats")
async def get_schedules_stats(api_key_valid: bool = Depends(verify_api_key)):
"""Récupère les statistiques des schedules."""
stats = scheduler_service.get_stats()
upcoming = scheduler_service.get_upcoming_executions(limit=5)
return {
"stats": stats.dict(),
"upcoming": upcoming
}
@router.get("/upcoming")
async def get_upcoming_schedules(
limit: int = 10,
api_key_valid: bool = Depends(verify_api_key)
):
"""Récupère les prochaines exécutions planifiées."""
upcoming = scheduler_service.get_upcoming_executions(limit=limit)
return {
"upcoming": upcoming,
"count": len(upcoming)
}
@router.get("/validate-cron")
async def validate_cron_expression(
expression: str,
api_key_valid: bool = Depends(verify_api_key)
):
"""Valide une expression cron."""
result = scheduler_service.validate_cron_expression(expression)
return result
@router.get("/{schedule_id}")
async def get_schedule(
schedule_id: str,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Récupère les détails d'un schedule."""
repo = ScheduleRepository(db_session)
schedule = await repo.get(schedule_id)
if not schedule:
raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé")
return {
"id": schedule.id,
"name": schedule.name,
"playbook": schedule.playbook,
"target": schedule.target,
"schedule_type": schedule.schedule_type,
"recurrence_type": schedule.recurrence_type,
"recurrence_time": schedule.recurrence_time,
"recurrence_days": json.loads(schedule.recurrence_days) if schedule.recurrence_days else None,
"cron_expression": schedule.cron_expression,
"enabled": schedule.enabled,
"notification_type": schedule.notification_type or "all",
"tags": json.loads(schedule.tags) if schedule.tags else [],
"next_run": schedule.next_run,
"last_run": schedule.last_run,
"created_at": schedule.created_at,
"updated_at": schedule.updated_at,
}
@router.post("")
async def create_schedule(
request: ScheduleCreateRequest,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Crée un nouveau schedule."""
# Vérifier que le playbook existe
playbooks = ansible_service.get_playbooks()
playbook_names = [p['filename'] for p in playbooks] + [p['name'] for p in playbooks]
playbook_file = request.playbook
if not playbook_file.endswith(('.yml', '.yaml')):
playbook_file = f"{playbook_file}.yml"
if playbook_file not in playbook_names and request.playbook not in playbook_names:
raise HTTPException(status_code=400, detail=f"Playbook '{request.playbook}' non trouvé")
# Récupérer les infos du playbook pour validation
playbook_info = next((pb for pb in playbooks if pb['filename'] == playbook_file or pb['name'] == request.playbook), None)
# Vérifier la cible
if request.target_type == "group":
groups = ansible_service.get_groups()
if request.target not in groups and request.target != "all":
raise HTTPException(status_code=400, detail=f"Groupe '{request.target}' non trouvé")
else:
if not ansible_service.host_exists(request.target):
raise HTTPException(status_code=400, detail=f"Hôte '{request.target}' non trouvé")
# Valider la compatibilité playbook-target
if playbook_info:
playbook_hosts = playbook_info.get('hosts', 'all')
if not ansible_service.is_target_compatible_with_playbook(request.target, playbook_hosts):
raise HTTPException(
status_code=400,
detail=f"Le playbook '{request.playbook}' (hosts: {playbook_hosts}) n'est pas compatible avec la cible '{request.target}'."
)
# Valider la récurrence
if request.schedule_type == "recurring" and not request.recurrence:
raise HTTPException(status_code=400, detail="La récurrence est requise pour un schedule récurrent")
if request.recurrence and request.recurrence.type == "custom":
if not request.recurrence.cron_expression:
raise HTTPException(status_code=400, detail="Expression cron requise pour le type 'custom'")
validation = scheduler_service.validate_cron_expression(request.recurrence.cron_expression)
if not validation["valid"]:
raise HTTPException(status_code=400, detail=f"Expression cron invalide: {validation.get('error')}")
# Créer en DB
repo = ScheduleRepository(db_session)
schedule_id = f"sched_{uuid.uuid4().hex[:12]}"
recurrence = request.recurrence
schedule_obj = await repo.create(
id=schedule_id,
name=request.name,
description=request.description,
playbook=playbook_file,
target_type=request.target_type,
target=request.target,
extra_vars=request.extra_vars,
schedule_type=request.schedule_type,
schedule_time=request.start_at,
recurrence_type=recurrence.type if recurrence else None,
recurrence_time=recurrence.time if recurrence else None,
recurrence_days=json.dumps(recurrence.days) if recurrence and recurrence.days else None,
cron_expression=recurrence.cron_expression if recurrence else None,
timezone=request.timezone,
start_at=request.start_at,
end_at=request.end_at,
enabled=request.enabled,
retry_on_failure=request.retry_on_failure,
timeout=request.timeout,
notification_type=request.notification_type,
tags=json.dumps(request.tags) if request.tags else None,
)
await db_session.commit()
# Créer le schedule Pydantic et l'ajouter au cache du scheduler
pydantic_schedule = Schedule(
id=schedule_id,
name=request.name,
description=request.description,
playbook=playbook_file,
target_type=request.target_type,
target=request.target,
extra_vars=request.extra_vars,
schedule_type=request.schedule_type,
recurrence=request.recurrence,
timezone=request.timezone,
start_at=request.start_at,
end_at=request.end_at,
enabled=request.enabled,
retry_on_failure=request.retry_on_failure,
timeout=request.timeout,
notification_type=request.notification_type,
tags=request.tags or [],
)
scheduler_service.add_schedule_to_cache(pydantic_schedule)
# Log en DB
log_repo = LogRepository(db_session)
await log_repo.create(
level="INFO",
message=f"Schedule '{request.name}' créé pour {playbook_file} sur {request.target}",
source="scheduler",
)
await db_session.commit()
# Notifier via WebSocket
await ws_manager.broadcast({
"type": "schedule_created",
"data": {
"id": schedule_obj.id,
"name": schedule_obj.name,
"playbook": schedule_obj.playbook,
"target": schedule_obj.target,
}
})
return {
"success": True,
"message": f"Schedule '{request.name}' créé avec succès",
"schedule": {
"id": schedule_obj.id,
"name": schedule_obj.name,
"playbook": schedule_obj.playbook,
"target": schedule_obj.target,
"enabled": schedule_obj.enabled,
}
}
@router.put("/{schedule_id}")
async def update_schedule(
schedule_id: str,
request: ScheduleUpdateRequest,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Met à jour un schedule existant."""
sched = scheduler_service.get_schedule(schedule_id)
repo = ScheduleRepository(db_session)
schedule = await repo.get(schedule_id)
if not sched and not schedule:
raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé")
schedule_name = sched.name if sched else schedule.name
# Valider le playbook si modifié
if request.playbook:
playbooks = ansible_service.get_playbooks()
playbook_names = [p['filename'] for p in playbooks] + [p['name'] for p in playbooks]
playbook_file = request.playbook
if not playbook_file.endswith(('.yml', '.yaml')):
playbook_file = f"{playbook_file}.yml"
if playbook_file not in playbook_names and request.playbook not in playbook_names:
raise HTTPException(status_code=400, detail=f"Playbook '{request.playbook}' non trouvé")
# Valider l'expression cron si modifiée
if request.recurrence and request.recurrence.type == "custom":
if request.recurrence.cron_expression:
validation = scheduler_service.validate_cron_expression(request.recurrence.cron_expression)
if not validation["valid"]:
raise HTTPException(status_code=400, detail=f"Expression cron invalide: {validation.get('error')}")
# Mettre à jour en DB
update_fields = {}
if request.name:
update_fields["name"] = request.name
if request.description:
update_fields["description"] = request.description
if request.playbook:
update_fields["playbook"] = request.playbook
if request.target:
update_fields["target"] = request.target
if request.schedule_type:
update_fields["schedule_type"] = request.schedule_type
if request.timezone:
update_fields["timezone"] = request.timezone
if request.enabled is not None:
update_fields["enabled"] = request.enabled
if request.retry_on_failure is not None:
update_fields["retry_on_failure"] = request.retry_on_failure
if request.timeout is not None:
update_fields["timeout"] = request.timeout
if request.notification_type:
update_fields["notification_type"] = request.notification_type
if request.tags:
update_fields["tags"] = json.dumps(request.tags)
if request.recurrence:
update_fields["recurrence_type"] = request.recurrence.type
update_fields["recurrence_time"] = request.recurrence.time
update_fields["recurrence_days"] = json.dumps(request.recurrence.days) if request.recurrence.days else None
update_fields["cron_expression"] = request.recurrence.cron_expression
if schedule:
await repo.update(schedule, **update_fields)
await db_session.commit()
scheduler_service.update_schedule(schedule_id, request)
# Log en DB
log_repo = LogRepository(db_session)
await log_repo.create(
level="INFO",
message=f"Schedule '{schedule_name}' mis à jour",
source="scheduler",
)
await db_session.commit()
await ws_manager.broadcast({
"type": "schedule_updated",
"data": {"id": schedule_id, "name": schedule_name}
})
return {
"success": True,
"message": f"Schedule '{schedule_name}' mis à jour",
"schedule": {"id": schedule_id, "name": schedule_name}
}
@router.delete("/{schedule_id}")
async def delete_schedule(
schedule_id: str,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Supprime un schedule."""
repo = ScheduleRepository(db_session)
schedule = await repo.get(schedule_id)
if not schedule:
try:
scheduler_service.delete_schedule(schedule_id)
except Exception:
pass
return {
"success": True,
"message": f"Schedule '{schedule_id}' déjà supprimé ou inexistant."
}
schedule_name = schedule.name
await repo.soft_delete(schedule_id)
await db_session.commit()
scheduler_service.delete_schedule(schedule_id)
log_repo = LogRepository(db_session)
await log_repo.create(
level="WARN",
message=f"Schedule '{schedule_name}' supprimé",
source="scheduler",
)
await db_session.commit()
await ws_manager.broadcast({
"type": "schedule_deleted",
"data": {"id": schedule_id, "name": schedule_name}
})
return {
"success": True,
"message": f"Schedule '{schedule_name}' supprimé"
}
@router.post("/{schedule_id}/run")
async def run_schedule_now(
schedule_id: str,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Exécute immédiatement un schedule."""
sched = scheduler_service.get_schedule(schedule_id)
if not sched:
repo = ScheduleRepository(db_session)
schedule = await repo.get(schedule_id)
if not schedule:
raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé")
schedule_name = schedule.name
else:
schedule_name = sched.name
run = await scheduler_service.run_now(schedule_id)
return {
"success": True,
"message": f"Schedule '{schedule_name}' lancé",
"run": run.dict() if run else None
}
@router.post("/{schedule_id}/pause")
async def pause_schedule(
schedule_id: str,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Met en pause un schedule."""
sched = scheduler_service.get_schedule(schedule_id)
repo = ScheduleRepository(db_session)
schedule = await repo.get(schedule_id)
if not sched and not schedule:
raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé")
schedule_name = sched.name if sched else schedule.name
if schedule:
await repo.update(schedule, enabled=False)
await db_session.commit()
scheduler_service.pause_schedule(schedule_id)
log_repo = LogRepository(db_session)
await log_repo.create(
level="INFO",
message=f"Schedule '{schedule_name}' mis en pause",
source="scheduler",
)
await db_session.commit()
await ws_manager.broadcast({
"type": "schedule_updated",
"data": {"id": schedule_id, "name": schedule_name, "enabled": False}
})
return {
"success": True,
"message": f"Schedule '{schedule_name}' mis en pause",
"schedule": {"id": schedule_id, "name": schedule_name, "enabled": False}
}
@router.post("/{schedule_id}/resume")
async def resume_schedule(
schedule_id: str,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Reprend un schedule en pause."""
sched = scheduler_service.get_schedule(schedule_id)
repo = ScheduleRepository(db_session)
schedule = await repo.get(schedule_id)
if not sched and not schedule:
raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé")
schedule_name = sched.name if sched else schedule.name
if schedule:
await repo.update(schedule, enabled=True)
await db_session.commit()
scheduler_service.resume_schedule(schedule_id)
log_repo = LogRepository(db_session)
await log_repo.create(
level="INFO",
message=f"Schedule '{schedule_name}' repris",
source="scheduler",
)
await db_session.commit()
await ws_manager.broadcast({
"type": "schedule_updated",
"data": {"id": schedule_id, "name": schedule_name, "enabled": True}
})
return {
"success": True,
"message": f"Schedule '{schedule_name}' repris",
"schedule": {"id": schedule_id, "name": schedule_name, "enabled": True}
}
@router.get("/{schedule_id}/runs")
async def get_schedule_runs(
schedule_id: str,
limit: int = 50,
offset: int = 0,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Récupère l'historique des exécutions d'un schedule."""
sched = scheduler_service.get_schedule(schedule_id)
repo = ScheduleRepository(db_session)
schedule = await repo.get(schedule_id)
if not sched and not schedule:
raise HTTPException(status_code=404, detail=f"Schedule '{schedule_id}' non trouvé")
schedule_name = sched.name if sched else schedule.name
run_repo = ScheduleRunRepository(db_session)
runs = await run_repo.list_for_schedule(schedule_id, limit=limit, offset=offset)
return {
"schedule_id": schedule_id,
"schedule_name": schedule_name,
"runs": [
{
"id": r.id,
"status": r.status,
"started_at": r.started_at,
"finished_at": r.completed_at,
"duration_seconds": r.duration,
"error_message": r.error_message,
}
for r in runs
],
"count": len(runs)
}