homelab_automation/app/app_optimized.py

6590 lines
242 KiB
Python

"""
Homelab Automation Dashboard - Backend Optimisé
API REST moderne avec FastAPI pour la gestion d'homelab
"""
from datetime import datetime, timezone, timedelta
from pathlib import Path
from time import perf_counter, time
import os
import re
import shutil
import subprocess
import sqlite3
import yaml
from abc import ABC, abstractmethod
from typing import Literal, Any, List, Dict, Optional
from threading import Lock
import asyncio
import json
import uuid
# APScheduler imports
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.jobstores.memory import MemoryJobStore
from apscheduler.executors.asyncio import AsyncIOExecutor
from croniter import croniter
import pytz
from fastapi import FastAPI, HTTPException, Depends, Request, Form, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
from fastapi.security import APIKeyHeader
from fastapi.templating import Jinja2Templates
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, field_validator, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy import select
from models.database import get_db, async_session_maker # type: ignore
from crud.host import HostRepository # type: ignore
from crud.bootstrap_status import BootstrapStatusRepository # type: ignore
from crud.log import LogRepository # type: ignore
from crud.task import TaskRepository # type: ignore
from crud.schedule import ScheduleRepository # type: ignore
from crud.schedule_run import ScheduleRunRepository # type: ignore
from models.database import init_db # type: ignore
from services.notification_service import notification_service, send_notification # type: ignore
from schemas.notification import NotificationRequest, NotificationResponse # type: ignore
BASE_DIR = Path(__file__).resolve().parent
# Configuration avancée de l'application
app = FastAPI(
title="Homelab Automation Dashboard API",
version="1.0.0",
description="API REST moderne pour la gestion automatique d'homelab",
docs_url="/api/docs",
redoc_url="/api/redoc"
)
# Middleware CORS pour le développement
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # À restreindre en production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/static", StaticFiles(directory=BASE_DIR, html=False), name="static")
# Configuration des chemins et variables d'environnement
LOGS_DIR = Path(os.environ.get("LOGS_DIR", "/logs"))
ANSIBLE_DIR = BASE_DIR.parent / "ansible"
SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", str(Path.home() / ".ssh" / "id_rsa"))
SSH_USER = os.environ.get("SSH_USER", "automation")
SSH_REMOTE_USER = os.environ.get("SSH_REMOTE_USER", "root")
DB_PATH = LOGS_DIR / "homelab.db"
API_KEY = os.environ.get("API_KEY", "dev-key-12345")
# Répertoire pour les logs de tâches en markdown (format YYYY/MM/JJ)
DIR_LOGS_TASKS = Path(os.environ.get("DIR_LOGS_TASKS", str(BASE_DIR.parent / "tasks_logs")))
# Fichier JSON pour l'historique des commandes ad-hoc
ADHOC_HISTORY_FILE = DIR_LOGS_TASKS / ".adhoc_history.json"
# Fichier JSON pour les statuts persistés
BOOTSTRAP_STATUS_FILE = DIR_LOGS_TASKS / ".bootstrap_status.json"
HOST_STATUS_FILE = ANSIBLE_DIR / ".host_status.json"
# Mapping des actions vers les playbooks
ACTION_PLAYBOOK_MAP = {
'upgrade': 'vm-upgrade.yml',
'reboot': 'vm-reboot.yml',
'health-check': 'health-check.yml',
'backup': 'backup-config.yml',
'bootstrap': 'bootstrap-host.yml',
}
# Gestionnaire de clés API
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
# Modèles Pydantic améliorés
class CommandResult(BaseModel):
status: str
return_code: int
stdout: str
stderr: Optional[str] = None
execution_time: Optional[float] = None
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Host(BaseModel):
id: str
name: str
ip: str
status: Literal["online", "offline", "warning"]
os: str
last_seen: Optional[datetime] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
groups: List[str] = [] # Groupes Ansible auxquels appartient l'hôte
bootstrap_ok: bool = False # Indique si le bootstrap a été effectué avec succès
bootstrap_date: Optional[datetime] = None # Date du dernier bootstrap réussi
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
class Task(BaseModel):
id: str
name: str
host: str
status: Literal["pending", "running", "completed", "failed", "cancelled"]
progress: int = Field(ge=0, le=100, default=0)
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
duration: Optional[str] = None
output: Optional[str] = None
error: Optional[str] = None
class Config:
json_encoders = {
datetime: lambda v: v.isoformat() if v else None
}
class LogEntry(BaseModel):
id: int
timestamp: datetime
level: Literal["DEBUG", "INFO", "WARN", "ERROR"]
message: str
source: Optional[str] = None
host: Optional[str] = None
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
class SystemMetrics(BaseModel):
online_hosts: int
total_tasks: int
success_rate: float
uptime: float
cpu_usage: float
memory_usage: float
disk_usage: float
class HealthCheck(BaseModel):
host: str
ssh_ok: bool = False
ansible_ok: bool = False
sudo_ok: bool = False
reachable: bool = False
error_message: Optional[str] = None
response_time: Optional[float] = None
cached: bool = False
cache_age: int = 0
class AnsibleExecutionRequest(BaseModel):
playbook: str = Field(..., description="Nom du playbook à exécuter")
target: str = Field(default="all", description="Hôte ou groupe cible")
extra_vars: Optional[Dict[str, Any]] = Field(default=None, description="Variables supplémentaires")
check_mode: bool = Field(default=False, description="Mode dry-run (--check)")
verbose: bool = Field(default=False, description="Mode verbeux")
class AnsibleInventoryHost(BaseModel):
name: str
ansible_host: str
group: str
groups: List[str] = [] # All groups this host belongs to
vars: Dict[str, Any] = {}
class TaskRequest(BaseModel):
host: Optional[str] = Field(default=None, description="Hôte cible")
group: Optional[str] = Field(default=None, description="Groupe cible")
action: str = Field(..., description="Action à exécuter")
cmd: Optional[str] = Field(default=None, description="Commande personnalisée")
extra_vars: Optional[Dict[str, Any]] = Field(default=None, description="Variables Ansible")
tags: Optional[List[str]] = Field(default=None, description="Tags Ansible")
dry_run: bool = Field(default=False, description="Mode simulation")
ssh_user: Optional[str] = Field(default=None, description="Utilisateur SSH")
ssh_password: Optional[str] = Field(default=None, description="Mot de passe SSH")
@field_validator('action')
@classmethod
def validate_action(cls, v: str) -> str:
valid_actions = ['upgrade', 'reboot', 'health-check', 'backup', 'deploy', 'rollback', 'maintenance', 'bootstrap']
if v not in valid_actions:
raise ValueError(f'Action doit être l\'une de: {", ".join(valid_actions)}')
return v
class HostRequest(BaseModel):
name: str = Field(..., min_length=3, max_length=100, description="Hostname (ex: server.domain.home)")
# ansible_host peut être soit une IPv4, soit un hostname résolvable → on enlève la contrainte de pattern
ip: Optional[str] = Field(default=None, description="Adresse IP ou hostname (optionnel si hostname résolvable)")
os: str = Field(default="Linux", min_length=3, max_length=50)
ssh_user: Optional[str] = Field(default="root", min_length=1, max_length=50)
ssh_port: int = Field(default=22, ge=1, le=65535)
description: Optional[str] = Field(default=None, max_length=200)
env_group: str = Field(..., description="Groupe d'environnement (ex: env_homelab, env_prod)")
role_groups: List[str] = Field(default=[], description="Groupes de rôles (ex: role_proxmox, role_sbc)")
class HostUpdateRequest(BaseModel):
"""Requête de mise à jour d'un hôte"""
env_group: Optional[str] = Field(default=None, description="Nouveau groupe d'environnement")
role_groups: Optional[List[str]] = Field(default=None, description="Nouveaux groupes de rôles")
ansible_host: Optional[str] = Field(default=None, description="Nouvelle adresse ansible_host")
class GroupRequest(BaseModel):
"""Requête pour créer un groupe"""
name: str = Field(..., min_length=3, max_length=50, description="Nom du groupe (ex: env_prod, role_web)")
type: str = Field(..., description="Type de groupe: 'env' ou 'role'")
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
import re
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
raise ValueError('Le nom du groupe ne peut contenir que des lettres, chiffres, tirets et underscores')
return v
@field_validator('type')
@classmethod
def validate_type(cls, v: str) -> str:
if v not in ['env', 'role']:
raise ValueError("Le type doit être 'env' ou 'role'")
return v
class GroupUpdateRequest(BaseModel):
"""Requête pour modifier un groupe"""
new_name: str = Field(..., min_length=3, max_length=50, description="Nouveau nom du groupe")
@field_validator('new_name')
@classmethod
def validate_new_name(cls, v: str) -> str:
import re
if not re.match(r'^[a-zA-Z0-9_-]+$', v):
raise ValueError('Le nom du groupe ne peut contenir que des lettres, chiffres, tirets et underscores')
return v
class GroupDeleteRequest(BaseModel):
"""Requête pour supprimer un groupe"""
move_hosts_to: Optional[str] = Field(default=None, description="Groupe vers lequel déplacer les hôtes")
class AdHocCommandRequest(BaseModel):
"""Requête pour exécuter une commande ad-hoc Ansible"""
target: str = Field(..., description="Hôte ou groupe cible")
command: str = Field(..., description="Commande shell à exécuter")
module: str = Field(default="shell", description="Module Ansible (shell, command, raw)")
become: bool = Field(default=False, description="Exécuter avec sudo")
timeout: int = Field(default=60, ge=5, le=600, description="Timeout en secondes")
category: Optional[str] = Field(default="default", description="Catégorie d'historique pour cette commande")
class AdHocCommandResult(BaseModel):
"""Résultat d'une commande ad-hoc"""
target: str
command: str
success: bool
return_code: int
stdout: str
stderr: Optional[str] = None
duration: float
hosts_results: Optional[Dict[str, Any]] = None
class AdHocHistoryEntry(BaseModel):
"""Entrée dans l'historique des commandes ad-hoc"""
id: str
command: str
target: str
module: str
become: bool
category: str = "default"
description: Optional[str] = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
last_used: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
use_count: int = 1
class AdHocHistoryCategory(BaseModel):
"""Catégorie pour organiser les commandes ad-hoc"""
name: str
description: Optional[str] = None
color: str = "#7c3aed"
icon: str = "fa-folder"
class TaskLogFile(BaseModel):
"""Représentation d'un fichier de log de tâche"""
id: str
filename: str
path: str
task_name: str
target: str
status: str
date: str # Format YYYY-MM-DD
year: str
month: str
day: str
created_at: datetime
size_bytes: int
# Nouveaux champs pour affichage enrichi
start_time: Optional[str] = None # Format ISO ou HH:MM:SS
end_time: Optional[str] = None # Format ISO ou HH:MM:SS
duration: Optional[str] = None # Durée formatée
duration_seconds: Optional[int] = None # Durée en secondes
hosts: List[str] = [] # Liste des hôtes impliqués
category: Optional[str] = None # Catégorie (Playbook, Ad-hoc, etc.)
subcategory: Optional[str] = None # Sous-catégorie
target_type: Optional[str] = None # Type de cible: 'host', 'group', 'role'
source_type: Optional[str] = None # Source: 'scheduled', 'manual', 'adhoc'
class TasksFilterParams(BaseModel):
"""Paramètres de filtrage des tâches"""
status: Optional[str] = None # pending, running, completed, failed, all
year: Optional[str] = None
month: Optional[str] = None
day: Optional[str] = None
hour_start: Optional[str] = None # Heure de début HH:MM
hour_end: Optional[str] = None # Heure de fin HH:MM
target: Optional[str] = None
source_type: Optional[str] = None # scheduled, manual, adhoc
search: Optional[str] = None
limit: int = 50 # Pagination côté serveur
offset: int = 0
# ===== MODÈLES PLANIFICATEUR (SCHEDULER) =====
class ScheduleRecurrence(BaseModel):
"""Configuration de récurrence pour un schedule"""
type: Literal["daily", "weekly", "monthly", "custom"] = "daily"
time: str = Field(default="02:00", description="Heure d'exécution HH:MM")
days: Optional[List[int]] = Field(default=None, description="Jours de la semaine (1-7, lundi=1) pour weekly")
day_of_month: Optional[int] = Field(default=None, ge=1, le=31, description="Jour du mois (1-31) pour monthly")
cron_expression: Optional[str] = Field(default=None, description="Expression cron pour custom")
class Schedule(BaseModel):
"""Modèle d'un schedule de playbook"""
id: str = Field(default_factory=lambda: f"sched_{uuid.uuid4().hex[:12]}")
name: str = Field(..., min_length=3, max_length=100, description="Nom du schedule")
description: Optional[str] = Field(default=None, max_length=500)
playbook: str = Field(..., description="Nom du playbook à exécuter")
target_type: Literal["group", "host"] = Field(default="group", description="Type de cible")
target: str = Field(default="all", description="Nom du groupe ou hôte cible")
extra_vars: Optional[Dict[str, Any]] = Field(default=None, description="Variables supplémentaires")
schedule_type: Literal["once", "recurring"] = Field(default="recurring")
recurrence: Optional[ScheduleRecurrence] = Field(default=None)
timezone: str = Field(default="America/Montreal", description="Fuseau horaire")
start_at: Optional[datetime] = Field(default=None, description="Date de début (optionnel)")
end_at: Optional[datetime] = Field(default=None, description="Date de fin (optionnel)")
next_run_at: Optional[datetime] = Field(default=None, description="Prochaine exécution calculée")
last_run_at: Optional[datetime] = Field(default=None, description="Dernière exécution")
last_status: Literal["success", "failed", "running", "never"] = Field(default="never")
enabled: bool = Field(default=True, description="Schedule actif ou en pause")
retry_on_failure: int = Field(default=0, ge=0, le=3, description="Nombre de tentatives en cas d'échec")
timeout: int = Field(default=3600, ge=60, le=86400, description="Timeout en secondes")
notification_type: Literal["none", "all", "errors"] = Field(default="all", description="Type de notification: none, all, errors")
tags: List[str] = Field(default=[], description="Tags pour catégorisation")
run_count: int = Field(default=0, description="Nombre total d'exécutions")
success_count: int = Field(default=0, description="Nombre de succès")
failure_count: int = Field(default=0, description="Nombre d'échecs")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Config:
json_encoders = {
datetime: lambda v: v.isoformat() if v else None
}
@field_validator('recurrence', mode='before')
@classmethod
def validate_recurrence(cls, v, info):
# Si schedule_type est 'once', recurrence n'est pas obligatoire
return v
class ScheduleRun(BaseModel):
"""Historique d'une exécution de schedule"""
id: str = Field(default_factory=lambda: f"run_{uuid.uuid4().hex[:12]}")
schedule_id: str = Field(..., description="ID du schedule parent")
task_id: Optional[str] = Field(default=None, description="ID de la tâche créée")
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
finished_at: Optional[datetime] = Field(default=None)
status: Literal["running", "success", "failed", "canceled"] = Field(default="running")
duration_seconds: Optional[float] = Field(default=None)
hosts_impacted: int = Field(default=0)
error_message: Optional[str] = Field(default=None)
retry_attempt: int = Field(default=0, description="Numéro de la tentative (0 = première)")
class Config:
json_encoders = {
datetime: lambda v: v.isoformat() if v else None
}
class ScheduleCreateRequest(BaseModel):
"""Requête de création d'un schedule"""
name: str = Field(..., min_length=3, max_length=100)
description: Optional[str] = Field(default=None, max_length=500)
playbook: str = Field(...)
target_type: Literal["group", "host"] = Field(default="group")
target: str = Field(default="all")
extra_vars: Optional[Dict[str, Any]] = Field(default=None)
schedule_type: Literal["once", "recurring"] = Field(default="recurring")
recurrence: Optional[ScheduleRecurrence] = Field(default=None)
timezone: str = Field(default="America/Montreal")
start_at: Optional[datetime] = Field(default=None)
end_at: Optional[datetime] = Field(default=None)
enabled: bool = Field(default=True)
retry_on_failure: int = Field(default=0, ge=0, le=3)
timeout: int = Field(default=3600, ge=60, le=86400)
notification_type: Literal["none", "all", "errors"] = Field(default="all")
tags: List[str] = Field(default=[])
@field_validator('timezone')
@classmethod
def validate_timezone(cls, v: str) -> str:
try:
pytz.timezone(v)
return v
except pytz.exceptions.UnknownTimeZoneError:
raise ValueError(f"Fuseau horaire invalide: {v}")
class ScheduleUpdateRequest(BaseModel):
"""Requête de mise à jour d'un schedule"""
name: Optional[str] = Field(default=None, min_length=3, max_length=100)
description: Optional[str] = Field(default=None, max_length=500)
playbook: Optional[str] = Field(default=None)
target_type: Optional[Literal["group", "host"]] = Field(default=None)
target: Optional[str] = Field(default=None)
extra_vars: Optional[Dict[str, Any]] = Field(default=None)
schedule_type: Optional[Literal["once", "recurring"]] = Field(default=None)
recurrence: Optional[ScheduleRecurrence] = Field(default=None)
timezone: Optional[str] = Field(default=None)
start_at: Optional[datetime] = Field(default=None)
end_at: Optional[datetime] = Field(default=None)
enabled: Optional[bool] = Field(default=None)
retry_on_failure: Optional[int] = Field(default=None, ge=0, le=3)
timeout: Optional[int] = Field(default=None, ge=60, le=86400)
notification_type: Optional[Literal["none", "all", "errors"]] = Field(default=None)
tags: Optional[List[str]] = Field(default=None)
class ScheduleStats(BaseModel):
"""Statistiques globales des schedules"""
total: int = 0
active: int = 0
paused: int = 0
expired: int = 0
next_execution: Optional[datetime] = None
next_schedule_name: Optional[str] = None
failures_24h: int = 0
executions_24h: int = 0
success_rate_7d: float = 0.0
# ===== SERVICE DE LOGGING MARKDOWN =====
class TaskLogService:
"""Service pour gérer les logs de tâches en fichiers markdown"""
def __init__(self, base_dir: Path):
self.base_dir = base_dir
self._ensure_base_dir()
# Cache des métadonnées pour éviter de relire les fichiers
self._metadata_cache: Dict[str, Dict[str, Any]] = {}
self._cache_file = base_dir / ".metadata_cache.json"
# Index complet des logs (construit une fois, mis à jour incrémentalement)
self._logs_index: List[Dict[str, Any]] = []
self._index_built = False
self._last_scan_time = 0.0
self._load_cache()
def _ensure_base_dir(self):
"""Crée le répertoire de base s'il n'existe pas"""
self.base_dir.mkdir(parents=True, exist_ok=True)
def _load_cache(self):
"""Charge le cache des métadonnées depuis le fichier"""
try:
if self._cache_file.exists():
import json
with open(self._cache_file, 'r', encoding='utf-8') as f:
self._metadata_cache = json.load(f)
except Exception:
self._metadata_cache = {}
def _save_cache(self):
"""Sauvegarde le cache des métadonnées dans le fichier"""
try:
import json
with open(self._cache_file, 'w', encoding='utf-8') as f:
json.dump(self._metadata_cache, f, ensure_ascii=False)
except Exception:
pass
def _get_cached_metadata(self, file_path: str, file_mtime: float) -> Optional[Dict[str, Any]]:
"""Récupère les métadonnées du cache si elles sont valides"""
cached = self._metadata_cache.get(file_path)
if cached and cached.get('_mtime') == file_mtime:
return cached
return None
def _cache_metadata(self, file_path: str, file_mtime: float, metadata: Dict[str, Any]):
"""Met en cache les métadonnées d'un fichier"""
metadata['_mtime'] = file_mtime
self._metadata_cache[file_path] = metadata
def _build_index(self, force: bool = False):
"""Construit l'index complet des logs (appelé une seule fois au démarrage ou après 60s)"""
import time
current_time = time.time()
# Ne reconstruire que si nécessaire (toutes les 60 secondes max ou si forcé)
if self._index_built and not force and (current_time - self._last_scan_time) < 60:
return
self._logs_index = []
cache_updated = False
if not self.base_dir.exists():
self._index_built = True
self._last_scan_time = current_time
return
# Parcourir tous les fichiers
for year_dir in self.base_dir.iterdir():
if not year_dir.is_dir() or not year_dir.name.isdigit():
continue
for month_dir in year_dir.iterdir():
if not month_dir.is_dir():
continue
for day_dir in month_dir.iterdir():
if not day_dir.is_dir():
continue
for md_file in day_dir.glob("*.md"):
try:
entry = self._index_file(md_file)
if entry:
if entry.get('_cache_updated'):
cache_updated = True
del entry['_cache_updated']
self._logs_index.append(entry)
except Exception:
continue
# Trier par date décroissante
self._logs_index.sort(key=lambda x: x.get('created_at', 0), reverse=True)
self._index_built = True
self._last_scan_time = current_time
if cache_updated:
self._save_cache()
def _index_file(self, md_file: Path) -> Optional[Dict[str, Any]]:
"""Indexe un fichier markdown et retourne ses métadonnées"""
parts = md_file.stem.split("_")
if len(parts) < 4:
return None
file_status = parts[-1]
file_hour_str = parts[1] if len(parts) > 1 else "000000"
# Extraire la date du chemin
try:
rel_path = md_file.relative_to(self.base_dir)
path_parts = rel_path.parts
if len(path_parts) >= 3:
log_year, log_month, log_day = path_parts[0], path_parts[1], path_parts[2]
else:
return None
except:
return None
stat = md_file.stat()
file_path_str = str(md_file)
file_mtime = stat.st_mtime
# Vérifier le cache
cached = self._get_cached_metadata(file_path_str, file_mtime)
cache_updated = False
if cached:
task_name = cached.get('task_name', '')
file_target = cached.get('target', '')
metadata = cached
else:
# Lire le fichier
if len(parts) >= 5:
file_target = parts[3]
task_name_from_file = "_".join(parts[4:-1]) if len(parts) > 5 else parts[4] if len(parts) > 4 else "unknown"
else:
file_target = ""
task_name_from_file = "_".join(parts[3:-1]) if len(parts) > 4 else parts[3] if len(parts) > 3 else "unknown"
try:
content = md_file.read_text(encoding='utf-8')
metadata = self._parse_markdown_metadata(content)
task_name_match = re.search(r'^#\s*[✅❌🔄⏳🚫❓]?\s*(.+)$', content, re.MULTILINE)
if task_name_match:
task_name = task_name_match.group(1).strip()
else:
task_name = task_name_from_file.replace("_", " ")
target_match = re.search(r'\|\s*\*\*Cible\*\*\s*\|\s*`([^`]+)`', content)
if target_match:
file_target = target_match.group(1).strip()
detected_source = self._detect_source_type(task_name, content)
metadata['source_type'] = detected_source
metadata['task_name'] = task_name
metadata['target'] = file_target
self._cache_metadata(file_path_str, file_mtime, metadata)
cache_updated = True
except Exception:
metadata = {'source_type': 'manual'}
task_name = task_name_from_file.replace("_", " ")
return {
'id': parts[0] + "_" + parts[1] + "_" + parts[2] if len(parts) > 2 else parts[0],
'filename': md_file.name,
'path': file_path_str,
'task_name': task_name,
'target': file_target,
'status': file_status,
'date': f"{log_year}-{log_month}-{log_day}",
'year': log_year,
'month': log_month,
'day': log_day,
'hour_str': file_hour_str,
'created_at': stat.st_ctime,
'size_bytes': stat.st_size,
'start_time': metadata.get('start_time'),
'end_time': metadata.get('end_time'),
'duration': metadata.get('duration'),
'duration_seconds': metadata.get('duration_seconds'),
'hosts': metadata.get('hosts', []),
'category': metadata.get('category'),
'subcategory': metadata.get('subcategory'),
'target_type': metadata.get('target_type'),
'source_type': metadata.get('source_type'),
'_cache_updated': cache_updated
}
def invalidate_index(self):
"""Force la reconstruction de l'index au prochain appel"""
self._index_built = False
def _get_date_path(self, dt: datetime = None) -> Path:
"""Retourne le chemin du répertoire pour une date donnée (YYYY/MM/JJ)"""
if dt is None:
dt = datetime.now(timezone.utc)
year = dt.strftime("%Y")
month = dt.strftime("%m")
day = dt.strftime("%d")
return self.base_dir / year / month / day
def _generate_task_id(self) -> str:
"""Génère un ID unique pour une tâche"""
import uuid
return f"task_{datetime.now(timezone.utc).strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}"
def save_task_log(self, task: 'Task', output: str = "", error: str = "", source_type: str = None) -> str:
"""Sauvegarde un log de tâche en markdown et retourne le chemin.
Args:
task: L'objet tâche
output: La sortie de la tâche
error: Les erreurs éventuelles
source_type: Type de source ('scheduled', 'manual', 'adhoc')
"""
dt = task.start_time or datetime.now(timezone.utc)
date_path = self._get_date_path(dt)
date_path.mkdir(parents=True, exist_ok=True)
# Générer le nom du fichier
task_id = self._generate_task_id()
status_emoji = {
"completed": "",
"failed": "",
"running": "🔄",
"pending": "",
"cancelled": "🚫"
}.get(task.status, "")
# Détecter le type de source si non fourni
if not source_type:
task_name_lower = task.name.lower()
if '[planifié]' in task_name_lower or '[scheduled]' in task_name_lower:
source_type = 'scheduled'
elif 'ad-hoc' in task_name_lower or 'adhoc' in task_name_lower:
source_type = 'adhoc'
else:
source_type = 'manual'
# Labels pour le type de source
source_labels = {'scheduled': 'Planifié', 'manual': 'Manuel', 'adhoc': 'Ad-hoc'}
source_label = source_labels.get(source_type, 'Manuel')
# Sanitize task name and host for filename
safe_name = task.name.replace(' ', '_').replace(':', '').replace('/', '-')[:50]
safe_host = task.host.replace(' ', '_').replace(':', '').replace('/', '-')[:30] if task.host else 'unknown'
filename = f"{task_id}_{safe_host}_{safe_name}_{task.status}.md"
filepath = date_path / filename
# Créer le contenu markdown
md_content = f"""# {status_emoji} {task.name}
## Informations
| Propriété | Valeur |
|-----------|--------|
| **ID** | `{task.id}` |
| **Nom** | {task.name} |
| **Cible** | `{task.host}` |
| **Statut** | {task.status} |
| **Type** | {source_label} |
| **Progression** | {task.progress}% |
| **Début** | {task.start_time.isoformat() if task.start_time else 'N/A'} |
| **Fin** | {task.end_time.isoformat() if task.end_time else 'N/A'} |
| **Durée** | {task.duration or 'N/A'} |
## Sortie
```
{output or task.output or '(Aucune sortie)'}
```
"""
if error or task.error:
md_content += f"""## Erreurs
```
{error or task.error}
```
"""
md_content += f"""---
*Généré automatiquement par Homelab Automation Dashboard*
*Date: {datetime.now(timezone.utc).isoformat()}*
"""
# Écrire le fichier
filepath.write_text(md_content, encoding='utf-8')
# Invalider l'index pour qu'il soit reconstruit au prochain appel
self.invalidate_index()
return str(filepath)
def _parse_markdown_metadata(self, content: str) -> Dict[str, Any]:
"""Parse le contenu markdown pour extraire les métadonnées enrichies"""
metadata = {
'start_time': None,
'end_time': None,
'duration': None,
'duration_seconds': None,
'hosts': [],
'category': None,
'subcategory': None,
'target_type': None,
'source_type': None
}
# Extraire les heures de début et fin
start_match = re.search(r'\|\s*\*\*Début\*\*\s*\|\s*([^|]+)', content)
if start_match:
start_val = start_match.group(1).strip()
if start_val and start_val != 'N/A':
metadata['start_time'] = start_val
end_match = re.search(r'\|\s*\*\*Fin\*\*\s*\|\s*([^|]+)', content)
if end_match:
end_val = end_match.group(1).strip()
if end_val and end_val != 'N/A':
metadata['end_time'] = end_val
duration_match = re.search(r'\|\s*\*\*Durée\*\*\s*\|\s*([^|]+)', content)
if duration_match:
dur_val = duration_match.group(1).strip()
if dur_val and dur_val != 'N/A':
metadata['duration'] = dur_val
# Convertir en secondes si possible
metadata['duration_seconds'] = self._parse_duration_to_seconds(dur_val)
# Extraire les hôtes depuis la sortie Ansible
# Pattern pour les hôtes dans PLAY RECAP ou les résultats de tâches
host_patterns = [
r'^([a-zA-Z0-9][a-zA-Z0-9._-]+)\s*:\s*ok=', # PLAY RECAP format
r'^\s*([a-zA-Z0-9][a-zA-Z0-9._-]+)\s*\|\s*(SUCCESS|CHANGED|FAILED|UNREACHABLE)', # Ad-hoc format
]
hosts_found = set()
for pattern in host_patterns:
for match in re.finditer(pattern, content, re.MULTILINE):
host = match.group(1).strip()
if host and len(host) > 2 and '.' in host or len(host) > 5:
hosts_found.add(host)
metadata['hosts'] = sorted(list(hosts_found))
# Détecter la catégorie
task_name_match = re.search(r'^#\s*[✅❌🔄⏳🚫❓]?\s*(.+)$', content, re.MULTILINE)
if task_name_match:
task_name = task_name_match.group(1).strip().lower()
if 'playbook' in task_name:
metadata['category'] = 'Playbook'
# Extraire sous-catégorie du nom
if 'health' in task_name:
metadata['subcategory'] = 'Health Check'
elif 'backup' in task_name:
metadata['subcategory'] = 'Backup'
elif 'upgrade' in task_name or 'update' in task_name:
metadata['subcategory'] = 'Upgrade'
elif 'bootstrap' in task_name:
metadata['subcategory'] = 'Bootstrap'
elif 'reboot' in task_name:
metadata['subcategory'] = 'Reboot'
elif 'ad-hoc' in task_name or 'adhoc' in task_name:
metadata['category'] = 'Ad-hoc'
else:
metadata['category'] = 'Autre'
# Détecter le type de cible
target_match = re.search(r'\|\s*\*\*Cible\*\*\s*\|\s*`([^`]+)`', content)
if target_match:
target_val = target_match.group(1).strip()
if target_val == 'all':
metadata['target_type'] = 'group'
elif target_val.startswith('env_') or target_val.startswith('role_'):
metadata['target_type'] = 'group'
elif '.' in target_val:
metadata['target_type'] = 'host'
else:
metadata['target_type'] = 'group'
# Extraire le type de source depuis le markdown (si présent)
type_match = re.search(r'\|\s*\*\*Type\*\*\s*\|\s*([^|]+)', content)
if type_match:
type_val = type_match.group(1).strip().lower()
if 'planifié' in type_val or 'scheduled' in type_val:
metadata['source_type'] = 'scheduled'
elif 'ad-hoc' in type_val or 'adhoc' in type_val:
metadata['source_type'] = 'adhoc'
elif 'manuel' in type_val or 'manual' in type_val:
metadata['source_type'] = 'manual'
return metadata
def _parse_duration_to_seconds(self, duration_str: str) -> Optional[int]:
"""Convertit une chaîne de durée en secondes"""
if not duration_str:
return None
total_seconds = 0
# Pattern: Xh Xm Xs ou X:XX:XX ou Xs
s_clean = duration_str.strip()
# Gérer explicitement les secondes seules (avec éventuellement des décimales),
# par ex. "1.69s" ou "2,5 s"
sec_only_match = re.match(r'^(\d+(?:[\.,]\d+)?)\s*s$', s_clean)
if sec_only_match:
sec_val_str = sec_only_match.group(1).replace(',', '.')
try:
sec_val = float(sec_val_str)
except ValueError:
sec_val = 0.0
return int(round(sec_val)) if sec_val > 0 else None
# Format HH:MM:SS
hms_match = re.match(r'^(\d+):(\d+):(\d+)$', s_clean)
if hms_match:
h, m, s = map(int, hms_match.groups())
return h * 3600 + m * 60 + s
# Format avec h, m, s (entiers uniquement, pour éviter de mal parser des décimales)
hours = re.search(r'(\d+)\s*h', s_clean)
minutes = re.search(r'(\d+)\s*m', s_clean)
seconds = re.search(r'(\d+)\s*s', s_clean)
if hours:
total_seconds += int(hours.group(1)) * 3600
if minutes:
total_seconds += int(minutes.group(1)) * 60
if seconds:
total_seconds += int(seconds.group(1))
return total_seconds if total_seconds > 0 else None
def get_task_logs(self,
year: str = None,
month: str = None,
day: str = None,
status: str = None,
target: str = None,
category: str = None,
source_type: str = None,
hour_start: str = None,
hour_end: str = None,
limit: int = 50,
offset: int = 0) -> tuple[List[TaskLogFile], int]:
"""Récupère la liste des logs de tâches avec filtrage et pagination.
OPTIMISATION: Utilise un index en mémoire construit une seule fois,
puis filtre rapidement sans relire les fichiers.
Returns:
tuple: (logs paginés, total count avant pagination)
"""
# Construire l'index si nécessaire (une seule fois, puis toutes les 60s)
self._build_index()
# Convertir les heures de filtrage en minutes pour comparaison
hour_start_minutes = None
hour_end_minutes = None
if hour_start:
try:
h, m = map(int, hour_start.split(':'))
hour_start_minutes = h * 60 + m
except:
pass
if hour_end:
try:
h, m = map(int, hour_end.split(':'))
hour_end_minutes = h * 60 + m
except:
pass
# Filtrer l'index (très rapide, pas de lecture de fichiers)
filtered = []
for entry in self._logs_index:
# Filtrer par date
if year and entry['year'] != year:
continue
if month and entry['month'] != month:
continue
if day and entry['day'] != day:
continue
# Filtrer par statut
if status and status != "all" and entry['status'] != status:
continue
# Filtrer par heure
if hour_start_minutes is not None or hour_end_minutes is not None:
try:
file_hour_str = entry.get('hour_str', '000000')
file_h = int(file_hour_str[:2])
file_m = int(file_hour_str[2:4])
file_minutes = file_h * 60 + file_m
if hour_start_minutes is not None and file_minutes < hour_start_minutes:
continue
if hour_end_minutes is not None and file_minutes > hour_end_minutes:
continue
except:
pass
# Filtrer par target
if target and target != "all":
file_target = entry.get('target', '')
if file_target and target.lower() not in file_target.lower():
continue
# Filtrer par catégorie
if category and category != "all":
file_category = entry.get('category', '')
if file_category and category.lower() not in file_category.lower():
continue
# Filtrer par type de source
if source_type and source_type != "all":
file_source = entry.get('source_type', '')
if file_source != source_type:
continue
filtered.append(entry)
# Convertir en TaskLogFile
total_count = len(filtered)
paginated = filtered[offset:offset + limit] if limit > 0 else filtered
logs = [
TaskLogFile(
id=e['id'],
filename=e['filename'],
path=e['path'],
task_name=e['task_name'],
target=e['target'],
status=e['status'],
date=e['date'],
year=e['year'],
month=e['month'],
day=e['day'],
created_at=datetime.fromtimestamp(e['created_at'], tz=timezone.utc),
size_bytes=e['size_bytes'],
start_time=e.get('start_time'),
end_time=e.get('end_time'),
duration=e.get('duration'),
duration_seconds=e.get('duration_seconds'),
hosts=e.get('hosts', []),
category=e.get('category'),
subcategory=e.get('subcategory'),
target_type=e.get('target_type'),
source_type=e.get('source_type')
)
for e in paginated
]
return logs, total_count
def _detect_source_type(self, task_name: str, content: str) -> str:
"""Détecte le type de source d'une tâche: scheduled, manual, adhoc"""
task_name_lower = task_name.lower()
content_lower = content.lower()
# Détecter les tâches planifiées
if '[planifié]' in task_name_lower or '[scheduled]' in task_name_lower:
return 'scheduled'
if 'schedule_id' in content_lower or 'planifié' in content_lower:
return 'scheduled'
# Détecter les commandes ad-hoc
if 'ad-hoc' in task_name_lower or 'adhoc' in task_name_lower:
return 'adhoc'
if 'commande ad-hoc' in content_lower or 'ansible ad-hoc' in content_lower:
return 'adhoc'
# Pattern ad-hoc: module ansible direct (ping, shell, command, etc.)
if re.search(r'\|\s*\*\*Module\*\*\s*\|', content):
return 'adhoc'
# Par défaut, c'est une exécution manuelle de playbook
return 'manual'
def get_available_dates(self) -> Dict[str, Any]:
"""Retourne la structure des dates disponibles pour le filtrage"""
dates = {"years": {}}
if not self.base_dir.exists():
return dates
for year_dir in sorted(self.base_dir.iterdir(), reverse=True):
if year_dir.is_dir() and year_dir.name.isdigit():
year = year_dir.name
dates["years"][year] = {"months": {}}
for month_dir in sorted(year_dir.iterdir(), reverse=True):
if month_dir.is_dir() and month_dir.name.isdigit():
month = month_dir.name
dates["years"][year]["months"][month] = {"days": []}
for day_dir in sorted(month_dir.iterdir(), reverse=True):
if day_dir.is_dir() and day_dir.name.isdigit():
day = day_dir.name
count = len(list(day_dir.glob("*.md")))
dates["years"][year]["months"][month]["days"].append({
"day": day,
"count": count
})
return dates
def get_stats(self) -> Dict[str, int]:
"""Retourne les statistiques des tâches"""
stats = {"total": 0, "completed": 0, "failed": 0, "running": 0, "pending": 0}
# Utiliser limit=0 pour récupérer tous les logs (sans pagination)
logs, _ = self.get_task_logs(limit=0)
for log in logs:
stats["total"] += 1
if log.status in stats:
stats[log.status] += 1
return stats
# ===== SERVICE HISTORIQUE COMMANDES AD-HOC (VERSION BD) =====
class AdHocHistoryService:
"""Service pour gérer l'historique des commandes ad-hoc avec catégories.
Implémentation basée sur la BD (table ``logs``) via LogRepository,
sans aucun accès aux fichiers JSON (.adhoc_history.json).
"""
def __init__(self) -> None:
# Pas de fichier, tout est stocké en BD
pass
async def _get_commands_logs(self, session: AsyncSession) -> List["Log"]:
from models.log import Log
stmt = (
select(Log)
.where(Log.source == "adhoc_history")
.order_by(Log.created_at.desc())
)
result = await session.execute(stmt)
return result.scalars().all()
async def _get_categories_logs(self, session: AsyncSession) -> List["Log"]:
from models.log import Log
stmt = (
select(Log)
.where(Log.source == "adhoc_category")
.order_by(Log.created_at.asc())
)
result = await session.execute(stmt)
return result.scalars().all()
async def add_command(
self,
command: str,
target: str,
module: str,
become: bool,
category: str = "default",
description: str | None = None,
) -> AdHocHistoryEntry:
"""Ajoute ou met à jour une commande dans l'historique (stockée dans logs.details)."""
from models.log import Log
from crud.log import LogRepository
async with async_session_maker() as session:
repo = LogRepository(session)
# Charger tous les logs d'historique et chercher une entrée existante
logs = await self._get_commands_logs(session)
existing_log: Optional[Log] = None
for log in logs:
details = log.details or {}
if details.get("command") == command and details.get("target") == target:
existing_log = log
break
now = datetime.now(timezone.utc)
if existing_log is not None:
details = existing_log.details or {}
details.setdefault("id", details.get("id") or f"adhoc_{existing_log.id}")
details["command"] = command
details["target"] = target
details["module"] = module
details["become"] = bool(become)
details["category"] = category or details.get("category", "default")
if description is not None:
details["description"] = description
details["created_at"] = details.get("created_at") or now.isoformat()
details["last_used"] = now.isoformat()
details["use_count"] = int(details.get("use_count", 1)) + 1
existing_log.details = details
await session.commit()
data = details
else:
import uuid
entry_id = f"adhoc_{uuid.uuid4().hex[:8]}"
details = {
"id": entry_id,
"command": command,
"target": target,
"module": module,
"become": bool(become),
"category": category or "default",
"description": description,
"created_at": now.isoformat(),
"last_used": now.isoformat(),
"use_count": 1,
}
log = await repo.create(
level="INFO",
source="adhoc_history",
message=command,
details=details,
)
await session.commit()
data = log.details or details
# Construire l'entrée Pydantic
return AdHocHistoryEntry(
id=data.get("id"),
command=data.get("command", command),
target=data.get("target", target),
module=data.get("module", module),
become=bool(data.get("become", become)),
category=data.get("category", category or "default"),
description=data.get("description", description),
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00"))
if isinstance(data.get("created_at"), str)
else now,
last_used=datetime.fromisoformat(data["last_used"].replace("Z", "+00:00"))
if isinstance(data.get("last_used"), str)
else now,
use_count=int(data.get("use_count", 1)),
)
async def get_commands(
self,
category: str | None = None,
search: str | None = None,
limit: int = 50,
) -> List[AdHocHistoryEntry]:
"""Récupère les commandes de l'historique depuis la BD."""
async with async_session_maker() as session:
logs = await self._get_commands_logs(session)
commands: List[AdHocHistoryEntry] = []
for log in logs:
details = log.details or {}
cmd = details.get("command") or log.message or ""
if category and details.get("category", "default") != category:
continue
if search and search.lower() not in cmd.lower():
continue
created_at_raw = details.get("created_at")
last_used_raw = details.get("last_used")
try:
created_at = (
datetime.fromisoformat(created_at_raw.replace("Z", "+00:00"))
if isinstance(created_at_raw, str)
else log.created_at
)
except Exception:
created_at = log.created_at
try:
last_used = (
datetime.fromisoformat(last_used_raw.replace("Z", "+00:00"))
if isinstance(last_used_raw, str)
else created_at
)
except Exception:
last_used = created_at
entry = AdHocHistoryEntry(
id=details.get("id") or f"adhoc_{log.id}",
command=cmd,
target=details.get("target", ""),
module=details.get("module", "shell"),
become=bool(details.get("become", False)),
category=details.get("category", "default"),
description=details.get("description"),
created_at=created_at,
last_used=last_used,
use_count=int(details.get("use_count", 1)),
)
commands.append(entry)
# Trier par last_used décroissant
commands.sort(key=lambda x: x.last_used, reverse=True)
return commands[:limit]
async def get_categories(self) -> List[AdHocHistoryCategory]:
"""Récupère la liste des catégories depuis la BD.
Si aucune catégorie n'est présente, les catégories par défaut sont créées.
"""
from crud.log import LogRepository
async with async_session_maker() as session:
logs = await self._get_categories_logs(session)
if not logs:
# Initialiser avec les catégories par défaut
defaults = [
{"name": "default", "description": "Commandes générales", "color": "#7c3aed", "icon": "fa-terminal"},
{"name": "diagnostic", "description": "Commandes de diagnostic", "color": "#10b981", "icon": "fa-stethoscope"},
{"name": "maintenance", "description": "Commandes de maintenance", "color": "#f59e0b", "icon": "fa-wrench"},
{"name": "deployment", "description": "Commandes de déploiement", "color": "#3b82f6", "icon": "fa-rocket"},
]
repo = LogRepository(session)
for cat in defaults:
await repo.create(
level="INFO",
source="adhoc_category",
message=cat["name"],
details=cat,
)
await session.commit()
logs = await self._get_categories_logs(session)
categories: List[AdHocHistoryCategory] = []
for log in logs:
data = log.details or {}
categories.append(
AdHocHistoryCategory(
name=data.get("name") or log.message,
description=data.get("description"),
color=data.get("color", "#7c3aed"),
icon=data.get("icon", "fa-folder"),
)
)
return categories
async def add_category(
self,
name: str,
description: str | None = None,
color: str = "#7c3aed",
icon: str = "fa-folder",
) -> AdHocHistoryCategory:
"""Ajoute une nouvelle catégorie en BD (ou renvoie l'existante)."""
from crud.log import LogRepository
async with async_session_maker() as session:
logs = await self._get_categories_logs(session)
for log in logs:
data = log.details or {}
if data.get("name") == name:
return AdHocHistoryCategory(
name=data.get("name"),
description=data.get("description"),
color=data.get("color", color),
icon=data.get("icon", icon),
)
repo = LogRepository(session)
details = {
"name": name,
"description": description,
"color": color,
"icon": icon,
}
await repo.create(
level="INFO",
source="adhoc_category",
message=name,
details=details,
)
await session.commit()
return AdHocHistoryCategory(**details)
async def delete_command(self, command_id: str) -> bool:
"""Supprime une commande de l'historique (ligne dans logs)."""
from models.log import Log
async with async_session_maker() as session:
stmt = select(Log).where(Log.source == "adhoc_history")
result = await session.execute(stmt)
logs = result.scalars().all()
target_log: Optional[Log] = None
for log in logs:
details = log.details or {}
if details.get("id") == command_id:
target_log = log
break
if not target_log:
return False
await session.delete(target_log)
await session.commit()
return True
async def update_command_category(
self,
command_id: str,
category: str,
description: str | None = None,
) -> bool:
"""Met à jour la catégorie d'une commande dans l'historique."""
from models.log import Log
async with async_session_maker() as session:
stmt = select(Log).where(Log.source == "adhoc_history")
result = await session.execute(stmt)
logs = result.scalars().all()
for log in logs:
details = log.details or {}
if details.get("id") == command_id:
details["category"] = category
if description is not None:
details["description"] = description
log.details = details
await session.commit()
return True
return False
async def update_category(
self,
category_name: str,
new_name: str,
description: str,
color: str,
icon: str,
) -> bool:
"""Met à jour une catégorie existante et les commandes associées."""
from models.log import Log
async with async_session_maker() as session:
# Mettre à jour la catégorie elle-même
logs_cat = await self._get_categories_logs(session)
target_log: Optional[Log] = None
for log in logs_cat:
data = log.details or {}
if data.get("name") == category_name:
target_log = log
break
if not target_log:
return False
data = target_log.details or {}
old_name = data.get("name", category_name)
data["name"] = new_name
data["description"] = description
data["color"] = color
data["icon"] = icon
target_log.details = data
# Mettre à jour les commandes qui référencent cette catégorie
stmt_cmd = select(Log).where(Log.source == "adhoc_history")
result_cmd = await session.execute(stmt_cmd)
for cmd_log in result_cmd.scalars().all():
det = cmd_log.details or {}
if det.get("category") == old_name:
det["category"] = new_name
cmd_log.details = det
await session.commit()
return True
async def delete_category(self, category_name: str) -> bool:
"""Supprime une catégorie et déplace ses commandes vers 'default'."""
if category_name == "default":
return False
from models.log import Log
async with async_session_maker() as session:
# Trouver la catégorie
logs_cat = await self._get_categories_logs(session)
target_log: Optional[Log] = None
for log in logs_cat:
data = log.details or {}
if data.get("name") == category_name:
target_log = log
break
if not target_log:
return False
# Déplacer les commandes vers "default"
stmt_cmd = select(Log).where(Log.source == "adhoc_history")
result_cmd = await session.execute(stmt_cmd)
for cmd_log in result_cmd.scalars().all():
det = cmd_log.details or {}
if det.get("category") == category_name:
det["category"] = "default"
cmd_log.details = det
# Supprimer la catégorie
await session.delete(target_log)
await session.commit()
return True
# ===== SERVICE BOOTSTRAP STATUS (VERSION BD) =====
class BootstrapStatusService:
"""Service pour gérer le statut de bootstrap des hôtes.
Cette version utilise la base de données SQLite via SQLAlchemy async.
Note: Le modèle BD utilise host_id (FK), mais ce service utilise host_name
pour la compatibilité avec le code existant. Il fait la correspondance via HostRepository.
"""
def __init__(self):
# Cache en mémoire pour éviter les requêtes BD répétées
self._cache: Dict[str, Dict] = {}
async def _get_host_id_by_name(self, session: AsyncSession, host_name: str) -> Optional[str]:
"""Récupère l'ID d'un hôte par son nom"""
from crud.host import HostRepository
repo = HostRepository(session)
host = await repo.get_by_name(host_name)
return host.id if host else None
def set_bootstrap_status(self, host_name: str, success: bool, details: str = None) -> Dict:
"""Enregistre le statut de bootstrap d'un hôte (version synchrone avec cache)"""
status_data = {
"bootstrap_ok": success,
"bootstrap_date": datetime.now(timezone.utc).isoformat(),
"details": details
}
self._cache[host_name] = status_data
# Planifier la sauvegarde en BD de manière asynchrone
asyncio.create_task(self._save_to_db(host_name, success, details))
return status_data
async def _save_to_db(self, host_name: str, success: bool, details: str = None):
"""Sauvegarde le statut dans la BD"""
try:
async with async_session_maker() as session:
host_id = await self._get_host_id_by_name(session, host_name)
if not host_id:
print(f"Host '{host_name}' non trouvé en BD pour bootstrap status")
return
from crud.bootstrap_status import BootstrapStatusRepository
repo = BootstrapStatusRepository(session)
await repo.create(
host_id=host_id,
status="success" if success else "failed",
last_attempt=datetime.now(timezone.utc),
error_message=None if success else details,
)
await session.commit()
except Exception as e:
print(f"Erreur sauvegarde bootstrap status en BD: {e}")
def get_bootstrap_status(self, host_name: str) -> Dict:
"""Récupère le statut de bootstrap d'un hôte depuis le cache"""
return self._cache.get(host_name, {
"bootstrap_ok": False,
"bootstrap_date": None,
"details": None
})
def get_all_status(self) -> Dict[str, Dict]:
"""Récupère le statut de tous les hôtes depuis le cache"""
return self._cache.copy()
def remove_host(self, host_name: str) -> bool:
"""Supprime le statut d'un hôte du cache"""
if host_name in self._cache:
del self._cache[host_name]
return True
return False
async def load_from_db(self):
"""Charge tous les statuts depuis la BD dans le cache (appelé au démarrage)"""
try:
async with async_session_maker() as session:
from crud.bootstrap_status import BootstrapStatusRepository
from crud.host import HostRepository
from sqlalchemy import select
from models.bootstrap_status import BootstrapStatus
from models.host import Host
# Récupérer tous les derniers statuts avec les noms d'hôtes
stmt = (
select(BootstrapStatus, Host.name)
.join(Host, BootstrapStatus.host_id == Host.id)
.order_by(BootstrapStatus.created_at.desc())
)
result = await session.execute(stmt)
# Garder seulement le dernier statut par hôte
seen_hosts = set()
for bs, host_name in result:
if host_name not in seen_hosts:
self._cache[host_name] = {
"bootstrap_ok": bs.status == "success",
"bootstrap_date": bs.last_attempt.isoformat() if bs.last_attempt else bs.created_at.isoformat(),
"details": bs.error_message
}
seen_hosts.add(host_name)
print(f"📋 {len(self._cache)} statut(s) bootstrap chargé(s) depuis la BD")
except Exception as e:
print(f"Erreur chargement bootstrap status depuis BD: {e}")
# ===== SERVICE HOST STATUS =====
class HostStatusService:
"""Service simple pour stocker le statut runtime des hôtes en mémoire.
Cette implémentation ne persiste plus dans un fichier JSON ; les données
sont conservées uniquement pendant la vie du processus.
"""
def __init__(self):
# Dictionnaire: host_name -> {"status": str, "last_seen": Optional[datetime|str], "os": Optional[str]}
self._hosts: Dict[str, Dict[str, Any]] = {}
def set_status(self, host_name: str, status: str, last_seen: Optional[datetime], os_info: Optional[str]) -> Dict:
"""Met à jour le statut d'un hôte en mémoire."""
entry = {
"status": status,
"last_seen": last_seen if isinstance(last_seen, datetime) else last_seen,
"os": os_info,
}
self._hosts[host_name] = entry
return entry
def get_status(self, host_name: str) -> Dict:
"""Récupère le statut d'un hôte, avec valeurs par défaut si absent."""
return self._hosts.get(host_name, {"status": "online", "last_seen": None, "os": None})
def get_all_status(self) -> Dict[str, Dict]:
"""Retourne une copie de tous les statuts connus."""
return dict(self._hosts)
def remove_host(self, host_name: str) -> bool:
"""Supprime le statut d'un hôte de la mémoire."""
if host_name in self._hosts:
del self._hosts[host_name]
return True
return False
# ===== SERVICE PLANIFICATEUR (SCHEDULER) - VERSION BD =====
# Import du modèle SQLAlchemy Schedule (distinct du Pydantic Schedule)
from models.schedule import Schedule as ScheduleModel
from models.schedule_run import ScheduleRun as ScheduleRunModel
class SchedulerService:
"""Service pour gérer les schedules de playbooks avec APScheduler.
Cette version utilise uniquement la base de données SQLite (via SQLAlchemy async)
pour stocker les cédules et leur historique d'exécution.
"""
def __init__(self):
# Configurer APScheduler
jobstores = {'default': MemoryJobStore()}
executors = {'default': AsyncIOExecutor()}
job_defaults = {'coalesce': True, 'max_instances': 1, 'misfire_grace_time': 300}
self.scheduler = AsyncIOScheduler(
jobstores=jobstores,
executors=executors,
job_defaults=job_defaults,
timezone=pytz.UTC
)
self._started = False
# Cache en mémoire des schedules (Pydantic) pour éviter les requêtes BD répétées
self._schedules_cache: Dict[str, Schedule] = {}
async def start_async(self):
"""Démarre le scheduler et charge tous les schedules actifs depuis la BD"""
if not self._started:
self.scheduler.start()
self._started = True
# Charger les schedules actifs depuis la BD
await self._load_active_schedules_from_db()
print("📅 Scheduler démarré avec succès (BD)")
def start(self):
"""Démarre le scheduler (version synchrone pour compatibilité)"""
if not self._started:
self.scheduler.start()
self._started = True
print("📅 Scheduler démarré (chargement BD différé)")
def shutdown(self):
"""Arrête le scheduler proprement"""
if self._started:
self.scheduler.shutdown(wait=False)
self._started = False
async def _load_active_schedules_from_db(self):
"""Charge tous les schedules actifs depuis la BD dans APScheduler"""
try:
async with async_session_maker() as session:
repo = ScheduleRepository(session)
db_schedules = await repo.list(limit=1000)
for db_sched in db_schedules:
if db_sched.enabled:
try:
# Convertir le modèle SQLAlchemy en Pydantic Schedule
pydantic_sched = self._db_to_pydantic(db_sched)
self._schedules_cache[pydantic_sched.id] = pydantic_sched
self._add_job_for_schedule(pydantic_sched)
except Exception as e:
print(f"Erreur chargement schedule {db_sched.id}: {e}")
print(f"📅 {len(self._schedules_cache)} schedule(s) chargé(s) depuis la BD")
except Exception as e:
print(f"Erreur chargement schedules depuis BD: {e}")
def _db_to_pydantic(self, db_sched: ScheduleModel) -> Schedule:
"""Convertit un modèle SQLAlchemy Schedule en Pydantic Schedule"""
# Reconstruire l'objet recurrence depuis les colonnes BD
recurrence = None
if db_sched.recurrence_type:
recurrence = ScheduleRecurrence(
type=db_sched.recurrence_type,
time=db_sched.recurrence_time or "02:00",
days=json.loads(db_sched.recurrence_days) if db_sched.recurrence_days else None,
cron_expression=db_sched.cron_expression,
)
return Schedule(
id=db_sched.id,
name=db_sched.name,
description=db_sched.description,
playbook=db_sched.playbook,
target_type=db_sched.target_type or "group",
target=db_sched.target,
extra_vars=db_sched.extra_vars,
schedule_type=db_sched.schedule_type,
recurrence=recurrence,
timezone=db_sched.timezone or "America/Montreal",
start_at=db_sched.start_at,
end_at=db_sched.end_at,
next_run_at=db_sched.next_run,
last_run_at=db_sched.last_run,
last_status=db_sched.last_status or "never",
enabled=db_sched.enabled,
retry_on_failure=db_sched.retry_on_failure or 0,
timeout=db_sched.timeout or 3600,
notification_type=db_sched.notification_type or "all",
tags=json.loads(db_sched.tags) if db_sched.tags else [],
run_count=db_sched.run_count or 0,
success_count=db_sched.success_count or 0,
failure_count=db_sched.failure_count or 0,
created_at=db_sched.created_at,
updated_at=db_sched.updated_at,
)
def _build_cron_trigger(self, schedule: Schedule) -> Optional[CronTrigger]:
"""Construit un trigger cron à partir de la configuration du schedule"""
if schedule.schedule_type == "once":
return None
recurrence = schedule.recurrence
if not recurrence:
return None
tz = pytz.timezone(schedule.timezone)
hour, minute = recurrence.time.split(':') if recurrence.time else ("2", "0")
try:
if recurrence.type == "daily":
return CronTrigger(hour=int(hour), minute=int(minute), timezone=tz)
elif recurrence.type == "weekly":
# Convertir jours (1-7 lundi=1) en format cron (0-6 lundi=0)
days = recurrence.days or [1]
day_of_week = ','.join(str(d - 1) for d in days)
return CronTrigger(day_of_week=day_of_week, hour=int(hour), minute=int(minute), timezone=tz)
elif recurrence.type == "monthly":
day = recurrence.day_of_month or 1
return CronTrigger(day=day, hour=int(hour), minute=int(minute), timezone=tz)
elif recurrence.type == "custom" and recurrence.cron_expression:
# Parser l'expression cron
parts = recurrence.cron_expression.split()
if len(parts) == 5:
return CronTrigger.from_crontab(recurrence.cron_expression, timezone=tz)
else:
# Expression cron étendue (6 champs avec secondes)
return CronTrigger(
second=parts[0] if len(parts) > 5 else '0',
minute=parts[0] if len(parts) == 5 else parts[1],
hour=parts[1] if len(parts) == 5 else parts[2],
day=parts[2] if len(parts) == 5 else parts[3],
month=parts[3] if len(parts) == 5 else parts[4],
day_of_week=parts[4] if len(parts) == 5 else parts[5],
timezone=tz
)
except Exception as e:
print(f"Erreur construction trigger cron: {e}")
return None
return None
def _add_job_for_schedule(self, schedule: Schedule):
"""Ajoute un job APScheduler pour un schedule"""
job_id = f"schedule_{schedule.id}"
# Supprimer le job existant s'il existe
try:
self.scheduler.remove_job(job_id)
except:
pass
if schedule.schedule_type == "once":
# Exécution unique
if schedule.start_at and schedule.start_at > datetime.now(timezone.utc):
trigger = DateTrigger(run_date=schedule.start_at, timezone=pytz.UTC)
self.scheduler.add_job(
self._execute_schedule,
trigger,
id=job_id,
args=[schedule.id],
replace_existing=True
)
else:
# Exécution récurrente
trigger = self._build_cron_trigger(schedule)
if trigger:
self.scheduler.add_job(
self._execute_schedule,
trigger,
id=job_id,
args=[schedule.id],
replace_existing=True
)
# Calculer et mettre à jour next_run_at
self._update_next_run(schedule.id)
def _update_next_run(self, schedule_id: str):
"""Met à jour le champ next_run dans le cache et planifie la mise à jour BD"""
job_id = f"schedule_{schedule_id}"
try:
job = self.scheduler.get_job(job_id)
if job and job.next_run_time:
# Mettre à jour le cache
if schedule_id in self._schedules_cache:
self._schedules_cache[schedule_id].next_run_at = job.next_run_time
# Mettre à jour la BD de manière asynchrone
asyncio.create_task(self._update_next_run_in_db(schedule_id, job.next_run_time))
except:
pass
async def _update_next_run_in_db(self, schedule_id: str, next_run: datetime):
"""Met à jour next_run dans la BD"""
try:
async with async_session_maker() as session:
repo = ScheduleRepository(session)
db_sched = await repo.get(schedule_id)
if db_sched:
await repo.update(db_sched, next_run=next_run)
await session.commit()
except Exception as e:
print(f"Erreur mise à jour next_run BD: {e}")
async def _update_schedule_in_db(self, schedule: Schedule):
"""Met à jour un schedule dans la BD"""
try:
async with async_session_maker() as session:
repo = ScheduleRepository(session)
db_sched = await repo.get(schedule.id)
if db_sched:
await repo.update(
db_sched,
enabled=schedule.enabled,
last_run=schedule.last_run_at,
last_status=schedule.last_status,
run_count=schedule.run_count,
success_count=schedule.success_count,
failure_count=schedule.failure_count,
)
await session.commit()
except Exception as e:
print(f"Erreur mise à jour schedule BD: {e}")
async def _execute_schedule(self, schedule_id: str):
"""Exécute un schedule (appelé par APScheduler)"""
# Import circulaire évité en utilisant les variables globales
global ws_manager, ansible_service, db, task_log_service
# Récupérer le schedule depuis le cache ou la BD
schedule = self._schedules_cache.get(schedule_id)
if not schedule:
# Charger depuis la BD
try:
async with async_session_maker() as session:
repo = ScheduleRepository(session)
db_sched = await repo.get(schedule_id)
if db_sched:
schedule = self._db_to_pydantic(db_sched)
self._schedules_cache[schedule_id] = schedule
except Exception as e:
print(f"Erreur chargement schedule {schedule_id}: {e}")
if not schedule:
print(f"Schedule {schedule_id} non trouvé")
return
# Vérifier si le schedule est encore actif
if not schedule.enabled:
return
# Vérifier la fenêtre temporelle
now = datetime.now(timezone.utc)
if schedule.end_at and now > schedule.end_at:
# Schedule expiré, le désactiver
schedule.enabled = False
self._schedules_cache[schedule_id] = schedule
await self._update_schedule_in_db(schedule)
return
# Créer un ScheduleRun Pydantic pour les notifications
run = ScheduleRun(schedule_id=schedule_id)
# Mettre à jour le schedule
schedule.last_run_at = now
schedule.last_status = "running"
schedule.run_count += 1
self._schedules_cache[schedule_id] = schedule
# Notifier via WebSocket
try:
await ws_manager.broadcast({
"type": "schedule_run_started",
"data": {
"schedule_id": schedule_id,
"schedule_name": schedule.name,
"run": run.dict(),
"status": "running"
}
})
except:
pass
# Créer une tâche
task_id = str(db.get_next_id("tasks"))
playbook_name = schedule.playbook.replace('.yml', '').replace('-', ' ').title()
task = Task(
id=task_id,
name=f"[Planifié] {playbook_name}",
host=schedule.target,
status="running",
progress=0,
start_time=now
)
db.tasks.insert(0, task)
# Mettre à jour le run avec le task_id
run.task_id = task_id
# Notifier la création de tâche
try:
await ws_manager.broadcast({
"type": "task_created",
"data": task.dict()
})
except:
pass
# Exécuter le playbook
start_time = perf_counter()
try:
result = await ansible_service.execute_playbook(
playbook=schedule.playbook,
target=schedule.target,
extra_vars=schedule.extra_vars,
check_mode=False,
verbose=True
)
execution_time = perf_counter() - start_time
success = result.get("success", False)
# Mettre à jour la tâche
task.status = "completed" if success else "failed"
task.progress = 100
task.end_time = datetime.now(timezone.utc)
task.duration = f"{execution_time:.1f}s"
task.output = result.get("stdout", "")
task.error = result.get("stderr", "") if not success else None
# Mettre à jour le run
run.status = "success" if success else "failed"
run.finished_at = datetime.now(timezone.utc)
run.duration_seconds = execution_time
run.error_message = result.get("stderr", "") if not success else None
# Compter les hôtes impactés
stdout = result.get("stdout", "")
host_count = len(re.findall(r'^[a-zA-Z0-9][a-zA-Z0-9._-]+\s*:\s*ok=', stdout, re.MULTILINE))
run.hosts_impacted = host_count
# Mettre à jour le schedule
schedule.last_status = "success" if success else "failed"
if success:
schedule.success_count += 1
else:
schedule.failure_count += 1
# Sauvegarder le schedule dans le cache et la BD
self._schedules_cache[schedule_id] = schedule
await self._update_schedule_in_db(schedule)
# Sauvegarder le log markdown (tâche planifiée)
try:
task_log_service.save_task_log(
task=task,
output=result.get("stdout", ""),
error=result.get("stderr", ""),
source_type='scheduled'
)
except:
pass
# Notifier
await ws_manager.broadcast({
"type": "schedule_run_finished",
"data": {
"schedule_id": schedule_id,
"schedule_name": schedule.name,
"run": run.dict(),
"status": run.status,
"success": success
}
})
await ws_manager.broadcast({
"type": "task_completed",
"data": {
"id": task_id,
"status": task.status,
"progress": 100,
"duration": task.duration,
"success": success
}
})
# Log
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="INFO" if success else "ERROR",
message=f"Schedule '{schedule.name}' exécuté: {'succès' if success else 'échec'}",
source="scheduler",
host=schedule.target
)
db.logs.insert(0, log_entry)
# Notification NTFY selon le type configuré
await self._send_schedule_notification(schedule, success, run.error_message)
# Enregistrer l'exécution dans la base de données (schedule_runs)
try:
async with async_session_maker() as db_session:
run_repo = ScheduleRunRepository(db_session)
await run_repo.create(
schedule_id=schedule_id,
task_id=task_id,
status=run.status,
started_at=run.started_at,
completed_at=run.finished_at,
duration=run.duration_seconds,
error_message=run.error_message,
output=result.get("stdout", "") if success else result.get("stderr", ""),
)
await db_session.commit()
except Exception:
# Ne jamais casser l'exécution du scheduler à cause de la persistance BD
pass
except Exception as e:
# Échec de l'exécution
execution_time = perf_counter() - start_time
task.status = "failed"
task.end_time = datetime.now(timezone.utc)
task.error = str(e)
run.status = "failed"
run.finished_at = datetime.now(timezone.utc)
run.duration_seconds = execution_time
run.error_message = str(e)
schedule.last_status = "failed"
schedule.failure_count += 1
# Sauvegarder le schedule dans le cache et la BD
self._schedules_cache[schedule_id] = schedule
await self._update_schedule_in_db(schedule)
# Enregistrer l'échec dans la BD (schedule_runs)
try:
async with async_session_maker() as db_session:
run_repo = ScheduleRunRepository(db_session)
await run_repo.create(
schedule_id=schedule_id,
task_id=task_id,
status=run.status,
started_at=run.started_at,
completed_at=run.finished_at,
duration=run.duration_seconds,
error_message=run.error_message,
output=str(e),
)
await db_session.commit()
except Exception:
pass
try:
task_log_service.save_task_log(task=task, error=str(e), source_type='scheduled')
except:
pass
try:
await ws_manager.broadcast({
"type": "schedule_run_finished",
"data": {
"schedule_id": schedule_id,
"run": run.dict(),
"status": "failed",
"error": str(e)
}
})
await ws_manager.broadcast({
"type": "task_failed",
"data": {"id": task_id, "status": "failed", "error": str(e)}
})
except:
pass
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="ERROR",
message=f"Erreur schedule '{schedule.name}': {str(e)}",
source="scheduler",
host=schedule.target
)
db.logs.insert(0, log_entry)
# Notification NTFY pour l'échec
await self._send_schedule_notification(schedule, False, str(e))
# Mettre à jour next_run_at
self._update_next_run(schedule_id)
async def _send_schedule_notification(self, schedule: Schedule, success: bool, error_message: Optional[str] = None):
"""Envoie une notification NTFY selon le type configuré pour le schedule.
Args:
schedule: Le schedule exécuté
success: True si l'exécution a réussi
error_message: Message d'erreur en cas d'échec
"""
# Vérifier le type de notification configuré
notification_type = getattr(schedule, 'notification_type', 'all')
# Ne pas notifier si "none"
if notification_type == "none":
return
# Ne notifier que les erreurs si "errors"
if notification_type == "errors" and success:
return
# Envoyer la notification
try:
if success:
await notification_service.notify_schedule_executed(
schedule_name=schedule.name,
success=True,
details=f"Cible: {schedule.target}"
)
else:
await notification_service.notify_schedule_executed(
schedule_name=schedule.name,
success=False,
details=error_message or "Erreur inconnue"
)
except Exception as notif_error:
print(f"Erreur envoi notification schedule: {notif_error}")
# ===== MÉTHODES PUBLIQUES CRUD (VERSION BD) =====
def get_all_schedules(self,
enabled: Optional[bool] = None,
playbook: Optional[str] = None,
tag: Optional[str] = None) -> List[Schedule]:
"""Récupère tous les schedules depuis le cache avec filtrage optionnel"""
schedules = list(self._schedules_cache.values())
# Filtres
if enabled is not None:
schedules = [s for s in schedules if s.enabled == enabled]
if playbook:
schedules = [s for s in schedules if playbook.lower() in s.playbook.lower()]
if tag:
schedules = [s for s in schedules if tag in s.tags]
# Trier par prochaine exécution
schedules.sort(key=lambda x: x.next_run_at or datetime.max.replace(tzinfo=timezone.utc))
return schedules
def get_schedule(self, schedule_id: str) -> Optional[Schedule]:
"""Récupère un schedule par ID depuis le cache"""
return self._schedules_cache.get(schedule_id)
def create_schedule(self, request: ScheduleCreateRequest) -> Schedule:
"""Crée un nouveau schedule (sauvegarde en BD via l'endpoint)"""
schedule = Schedule(
name=request.name,
description=request.description,
playbook=request.playbook,
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
)
# Ajouter au cache
self._schedules_cache[schedule.id] = schedule
# Ajouter le job si actif
if schedule.enabled and self._started:
self._add_job_for_schedule(schedule)
return schedule
def update_schedule(self, schedule_id: str, request: ScheduleUpdateRequest) -> Optional[Schedule]:
"""Met à jour un schedule existant"""
schedule = self.get_schedule(schedule_id)
if not schedule:
return None
# Appliquer les modifications
update_data = request.dict(exclude_unset=True, exclude_none=True)
for key, value in update_data.items():
# La récurrence arrive du frontend comme un dict, il faut la retransformer
# en objet ScheduleRecurrence pour que _build_cron_trigger fonctionne.
if key == "recurrence" and isinstance(value, dict):
try:
value = ScheduleRecurrence(**value)
except Exception:
pass
if hasattr(schedule, key):
setattr(schedule, key, value)
schedule.updated_at = datetime.now(timezone.utc)
# Mettre à jour le cache
self._schedules_cache[schedule_id] = schedule
# Mettre à jour le job
if self._started:
job_id = f"schedule_{schedule_id}"
try:
self.scheduler.remove_job(job_id)
except:
pass
if schedule.enabled:
self._add_job_for_schedule(schedule)
return schedule
def delete_schedule(self, schedule_id: str) -> bool:
"""Supprime un schedule du cache et du scheduler"""
if schedule_id in self._schedules_cache:
del self._schedules_cache[schedule_id]
# Supprimer le job
job_id = f"schedule_{schedule_id}"
try:
self.scheduler.remove_job(job_id)
except:
pass
return True
def pause_schedule(self, schedule_id: str) -> Optional[Schedule]:
"""Met en pause un schedule"""
schedule = self.get_schedule(schedule_id)
if not schedule:
return None
schedule.enabled = False
self._schedules_cache[schedule_id] = schedule
# Supprimer le job
job_id = f"schedule_{schedule_id}"
try:
self.scheduler.remove_job(job_id)
except:
pass
return schedule
def resume_schedule(self, schedule_id: str) -> Optional[Schedule]:
"""Reprend un schedule en pause"""
schedule = self.get_schedule(schedule_id)
if not schedule:
return None
schedule.enabled = True
self._schedules_cache[schedule_id] = schedule
# Ajouter le job
if self._started:
self._add_job_for_schedule(schedule)
return schedule
async def run_now(self, schedule_id: str) -> Optional[ScheduleRun]:
"""Exécute immédiatement un schedule"""
schedule = self.get_schedule(schedule_id)
if not schedule:
return None
# Exécuter de manière asynchrone
await self._execute_schedule(schedule_id)
# Retourner un ScheduleRun vide (le vrai est en BD)
return ScheduleRun(schedule_id=schedule_id, status="running")
def get_stats(self) -> ScheduleStats:
"""Calcule les statistiques globales des schedules depuis le cache"""
schedules = self.get_all_schedules()
now = datetime.now(timezone.utc)
stats = ScheduleStats()
stats.total = len(schedules)
stats.active = len([s for s in schedules if s.enabled])
stats.paused = len([s for s in schedules if not s.enabled])
# Schedules expirés
stats.expired = len([s for s in schedules if s.end_at and s.end_at < now])
# Prochaine exécution
active_schedules = [s for s in schedules if s.enabled and s.next_run_at]
if active_schedules:
next_schedule = min(active_schedules, key=lambda x: x.next_run_at)
stats.next_execution = next_schedule.next_run_at
stats.next_schedule_name = next_schedule.name
# Stats basées sur les compteurs des schedules (pas besoin de lire les runs)
total_runs = sum(s.run_count for s in schedules)
total_success = sum(s.success_count for s in schedules)
total_failures = sum(s.failure_count for s in schedules)
# Approximation des stats 24h basée sur les compteurs
stats.executions_24h = total_runs # Approximation
stats.failures_24h = total_failures # Approximation
if total_runs > 0:
stats.success_rate_7d = round((total_success / total_runs) * 100, 1)
return stats
def get_upcoming_executions(self, limit: int = 5) -> List[Dict]:
"""Retourne les prochaines exécutions planifiées"""
schedules = self.get_all_schedules(enabled=True)
upcoming = []
for s in schedules:
if s.next_run_at:
upcoming.append({
"schedule_id": s.id,
"schedule_name": s.name,
"playbook": s.playbook,
"target": s.target,
"next_run_at": s.next_run_at.isoformat() if s.next_run_at else None,
"tags": s.tags
})
upcoming.sort(key=lambda x: x['next_run_at'] or '')
return upcoming[:limit]
def validate_cron_expression(self, expression: str) -> Dict:
"""Valide une expression cron et retourne les prochaines exécutions"""
try:
cron = croniter(expression, datetime.now())
next_runs = [cron.get_next(datetime).isoformat() for _ in range(5)]
return {
"valid": True,
"next_runs": next_runs,
"expression": expression
}
except Exception as e:
return {
"valid": False,
"error": str(e),
"expression": expression
}
def add_schedule_to_cache(self, schedule: Schedule):
"""Ajoute un schedule au cache (appelé après création en BD)"""
self._schedules_cache[schedule.id] = schedule
if schedule.enabled and self._started:
self._add_job_for_schedule(schedule)
def remove_schedule_from_cache(self, schedule_id: str):
"""Supprime un schedule du cache"""
if schedule_id in self._schedules_cache:
del self._schedules_cache[schedule_id]
# Instances globales des services
task_log_service = TaskLogService(DIR_LOGS_TASKS)
adhoc_history_service = AdHocHistoryService() # Stockage en BD via la table logs
bootstrap_status_service = BootstrapStatusService() # Plus de fichier JSON, utilise la BD
host_status_service = HostStatusService() # Ne persiste plus dans .host_status.json
scheduler_service = SchedulerService() # Plus de fichiers JSON
class WebSocketManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
self.lock = Lock()
async def connect(self, websocket: WebSocket):
await websocket.accept()
with self.lock:
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
with self.lock:
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
with self.lock:
disconnected = []
for connection in self.active_connections:
try:
await connection.send_json(message)
except:
disconnected.append(connection)
# Nettoyer les connexions déconnectées
for conn in disconnected:
self.active_connections.remove(conn)
# Instance globale du gestionnaire WebSocket
ws_manager = WebSocketManager()
# Dictionnaire pour stocker les tâches asyncio et processus en cours (pour annulation)
# Format: {task_id: {"asyncio_task": Task, "process": Process, "cancelled": bool}}
running_task_handles: Dict[str, Dict] = {}
# Service Ansible
class AnsibleService:
"""Service pour exécuter les playbooks Ansible"""
def __init__(self, ansible_dir: Path):
self.ansible_dir = ansible_dir
self.playbooks_dir = ansible_dir / "playbooks"
self.inventory_path = ansible_dir / "inventory" / "hosts.yml"
self._inventory_cache: Optional[Dict] = None
def get_playbooks(self) -> List[Dict[str, Any]]:
"""Liste les playbooks disponibles avec leurs métadonnées (category/subcategory/hosts).
Les métadonnées sont lues en priorité dans play['vars'] pour être compatibles
avec la syntaxe Ansible (category/subcategory ne sont pas des clés de Play).
Le champ 'hosts' est extrait pour permettre le filtrage par compatibilité.
"""
playbooks = []
if self.playbooks_dir.exists():
for pb in self.playbooks_dir.glob("*.yml"):
# Récupérer les infos du fichier
stat = pb.stat()
playbook_info = {
"name": pb.stem,
"filename": pb.name,
"path": str(pb),
"category": "general",
"subcategory": "other",
"hosts": "all", # Valeur par défaut
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
}
# Extract category/subcategory/hosts from playbook
try:
with open(pb, 'r', encoding='utf-8') as f:
content = yaml.safe_load(f)
if content and isinstance(content, list) and len(content) > 0:
play = content[0]
vars_ = play.get('vars', {}) or {}
# Lecture de category avec fallback: play puis vars
if 'category' in play:
playbook_info['category'] = play['category']
elif 'category' in vars_:
playbook_info['category'] = vars_['category']
# Lecture de subcategory avec fallback
if 'subcategory' in play:
playbook_info['subcategory'] = play['subcategory']
elif 'subcategory' in vars_:
playbook_info['subcategory'] = vars_['subcategory']
# Lecture du champ 'hosts' (cible du playbook)
if 'hosts' in play:
playbook_info['hosts'] = play['hosts']
if 'name' in play:
playbook_info['description'] = play['name']
except Exception:
# On ignore les erreurs de parsing individuelles pour ne pas
# casser l'ensemble de la liste de playbooks.
pass
playbooks.append(playbook_info)
return playbooks
def get_playbook_categories(self) -> Dict[str, List[str]]:
"""Retourne les catégories et sous-catégories des playbooks"""
categories = {}
for pb in self.get_playbooks():
cat = pb.get('category', 'general')
subcat = pb.get('subcategory', 'other')
if cat not in categories:
categories[cat] = []
if subcat not in categories[cat]:
categories[cat].append(subcat)
return categories
def is_target_compatible_with_playbook(self, target: str, playbook_hosts: str) -> bool:
"""Vérifie si une cible (host ou groupe) est compatible avec le champ 'hosts' d'un playbook.
Args:
target: Nom de l'hôte ou du groupe cible
playbook_hosts: Valeur du champ 'hosts' du playbook
Returns:
True si la cible est compatible avec le playbook
Exemples:
- playbook_hosts='all' → compatible avec tout
- playbook_hosts='role_proxmox' → compatible avec le groupe role_proxmox et ses hôtes
- playbook_hosts='server.home' → compatible uniquement avec cet hôte
"""
# 'all' accepte tout
if playbook_hosts == 'all':
return True
# Si la cible correspond exactement au champ hosts
if target == playbook_hosts:
return True
# Charger l'inventaire pour vérifier les appartenances
inventory = self.load_inventory()
# Si playbook_hosts est un groupe, vérifier si target est un hôte de ce groupe
if self.group_exists(playbook_hosts):
hosts_in_group = self.get_group_hosts(playbook_hosts)
if target in hosts_in_group:
return True
# Vérifier aussi si target est un sous-groupe du groupe playbook_hosts
if target in self.get_groups():
# Vérifier si tous les hôtes du groupe target sont dans playbook_hosts
target_hosts = set(self.get_group_hosts(target))
playbook_group_hosts = set(hosts_in_group)
if target_hosts and target_hosts.issubset(playbook_group_hosts):
return True
# Si playbook_hosts est un hôte et target est un groupe contenant cet hôte
if target in self.get_groups():
hosts_in_target = self.get_group_hosts(target)
if playbook_hosts in hosts_in_target:
return True
return False
def get_compatible_playbooks(self, target: str) -> List[Dict[str, Any]]:
"""Retourne la liste des playbooks compatibles avec une cible donnée.
Args:
target: Nom de l'hôte ou du groupe
Returns:
Liste des playbooks compatibles avec leurs métadonnées
"""
all_playbooks = self.get_playbooks()
compatible = []
for pb in all_playbooks:
playbook_hosts = pb.get('hosts', 'all')
if self.is_target_compatible_with_playbook(target, playbook_hosts):
compatible.append(pb)
return compatible
def load_inventory(self) -> Dict:
"""Charge l'inventaire Ansible depuis le fichier YAML"""
if self._inventory_cache:
return self._inventory_cache
if not self.inventory_path.exists():
return {}
with open(self.inventory_path, 'r') as f:
self._inventory_cache = yaml.safe_load(f)
return self._inventory_cache
def get_hosts_from_inventory(self, group_filter: str = None) -> List[AnsibleInventoryHost]:
"""Extrait la liste des hôtes de l'inventaire sans doublons.
Args:
group_filter: Si spécifié, filtre les hôtes par ce groupe
"""
inventory = self.load_inventory()
# Use dict to track unique hosts and accumulate their groups
hosts_dict: Dict[str, AnsibleInventoryHost] = {}
def extract_hosts(data: Dict, current_group: str = ""):
if not isinstance(data, dict):
return
# Extraire les hôtes directs
if 'hosts' in data:
for host_name, host_data in data['hosts'].items():
host_data = host_data or {}
if host_name in hosts_dict:
# Host already exists, add group to its groups list
if current_group and current_group not in hosts_dict[host_name].groups:
hosts_dict[host_name].groups.append(current_group)
else:
# New host
hosts_dict[host_name] = AnsibleInventoryHost(
name=host_name,
ansible_host=host_data.get('ansible_host', host_name),
group=current_group,
groups=[current_group] if current_group else [],
vars=host_data
)
# Parcourir les enfants (sous-groupes)
if 'children' in data:
for child_name, child_data in data['children'].items():
extract_hosts(child_data, child_name)
extract_hosts(inventory.get('all', {}))
# Convert to list
hosts = list(hosts_dict.values())
# Apply group filter if specified
if group_filter and group_filter != 'all':
hosts = [h for h in hosts if group_filter in h.groups]
return hosts
def invalidate_cache(self):
"""Invalide le cache de l'inventaire pour forcer un rechargement"""
self._inventory_cache = None
def get_groups(self) -> List[str]:
"""Extrait la liste des groupes de l'inventaire"""
inventory = self.load_inventory()
groups = set()
def extract_groups(data: Dict, parent: str = ""):
if not isinstance(data, dict):
return
if 'children' in data:
for child_name in data['children'].keys():
groups.add(child_name)
extract_groups(data['children'][child_name], child_name)
extract_groups(inventory.get('all', {}))
return sorted(list(groups))
def get_env_groups(self) -> List[str]:
"""Retourne uniquement les groupes d'environnement (préfixés par env_)"""
return [g for g in self.get_groups() if g.startswith('env_')]
def get_role_groups(self) -> List[str]:
"""Retourne uniquement les groupes de rôles (préfixés par role_)"""
return [g for g in self.get_groups() if g.startswith('role_')]
def _save_inventory(self, inventory: Dict):
"""Sauvegarde l'inventaire dans le fichier YAML"""
# Créer une sauvegarde avant modification
backup_path = self.inventory_path.with_suffix('.yml.bak')
if self.inventory_path.exists():
import shutil
shutil.copy2(self.inventory_path, backup_path)
with open(self.inventory_path, 'w', encoding='utf-8') as f:
yaml.dump(inventory, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
# Invalider le cache
self.invalidate_cache()
def add_host_to_inventory(self, hostname: str, env_group: str, role_groups: List[str], ansible_host: str = None) -> bool:
"""Ajoute un hôte à l'inventaire Ansible
Args:
hostname: Nom de l'hôte (ex: server.domain.home)
env_group: Groupe d'environnement (ex: env_homelab)
role_groups: Liste des groupes de rôles (ex: ['role_proxmox', 'role_sbc'])
ansible_host: Adresse IP ou hostname pour ansible_host (optionnel)
Returns:
True si l'ajout a réussi
"""
inventory = self.load_inventory()
# S'assurer que la structure existe
if 'all' not in inventory:
inventory['all'] = {}
if 'children' not in inventory['all']:
inventory['all']['children'] = {}
children = inventory['all']['children']
# Ajouter au groupe d'environnement
if env_group not in children:
children[env_group] = {'hosts': {}}
if 'hosts' not in children[env_group]:
children[env_group]['hosts'] = {}
# Définir les variables de l'hôte
host_vars = None
if ansible_host and ansible_host != hostname:
host_vars = {'ansible_host': ansible_host}
children[env_group]['hosts'][hostname] = host_vars
# Ajouter aux groupes de rôles
for role_group in role_groups:
if role_group not in children:
children[role_group] = {'hosts': {}}
if 'hosts' not in children[role_group]:
children[role_group]['hosts'] = {}
children[role_group]['hosts'][hostname] = None
self._save_inventory(inventory)
return True
def remove_host_from_inventory(self, hostname: str) -> bool:
"""Supprime un hôte de tous les groupes de l'inventaire
Args:
hostname: Nom de l'hôte à supprimer
Returns:
True si la suppression a réussi
"""
inventory = self.load_inventory()
if 'all' not in inventory or 'children' not in inventory['all']:
return False
children = inventory['all']['children']
removed = False
# Parcourir tous les groupes et supprimer l'hôte
for group_name, group_data in children.items():
if isinstance(group_data, dict) and 'hosts' in group_data:
if hostname in group_data['hosts']:
del group_data['hosts'][hostname]
removed = True
if removed:
self._save_inventory(inventory)
# Supprimer aussi les statuts persistés (bootstrap + health)
bootstrap_status_service.remove_host(hostname)
try:
host_status_service.remove_host(hostname)
except Exception:
pass
return removed
def update_host_groups(self, hostname: str, env_group: str = None, role_groups: List[str] = None, ansible_host: str = None) -> bool:
"""Met à jour les groupes d'un hôte existant
Args:
hostname: Nom de l'hôte à modifier
env_group: Nouveau groupe d'environnement (None = pas de changement)
role_groups: Nouvelle liste de groupes de rôles (None = pas de changement)
ansible_host: Nouvelle adresse ansible_host (None = pas de changement)
Returns:
True si la mise à jour a réussi
"""
inventory = self.load_inventory()
if 'all' not in inventory or 'children' not in inventory['all']:
return False
children = inventory['all']['children']
# Trouver le groupe d'environnement actuel
current_env_group = None
current_role_groups = []
current_ansible_host = None
for group_name, group_data in children.items():
if isinstance(group_data, dict) and 'hosts' in group_data:
if hostname in group_data['hosts']:
if group_name.startswith('env_'):
current_env_group = group_name
# Récupérer ansible_host si défini
host_vars = group_data['hosts'][hostname]
if isinstance(host_vars, dict) and 'ansible_host' in host_vars:
current_ansible_host = host_vars['ansible_host']
elif group_name.startswith('role_'):
current_role_groups.append(group_name)
if not current_env_group:
return False # Hôte non trouvé
# Appliquer les changements
new_env_group = env_group if env_group else current_env_group
new_role_groups = role_groups if role_groups is not None else current_role_groups
new_ansible_host = ansible_host if ansible_host else current_ansible_host
# Supprimer l'hôte de tous les groupes actuels
for group_name, group_data in children.items():
if isinstance(group_data, dict) and 'hosts' in group_data:
if hostname in group_data['hosts']:
del group_data['hosts'][hostname]
# Ajouter au nouveau groupe d'environnement
if new_env_group not in children:
children[new_env_group] = {'hosts': {}}
if 'hosts' not in children[new_env_group]:
children[new_env_group]['hosts'] = {}
host_vars = None
if new_ansible_host and new_ansible_host != hostname:
host_vars = {'ansible_host': new_ansible_host}
children[new_env_group]['hosts'][hostname] = host_vars
# Ajouter aux nouveaux groupes de rôles
for role_group in new_role_groups:
if role_group not in children:
children[role_group] = {'hosts': {}}
if 'hosts' not in children[role_group]:
children[role_group]['hosts'] = {}
children[role_group]['hosts'][hostname] = None
self._save_inventory(inventory)
return True
def host_exists(self, hostname: str) -> bool:
"""Vérifie si un hôte existe dans l'inventaire"""
hosts = self.get_hosts_from_inventory()
return any(h.name == hostname for h in hosts)
def group_exists(self, group_name: str) -> bool:
"""Vérifie si un groupe existe dans l'inventaire"""
return group_name in self.get_groups()
def add_group(self, group_name: str) -> bool:
"""Ajoute un nouveau groupe à l'inventaire
Args:
group_name: Nom du groupe (doit commencer par env_ ou role_)
Returns:
True si l'ajout a réussi
"""
if self.group_exists(group_name):
return False # Groupe existe déjà
inventory = self.load_inventory()
# S'assurer que la structure existe
if 'all' not in inventory:
inventory['all'] = {}
if 'children' not in inventory['all']:
inventory['all']['children'] = {}
# Ajouter le groupe vide
inventory['all']['children'][group_name] = {'hosts': {}}
self._save_inventory(inventory)
return True
def rename_group(self, old_name: str, new_name: str) -> bool:
"""Renomme un groupe dans l'inventaire
Args:
old_name: Nom actuel du groupe
new_name: Nouveau nom du groupe
Returns:
True si le renommage a réussi
"""
if not self.group_exists(old_name):
return False # Groupe source n'existe pas
if self.group_exists(new_name):
return False # Groupe cible existe déjà
inventory = self.load_inventory()
children = inventory.get('all', {}).get('children', {})
if old_name not in children:
return False
# Copier les données du groupe vers le nouveau nom
children[new_name] = children[old_name]
del children[old_name]
self._save_inventory(inventory)
return True
def delete_group(self, group_name: str, move_hosts_to: str = None) -> Dict[str, Any]:
"""Supprime un groupe de l'inventaire
Args:
group_name: Nom du groupe à supprimer
move_hosts_to: Groupe vers lequel déplacer les hôtes (optionnel)
Returns:
Dict avec le résultat de l'opération
"""
if not self.group_exists(group_name):
return {"success": False, "error": "Groupe non trouvé"}
inventory = self.load_inventory()
children = inventory.get('all', {}).get('children', {})
if group_name not in children:
return {"success": False, "error": "Groupe non trouvé dans children"}
group_data = children[group_name]
hosts_in_group = list(group_data.get('hosts', {}).keys()) if group_data else []
# Si des hôtes sont dans le groupe et qu'on veut les déplacer
if hosts_in_group and move_hosts_to:
if not self.group_exists(move_hosts_to) and move_hosts_to != group_name:
# Créer le groupe cible s'il n'existe pas
children[move_hosts_to] = {'hosts': {}}
if move_hosts_to in children:
if 'hosts' not in children[move_hosts_to]:
children[move_hosts_to]['hosts'] = {}
# Déplacer les hôtes
for hostname in hosts_in_group:
host_vars = group_data['hosts'].get(hostname)
children[move_hosts_to]['hosts'][hostname] = host_vars
# Supprimer le groupe
del children[group_name]
self._save_inventory(inventory)
return {
"success": True,
"hosts_affected": hosts_in_group,
"hosts_moved_to": move_hosts_to if hosts_in_group and move_hosts_to else None
}
def get_group_hosts(self, group_name: str) -> List[str]:
"""Retourne la liste des hôtes dans un groupe
Args:
group_name: Nom du groupe
Returns:
Liste des noms d'hôtes
"""
inventory = self.load_inventory()
children = inventory.get('all', {}).get('children', {})
if group_name not in children:
return []
group_data = children[group_name]
if not group_data or 'hosts' not in group_data:
return []
return list(group_data['hosts'].keys())
async def execute_playbook(
self,
playbook: str,
target: str = "all",
extra_vars: Optional[Dict[str, Any]] = None,
check_mode: bool = False,
verbose: bool = False
) -> Dict[str, Any]:
"""Exécute un playbook Ansible"""
# Résoudre le chemin du playbook
# On accepte soit un nom avec extension, soit un nom sans extension (ex: "health-check")
playbook_path = self.playbooks_dir / playbook
# Si le fichier n'existe pas tel quel, essayer avec des extensions courantes
if not playbook_path.exists():
from pathlib import Path
pb_name = Path(playbook).name # enlever d'éventuels chemins
# Si aucune extension n'est fournie, tester .yml puis .yaml
if not Path(pb_name).suffix:
for ext in (".yml", ".yaml"):
candidate = self.playbooks_dir / f"{pb_name}{ext}"
if candidate.exists():
playbook_path = candidate
break
if not playbook_path.exists():
# À ce stade, on n'a trouvé aucun fichier correspondant
raise FileNotFoundError(f"Playbook introuvable: {playbook}")
# Construire la commande ansible-playbook
cmd = [
"ansible-playbook",
str(playbook_path),
"-i", str(self.inventory_path),
"--limit", target
]
if check_mode:
cmd.append("--check")
if verbose:
cmd.append("-v")
if extra_vars:
cmd.extend(["--extra-vars", json.dumps(extra_vars)])
private_key = find_ssh_private_key()
if private_key:
cmd.extend(["--private-key", private_key])
if SSH_USER:
cmd.extend(["-u", SSH_USER])
start_time = perf_counter()
try:
# Exécuter la commande
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(self.ansible_dir)
)
stdout, stderr = await process.communicate()
execution_time = perf_counter() - start_time
return {
"success": process.returncode == 0,
"return_code": process.returncode,
"stdout": stdout.decode('utf-8', errors='replace'),
"stderr": stderr.decode('utf-8', errors='replace'),
"execution_time": round(execution_time, 2),
"command": " ".join(cmd)
}
except FileNotFoundError:
return {
"success": False,
"return_code": -1,
"stdout": "",
"stderr": "ansible-playbook non trouvé. Vérifiez que Ansible est installé.",
"execution_time": 0,
"command": " ".join(cmd)
}
except Exception as e:
return {
"success": False,
"return_code": -1,
"stdout": "",
"stderr": str(e),
"execution_time": perf_counter() - start_time,
"command": " ".join(cmd)
}
# Instance globale du service Ansible
ansible_service = AnsibleService(ANSIBLE_DIR)
# ===== SERVICE BOOTSTRAP SSH =====
class BootstrapRequest(BaseModel):
"""Requête de bootstrap pour un hôte"""
host: str = Field(..., description="Adresse IP ou hostname de l'hôte")
root_password: str = Field(..., description="Mot de passe root pour la connexion initiale")
automation_user: str = Field(default="automation", description="Nom de l'utilisateur d'automatisation à créer")
class CommandResult(BaseModel):
"""Résultat d'une commande SSH"""
status: str
return_code: int
stdout: str
stderr: Optional[str] = None
def find_ssh_private_key() -> Optional[str]:
"""Trouve une clé privée SSH disponible en inspectant plusieurs répertoires."""
candidate_dirs = []
env_path = Path(SSH_KEY_PATH)
candidate_dirs.append(env_path.parent)
candidate_dirs.append(Path("/app/ssh_keys"))
candidate_dirs.append(Path.home() / ".ssh")
seen = set()
key_paths: List[str] = []
for directory in candidate_dirs:
if not directory or not directory.exists():
continue
for name in [
env_path.name,
"id_automation_ansible",
"id_rsa",
"id_ed25519",
"id_ecdsa",
]:
path = directory / name
if str(path) not in seen:
seen.add(str(path))
key_paths.append(str(path))
# Ajouter dynamiquement toutes les clés sans extension .pub
for file in directory.iterdir():
if file.is_file() and not file.suffix and not file.name.startswith("known_hosts"):
if str(file) not in seen:
seen.add(str(file))
key_paths.append(str(file))
for key_path in key_paths:
if key_path and Path(key_path).exists():
return key_path
return None
def run_ssh_command(
host: str,
command: str,
ssh_user: str = "root",
ssh_password: Optional[str] = None,
timeout: int = 60
) -> tuple:
"""Exécute une commande SSH sur un hôte distant.
Returns:
tuple: (return_code, stdout, stderr)
"""
ssh_cmd = ["ssh"]
# Options SSH communes
ssh_opts = [
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "ConnectTimeout=10",
"-o", "BatchMode=no" if ssh_password else "BatchMode=yes",
]
# Si pas de mot de passe, utiliser la clé SSH
if not ssh_password:
private_key = find_ssh_private_key()
if private_key:
ssh_opts.extend(["-i", private_key])
ssh_cmd.extend(ssh_opts)
ssh_cmd.append(f"{ssh_user}@{host}")
ssh_cmd.append(command)
try:
if ssh_password:
# Utiliser sshpass pour l'authentification par mot de passe
full_cmd = ["sshpass", "-p", ssh_password] + ssh_cmd
else:
full_cmd = ssh_cmd
result = subprocess.run(
full_cmd,
capture_output=True,
text=True,
timeout=timeout
)
return result.returncode, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return -1, "", f"Timeout après {timeout} secondes"
except FileNotFoundError as e:
if "sshpass" in str(e):
return -1, "", "sshpass n'est pas installé. Installez-le avec: apt install sshpass"
return -1, "", str(e)
except Exception as e:
return -1, "", str(e)
def bootstrap_host(host: str, root_password: str, automation_user: str = "automation") -> CommandResult:
"""Prépare un hôte pour Ansible (création user, clé SSH, sudo, python3) pour Debian/Alpine/FreeBSD.
Utilise un script shell complet uploadé via heredoc pour éviter les problèmes de quoting.
"""
import logging
logger = logging.getLogger("bootstrap")
# Chercher la clé publique dans plusieurs emplacements possibles
primary_dirs = [
Path(SSH_KEY_PATH).parent,
Path("/app/ssh_keys"),
Path.home() / ".ssh",
]
ssh_dir = primary_dirs[0]
pub_paths = [
SSH_KEY_PATH + ".pub",
"/app/ssh_keys/id_rsa.pub",
"/app/ssh_keys/id_ed25519.pub",
"/app/ssh_keys/id_ecdsa.pub",
"/app/ssh_keys/id_automation_ansible.pub",
]
# Ajouter dynamiquement toutes les clés .pub trouvées dans le répertoire SSH
for directory in primary_dirs:
if not directory.exists():
continue
for f in directory.iterdir():
if f.is_file() and f.suffix == ".pub" and str(f) not in pub_paths:
pub_paths.append(str(f))
logger.info(f"SSH_KEY_PATH = {SSH_KEY_PATH}")
logger.info(f"Recherche de clé publique dans: {pub_paths}")
pub_key = None
pub_path_used = None
for pub_path in pub_paths:
try:
if Path(pub_path).exists():
pub_key = Path(pub_path).read_text(encoding="utf-8").strip()
if pub_key:
pub_path_used = pub_path
logger.info(f"Clé publique trouvée: {pub_path}")
break
except Exception as e:
logger.warning(f"Erreur lecture {pub_path}: {e}")
continue
if not pub_key:
# Lister les fichiers disponibles pour le debug
ssh_dir = Path(SSH_KEY_PATH).parent
available_files = []
if ssh_dir.exists():
available_files = [f.name for f in ssh_dir.iterdir()]
raise HTTPException(
status_code=500,
detail=f"Clé publique SSH non trouvée. Chemins testés: {pub_paths}. Fichiers disponibles dans {ssh_dir}: {available_files}",
)
# Script shell complet, robuste, avec logs détaillés
bootstrap_script = f"""#!/bin/sh
set -e
AUT_USER="{automation_user}"
echo "=== Bootstrap Ansible Host ==="
echo "User: $AUT_USER"
echo ""
# 1) Détection OS
if command -v apk >/dev/null 2>&1; then
OS_TYPE="alpine"
echo "[1/7] OS détecté: Alpine Linux"
elif [ "$(uname -s 2>/dev/null)" = "FreeBSD" ] || \
command -v pkg >/dev/null 2>&1 || \
( [ -f /etc/os-release ] && grep -qi 'ID=freebsd' /etc/os-release ); then
OS_TYPE="freebsd"
echo "[1/7] OS détecté: FreeBSD"
else
OS_TYPE="debian"
echo "[1/7] OS détecté: Debian-like"
fi
# 2) Vérification / préparation utilisateur
echo "[2/7] Vérification utilisateur/groupe..."
if id "$AUT_USER" >/dev/null 2>&1; then
echo " - Utilisateur déjà existant: $AUT_USER (aucune suppression)"
else
echo " - Utilisateur inexistant, il sera créé"
fi
# 3) Création utilisateur (idempotent)
echo "[3/7] Création utilisateur $AUT_USER..."
if id "$AUT_USER" >/dev/null 2>&1; then
echo " - Utilisateur déjà présent, réutilisation"
elif [ "$OS_TYPE" = "alpine" ]; then
adduser -D "$AUT_USER"
echo " - Utilisateur créé (Alpine: adduser -D)"
elif [ "$OS_TYPE" = "freebsd" ]; then
pw useradd "$AUT_USER" -m -s /bin/sh
echo " - Utilisateur créé (FreeBSD: pw useradd)"
else
useradd -m -s /bin/bash "$AUT_USER" || useradd -m -s /bin/sh "$AUT_USER"
echo " - Utilisateur créé (Debian: useradd -m)"
fi
# 3b) S'assurer que le compte n'est pas verrouillé
echo " - Vérification du verrouillage du compte..."
if command -v passwd >/dev/null 2>&1; then
passwd -u "$AUT_USER" 2>/dev/null || true
fi
if command -v usermod >/dev/null 2>&1; then
usermod -U "$AUT_USER" 2>/dev/null || true
fi
# 4) Configuration clé SSH
echo "[4/7] Configuration clé SSH..."
HOME_DIR=$(getent passwd "$AUT_USER" | cut -d: -f6)
if [ -z "$HOME_DIR" ]; then
HOME_DIR="/home/$AUT_USER"
fi
echo " - HOME_DIR: $HOME_DIR"
mkdir -p "$HOME_DIR/.ssh"
chown "$AUT_USER":"$AUT_USER" "$HOME_DIR/.ssh"
chmod 700 "$HOME_DIR/.ssh"
echo " - Répertoire .ssh créé et configuré"
cat > "$HOME_DIR/.ssh/authorized_keys" << 'SSHKEY_EOF'
{pub_key}
SSHKEY_EOF
chown "$AUT_USER":"$AUT_USER" "$HOME_DIR/.ssh/authorized_keys"
chmod 600 "$HOME_DIR/.ssh/authorized_keys"
echo " - Clé publique installée dans authorized_keys"
if [ -s "$HOME_DIR/.ssh/authorized_keys" ]; then
KEY_COUNT=$(wc -l < "$HOME_DIR/.ssh/authorized_keys")
echo " - Vérification: $KEY_COUNT clé(s) dans authorized_keys"
else
echo " - ERREUR: authorized_keys vide ou absent!"
exit 1
fi
# 5) Installation sudo
echo "[5/7] Installation sudo..."
if command -v sudo >/dev/null 2>&1; then
echo " - sudo déjà installé"
else
if [ "$OS_TYPE" = "alpine" ]; then
apk add --no-cache sudo
echo " - sudo installé (apk)"
elif [ "$OS_TYPE" = "freebsd" ]; then
pkg install -y sudo
echo " - sudo installé (pkg)"
else
apt-get update -qq && apt-get install -y sudo
echo " - sudo installé (apt)"
fi
fi
# 6) Configuration sudoers
echo "[6/7] Configuration sudoers..."
if [ ! -d /etc/sudoers.d ]; then
mkdir -p /etc/sudoers.d
chmod 750 /etc/sudoers.d 2>/dev/null || true
echo " - Répertoire /etc/sudoers.d créé"
fi
echo "$AUT_USER ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/automation
chmod 440 /etc/sudoers.d/automation
echo " - Sudoers configuré: /etc/sudoers.d/automation"
# 7) Installation Python3
echo "[7/7] Installation Python3..."
if command -v python3 >/dev/null 2>&1; then
PYTHON_VERSION=$(python3 --version 2>&1)
echo " - Python3 déjà installé: $PYTHON_VERSION"
else
if [ "$OS_TYPE" = "alpine" ]; then
apk add --no-cache python3
echo " - Python3 installé (apk)"
elif [ "$OS_TYPE" = "freebsd" ]; then
pkg install -y python3
echo " - Python3 installé (pkg)"
else
apt-get update -qq && apt-get install -y python3
echo " - Python3 installé (apt)"
fi
fi
echo ""
echo "=== Bootstrap terminé avec succès ==="
echo "Utilisateur: $AUT_USER"
echo "HOME: $HOME_DIR"
echo "SSH: $HOME_DIR/.ssh/authorized_keys"
echo "Sudo: /etc/sudoers.d/automation"
"""
# Envoyer le script de manière compatible avec tous les shells
lines = bootstrap_script.splitlines()
def _sh_single_quote(s: str) -> str:
"""Protège une chaîne pour un shell POSIX en simple quotes."""
return "'" + s.replace("'", "'\"'\"'") + "'"
quoted_lines = " ".join(_sh_single_quote(line) for line in lines)
remote_cmd = f"printf '%s\\n' {quoted_lines} | sh"
rc, out, err = run_ssh_command(
host,
remote_cmd,
ssh_user="root",
ssh_password=root_password,
)
if rc != 0:
raise HTTPException(
status_code=500,
detail={
"status": "error",
"return_code": rc,
"stdout": out,
"stderr": err,
},
)
# Vérification: tester la connexion SSH par clé avec l'utilisateur d'automatisation
verify_rc, verify_out, verify_err = run_ssh_command(
host,
"echo 'ssh_key_ok'",
ssh_user=automation_user,
ssh_password=None,
)
if verify_rc != 0:
combined_stdout = (out or "") + f"\n\n[SSH VERIFY] Échec de la connexion par clé pour {automation_user}@{host}\n" + (verify_out or "")
combined_stderr = (err or "") + f"\n\n[SSH VERIFY] " + (verify_err or "Aucune erreur détaillée")
raise HTTPException(
status_code=500,
detail={
"status": "error",
"return_code": verify_rc,
"stdout": combined_stdout,
"stderr": combined_stderr,
},
)
# Succès complet
final_stdout = (out or "") + f"\n\n[SSH VERIFY] Connexion par clé OK pour {automation_user}@{host}"
return CommandResult(
status="ok",
return_code=0,
stdout=final_stdout,
stderr=err,
)
# Base de données hybride : hôtes depuis Ansible, tâches/logs en mémoire
class HybridDB:
"""Base de données qui charge les hôtes depuis l'inventaire Ansible"""
def __init__(self, ansible_svc: AnsibleService):
self.ansible_service = ansible_svc
self._hosts_cache: Optional[List[Host]] = None
self._hosts_cache_time: float = 0
self._cache_ttl: float = 60 # Cache de 60 secondes
# Statuts runtime des hôtes (en mémoire) rechargés depuis le fichier JSON persistant
self._host_runtime_status: Dict[str, Dict[str, Any]] = {}
try:
persisted_hosts = host_status_service.get_all_status()
for host_name, info in persisted_hosts.items():
last_seen_raw = info.get("last_seen")
last_seen_dt: Optional[datetime] = None
if isinstance(last_seen_raw, str):
try:
last_seen_dt = datetime.fromisoformat(last_seen_raw.replace("Z", "+00:00"))
except Exception:
last_seen_dt = None
elif isinstance(last_seen_raw, datetime):
last_seen_dt = last_seen_raw
self._host_runtime_status[host_name] = {
"status": info.get("status", "online"),
"last_seen": last_seen_dt,
"os": info.get("os"),
}
except Exception:
# En cas de problème de lecture, on repartira d'un état en mémoire vierge
self._host_runtime_status = {}
# Tâches et logs en mémoire (persistés pendant l'exécution)
self.tasks: List[Task] = []
self.logs: List[LogEntry] = [
LogEntry(id=1, timestamp=datetime.now(timezone.utc), level="INFO",
message="Dashboard démarré - Inventaire Ansible chargé")
]
self._id_counters = {"hosts": 100, "tasks": 1, "logs": 2}
@property
def hosts(self) -> List[Host]:
"""Charge les hôtes depuis l'inventaire Ansible avec cache"""
current_time = time()
# Retourner le cache si valide
if self._hosts_cache and (current_time - self._hosts_cache_time) < self._cache_ttl:
return self._hosts_cache
# Recharger depuis Ansible
self._hosts_cache = self._load_hosts_from_ansible()
self._hosts_cache_time = current_time
return self._hosts_cache
def _load_hosts_from_ansible(self) -> List[Host]:
"""Convertit l'inventaire Ansible en liste d'hôtes (sans doublons)"""
hosts = []
ansible_hosts = self.ansible_service.get_hosts_from_inventory()
# Charger tous les statuts de bootstrap
all_bootstrap_status = bootstrap_status_service.get_all_status()
for idx, ah in enumerate(ansible_hosts, start=1):
# Extraire le groupe principal depuis les groupes
primary_group = ah.groups[0] if ah.groups else "unknown"
# Récupérer le statut bootstrap pour cet hôte
bootstrap_info = all_bootstrap_status.get(ah.name, {})
bootstrap_ok = bootstrap_info.get("bootstrap_ok", False)
bootstrap_date_str = bootstrap_info.get("bootstrap_date")
bootstrap_date = None
if bootstrap_date_str:
try:
bootstrap_date = datetime.fromisoformat(bootstrap_date_str.replace("Z", "+00:00"))
except:
pass
runtime_status = self._host_runtime_status.get(ah.name, {})
status = runtime_status.get("status", "online")
last_seen = runtime_status.get("last_seen")
os_label = runtime_status.get("os", f"Linux ({primary_group})")
host = Host(
id=str(idx),
name=ah.name,
ip=ah.ansible_host,
status=status,
os=os_label,
last_seen=last_seen,
groups=ah.groups, # Tous les groupes de l'hôte
bootstrap_ok=bootstrap_ok,
bootstrap_date=bootstrap_date
)
hosts.append(host)
return hosts
def refresh_hosts(self):
"""Force le rechargement des hôtes depuis Ansible"""
self._hosts_cache = None
return self.hosts
def update_host_status(self, host_name: str, status: str, os_info: str = None):
"""Met à jour le statut d'un hôte après un health-check"""
for host in self.hosts:
if host.name == host_name:
host.status = status
host.last_seen = datetime.now(timezone.utc)
if os_info:
host.os = os_info
self._host_runtime_status[host_name] = {
"status": host.status,
"last_seen": host.last_seen,
"os": host.os,
}
# Persister dans le fichier JSON partagé avec Ansible
try:
host_status_service.set_status(host_name, host.status, host.last_seen, host.os)
except Exception:
# Ne pas casser l'exécution si la persistance échoue
pass
break
@property
def metrics(self) -> SystemMetrics:
"""Calcule les métriques en temps réel basées sur les logs de tâches"""
hosts = self.hosts
# Utiliser les statistiques des fichiers de logs de tâches
task_stats = task_log_service.get_stats()
total_tasks = task_stats.get("total", 0)
completed_tasks = task_stats.get("completed", 0)
failed_tasks = task_stats.get("failed", 0)
total_finished = completed_tasks + failed_tasks
return SystemMetrics(
online_hosts=len([h for h in hosts if h.status == "online"]),
total_tasks=total_tasks,
success_rate=round((completed_tasks / total_finished * 100) if total_finished > 0 else 100, 1),
uptime=99.9,
cpu_usage=0,
memory_usage=0,
disk_usage=0
)
def get_next_id(self, collection: str) -> int:
self._id_counters[collection] += 1
return self._id_counters[collection] - 1
# Instance globale de la base de données hybride
db = HybridDB(ansible_service)
# Dépendances FastAPI
async def verify_api_key(api_key: str = Depends(api_key_header)) -> bool:
"""Vérifie la clé API fournie"""
if not api_key or api_key != API_KEY:
raise HTTPException(status_code=401, detail="Clé API invalide ou manquante")
return True
# Routes API
@app.get("/", response_class=HTMLResponse)
async def root(request: Request):
"""Page principale du dashboard"""
return FileResponse(BASE_DIR / "index.html")
@app.get("/api", response_class=HTMLResponse)
async def api_home(request: Request):
"""Page d'accueil de l'API Homelab Dashboard"""
return """
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Homelab Dashboard API</title>
<style>
body { font-family: 'Inter', sans-serif; background: #0a0a0a; color: white; margin: 0; padding: 40px; }
.container { max-width: 800px; margin: 0 auto; text-align: center; }
.gradient-text { background: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.card { background: rgba(42, 42, 42, 0.8); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; padding: 24px; margin: 20px 0; }
.btn { background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 100%); color: white; padding: 12px 24px; border: none; border-radius: 8px; text-decoration: none; display: inline-block; margin: 10px; transition: all 0.3s ease; }
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 25px rgba(124, 58, 237, 0.3); }
</style>
</head>
<body>
<div class="container">
<h1 class="gradient-text" style="font-size: 3rem; margin-bottom: 1rem;">Homelab Dashboard API</h1>
<p style="font-size: 1.2rem; color: #a1a1aa; margin-bottom: 2rem;">
API REST moderne pour la gestion automatique d'homelab
</p>
<div class="card">
<h2 style="color: #7c3aed; margin-bottom: 1rem;">Documentation API</h2>
<p style="margin-bottom: 1.5rem;">Explorez les endpoints disponibles et testez les fonctionnalités</p>
<div>
<a href="/api/docs" class="btn">
<i class="fas fa-book"></i> Documentation Interactive
</a>
<a href="/api/redoc" class="btn">
<i class="fas fa-file-alt"></i> Documentation Alternative
</a>
</div>
</div>
<div class="card">
<h2 style="color: #7c3aed; margin-bottom: 1rem;">Endpoints Principaux</h2>
<div style="text-align: left; max-width: 600px; margin: 0 auto;">
<div style="margin-bottom: 1rem;">
<strong style="color: #10b981;">GET</strong>
<code style="background: #1f2937; padding: 4px 8px; border-radius: 4px;">/api/hosts</code>
<span style="color: #a1a1aa;"> - Liste des hôtes</span>
</div>
<div style="margin-bottom: 1rem;">
<strong style="color: #3b82f6;">POST</strong>
<code style="background: #1f2937; padding: 4px 8px; border-radius: 4px;">/api/tasks</code>
<span style="color: #a1a1aa;"> - Créer une tâche</span>
</div>
<div style="margin-bottom: 1rem;">
<strong style="color: #10b981;">GET</strong>
<code style="background: #1f2937; padding: 4px 8px; border-radius: 4px;">/api/metrics</code>
<span style="color: #a1a1aa;"> - Métriques système</span>
</div>
<div style="margin-bottom: 1rem;">
<strong style="color: #f59e0b;">WS</strong>
<code style="background: #1f2937; padding: 4px 8px; border-radius: 4px;">/ws</code>
<span style="color: #a1a1aa;"> - WebSocket temps réel</span>
</div>
</div>
</div>
<div style="margin-top: 2rem; color: #6b7280; font-size: 0.9rem;">
<p>Version 1.0.0 | Développé avec FastAPI et technologies modernes</p>
</div>
</div>
</body>
</html>
"""
# ===== ENDPOINTS HOSTS - Routes statiques d'abord =====
@app.get("/api/hosts/groups")
async def get_host_groups(api_key_valid: bool = Depends(verify_api_key)):
"""Récupère les groupes disponibles pour les hôtes (environnements et rôles)"""
return {
"env_groups": ansible_service.get_env_groups(),
"role_groups": ansible_service.get_role_groups(),
"all_groups": ansible_service.get_groups()
}
# ===== ENDPOINTS GROUPS - Gestion des groupes d'environnement et de rôles =====
@app.get("/api/groups")
async def get_all_groups(api_key_valid: bool = Depends(verify_api_key)):
"""Récupère tous les groupes avec leurs détails"""
env_groups = ansible_service.get_env_groups()
role_groups = ansible_service.get_role_groups()
groups = []
for g in env_groups:
hosts = ansible_service.get_group_hosts(g)
groups.append({
"name": g,
"type": "env",
"display_name": g.replace('env_', ''),
"hosts_count": len(hosts),
"hosts": hosts
})
for g in role_groups:
hosts = ansible_service.get_group_hosts(g)
groups.append({
"name": g,
"type": "role",
"display_name": g.replace('role_', ''),
"hosts_count": len(hosts),
"hosts": hosts
})
return {
"groups": groups,
"env_count": len(env_groups),
"role_count": len(role_groups)
}
@app.get("/api/groups/{group_name}")
async def get_group_details(group_name: str, api_key_valid: bool = Depends(verify_api_key)):
"""Récupère les détails d'un groupe spécifique"""
if not ansible_service.group_exists(group_name):
raise HTTPException(status_code=404, detail=f"Groupe '{group_name}' non trouvé")
hosts = ansible_service.get_group_hosts(group_name)
group_type = "env" if group_name.startswith("env_") else "role" if group_name.startswith("role_") else "other"
return {
"name": group_name,
"type": group_type,
"display_name": group_name.replace('env_', '').replace('role_', ''),
"hosts_count": len(hosts),
"hosts": hosts
}
@app.post("/api/groups")
async def create_group(group_request: GroupRequest, api_key_valid: bool = Depends(verify_api_key)):
"""Crée un nouveau groupe d'environnement ou de rôle"""
# Construire le nom complet du groupe
prefix = "env_" if group_request.type == "env" else "role_"
# Si le nom ne commence pas déjà par le préfixe, l'ajouter
if group_request.name.startswith(prefix):
full_name = group_request.name
else:
full_name = f"{prefix}{group_request.name}"
# Vérifier si le groupe existe déjà
if ansible_service.group_exists(full_name):
raise HTTPException(status_code=400, detail=f"Le groupe '{full_name}' existe déjà")
# Créer le groupe
success = ansible_service.add_group(full_name)
if not success:
raise HTTPException(status_code=500, detail="Erreur lors de la création du groupe")
return {
"success": True,
"message": f"Groupe '{full_name}' créé avec succès",
"group": {
"name": full_name,
"type": group_request.type,
"display_name": full_name.replace('env_', '').replace('role_', ''),
"hosts_count": 0,
"hosts": []
}
}
@app.put("/api/groups/{group_name}")
async def update_group(group_name: str, group_update: GroupUpdateRequest, api_key_valid: bool = Depends(verify_api_key)):
"""Renomme un groupe existant"""
if not ansible_service.group_exists(group_name):
raise HTTPException(status_code=404, detail=f"Groupe '{group_name}' non trouvé")
# Déterminer le type du groupe
if group_name.startswith("env_"):
prefix = "env_"
group_type = "env"
elif group_name.startswith("role_"):
prefix = "role_"
group_type = "role"
else:
raise HTTPException(status_code=400, detail="Seuls les groupes env_ et role_ peuvent être modifiés")
# Construire le nouveau nom
if group_update.new_name.startswith(prefix):
new_full_name = group_update.new_name
else:
new_full_name = f"{prefix}{group_update.new_name}"
# Vérifier si le nouveau nom existe déjà
if ansible_service.group_exists(new_full_name):
raise HTTPException(status_code=400, detail=f"Le groupe '{new_full_name}' existe déjà")
# Renommer le groupe
success = ansible_service.rename_group(group_name, new_full_name)
if not success:
raise HTTPException(status_code=500, detail="Erreur lors du renommage du groupe")
hosts = ansible_service.get_group_hosts(new_full_name)
return {
"success": True,
"message": f"Groupe renommé de '{group_name}' vers '{new_full_name}'",
"group": {
"name": new_full_name,
"type": group_type,
"display_name": new_full_name.replace('env_', '').replace('role_', ''),
"hosts_count": len(hosts),
"hosts": hosts
}
}
@app.delete("/api/groups/{group_name}")
async def delete_group(
group_name: str,
move_hosts_to: Optional[str] = None,
api_key_valid: bool = Depends(verify_api_key)
):
"""Supprime un groupe existant
Args:
group_name: Nom du groupe à supprimer
move_hosts_to: Groupe vers lequel déplacer les hôtes (optionnel, query param)
"""
if not ansible_service.group_exists(group_name):
raise HTTPException(status_code=404, detail=f"Groupe '{group_name}' non trouvé")
# Vérifier si le groupe contient des hôtes
hosts_in_group = ansible_service.get_group_hosts(group_name)
# Si le groupe contient des hôtes et qu'on ne spécifie pas où les déplacer
if hosts_in_group and not move_hosts_to:
# Pour les groupes d'environnement, c'est critique car les hôtes doivent avoir un env
if group_name.startswith("env_"):
raise HTTPException(
status_code=400,
detail=f"Le groupe contient {len(hosts_in_group)} hôte(s). Spécifiez 'move_hosts_to' pour les déplacer."
)
# Si on veut déplacer les hôtes, vérifier que le groupe cible est valide
if move_hosts_to:
# Vérifier que le groupe cible est du même type
if group_name.startswith("env_") and not move_hosts_to.startswith("env_"):
raise HTTPException(status_code=400, detail="Les hôtes doivent être déplacés vers un groupe d'environnement")
if group_name.startswith("role_") and not move_hosts_to.startswith("role_"):
raise HTTPException(status_code=400, detail="Les hôtes doivent être déplacés vers un groupe de rôle")
# Supprimer le groupe
result = ansible_service.delete_group(group_name, move_hosts_to)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Erreur lors de la suppression"))
return {
"success": True,
"message": f"Groupe '{group_name}' supprimé avec succès",
"hosts_affected": result.get("hosts_affected", []),
"hosts_moved_to": result.get("hosts_moved_to")
}
def _host_to_response(host_obj, bootstrap_status: Optional["BootstrapStatus"] = None) -> Dict[str, Any]:
"""Map DB host + latest bootstrap to API-compatible payload."""
return {
"id": host_obj.id,
"name": host_obj.name,
"ip": getattr(host_obj, "ip_address", None),
"status": host_obj.status,
"os": "Linux", # valeur par défaut faute d'info stockée
"last_seen": host_obj.last_seen,
"created_at": host_obj.created_at,
"groups": [g for g in [getattr(host_obj, "ansible_group", None)] if g],
"bootstrap_ok": (bootstrap_status.status == "success") if bootstrap_status else False,
"bootstrap_date": bootstrap_status.last_attempt if bootstrap_status else None,
}
@app.get("/api/hosts/by-name/{host_name}")
async def get_host_by_name(
host_name: str,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
repo = HostRepository(db_session)
bs_repo = BootstrapStatusRepository(db_session)
host = await repo.get_by_ip(host_name) or await repo.get(host_name)
if not host:
raise HTTPException(status_code=404, detail="Hôte non trouvé")
bootstrap = await bs_repo.latest_for_host(host.id)
return _host_to_response(host, bootstrap)
@app.get("/api/hosts")
async def get_hosts(
bootstrap_status: Optional[str] = None,
limit: int = 100,
offset: int = 0,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
repo = HostRepository(db_session)
bs_repo = BootstrapStatusRepository(db_session)
hosts = await repo.list(limit=limit, offset=offset)
# Si la base ne contient encore aucun hôte, on retombe sur les hôtes Ansible via la DB hybride
if not hosts:
hybrid_hosts = db.hosts
fallback_results = []
for h in hybrid_hosts:
# Appliquer les mêmes filtres de bootstrap que pour la version DB
if bootstrap_status == "ready" and not h.bootstrap_ok:
continue
if bootstrap_status == "not_configured" and h.bootstrap_ok:
continue
fallback_results.append(
{
"id": h.id,
"name": h.name,
"ip": h.ip,
"status": h.status,
"os": h.os,
"last_seen": h.last_seen,
# created_at est déjà géré par le modèle Pydantic Host (default_factory)
"created_at": h.created_at,
"groups": h.groups,
"bootstrap_ok": h.bootstrap_ok,
"bootstrap_date": h.bootstrap_date,
}
)
return fallback_results
results = []
for host in hosts:
bootstrap = await bs_repo.latest_for_host(host.id)
if bootstrap_status == "ready" and not (bootstrap and bootstrap.status == "success"):
continue
if bootstrap_status == "not_configured" and bootstrap and bootstrap.status == "success":
continue
results.append(_host_to_response(host, bootstrap))
return results
@app.get("/api/hosts/{host_id}")
async def get_host(
host_id: str,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
repo = HostRepository(db_session)
bs_repo = BootstrapStatusRepository(db_session)
host = await repo.get(host_id)
if not host:
raise HTTPException(status_code=404, detail="Hôte non trouvé")
bootstrap = await bs_repo.latest_for_host(host.id)
return _host_to_response(host, bootstrap)
@app.post("/api/hosts")
async def create_host(
host_request: HostRequest,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
repo = HostRepository(db_session)
bs_repo = BootstrapStatusRepository(db_session)
# Vérifier si l'hôte existe déjà
existing = await repo.get_by_ip(host_request.name)
if existing:
raise HTTPException(status_code=400, detail=f"L'hôte '{host_request.name}' existe déjà")
# Valider le groupe d'environnement
env_groups = ansible_service.get_env_groups()
if host_request.env_group not in env_groups and not host_request.env_group.startswith("env_"):
raise HTTPException(status_code=400, detail=f"Le groupe d'environnement doit commencer par 'env_'. Groupes existants: {env_groups}")
# Valider les groupes de rôles
role_groups = ansible_service.get_role_groups()
for role in host_request.role_groups:
if role not in role_groups and not role.startswith("role_"):
raise HTTPException(status_code=400, detail=f"Le groupe de rôle '{role}' doit commencer par 'role_'. Groupes existants: {role_groups}")
try:
# Ajouter l'hôte à l'inventaire Ansible
ansible_service.add_host_to_inventory(
hostname=host_request.name,
env_group=host_request.env_group,
role_groups=host_request.role_groups,
ansible_host=host_request.ip,
)
# Créer en base
host = await repo.create(
id=uuid.uuid4().hex,
name=host_request.name,
ip_address=host_request.ip or host_request.name,
ansible_group=host_request.env_group,
status="unknown",
reachable=False,
last_seen=None,
)
bootstrap = await bs_repo.latest_for_host(host.id)
await db_session.commit()
# Notifier les clients WebSocket
await ws_manager.broadcast(
{
"type": "host_created",
"data": _host_to_response(host, bootstrap),
}
)
return {
"message": f"Hôte '{host_request.name}' ajouté avec succès",
"host": _host_to_response(host, bootstrap),
"inventory_updated": True,
}
except HTTPException:
raise
except Exception as e:
await db_session.rollback()
raise HTTPException(status_code=500, detail=f"Erreur lors de l'ajout de l'hôte: {str(e)}")
@app.put("/api/hosts/{host_name}")
async def update_host(
host_name: str,
update_request: HostUpdateRequest,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
repo = HostRepository(db_session)
bs_repo = BootstrapStatusRepository(db_session)
host = await repo.get_by_ip(host_name) or await repo.get(host_name)
if not host:
raise HTTPException(status_code=404, detail=f"Hôte '{host_name}' non trouvé")
# Valider le groupe d'environnement si fourni
if update_request.env_group:
env_groups = ansible_service.get_env_groups()
if update_request.env_group not in env_groups and not update_request.env_group.startswith("env_"):
raise HTTPException(status_code=400, detail=f"Le groupe d'environnement doit commencer par 'env_'")
# Valider les groupes de rôles si fournis
if update_request.role_groups:
for role in update_request.role_groups:
if not role.startswith("role_"):
raise HTTPException(status_code=400, detail=f"Le groupe de rôle '{role}' doit commencer par 'role_'")
try:
ansible_service.update_host_groups(
hostname=host_name,
env_group=update_request.env_group,
role_groups=update_request.role_groups,
ansible_host=update_request.ansible_host,
)
await repo.update(
host,
ansible_group=update_request.env_group or host.ansible_group,
)
await db_session.commit()
bootstrap = await bs_repo.latest_for_host(host.id)
await ws_manager.broadcast(
{
"type": "host_updated",
"data": _host_to_response(host, bootstrap),
}
)
return {
"message": f"Hôte '{host_name}' mis à jour avec succès",
"host": _host_to_response(host, bootstrap),
"inventory_updated": True,
}
except HTTPException:
await db_session.rollback()
raise
except Exception as e:
await db_session.rollback()
raise HTTPException(status_code=500, detail=f"Erreur lors de la mise à jour: {str(e)}")
@app.delete("/api/hosts/by-name/{host_name}")
async def delete_host_by_name(
host_name: str,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
repo = HostRepository(db_session)
host = await repo.get_by_ip(host_name) or await repo.get(host_name)
if not host:
raise HTTPException(status_code=404, detail=f"Hôte '{host_name}' non trouvé")
try:
ansible_service.remove_host_from_inventory(host_name)
await repo.soft_delete(host.id)
await db_session.commit()
await ws_manager.broadcast(
{
"type": "host_deleted",
"data": {"name": host_name},
}
)
return {"message": f"Hôte '{host_name}' supprimé avec succès", "inventory_updated": True}
except HTTPException:
await db_session.rollback()
raise
except Exception as e:
await db_session.rollback()
raise HTTPException(status_code=500, detail=f"Erreur lors de la suppression: {str(e)}")
@app.delete("/api/hosts/{host_id}")
async def delete_host(
host_id: str,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
repo = HostRepository(db_session)
host = await repo.get(host_id)
if not host:
raise HTTPException(status_code=404, detail="Hôte non trouvé")
return await delete_host_by_name(host.name, api_key_valid, db_session)
@app.get("/api/tasks")
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 de toutes les 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
]
@app.post("/api/tasks")
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 Ansible correspondant"""
task_names = {
'upgrade': 'Mise à jour système',
'reboot': 'Redémarrage système',
'health-check': 'Vérification de santé',
'backup': 'Sauvegarde',
'deploy': 'Déploiement',
'rollback': 'Rollback',
'maintenance': 'Maintenance',
'bootstrap': 'Bootstrap Ansible'
}
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 = task_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,
}
# Ajouter aussi à db.tasks (mémoire) pour la compatibilité avec execute_ansible_task
mem_task = Task(
id=task_obj.id,
name=task_name,
host=target,
status="running",
progress=0,
start_time=task_obj.started_at
)
db.tasks.insert(0, mem_task)
# Notifier les clients WebSocket
await ws_manager.broadcast({
"type": "task_created",
"data": response_data
})
# Exécuter le playbook Ansible en arrière-plan et stocker le handle
if playbook:
async_task = asyncio.create_task(execute_ansible_task(
task_id=task_obj.id,
playbook=playbook,
target=target,
extra_vars=task_request.extra_vars,
check_mode=task_request.dry_run
))
running_task_handles[task_obj.id] = {"asyncio_task": async_task, "process": None, "cancelled": False}
else:
# Pas de playbook correspondant, simuler
async_task = asyncio.create_task(simulate_task_execution(task_obj.id))
running_task_handles[task_obj.id] = {"asyncio_task": async_task, "process": None, "cancelled": False}
return response_data
# ===== ENDPOINTS LOGS DE TÂCHES (MARKDOWN) =====
# IMPORTANT: Ces routes doivent être AVANT /api/tasks/{task_id} pour éviter les conflits
@app.get("/api/tasks/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 avec filtrage et pagination"""
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
}
}
@app.get("/api/tasks/logs/dates")
async def get_task_logs_dates(api_key_valid: bool = Depends(verify_api_key)):
"""Récupère la structure des dates disponibles pour le filtrage"""
return task_log_service.get_available_dates()
@app.get("/api/tasks/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()
@app.get("/api/tasks/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)}")
@app.delete("/api/tasks/logs/{log_id}")
async def delete_task_log(log_id: str, api_key_valid: bool = Depends(verify_api_key)):
"""Supprime un fichier markdown 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()
return {"message": "Log supprimé", "id": log_id}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur suppression du fichier: {str(e)}")
@app.get("/api/tasks/running")
async def get_running_tasks(
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Récupère uniquement les tâches en cours d'exécution (running ou pending)"""
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)
}
@app.post("/api/tasks/{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 dans le dictionnaire des handles
if task_id in running_task_handles:
running_task_handles[task_id]["cancelled"] = True
# Annuler la tâche asyncio
async_task = running_task_handles[task_id].get("asyncio_task")
if async_task and not async_task.done():
async_task.cancel()
# Tuer le processus Ansible si présent
process = running_task_handles[task_id].get("process")
if process:
try:
process.terminate()
# Attendre un peu puis forcer si nécessaire
await asyncio.sleep(0.5)
if process.returncode is None:
process.kill()
except Exception:
pass
# Nettoyer le handle
del running_task_handles[task_id]
# Mettre à jour le statut en BD
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()
# Mettre à jour aussi dans db.tasks (mémoire) si présent
for t in db.tasks:
if str(t.id) == str(task_id):
t.status = "cancelled"
t.end_time = datetime.now(timezone.utc)
t.error = "Tâche annulée par l'utilisateur"
break
# 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()
# Notifier les clients WebSocket
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
}
@app.get("/api/tasks/{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,
}
@app.delete("/api/tasks/{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 (soft delete non implémenté pour tasks, suppression directe)"""
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()
# Notifier les clients WebSocket
await ws_manager.broadcast({
"type": "task_deleted",
"data": {"id": task_id}
})
return {"message": "Tâche supprimée avec succès"}
@app.get("/api/logs")
async def get_logs(
limit: int = 50,
offset: int = 0,
level: Optional[str] = None,
source: Optional[str] = None,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Récupère les logs récents avec filtrage optionnel"""
repo = LogRepository(db_session)
logs = await repo.list(limit=limit, offset=offset, level=level, source=source)
return [
{
"id": log.id,
"timestamp": log.created_at,
"level": log.level,
"message": log.message,
"source": log.source,
"host": log.host_id,
}
for log in logs
]
@app.post("/api/logs")
async def create_log(
level: str,
message: str,
source: Optional[str] = None,
host_id: Optional[str] = None,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Ajoute une nouvelle entrée de log"""
repo = LogRepository(db_session)
log = await repo.create(
level=level.upper(),
message=message,
source=source,
host_id=host_id,
)
await db_session.commit()
response_data = {
"id": log.id,
"timestamp": log.created_at,
"level": log.level,
"message": log.message,
"source": log.source,
"host": log.host_id,
}
# Notifier les clients WebSocket
await ws_manager.broadcast({
"type": "new_log",
"data": response_data
})
return response_data
@app.delete("/api/logs")
async def clear_logs(
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db),
):
"""Efface tous les logs (attention: opération destructive)"""
from sqlalchemy import delete
from models.log import Log as LogModel
await db_session.execute(delete(LogModel))
await db_session.commit()
return {"message": "Tous les logs ont été supprimés"}
@app.get("/api/metrics", response_model=SystemMetrics)
async def get_metrics(api_key_valid: bool = Depends(verify_api_key)):
"""Récupère les métriques système calculées dynamiquement"""
return db.metrics
@app.post("/api/hosts/refresh")
async def refresh_hosts(api_key_valid: bool = Depends(verify_api_key)):
"""Force le rechargement des hôtes depuis l'inventaire Ansible"""
ansible_service.invalidate_cache() # Clear ansible inventory cache first
hosts = db.refresh_hosts()
# Notifier les clients WebSocket
await ws_manager.broadcast({
"type": "hosts_refreshed",
"data": {"count": len(hosts)}
})
return {"message": f"{len(hosts)} hôtes rechargés depuis l'inventaire Ansible"}
# ===== ENDPOINTS ANSIBLE =====
@app.get("/api/ansible/playbooks")
async def get_ansible_playbooks(
target: Optional[str] = None,
api_key_valid: bool = Depends(verify_api_key)
):
"""Liste les playbooks Ansible disponibles avec leurs catégories.
Args:
target: Filtrer les playbooks compatibles avec cet hôte ou groupe (optionnel)
"""
if target:
playbooks = ansible_service.get_compatible_playbooks(target)
else:
playbooks = ansible_service.get_playbooks()
return {
"playbooks": playbooks,
"categories": ansible_service.get_playbook_categories(),
"ansible_dir": str(ANSIBLE_DIR),
"filter": target
}
@app.get("/api/ansible/inventory")
async def get_ansible_inventory(
group: Optional[str] = None,
api_key_valid: bool = Depends(verify_api_key)
):
"""Récupère l'inventaire Ansible avec les hôtes et groupes.
Args:
group: Filtrer les hôtes par groupe (optionnel)
"""
return {
"hosts": [h.dict() for h in ansible_service.get_hosts_from_inventory(group_filter=group)],
"groups": ansible_service.get_groups(),
"inventory_path": str(ansible_service.inventory_path),
"filter": group
}
@app.post("/api/ansible/execute")
async def execute_ansible_playbook(
request: AnsibleExecutionRequest,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db)
):
"""Exécute un playbook Ansible directement avec validation de compatibilité"""
start_time_dt = datetime.now(timezone.utc)
# Valider la compatibilité playbook-target
playbooks = ansible_service.get_playbooks()
playbook_info = next((pb for pb in playbooks if pb['filename'] == request.playbook or pb['name'] == request.playbook.replace('.yml', '').replace('.yaml', '')), None)
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}'. "
f"Ce playbook ne peut être exécuté que sur: {playbook_hosts}"
)
# Créer une tâche en BD
task_repo = TaskRepository(db_session)
task_id = f"pb_{uuid.uuid4().hex[:12]}"
playbook_name = request.playbook.replace('.yml', '').replace('-', ' ').title()
db_task = await task_repo.create(
id=task_id,
action=f"playbook:{request.playbook}",
target=request.target,
playbook=request.playbook,
status="running",
)
await task_repo.update(db_task, started_at=start_time_dt)
await db_session.commit()
# Créer aussi en mémoire pour la compatibilité
task = Task(
id=task_id,
name=f"Playbook: {playbook_name}",
host=request.target,
status="running",
progress=0,
start_time=start_time_dt
)
db.tasks.insert(0, task)
try:
result = await ansible_service.execute_playbook(
playbook=request.playbook,
target=request.target,
extra_vars=request.extra_vars,
check_mode=request.check_mode,
verbose=request.verbose
)
# Mettre à jour la tâche
task.status = "completed" if result["success"] else "failed"
task.progress = 100
task.end_time = datetime.now(timezone.utc)
task.duration = f"{result.get('execution_time', 0):.1f}s"
task.output = result.get("stdout", "")
task.error = result.get("stderr", "") if not result["success"] else None
# Ajouter un log
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="INFO" if result["success"] else "ERROR",
message=f"Playbook {request.playbook} exécuté sur {request.target}: {'succès' if result['success'] else 'échec'}",
source="ansible",
host=request.target
)
db.logs.insert(0, log_entry)
# Sauvegarder le log markdown
try:
task_log_service.save_task_log(
task=task,
output=result.get("stdout", ""),
error=result.get("stderr", "")
)
except Exception as log_error:
print(f"Erreur sauvegarde log markdown: {log_error}")
await ws_manager.broadcast({
"type": "ansible_execution",
"data": result
})
# Mettre à jour la BD
await task_repo.update(
db_task,
status=task.status,
completed_at=task.end_time,
error_message=task.error,
result_data={"output": result.get("stdout", "")[:5000]}
)
await db_session.commit()
# Envoyer notification ntfy (non-bloquant)
if result["success"]:
asyncio.create_task(notification_service.notify_task_completed(
task_name=task.name,
target=request.target,
duration=task.duration
))
else:
asyncio.create_task(notification_service.notify_task_failed(
task_name=task.name,
target=request.target,
error=result.get("stderr", "Erreur inconnue")[:200]
))
# Ajouter task_id au résultat
result["task_id"] = task_id
return result
except FileNotFoundError as e:
task.status = "failed"
task.end_time = datetime.now(timezone.utc)
task.error = str(e)
task_log_service.save_task_log(task=task, error=str(e))
await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=str(e))
await db_session.commit()
# Envoyer notification ntfy (non-bloquant)
asyncio.create_task(notification_service.notify_task_failed(
task_name=task.name,
target=request.target,
error=str(e)[:200]
))
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
task.status = "failed"
task.end_time = datetime.now(timezone.utc)
task.error = str(e)
task_log_service.save_task_log(task=task, error=str(e))
await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=str(e))
await db_session.commit()
# Envoyer notification ntfy (non-bloquant)
asyncio.create_task(notification_service.notify_task_failed(
task_name=task.name,
target=request.target,
error=str(e)[:200]
))
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/ansible/groups")
async def get_ansible_groups(api_key_valid: bool = Depends(verify_api_key)):
"""Récupère la liste des groupes Ansible"""
return {"groups": ansible_service.get_groups()}
# ===== ENDPOINTS PLAYBOOKS CRUD =====
class PlaybookContentRequest(BaseModel):
"""Requête pour sauvegarder le contenu d'un playbook"""
content: str = Field(..., description="Contenu YAML du playbook")
@app.get("/api/playbooks/{filename}/content")
async def get_playbook_content(
filename: str,
api_key_valid: bool = Depends(verify_api_key)
):
"""Récupère le contenu d'un playbook"""
playbook_path = ansible_service.playbooks_dir / filename
# Vérifier les extensions valides
if not filename.endswith(('.yml', '.yaml')):
raise HTTPException(status_code=400, detail="Extension de fichier invalide. Utilisez .yml ou .yaml")
if not playbook_path.exists():
raise HTTPException(status_code=404, detail=f"Playbook non trouvé: {filename}")
# Vérifier que le fichier est bien dans le répertoire playbooks (sécurité)
try:
playbook_path.resolve().relative_to(ansible_service.playbooks_dir.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Accès non autorisé")
try:
content = playbook_path.read_text(encoding='utf-8')
stat = playbook_path.stat()
return {
"filename": filename,
"content": content,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur lecture fichier: {str(e)}")
@app.put("/api/playbooks/{filename}/content")
async def save_playbook_content(
filename: str,
request: PlaybookContentRequest,
api_key_valid: bool = Depends(verify_api_key)
):
"""Sauvegarde le contenu d'un playbook (création ou modification)"""
# Vérifier les extensions valides
if not filename.endswith(('.yml', '.yaml')):
raise HTTPException(status_code=400, detail="Extension de fichier invalide. Utilisez .yml ou .yaml")
# Valider le nom de fichier (sécurité)
import re
if not re.match(r'^[a-zA-Z0-9_-]+\.(yml|yaml)$', filename):
raise HTTPException(status_code=400, detail="Nom de fichier invalide")
playbook_path = ansible_service.playbooks_dir / filename
# S'assurer que le répertoire existe
ansible_service.playbooks_dir.mkdir(parents=True, exist_ok=True)
# Valider le contenu YAML
try:
parsed = yaml.safe_load(request.content)
if parsed is None:
raise HTTPException(status_code=400, detail="Contenu YAML vide ou invalide")
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Erreur de syntaxe YAML: {str(e)}")
is_new = not playbook_path.exists()
try:
playbook_path.write_text(request.content, encoding='utf-8')
stat = playbook_path.stat()
# Log l'action
action = "créé" if is_new else "modifié"
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="INFO",
message=f"Playbook {filename} {action}",
source="playbook_editor"
)
db.logs.insert(0, log_entry)
return {
"success": True,
"message": f"Playbook {filename} {'créé' if is_new else 'sauvegardé'} avec succès",
"filename": filename,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"is_new": is_new
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur sauvegarde fichier: {str(e)}")
@app.delete("/api/playbooks/{filename}")
async def delete_playbook(
filename: str,
api_key_valid: bool = Depends(verify_api_key)
):
"""Supprime un playbook"""
# Vérifier les extensions valides
if not filename.endswith(('.yml', '.yaml')):
raise HTTPException(status_code=400, detail="Extension de fichier invalide")
playbook_path = ansible_service.playbooks_dir / filename
if not playbook_path.exists():
raise HTTPException(status_code=404, detail=f"Playbook non trouvé: {filename}")
# Vérifier que le fichier est bien dans le répertoire playbooks (sécurité)
try:
playbook_path.resolve().relative_to(ansible_service.playbooks_dir.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="Accès non autorisé")
try:
playbook_path.unlink()
# Log l'action
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="WARN",
message=f"Playbook {filename} supprimé",
source="playbook_editor"
)
db.logs.insert(0, log_entry)
return {
"success": True,
"message": f"Playbook {filename} supprimé avec succès"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur suppression fichier: {str(e)}")
@app.get("/api/ansible/ssh-config")
async def get_ssh_config(api_key_valid: bool = Depends(verify_api_key)):
"""Diagnostic de la configuration SSH pour le bootstrap"""
ssh_key_path = Path(SSH_KEY_PATH)
ssh_dir = ssh_key_path.parent
# Lister les fichiers dans le répertoire SSH
available_files = []
if ssh_dir.exists():
available_files = [f.name for f in ssh_dir.iterdir()]
# Vérifier les clés
private_key_exists = ssh_key_path.exists()
public_key_exists = Path(SSH_KEY_PATH + ".pub").exists()
# Chercher d'autres clés publiques
pub_keys_found = []
for ext in [".pub"]:
for key_type in ["id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"]:
key_path = ssh_dir / f"{key_type}{ext}"
if key_path.exists():
pub_keys_found.append(str(key_path))
# Trouver la clé privée qui sera utilisée
active_private_key = find_ssh_private_key()
return {
"ssh_key_path": SSH_KEY_PATH,
"ssh_dir": str(ssh_dir),
"ssh_dir_exists": ssh_dir.exists(),
"private_key_exists": private_key_exists,
"public_key_exists": public_key_exists,
"available_files": available_files,
"public_keys_found": pub_keys_found,
"active_private_key": active_private_key,
"ssh_user": SSH_USER,
"sshpass_available": shutil.which("sshpass") is not None,
}
@app.post("/api/ansible/adhoc", response_model=AdHocCommandResult)
async def execute_adhoc_command(
request: AdHocCommandRequest,
api_key_valid: bool = Depends(verify_api_key),
db_session: AsyncSession = Depends(get_db)
):
"""Exécute une commande ad-hoc Ansible sur un ou plusieurs hôtes.
Exemples:
- Lister les fichiers: {"target": "all", "command": "ls -la /tmp"}
- Vérifier l'espace disque: {"target": "proxmox", "command": "df -h", "become": true}
- Redémarrer un service: {"target": "web-servers", "command": "systemctl restart nginx", "become": true}
"""
start_time_perf = perf_counter()
start_time_dt = datetime.now(timezone.utc)
# Créer une tâche en BD
task_repo = TaskRepository(db_session)
task_id = f"adhoc_{uuid.uuid4().hex[:12]}"
task_name = f"Ad-hoc: {request.command[:40]}{'...' if len(request.command) > 40 else ''}"
db_task = await task_repo.create(
id=task_id,
action=f"adhoc:{request.module}",
target=request.target,
playbook=None,
status="running",
)
await task_repo.update(db_task, started_at=start_time_dt)
await db_session.commit()
# Créer aussi en mémoire pour la compatibilité
task = Task(
id=task_id,
name=task_name,
host=request.target,
status="running",
progress=0,
start_time=start_time_dt
)
db.tasks.insert(0, task)
# Construire la commande ansible
ansible_cmd = [
"ansible",
request.target,
"-i", str(ANSIBLE_DIR / "inventory" / "hosts.yml"),
"-m", request.module,
"-a", request.command,
"--timeout", str(request.timeout),
]
# Ajouter les options
if request.become:
ansible_cmd.append("--become")
private_key = find_ssh_private_key()
if private_key:
ansible_cmd.extend(["--private-key", private_key])
if SSH_USER:
ansible_cmd.extend(["-u", SSH_USER])
try:
result = subprocess.run(
ansible_cmd,
capture_output=True,
text=True,
timeout=request.timeout + 10,
cwd=str(ANSIBLE_DIR)
)
duration = perf_counter() - start_time_perf
success = result.returncode == 0
# Mettre à jour la tâche
task.status = "completed" if success else "failed"
task.progress = 100
task.end_time = datetime.now(timezone.utc)
task.duration = f"{round(duration, 2)}s"
task.output = result.stdout
task.error = result.stderr if result.stderr else None
# Sauvegarder le log de tâche en markdown (commande ad-hoc)
task_log_service.save_task_log(task, output=result.stdout, error=result.stderr or "", source_type='adhoc')
# Log de l'exécution
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="INFO" if success else "WARN",
message=f"Ad-hoc [{request.module}] sur {request.target}: {request.command[:50]}{'...' if len(request.command) > 50 else ''}",
source="ansible-adhoc",
host=request.target
)
db.logs.insert(0, log_entry)
# Notifier via WebSocket
await ws_manager.broadcast({
"type": "adhoc_executed",
"data": {
"target": request.target,
"command": request.command,
"success": success,
"task_id": task_id
}
})
# Sauvegarder dans l'historique des commandes ad-hoc (pour réutilisation)
await adhoc_history_service.add_command(
command=request.command,
target=request.target,
module=request.module,
become=request.become,
category=request.category or "default"
)
# Mettre à jour la BD
await task_repo.update(
db_task,
status=task.status,
completed_at=task.end_time,
error_message=task.error,
result_data={"output": result.stdout[:5000] if result.stdout else None}
)
await db_session.commit()
# Envoyer notification ntfy (non-bloquant)
if success:
asyncio.create_task(notification_service.notify_task_completed(
task_name=task.name,
target=request.target,
duration=task.duration
))
else:
asyncio.create_task(notification_service.notify_task_failed(
task_name=task.name,
target=request.target,
error=(result.stderr or "Erreur inconnue")[:200]
))
return AdHocCommandResult(
target=request.target,
command=request.command,
success=success,
return_code=result.returncode,
stdout=result.stdout,
stderr=result.stderr if result.stderr else None,
duration=round(duration, 2)
)
except subprocess.TimeoutExpired:
duration = perf_counter() - start_time_perf
# Mettre à jour la tâche en échec
task.status = "failed"
task.progress = 100
task.end_time = datetime.now(timezone.utc)
task.duration = f"{round(duration, 2)}s"
task.error = f"Timeout après {request.timeout} secondes"
# Sauvegarder le log de tâche (ad-hoc timeout)
task_log_service.save_task_log(task, error=task.error, source_type='adhoc')
# Mettre à jour la BD
await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=task.error)
await db_session.commit()
# Envoyer notification ntfy (non-bloquant)
asyncio.create_task(notification_service.notify_task_failed(
task_name=task.name,
target=request.target,
error=task.error[:200]
))
return AdHocCommandResult(
target=request.target,
command=request.command,
success=False,
return_code=-1,
stdout="",
stderr=f"Timeout après {request.timeout} secondes",
duration=round(duration, 2)
)
except FileNotFoundError:
duration = perf_counter() - start_time_perf
error_msg = "ansible non trouvé. Vérifiez que Ansible est installé et accessible."
# Mettre à jour la tâche en échec
task.status = "failed"
task.progress = 100
task.end_time = datetime.now(timezone.utc)
task.duration = f"{round(duration, 2)}s"
task.error = error_msg
# Sauvegarder le log de tâche (ad-hoc file not found)
task_log_service.save_task_log(task, error=error_msg, source_type='adhoc')
# Mettre à jour la BD
await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=error_msg)
await db_session.commit()
# Envoyer notification ntfy (non-bloquant)
asyncio.create_task(notification_service.notify_task_failed(
task_name=task.name,
target=request.target,
error=error_msg[:200]
))
return AdHocCommandResult(
target=request.target,
command=request.command,
success=False,
return_code=-1,
stdout="",
stderr=error_msg,
duration=round(duration, 2)
)
except Exception as e:
duration = perf_counter() - start_time_perf
error_msg = f"Erreur interne: {str(e)}"
# Mettre à jour la tâche en échec
task.status = "failed"
task.progress = 100
task.end_time = datetime.now(timezone.utc)
task.duration = f"{round(duration, 2)}s"
task.error = error_msg
# Sauvegarder le log de tâche (ad-hoc exception)
task_log_service.save_task_log(task, error=error_msg, source_type='adhoc')
# Mettre à jour la BD
await task_repo.update(db_task, status="failed", completed_at=task.end_time, error_message=error_msg)
await db_session.commit()
# Envoyer notification ntfy (non-bloquant)
asyncio.create_task(notification_service.notify_task_failed(
task_name=task.name,
target=request.target,
error=error_msg[:200]
))
# Return a proper result instead of raising HTTP 500
return AdHocCommandResult(
target=request.target,
command=request.command,
success=False,
return_code=-1,
stdout="",
stderr=error_msg,
duration=round(duration, 2)
)
@app.post("/api/ansible/bootstrap", response_model=CommandResult)
async def bootstrap_ansible_host(
request: BootstrapRequest,
api_key_valid: bool = Depends(verify_api_key)
):
"""Bootstrap un hôte pour Ansible.
Cette opération:
1. Se connecte à l'hôte via SSH avec le mot de passe root
2. Crée l'utilisateur d'automatisation (par défaut: automation)
3. Configure la clé SSH publique pour l'authentification sans mot de passe
4. Installe et configure sudo pour cet utilisateur
5. Installe Python3 (requis par Ansible)
6. Vérifie la connexion SSH par clé
Supporte: Debian/Ubuntu, Alpine Linux, FreeBSD
"""
import logging
import traceback
logger = logging.getLogger("bootstrap_endpoint")
try:
logger.info(f"Bootstrap request for host={request.host}, user={request.automation_user}")
result = bootstrap_host(
host=request.host,
root_password=request.root_password,
automation_user=request.automation_user
)
logger.info(f"Bootstrap result: status={result.status}, return_code={result.return_code}")
# Si le bootstrap a échoué (return_code != 0), lever une exception avec les détails
if result.return_code != 0:
raise HTTPException(
status_code=500,
detail={
"status": result.status,
"return_code": result.return_code,
"stdout": result.stdout,
"stderr": result.stderr
}
)
# Trouver le nom de l'hôte (peut être IP ou hostname)
host_name = request.host
for h in db.hosts:
if h.ip == request.host or h.name == request.host:
host_name = h.name
break
# Enregistrer le statut de bootstrap réussi
bootstrap_status_service.set_bootstrap_status(
host_name=host_name,
success=True,
details=f"Bootstrap réussi via API (user: {request.automation_user})"
)
# Invalider le cache des hôtes pour recharger avec le nouveau statut
db._hosts_cache = None
# Ajouter un log de succès
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="INFO",
message=f"Bootstrap réussi pour {host_name} (user: {request.automation_user})",
source="bootstrap",
host=host_name
)
db.logs.insert(0, log_entry)
# Notifier via WebSocket
await ws_manager.broadcast({
"type": "bootstrap_success",
"data": {
"host": host_name,
"user": request.automation_user,
"status": "ok",
"bootstrap_ok": True
}
})
# Envoyer notification ntfy (non-bloquant)
asyncio.create_task(notification_service.notify_bootstrap_success(host_name))
return result
except HTTPException as http_exc:
# Envoyer notification d'échec ntfy
error_detail = str(http_exc.detail) if http_exc.detail else "Erreur inconnue"
asyncio.create_task(notification_service.notify_bootstrap_failed(
hostname=request.host,
error=error_detail[:200]
))
raise
except Exception as e:
logger.error(f"Bootstrap exception: {e}")
logger.error(traceback.format_exc())
# Ajouter un log d'erreur
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="ERROR",
message=f"Échec bootstrap pour {request.host}: {str(e)}",
source="bootstrap",
host=request.host
)
db.logs.insert(0, log_entry)
# Envoyer notification d'échec ntfy
asyncio.create_task(notification_service.notify_bootstrap_failed(
hostname=request.host,
error=str(e)[:200]
))
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/health")
async def global_health_check():
"""Endpoint de healthcheck global utilisé par Docker.
Ne nécessite pas de clé API pour permettre aux orchestrateurs
de vérifier l'état du service facilement.
"""
return {
"status": "ok",
"service": "homelab-automation-api",
"timestamp": datetime.now(timezone.utc).isoformat()
}
# ===== ENDPOINTS BOOTSTRAP STATUS =====
@app.get("/api/bootstrap/status")
async def get_all_bootstrap_status(api_key_valid: bool = Depends(verify_api_key)):
"""Récupère le statut de bootstrap de tous les hôtes"""
return {
"hosts": bootstrap_status_service.get_all_status()
}
@app.get("/api/bootstrap/status/{host_name}")
async def get_host_bootstrap_status(
host_name: str,
api_key_valid: bool = Depends(verify_api_key)
):
"""Récupère le statut de bootstrap d'un hôte spécifique"""
status = bootstrap_status_service.get_bootstrap_status(host_name)
return {
"host": host_name,
**status
}
@app.post("/api/bootstrap/status/{host_name}")
async def set_host_bootstrap_status(
host_name: str,
success: bool = True,
details: Optional[str] = None,
api_key_valid: bool = Depends(verify_api_key)
):
"""Définit manuellement le statut de bootstrap d'un hôte"""
result = bootstrap_status_service.set_bootstrap_status(
host_name=host_name,
success=success,
details=details or f"Status défini manuellement"
)
# Invalider le cache des hôtes
db._hosts_cache = None
# Notifier via WebSocket
await ws_manager.broadcast({
"type": "bootstrap_status_updated",
"data": {
"host": host_name,
"bootstrap_ok": success
}
})
return {
"host": host_name,
"status": "updated",
**result
}
# ===== ENDPOINTS HISTORIQUE AD-HOC =====
@app.get("/api/adhoc/history")
async def get_adhoc_history(
category: Optional[str] = None,
search: Optional[str] = None,
limit: int = 50,
api_key_valid: bool = Depends(verify_api_key)
):
"""Récupère l'historique des commandes ad-hoc"""
commands = await adhoc_history_service.get_commands(
category=category,
search=search,
limit=limit,
)
return {
"commands": [cmd.dict() for cmd in commands],
"count": len(commands)
}
@app.get("/api/adhoc/categories")
async def get_adhoc_categories(api_key_valid: bool = Depends(verify_api_key)):
"""Récupère la liste des catégories de commandes ad-hoc"""
categories = await adhoc_history_service.get_categories()
return {"categories": [cat.dict() for cat in categories]}
@app.post("/api/adhoc/categories")
async def create_adhoc_category(
name: str,
description: Optional[str] = None,
color: str = "#7c3aed",
icon: str = "fa-folder",
api_key_valid: bool = Depends(verify_api_key)
):
"""Crée une nouvelle catégorie de commandes ad-hoc"""
category = await adhoc_history_service.add_category(name, description, color, icon)
return {"category": category.dict(), "message": "Catégorie créée"}
@app.put("/api/adhoc/categories/{category_name}")
async def update_adhoc_category(
category_name: str,
request: Request,
api_key_valid: bool = Depends(verify_api_key)
):
"""Met à jour une catégorie existante"""
try:
data = await request.json()
new_name = data.get("name", category_name)
description = data.get("description", "")
color = data.get("color", "#7c3aed")
icon = data.get("icon", "fa-folder")
success = await adhoc_history_service.update_category(category_name, new_name, description, color, icon)
if not success:
raise HTTPException(status_code=404, detail="Catégorie non trouvée")
return {"message": "Catégorie mise à jour", "category": new_name}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/adhoc/categories/{category_name}")
async def delete_adhoc_category(
category_name: str,
api_key_valid: bool = Depends(verify_api_key)
):
"""Supprime une catégorie et déplace ses commandes vers 'default'"""
if category_name == "default":
raise HTTPException(status_code=400, detail="La catégorie 'default' ne peut pas être supprimée")
success = await adhoc_history_service.delete_category(category_name)
if not success:
raise HTTPException(status_code=404, detail="Catégorie non trouvée")
return {"message": "Catégorie supprimée", "category": category_name}
@app.put("/api/adhoc/history/{command_id}/category")
async def update_adhoc_command_category(
command_id: str,
category: str,
description: Optional[str] = None,
api_key_valid: bool = Depends(verify_api_key)
):
"""Met à jour la catégorie d'une commande dans l'historique"""
success = await adhoc_history_service.update_command_category(command_id, category, description)
if not success:
raise HTTPException(status_code=404, detail="Commande non trouvée")
return {"message": "Catégorie mise à jour", "command_id": command_id, "category": category}
@app.delete("/api/adhoc/history/{command_id}")
async def delete_adhoc_command(command_id: str, api_key_valid: bool = Depends(verify_api_key)):
"""Supprime une commande de l'historique"""
success = await adhoc_history_service.delete_command(command_id)
if not success:
raise HTTPException(status_code=404, detail="Commande non trouvée")
return {"message": "Commande supprimée", "command_id": command_id}
@app.get("/api/health/{host_name}", response_model=HealthCheck)
async def check_host_health(host_name: str, api_key_valid: bool = Depends(verify_api_key)):
"""Effectue un health check sur un hôte spécifique et met à jour son last_seen"""
host = next((h for h in db.hosts if h.name == host_name), None)
if not host:
raise HTTPException(status_code=404, detail="Hôte non trouvé")
# Simuler un health check à partir du statut actuel
health_check = HealthCheck(
host=host_name,
ssh_ok=host.status == "online",
ansible_ok=host.status == "online",
sudo_ok=host.status == "online",
reachable=host.status != "offline",
response_time=0.123 if host.status == "online" else None,
error_message=None if host.status != "offline" else "Hôte injoignable"
)
# Mettre à jour le statut runtime + persistant
new_status = "online" if health_check.reachable else "offline"
db.update_host_status(host_name, new_status, host.os)
# Ajouter un log pour le health check
log_entry = LogEntry(
timestamp=datetime.now(timezone.utc),
level="INFO" if health_check.reachable else "ERROR",
message=f"Health check {'réussi' if health_check.reachable else 'échoué'} pour {host_name}",
source="health_check",
host=host_name
)
db.logs.insert(0, log_entry)
# Notifier les clients WebSocket
await ws_manager.broadcast({
"type": "health_check",
"data": health_check.dict()
})
return health_check
# WebSocket pour les mises à jour en temps réel
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await ws_manager.connect(websocket)
try:
while True:
# Garder la connexion ouverte
data = await websocket.receive_text()
# Traiter les messages entrants si nécessaire
except WebSocketDisconnect:
ws_manager.disconnect(websocket)
# Fonctions utilitaires
async def simulate_task_execution(task_id: str):
"""Simule l'exécution d'une tâche en arrière-plan"""
task = next((t for t in db.tasks if str(t.id) == str(task_id)), None)
if not task:
return
try:
# Simuler la progression
for progress in range(0, 101, 10):
task.progress = progress
# Notifier les clients WebSocket
await ws_manager.broadcast({
"type": "task_progress",
"data": {
"id": task_id,
"progress": progress
}
})
await asyncio.sleep(0.5) # Attendre 500ms entre chaque mise à jour
# Marquer la tâche comme terminée
task.status = "completed"
task.end_time = datetime.now(timezone.utc)
task.duration = "5s"
# Ajouter un log
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="INFO",
message=f"Tâche '{task.name}' terminée avec succès sur {task.host}",
source="task_manager",
host=task.host
)
db.logs.insert(0, log_entry)
# Notifier les clients WebSocket
await ws_manager.broadcast({
"type": "task_completed",
"data": {
"id": task_id,
"status": "completed",
"progress": 100
}
})
# Sauvegarder le log markdown
try:
task_log_service.save_task_log(task=task, output="Tâche simulée terminée avec succès")
except Exception as log_error:
print(f"Erreur sauvegarde log markdown: {log_error}")
except asyncio.CancelledError:
# Tâche annulée
task.status = "cancelled"
task.end_time = datetime.now(timezone.utc)
task.error = "Tâche annulée par l'utilisateur"
await ws_manager.broadcast({
"type": "task_cancelled",
"data": {
"id": task_id,
"status": "cancelled",
"message": "Tâche annulée par l'utilisateur"
}
})
finally:
# Nettoyer le handle de la tâche
if str(task_id) in running_task_handles:
del running_task_handles[str(task_id)]
async def execute_ansible_task(
task_id: str,
playbook: str,
target: str,
extra_vars: Optional[Dict[str, Any]] = None,
check_mode: bool = False
):
"""Exécute un playbook Ansible pour une tâche"""
task = next((t for t in db.tasks if str(t.id) == str(task_id)), None)
if not task:
return
# Notifier le début
task.progress = 10
await ws_manager.broadcast({
"type": "task_progress",
"data": {"id": task_id, "progress": 10, "message": "Démarrage du playbook Ansible..."}
})
start_time = perf_counter()
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
# Mettre à jour la tâche
task.progress = 100
task.status = "completed" if result["success"] else "failed"
task.end_time = datetime.now(timezone.utc)
task.duration = f"{execution_time:.1f}s"
task.output = result.get("stdout", "")
task.error = result.get("stderr", "") if not result["success"] else None
# Si c'est un health-check ciblé, mettre à jour le statut/last_seen de l'hôte
if "health-check" in playbook and target and target != "all":
try:
new_status = "online" if result["success"] else "offline"
db.update_host_status(target, new_status)
except Exception:
# Ne pas interrompre la gestion de la tâche si la MAJ de statut échoue
pass
# Ajouter un log
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="INFO" if result["success"] else "ERROR",
message=f"Tâche '{task.name}' {'terminée avec succès' if result['success'] else 'échouée'} sur {target}",
source="ansible",
host=target
)
db.logs.insert(0, log_entry)
# Notifier les clients WebSocket
await ws_manager.broadcast({
"type": "task_completed",
"data": {
"id": task_id,
"status": task.status,
"progress": 100,
"duration": task.duration,
"success": result["success"],
"output": result.get("stdout", "")[:500] # Limiter la taille
}
})
# Envoyer notification ntfy (non-bloquant)
if result["success"]:
asyncio.create_task(notification_service.notify_task_completed(
task_name=task.name,
target=target,
duration=task.duration
))
else:
asyncio.create_task(notification_service.notify_task_failed(
task_name=task.name,
target=target,
error=result.get("stderr", "Erreur inconnue")[:200]
))
# Sauvegarder le log markdown
try:
log_path = task_log_service.save_task_log(
task=task,
output=result.get("stdout", ""),
error=result.get("stderr", "")
)
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="DEBUG",
message=f"Log de tâche sauvegardé: {log_path}",
source="task_log",
host=target
)
db.logs.insert(0, log_entry)
except Exception as log_error:
print(f"Erreur sauvegarde log markdown: {log_error}")
except Exception as e:
task.status = "failed"
task.end_time = datetime.now(timezone.utc)
task.error = str(e)
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="ERROR",
message=f"Erreur lors de l'exécution de '{task.name}': {str(e)}",
source="ansible",
host=target
)
db.logs.insert(0, log_entry)
# Sauvegarder le log markdown même en cas d'échec
try:
task_log_service.save_task_log(task=task, error=str(e))
except Exception:
pass
await ws_manager.broadcast({
"type": "task_failed",
"data": {
"id": task_id,
"status": "failed",
"error": str(e)
}
})
except asyncio.CancelledError:
# Tâche annulée par l'utilisateur
task.status = "cancelled"
task.end_time = datetime.now(timezone.utc)
task.error = "Tâche annulée par l'utilisateur"
log_entry = LogEntry(
id=db.get_next_id("logs"),
timestamp=datetime.now(timezone.utc),
level="WARNING",
message=f"Tâche '{task.name}' annulée par l'utilisateur",
source="ansible",
host=target
)
db.logs.insert(0, log_entry)
await ws_manager.broadcast({
"type": "task_cancelled",
"data": {
"id": task_id,
"status": "cancelled",
"message": "Tâche annulée par l'utilisateur"
}
})
finally:
# Nettoyer le handle de la tâche
if str(task_id) in running_task_handles:
del running_task_handles[str(task_id)]
# Mettre à jour la BD avec le statut final
try:
async with async_session_maker() as session:
from crud.task import TaskRepository
repo = TaskRepository(session)
db_task = await repo.get(task_id)
if db_task:
await repo.update(
db_task,
status=task.status if task else "failed",
completed_at=datetime.now(timezone.utc),
error_message=task.error if task else None,
result_data={"output": task.output[:5000] if task and task.output else None}
)
await session.commit()
except Exception as db_error:
print(f"Erreur mise à jour BD pour tâche {task_id}: {db_error}")
# ===== ENDPOINTS PLANIFICATEUR (SCHEDULER) =====
@app.get("/api/schedules")
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 avec filtrage optionnel (via SchedulerService)."""
# Utiliser le SchedulerService comme source de vérité pour next_run_at / last_run_at
schedules = scheduler_service.get_all_schedules(
enabled=enabled,
playbook=playbook,
tag=tag,
)
# Pagination simple côté API (les schedules sont déjà triés par next_run_at)
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,
# Champs utilisés par le frontend pour "Prochaine" et historique
"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)}
@app.post("/api/schedules")
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 (stocké en DB) avec validation de compatibilité playbook-target"""
# 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}'. "
f"Ce playbook ne peut être exécuté que sur: {playbook_hosts}"
)
# 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,
}
}
@app.get("/api/schedules/stats")
async def get_schedules_stats(api_key_valid: bool = Depends(verify_api_key)):
"""Récupère les statistiques globales des schedules"""
stats = scheduler_service.get_stats()
upcoming = scheduler_service.get_upcoming_executions(limit=5)
return {
"stats": stats.dict(),
"upcoming": upcoming
}
@app.get("/api/schedules/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)
}
@app.get("/api/schedules/validate-cron")
async def validate_cron_expression(
expression: str,
api_key_valid: bool = Depends(verify_api_key)
):
"""Valide une expression cron et retourne les 5 prochaines exécutions"""
result = scheduler_service.validate_cron_expression(expression)
return result
@app.get("/api/schedules/{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 spécifique (depuis DB)"""
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,
}
@app.put("/api/schedules/{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 (DB + scheduler_service)"""
# Essayer d'abord via SchedulerService (source de vérité)
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
# Mettre à jour en DB si présent
if schedule:
await repo.update(schedule, **update_fields)
await db_session.commit()
# Aussi mettre à jour dans scheduler_service pour APScheduler
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()
# Notifier via WebSocket
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}
}
@app.delete("/api/schedules/{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 (soft delete en DB + suppression scheduler_service)"""
repo = ScheduleRepository(db_session)
schedule = await repo.get(schedule_id)
if not schedule:
# Aucun enregistrement en DB, mais on tente tout de même de le supprimer
# du SchedulerService (cas des anciens IDs internes du scheduler).
try:
scheduler_service.delete_schedule(schedule_id)
except Exception:
pass
return {
"success": True,
"message": f"Schedule '{schedule_id}' déjà supprimé ou inexistant en base, nettoyage scheduler effectué."
}
schedule_name = schedule.name
# Soft delete en DB
await repo.soft_delete(schedule_id)
await db_session.commit()
# Supprimer du scheduler_service
scheduler_service.delete_schedule(schedule_id)
# Log en DB
log_repo = LogRepository(db_session)
await log_repo.create(
level="WARN",
message=f"Schedule '{schedule_name}' supprimé",
source="scheduler",
)
await db_session.commit()
# Notifier via WebSocket
await ws_manager.broadcast({
"type": "schedule_deleted",
"data": {"id": schedule_id, "name": schedule_name}
})
return {
"success": True,
"message": f"Schedule '{schedule_name}' supprimé"
}
@app.post("/api/schedules/{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 (exécution forcée)"""
# Essayer d'abord via SchedulerService (source de vérité)
sched = scheduler_service.get_schedule(schedule_id)
if not sched:
# Fallback sur la DB
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
# Lancer l'exécution via scheduler_service
run = await scheduler_service.run_now(schedule_id)
return {
"success": True,
"message": f"Schedule '{schedule_name}' lancé",
"run": run.dict() if run else None
}
@app.post("/api/schedules/{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"""
# Essayer d'abord via SchedulerService (source de vérité)
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
# Mettre à jour en DB si présent
if schedule:
await repo.update(schedule, enabled=False)
await db_session.commit()
# Mettre à jour dans scheduler_service
scheduler_service.pause_schedule(schedule_id)
# Log en DB
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()
# Notifier via WebSocket
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}
}
@app.post("/api/schedules/{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"""
# Essayer d'abord via SchedulerService (source de vérité)
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
# Mettre à jour en DB si présent
if schedule:
await repo.update(schedule, enabled=True)
await db_session.commit()
# Mettre à jour dans scheduler_service
scheduler_service.resume_schedule(schedule_id)
# Log en DB
log_repo = LogRepository(db_session)
await log_repo.create(
level="INFO",
message=f"Schedule '{schedule_name}' repris",
source="scheduler",
)
await db_session.commit()
# Notifier via WebSocket
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}
}
@app.get("/api/schedules/{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 (depuis la base de données)"""
# Vérifier que le schedule existe soit dans le SchedulerService, soit en BD
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
# Récupérer les runs depuis la BD
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)
}
# ===== ENDPOINTS NOTIFICATIONS NTFY =====
@app.get("/api/notifications/config")
async def get_notification_config(api_key_valid: bool = Depends(verify_api_key)):
"""Récupère la configuration actuelle des notifications ntfy."""
config = notification_service.config
return {
"enabled": config.enabled,
"base_url": config.base_url,
"default_topic": config.default_topic,
"timeout": config.timeout,
"has_auth": config.has_auth,
}
@app.post("/api/notifications/test")
async def test_notification(
topic: Optional[str] = None,
message: str = "🧪 Test de notification depuis Homelab Automation API",
api_key_valid: bool = Depends(verify_api_key)
):
"""Envoie une notification de test pour vérifier la configuration ntfy."""
success = await notification_service.send(
topic=topic,
message=message,
title="🔔 Test Notification",
priority=3,
tags=["test_tube", "robot"]
)
return {
"success": success,
"topic": topic or notification_service.config.default_topic,
"message": "Notification envoyée" if success else "Échec de l'envoi (voir logs serveur)"
}
@app.post("/api/notifications/send", response_model=NotificationResponse)
async def send_custom_notification(
request: NotificationRequest,
api_key_valid: bool = Depends(verify_api_key)
):
"""Envoie une notification personnalisée via ntfy."""
return await notification_service.send_request(request)
@app.post("/api/notifications/toggle")
async def toggle_notifications(
enabled: bool,
api_key_valid: bool = Depends(verify_api_key)
):
"""Active ou désactive les notifications ntfy."""
from schemas.notification import NtfyConfig
# Reconfigurer le service avec le nouveau statut
current_config = notification_service.config
new_config = NtfyConfig(
base_url=current_config.base_url,
default_topic=current_config.default_topic,
enabled=enabled,
timeout=current_config.timeout,
username=current_config.username,
password=current_config.password,
token=current_config.token,
)
notification_service.reconfigure(new_config)
return {
"enabled": enabled,
"message": f"Notifications {'activées' if enabled else 'désactivées'}"
}
# ===== ÉVÉNEMENTS STARTUP/SHUTDOWN =====
@app.on_event("startup")
async def startup_event():
"""Événement de démarrage de l'application"""
print("🚀 Homelab Automation Dashboard démarré")
# Initialiser la base de données (créer les tables si nécessaire)
await init_db()
print("📦 Base de données SQLite initialisée")
# Charger les statuts bootstrap depuis la BD
await bootstrap_status_service.load_from_db()
# Démarrer le scheduler et charger les schedules depuis la BD
await scheduler_service.start_async()
# Afficher l'état du service de notification
ntfy_status = "activé" if notification_service.enabled else "désactivé"
print(f"🔔 Service de notification ntfy: {ntfy_status} ({notification_service.config.base_url})")
# Log de démarrage en base
async with async_session_maker() as session:
repo = LogRepository(session)
await repo.create(
level="INFO",
message="Application démarrée - Services initialisés (BD)",
source="system",
)
await session.commit()
# Notification ntfy au démarrage de l'application
startup_notif = notification_service.templates.app_started()
await notification_service.send(
message=startup_notif.message,
topic=startup_notif.topic,
title=startup_notif.title,
priority=startup_notif.priority,
tags=startup_notif.tags,
)
@app.on_event("shutdown")
async def shutdown_event():
"""Événement d'arrêt de l'application"""
print("👋 Arrêt de l'application...")
# Arrêter le scheduler
scheduler_service.shutdown()
# Notification ntfy à l'arrêt de l'application
shutdown_notif = notification_service.templates.app_stopped()
await notification_service.send(
message=shutdown_notif.message,
topic=shutdown_notif.topic,
title=shutdown_notif.title,
priority=shutdown_notif.priority,
tags=shutdown_notif.tags,
)
# Fermer le client HTTP du service de notification
await notification_service.close()
print("✅ Services arrêtés proprement")
# Démarrer l'application
if __name__ == "__main__":
uvicorn.run(
"app_optimized:app",
host="0.0.0.0",
port=8008,
reload=True,
log_level="info"
)