555 lines
18 KiB
Python
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)
|
|
}
|