Add JWT authentication with user/password login, role-based access control, and Bearer token support while maintaining legacy API key compatibility for transition; update README with comprehensive JWT security guidelines, installation instructions for Python/SSH, development startup scripts (.env, run_dev.sh/ps1), and migrate all API examples to JWT authentication; update Ansible inventory SSH key paths from Docker container paths to local user paths
This commit is contained in:
@@ -2,9 +2,58 @@
|
||||
Services métier pour l'API Homelab Automation.
|
||||
"""
|
||||
|
||||
from .auth_service import AuthService, auth_service, verify_password, hash_password, create_access_token, decode_token
|
||||
from .notification_service import NotificationService, notification_service
|
||||
from .builtin_playbooks import BuiltinPlaybookService, builtin_playbook_service, init_builtin_playbook_service
|
||||
from .websocket_service import WebSocketManager, ws_manager
|
||||
from .host_status_service import HostStatusService, host_status_service
|
||||
from .bootstrap_status_service import BootstrapStatusService, bootstrap_status_service
|
||||
from .task_log_service import TaskLogService
|
||||
from .adhoc_history_service import AdHocHistoryService, adhoc_history_service
|
||||
from .ansible_service import AnsibleService, ansible_service
|
||||
from .scheduler_service import SchedulerService, scheduler_service
|
||||
from .hybrid_db import HybridDB, db
|
||||
from .console_log_service import ConsoleLogCapture, console_log_service
|
||||
|
||||
__all__ = [
|
||||
# Auth
|
||||
"AuthService",
|
||||
"auth_service",
|
||||
"verify_password",
|
||||
"hash_password",
|
||||
"create_access_token",
|
||||
"decode_token",
|
||||
# Notifications
|
||||
"NotificationService",
|
||||
"notification_service",
|
||||
# Builtin playbooks
|
||||
"BuiltinPlaybookService",
|
||||
"builtin_playbook_service",
|
||||
"init_builtin_playbook_service",
|
||||
# WebSocket
|
||||
"WebSocketManager",
|
||||
"ws_manager",
|
||||
# Host status
|
||||
"HostStatusService",
|
||||
"host_status_service",
|
||||
# Bootstrap status
|
||||
"BootstrapStatusService",
|
||||
"bootstrap_status_service",
|
||||
# Task logs
|
||||
"TaskLogService",
|
||||
# Ad-hoc history
|
||||
"AdHocHistoryService",
|
||||
"adhoc_history_service",
|
||||
# Ansible
|
||||
"AnsibleService",
|
||||
"ansible_service",
|
||||
# Scheduler
|
||||
"SchedulerService",
|
||||
"scheduler_service",
|
||||
# Hybrid DB
|
||||
"HybridDB",
|
||||
"db",
|
||||
# Console logs
|
||||
"ConsoleLogCapture",
|
||||
"console_log_service",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Service de gestion de l'historique des commandes ad-hoc.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database import async_session_maker
|
||||
from app.schemas.ansible import AdHocHistoryEntry, AdHocHistoryCategory
|
||||
from app.core.constants import DEFAULT_ADHOC_CATEGORIES
|
||||
|
||||
|
||||
class AdHocHistoryService:
|
||||
"""Service pour gérer l'historique des commandes ad-hoc en base de données."""
|
||||
|
||||
def __init__(self):
|
||||
self._default_categories_initialized = False
|
||||
|
||||
async def _ensure_default_categories(self, session: AsyncSession):
|
||||
"""S'assure que les catégories par défaut existent."""
|
||||
if self._default_categories_initialized:
|
||||
return
|
||||
|
||||
from app.crud.log import LogRepository
|
||||
repo = LogRepository(session)
|
||||
|
||||
# Vérifier si des catégories existent déjà
|
||||
existing = await self.get_categories()
|
||||
if not existing:
|
||||
# Créer les catégories par défaut
|
||||
for cat in DEFAULT_ADHOC_CATEGORIES:
|
||||
await self._create_category_internal(
|
||||
session,
|
||||
name=cat["name"],
|
||||
description=cat.get("description"),
|
||||
color=cat.get("color", "#7c3aed"),
|
||||
icon=cat.get("icon", "fa-folder")
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
self._default_categories_initialized = True
|
||||
|
||||
async def _create_category_internal(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
description: str = None,
|
||||
color: str = "#7c3aed",
|
||||
icon: str = "fa-folder"
|
||||
):
|
||||
"""Crée une catégorie en base (interne)."""
|
||||
from app.models.log import Log
|
||||
# Utiliser le modèle Log avec un type spécial pour stocker les catégories
|
||||
log = Log(
|
||||
level="ADHOC_CATEGORY",
|
||||
message=name,
|
||||
source=description or "",
|
||||
host_id=None,
|
||||
task_id=f"{color}|{icon}", # Stocker color et icon dans task_id
|
||||
)
|
||||
session.add(log)
|
||||
|
||||
async def add_command(
|
||||
self,
|
||||
command: str,
|
||||
target: str,
|
||||
module: str = "shell",
|
||||
become: bool = False,
|
||||
category: str = "default",
|
||||
description: str = None
|
||||
) -> AdHocHistoryEntry:
|
||||
"""Ajoute une commande à l'historique."""
|
||||
async with async_session_maker() as session:
|
||||
await self._ensure_default_categories(session)
|
||||
|
||||
from app.models.log import Log
|
||||
cmd_id = f"adhoc_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
log = Log(
|
||||
level="ADHOC_COMMAND",
|
||||
message=command,
|
||||
source=category,
|
||||
host_id=target,
|
||||
task_id=cmd_id,
|
||||
)
|
||||
session.add(log)
|
||||
await session.commit()
|
||||
|
||||
return AdHocHistoryEntry(
|
||||
id=cmd_id,
|
||||
command=command,
|
||||
target=target,
|
||||
module=module,
|
||||
become=become,
|
||||
category=category,
|
||||
description=description,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_used=datetime.now(timezone.utc),
|
||||
use_count=1
|
||||
)
|
||||
|
||||
async def get_commands(
|
||||
self,
|
||||
category: str = None,
|
||||
search: str = None,
|
||||
limit: int = 50
|
||||
) -> List[AdHocHistoryEntry]:
|
||||
"""Récupère les commandes de l'historique."""
|
||||
async with async_session_maker() as session:
|
||||
from app.models.log import Log
|
||||
|
||||
stmt = select(Log).where(Log.level == "ADHOC_COMMAND")
|
||||
|
||||
if category and category != "all":
|
||||
stmt = stmt.where(Log.source == category)
|
||||
|
||||
stmt = stmt.order_by(Log.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
|
||||
commands = []
|
||||
for log in logs:
|
||||
if search and search.lower() not in log.message.lower():
|
||||
continue
|
||||
|
||||
commands.append(AdHocHistoryEntry(
|
||||
id=log.task_id or str(log.id),
|
||||
command=log.message,
|
||||
target=log.host_id or "all",
|
||||
module="shell",
|
||||
become=False,
|
||||
category=log.source or "default",
|
||||
created_at=log.created_at,
|
||||
last_used=log.created_at,
|
||||
use_count=1
|
||||
))
|
||||
|
||||
return commands
|
||||
|
||||
async def update_command_category(
|
||||
self,
|
||||
command_id: str,
|
||||
category: str,
|
||||
description: str = None
|
||||
) -> bool:
|
||||
"""Met à jour la catégorie d'une commande."""
|
||||
async with async_session_maker() as session:
|
||||
from app.models.log import Log
|
||||
|
||||
stmt = (
|
||||
update(Log)
|
||||
.where(Log.task_id == command_id)
|
||||
.where(Log.level == "ADHOC_COMMAND")
|
||||
.values(source=category)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete_command(self, command_id: str) -> bool:
|
||||
"""Supprime une commande de l'historique."""
|
||||
async with async_session_maker() as session:
|
||||
from app.models.log import Log
|
||||
|
||||
stmt = (
|
||||
delete(Log)
|
||||
.where(Log.task_id == command_id)
|
||||
.where(Log.level == "ADHOC_COMMAND")
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def get_categories(self) -> List[AdHocHistoryCategory]:
|
||||
"""Récupère la liste des catégories."""
|
||||
async with async_session_maker() as session:
|
||||
from app.models.log import Log
|
||||
|
||||
stmt = select(Log).where(Log.level == "ADHOC_CATEGORY")
|
||||
result = await session.execute(stmt)
|
||||
logs = result.scalars().all()
|
||||
|
||||
if not logs:
|
||||
# Retourner les catégories par défaut
|
||||
return [
|
||||
AdHocHistoryCategory(
|
||||
name=cat["name"],
|
||||
description=cat.get("description"),
|
||||
color=cat.get("color", "#7c3aed"),
|
||||
icon=cat.get("icon", "fa-folder")
|
||||
)
|
||||
for cat in DEFAULT_ADHOC_CATEGORIES
|
||||
]
|
||||
|
||||
categories = []
|
||||
for log in logs:
|
||||
# Extraire color et icon depuis task_id
|
||||
color, icon = "#7c3aed", "fa-folder"
|
||||
if log.task_id and "|" in log.task_id:
|
||||
parts = log.task_id.split("|", 1)
|
||||
color = parts[0]
|
||||
icon = parts[1] if len(parts) > 1 else "fa-folder"
|
||||
|
||||
categories.append(AdHocHistoryCategory(
|
||||
name=log.message,
|
||||
description=log.source or None,
|
||||
color=color,
|
||||
icon=icon
|
||||
))
|
||||
|
||||
return categories
|
||||
|
||||
async def add_category(
|
||||
self,
|
||||
name: str,
|
||||
description: str = None,
|
||||
color: str = "#7c3aed",
|
||||
icon: str = "fa-folder"
|
||||
) -> AdHocHistoryCategory:
|
||||
"""Ajoute une nouvelle catégorie."""
|
||||
async with async_session_maker() as session:
|
||||
await self._create_category_internal(session, name, description, color, icon)
|
||||
await session.commit()
|
||||
|
||||
return AdHocHistoryCategory(
|
||||
name=name,
|
||||
description=description,
|
||||
color=color,
|
||||
icon=icon
|
||||
)
|
||||
|
||||
async def update_category(
|
||||
self,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
description: str = None,
|
||||
color: str = "#7c3aed",
|
||||
icon: str = "fa-folder"
|
||||
) -> bool:
|
||||
"""Met à jour une catégorie existante."""
|
||||
async with async_session_maker() as session:
|
||||
from app.models.log import Log
|
||||
|
||||
# Mettre à jour la catégorie
|
||||
stmt = (
|
||||
update(Log)
|
||||
.where(Log.message == old_name)
|
||||
.where(Log.level == "ADHOC_CATEGORY")
|
||||
.values(
|
||||
message=new_name,
|
||||
source=description or "",
|
||||
task_id=f"{color}|{icon}"
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
# Mettre à jour les commandes associées si le nom a changé
|
||||
if old_name != new_name:
|
||||
stmt2 = (
|
||||
update(Log)
|
||||
.where(Log.source == old_name)
|
||||
.where(Log.level == "ADHOC_COMMAND")
|
||||
.values(source=new_name)
|
||||
)
|
||||
await session.execute(stmt2)
|
||||
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete_category(self, name: str) -> bool:
|
||||
"""Supprime une catégorie et déplace ses commandes vers 'default'."""
|
||||
if name == "default":
|
||||
return False
|
||||
|
||||
async with async_session_maker() as session:
|
||||
from app.models.log import Log
|
||||
|
||||
# Déplacer les commandes vers default
|
||||
stmt1 = (
|
||||
update(Log)
|
||||
.where(Log.source == name)
|
||||
.where(Log.level == "ADHOC_COMMAND")
|
||||
.values(source="default")
|
||||
)
|
||||
await session.execute(stmt1)
|
||||
|
||||
# Supprimer la catégorie
|
||||
stmt2 = (
|
||||
delete(Log)
|
||||
.where(Log.message == name)
|
||||
.where(Log.level == "ADHOC_CATEGORY")
|
||||
)
|
||||
result = await session.execute(stmt2)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
# Instance singleton du service
|
||||
adhoc_history_service = AdHocHistoryService()
|
||||
@@ -0,0 +1,577 @@
|
||||
"""
|
||||
Service de gestion d'Ansible (playbooks, inventaire, exécution).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.host_api import AnsibleInventoryHost
|
||||
from app.schemas.ansible import PlaybookInfo
|
||||
|
||||
|
||||
class AnsibleService:
|
||||
"""Service pour gérer les playbooks et l'inventaire Ansible."""
|
||||
|
||||
def __init__(self, ansible_dir: Path = None, ssh_key_path: str = None, ssh_user: str = None):
|
||||
self.ansible_dir = ansible_dir or settings.ansible_dir
|
||||
self.playbooks_dir = self.ansible_dir / "playbooks"
|
||||
self.inventory_path = self.ansible_dir / "inventory" / "hosts.yml"
|
||||
self.ssh_key_path = ssh_key_path or settings.ssh_key_path
|
||||
self.ssh_user = ssh_user or settings.ssh_user
|
||||
|
||||
# Cache
|
||||
self._inventory_cache: Optional[Dict] = None
|
||||
self._inventory_cache_time: float = 0
|
||||
self._playbooks_cache: Optional[List[PlaybookInfo]] = None
|
||||
self._playbooks_cache_time: float = 0
|
||||
self._cache_ttl = settings.inventory_cache_ttl
|
||||
|
||||
def invalidate_cache(self):
|
||||
"""Invalide les caches."""
|
||||
self._inventory_cache = None
|
||||
self._playbooks_cache = None
|
||||
|
||||
# ===== PLAYBOOKS =====
|
||||
|
||||
def get_playbooks(self) -> List[Dict[str, Any]]:
|
||||
"""Récupère la liste des playbooks disponibles."""
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
if self._playbooks_cache and (current_time - self._playbooks_cache_time) < self._cache_ttl:
|
||||
return self._playbooks_cache
|
||||
|
||||
playbooks = []
|
||||
|
||||
if not self.playbooks_dir.exists():
|
||||
return playbooks
|
||||
|
||||
# Parcourir le répertoire principal
|
||||
for item in self.playbooks_dir.iterdir():
|
||||
if item.is_file() and item.suffix in ['.yml', '.yaml']:
|
||||
pb = self._parse_playbook_file(item, "general", "other")
|
||||
if pb:
|
||||
playbooks.append(pb)
|
||||
elif item.is_dir() and not item.name.startswith('.'):
|
||||
# Sous-répertoire = catégorie
|
||||
category = item.name
|
||||
for subitem in item.iterdir():
|
||||
if subitem.is_file() and subitem.suffix in ['.yml', '.yaml']:
|
||||
pb = self._parse_playbook_file(subitem, category, "other")
|
||||
if pb:
|
||||
playbooks.append(pb)
|
||||
elif subitem.is_dir() and not subitem.name.startswith('.'):
|
||||
# Sous-sous-répertoire = subcategory
|
||||
subcategory = subitem.name
|
||||
for subsubitem in subitem.iterdir():
|
||||
if subsubitem.is_file() and subsubitem.suffix in ['.yml', '.yaml']:
|
||||
pb = self._parse_playbook_file(subsubitem, category, subcategory)
|
||||
if pb:
|
||||
playbooks.append(pb)
|
||||
|
||||
self._playbooks_cache = playbooks
|
||||
self._playbooks_cache_time = current_time
|
||||
return playbooks
|
||||
|
||||
def _parse_playbook_file(self, file_path: Path, category: str, subcategory: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse un fichier playbook et extrait ses métadonnées."""
|
||||
try:
|
||||
stat = file_path.stat()
|
||||
|
||||
# Lire le contenu pour extraire hosts
|
||||
hosts = "all"
|
||||
description = None
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
data = yaml.safe_load(content)
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
first_play = data[0]
|
||||
if isinstance(first_play, dict):
|
||||
hosts = first_play.get('hosts', 'all')
|
||||
# Chercher une description dans les commentaires
|
||||
if content.startswith('#'):
|
||||
first_line = content.split('\n')[0]
|
||||
description = first_line.lstrip('#').strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"name": file_path.stem,
|
||||
"filename": file_path.name,
|
||||
"path": str(file_path),
|
||||
"category": category,
|
||||
"subcategory": subcategory,
|
||||
"hosts": hosts,
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"description": description
|
||||
}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_playbook_categories(self) -> Dict[str, List[str]]:
|
||||
"""Retourne les catégories de playbooks organisées."""
|
||||
playbooks = self.get_playbooks()
|
||||
categories = {}
|
||||
|
||||
for pb in 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 est compatible avec un playbook."""
|
||||
# 'all' est toujours compatible
|
||||
if playbook_hosts == 'all' or target == 'all':
|
||||
return True
|
||||
|
||||
# Si le playbook cible exactement notre target
|
||||
if playbook_hosts == target:
|
||||
return True
|
||||
|
||||
# Vérifier si target fait partie des hosts du playbook
|
||||
# Le playbook peut avoir une expression avec ":"
|
||||
pb_hosts = [h.strip() for h in playbook_hosts.split(':')]
|
||||
if target in pb_hosts:
|
||||
return True
|
||||
|
||||
# Si le target est un groupe qui pourrait contenir les hosts du playbook
|
||||
# Dans ce cas, on laisse passer car c'est géré par Ansible
|
||||
groups = self.get_groups()
|
||||
if target in groups:
|
||||
return True
|
||||
|
||||
# Si le playbook cible un groupe spécifique et notre target est un hôte
|
||||
hosts = self.get_hosts_from_inventory()
|
||||
host_names = [h.name for h in hosts]
|
||||
if target in host_names:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_compatible_playbooks(self, target: str) -> List[Dict[str, Any]]:
|
||||
"""Retourne les playbooks compatibles avec une cible."""
|
||||
all_playbooks = self.get_playbooks()
|
||||
compatible = []
|
||||
|
||||
for pb in all_playbooks:
|
||||
if self.is_target_compatible_with_playbook(target, pb.get('hosts', 'all')):
|
||||
compatible.append(pb)
|
||||
|
||||
return compatible
|
||||
|
||||
# ===== INVENTAIRE =====
|
||||
|
||||
def load_inventory(self) -> Dict:
|
||||
"""Charge l'inventaire Ansible depuis le fichier YAML."""
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
if self._inventory_cache and (current_time - self._inventory_cache_time) < self._cache_ttl:
|
||||
return self._inventory_cache
|
||||
|
||||
if not self.inventory_path.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(self.inventory_path, 'r', encoding='utf-8') as f:
|
||||
inventory = yaml.safe_load(f) or {}
|
||||
self._inventory_cache = inventory
|
||||
self._inventory_cache_time = current_time
|
||||
return inventory
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _save_inventory(self, inventory: Dict):
|
||||
"""Sauvegarde l'inventaire dans le fichier YAML."""
|
||||
self.inventory_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(self.inventory_path, 'w', encoding='utf-8') as f:
|
||||
yaml.dump(inventory, f, default_flow_style=False, allow_unicode=True)
|
||||
|
||||
# Invalider le cache
|
||||
self._inventory_cache = None
|
||||
|
||||
def get_hosts_from_inventory(self, group_filter: str = None) -> List[AnsibleInventoryHost]:
|
||||
"""Récupère les hôtes depuis l'inventaire Ansible."""
|
||||
inventory = self.load_inventory()
|
||||
|
||||
# Dictionnaire pour collecter tous les groupes de chaque hôte
|
||||
host_data: Dict[str, Dict] = {}
|
||||
|
||||
def extract_hosts(data: Dict, parent_group: str = None):
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
for key, value in data.items():
|
||||
if key == 'hosts' and isinstance(value, dict):
|
||||
for host_name, host_vars in value.items():
|
||||
if host_name not in host_data:
|
||||
ansible_host = host_name
|
||||
if isinstance(host_vars, dict):
|
||||
ansible_host = host_vars.get('ansible_host', host_name)
|
||||
|
||||
host_data[host_name] = {
|
||||
'ansible_host': ansible_host,
|
||||
'groups': [],
|
||||
'vars': host_vars if isinstance(host_vars, dict) else {}
|
||||
}
|
||||
|
||||
# Ajouter ce groupe à la liste des groupes de l'hôte
|
||||
if parent_group and parent_group not in host_data[host_name]['groups']:
|
||||
host_data[host_name]['groups'].append(parent_group)
|
||||
|
||||
elif key == 'children' and isinstance(value, dict):
|
||||
for child_group, child_data in value.items():
|
||||
extract_hosts(child_data, child_group)
|
||||
|
||||
elif isinstance(value, dict) and key not in ['hosts', 'vars', 'children']:
|
||||
extract_hosts(value, key)
|
||||
|
||||
extract_hosts(inventory)
|
||||
|
||||
# Convertir en liste d'objets AnsibleInventoryHost
|
||||
hosts = []
|
||||
for host_name, data in host_data.items():
|
||||
# Filtrer par groupe si demandé
|
||||
if group_filter and group_filter not in data['groups']:
|
||||
continue
|
||||
|
||||
# Déterminer le groupe principal (premier groupe env_ ou premier groupe)
|
||||
primary_group = "ungrouped"
|
||||
for g in data['groups']:
|
||||
if g.startswith('env_'):
|
||||
primary_group = g
|
||||
break
|
||||
if primary_group == "ungrouped" and data['groups']:
|
||||
primary_group = data['groups'][0]
|
||||
|
||||
hosts.append(AnsibleInventoryHost(
|
||||
name=host_name,
|
||||
ansible_host=data['ansible_host'],
|
||||
group=primary_group,
|
||||
groups=data['groups'],
|
||||
vars=data['vars']
|
||||
))
|
||||
|
||||
return hosts
|
||||
|
||||
def get_groups(self) -> List[str]:
|
||||
"""Récupère la liste de tous les groupes."""
|
||||
inventory = self.load_inventory()
|
||||
groups = set()
|
||||
|
||||
def extract_groups(data: Dict):
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
|
||||
for key, value in data.items():
|
||||
if key in ['hosts', 'vars']:
|
||||
continue
|
||||
if key == 'children' and isinstance(value, dict):
|
||||
for child_group, child_data in value.items():
|
||||
groups.add(child_group)
|
||||
extract_groups(child_data)
|
||||
elif isinstance(value, dict):
|
||||
groups.add(key)
|
||||
extract_groups(value)
|
||||
|
||||
extract_groups(inventory)
|
||||
return sorted(list(groups))
|
||||
|
||||
def get_env_groups(self) -> List[str]:
|
||||
"""Récupère les groupes d'environnement (préfixe env_)."""
|
||||
return [g for g in self.get_groups() if g.startswith('env_')]
|
||||
|
||||
def get_role_groups(self) -> List[str]:
|
||||
"""Récupère les groupes de rôles (préfixe role_)."""
|
||||
return [g for g in self.get_groups() if g.startswith('role_')]
|
||||
|
||||
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 or h.ansible_host == hostname for h in hosts)
|
||||
|
||||
def group_exists(self, group_name: str) -> bool:
|
||||
"""Vérifie si un groupe existe."""
|
||||
return group_name in self.get_groups()
|
||||
|
||||
def add_host_to_inventory(
|
||||
self,
|
||||
hostname: str,
|
||||
env_group: str,
|
||||
role_groups: List[str] = None,
|
||||
ansible_host: str = None
|
||||
):
|
||||
"""Ajoute un hôte à l'inventaire."""
|
||||
inventory = self.load_inventory()
|
||||
|
||||
if 'all' not in inventory:
|
||||
inventory['all'] = {'children': {}}
|
||||
|
||||
children = inventory['all'].setdefault('children', {})
|
||||
|
||||
# Ajouter au groupe d'environnement
|
||||
if env_group not in children:
|
||||
children[env_group] = {'hosts': {}}
|
||||
|
||||
env_data = children[env_group]
|
||||
if 'hosts' not in env_data:
|
||||
env_data['hosts'] = {}
|
||||
|
||||
host_vars = {}
|
||||
if ansible_host and ansible_host != hostname:
|
||||
host_vars['ansible_host'] = ansible_host
|
||||
|
||||
env_data['hosts'][hostname] = host_vars or None
|
||||
|
||||
# Ajouter aux groupes de rôles
|
||||
for role in (role_groups or []):
|
||||
if role not in children:
|
||||
children[role] = {'hosts': {}}
|
||||
role_data = children[role]
|
||||
if 'hosts' not in role_data:
|
||||
role_data['hosts'] = {}
|
||||
role_data['hosts'][hostname] = None
|
||||
|
||||
self._save_inventory(inventory)
|
||||
|
||||
def remove_host_from_inventory(self, hostname: str):
|
||||
"""Supprime un hôte de l'inventaire."""
|
||||
inventory = self.load_inventory()
|
||||
|
||||
def remove_from_dict(data: Dict):
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
if 'hosts' in data and isinstance(data['hosts'], dict):
|
||||
data['hosts'].pop(hostname, None)
|
||||
if 'children' in data and isinstance(data['children'], dict):
|
||||
for child_data in data['children'].values():
|
||||
remove_from_dict(child_data)
|
||||
for key, value in list(data.items()):
|
||||
if key not in ['hosts', 'vars', 'children'] and isinstance(value, dict):
|
||||
remove_from_dict(value)
|
||||
|
||||
remove_from_dict(inventory)
|
||||
self._save_inventory(inventory)
|
||||
|
||||
def update_host_groups(
|
||||
self,
|
||||
hostname: str,
|
||||
env_group: str = None,
|
||||
role_groups: List[str] = None,
|
||||
ansible_host: str = None
|
||||
):
|
||||
"""Met à jour les groupes d'un hôte."""
|
||||
# Supprimer l'hôte de tous les groupes
|
||||
self.remove_host_from_inventory(hostname)
|
||||
|
||||
# Réajouter avec les nouveaux groupes
|
||||
if env_group:
|
||||
self.add_host_to_inventory(
|
||||
hostname=hostname,
|
||||
env_group=env_group,
|
||||
role_groups=role_groups or [],
|
||||
ansible_host=ansible_host
|
||||
)
|
||||
|
||||
def add_group(self, group_name: str, group_type: str = "role"):
|
||||
"""Ajoute un nouveau groupe."""
|
||||
inventory = self.load_inventory()
|
||||
|
||||
if 'all' not in inventory:
|
||||
inventory['all'] = {'children': {}}
|
||||
|
||||
children = inventory['all'].setdefault('children', {})
|
||||
|
||||
if group_name not in children:
|
||||
children[group_name] = {'hosts': {}}
|
||||
|
||||
self._save_inventory(inventory)
|
||||
|
||||
def rename_group(self, old_name: str, new_name: str):
|
||||
"""Renomme un groupe."""
|
||||
inventory = self.load_inventory()
|
||||
|
||||
if 'all' not in inventory or 'children' not in inventory['all']:
|
||||
return
|
||||
|
||||
children = inventory['all']['children']
|
||||
|
||||
if old_name in children:
|
||||
children[new_name] = children.pop(old_name)
|
||||
|
||||
self._save_inventory(inventory)
|
||||
|
||||
def delete_group(self, group_name: str, move_hosts_to: str = None):
|
||||
"""Supprime un groupe (optionnellement déplace les hôtes)."""
|
||||
inventory = self.load_inventory()
|
||||
|
||||
if 'all' not in inventory or 'children' not in inventory['all']:
|
||||
return
|
||||
|
||||
children = inventory['all']['children']
|
||||
|
||||
if group_name not in children:
|
||||
return
|
||||
|
||||
# Récupérer les hôtes du groupe
|
||||
group_hosts = []
|
||||
if 'hosts' in children[group_name]:
|
||||
group_hosts = list(children[group_name]['hosts'].keys())
|
||||
|
||||
# Déplacer les hôtes si demandé
|
||||
if move_hosts_to and move_hosts_to in children:
|
||||
target_group = children[move_hosts_to]
|
||||
if 'hosts' not in target_group:
|
||||
target_group['hosts'] = {}
|
||||
for host in group_hosts:
|
||||
target_group['hosts'][host] = children[group_name]['hosts'].get(host)
|
||||
|
||||
# Supprimer le groupe
|
||||
del children[group_name]
|
||||
|
||||
self._save_inventory(inventory)
|
||||
|
||||
def get_group_hosts(self, group_name: str) -> List[str]:
|
||||
"""Récupère les hôtes d'un groupe."""
|
||||
hosts = self.get_hosts_from_inventory(group_filter=group_name)
|
||||
return [h.name for h in hosts]
|
||||
|
||||
# ===== EXÉCUTION =====
|
||||
|
||||
async def execute_playbook(
|
||||
self,
|
||||
playbook: str,
|
||||
target: str = "all",
|
||||
extra_vars: Dict[str, Any] = None,
|
||||
check_mode: bool = False,
|
||||
verbose: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""Exécute un playbook Ansible de manière asynchrone."""
|
||||
start_time = perf_counter()
|
||||
|
||||
# Construire le chemin du playbook
|
||||
if not playbook.endswith(('.yml', '.yaml')):
|
||||
playbook = f"{playbook}.yml"
|
||||
|
||||
playbook_path = self._find_playbook_path(playbook)
|
||||
if not playbook_path or not playbook_path.exists():
|
||||
raise FileNotFoundError(f"Playbook non trouvé: {playbook}")
|
||||
|
||||
# Trouver la clé SSH
|
||||
private_key = self._find_ssh_private_key()
|
||||
|
||||
# Construire la commande
|
||||
cmd = [
|
||||
"ansible-playbook",
|
||||
str(playbook_path),
|
||||
"-i", str(self.inventory_path),
|
||||
"-l", target,
|
||||
]
|
||||
|
||||
if check_mode:
|
||||
cmd.append("--check")
|
||||
|
||||
if verbose:
|
||||
cmd.append("-v")
|
||||
|
||||
if private_key:
|
||||
cmd.extend(["--private-key", private_key])
|
||||
|
||||
if self.ssh_user:
|
||||
cmd.extend(["-u", self.ssh_user])
|
||||
|
||||
if extra_vars:
|
||||
import json
|
||||
cmd.extend(["--extra-vars", json.dumps(extra_vars)])
|
||||
|
||||
# Exécuter la commande
|
||||
try:
|
||||
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": execution_time,
|
||||
"playbook": playbook,
|
||||
"target": target,
|
||||
"check_mode": check_mode
|
||||
}
|
||||
|
||||
except FileNotFoundError:
|
||||
return {
|
||||
"success": False,
|
||||
"return_code": -1,
|
||||
"stdout": "",
|
||||
"stderr": "ansible-playbook non trouvé. Vérifiez que Ansible est installé.",
|
||||
"execution_time": perf_counter() - start_time,
|
||||
"playbook": playbook,
|
||||
"target": target,
|
||||
"check_mode": check_mode
|
||||
}
|
||||
|
||||
def _find_playbook_path(self, playbook: str) -> Optional[Path]:
|
||||
"""Trouve le chemin complet d'un playbook."""
|
||||
# Chemin direct
|
||||
direct_path = self.playbooks_dir / playbook
|
||||
if direct_path.exists():
|
||||
return direct_path
|
||||
|
||||
# Chercher dans les sous-répertoires
|
||||
for item in self.playbooks_dir.rglob(playbook):
|
||||
if item.is_file():
|
||||
return item
|
||||
|
||||
return None
|
||||
|
||||
def _find_ssh_private_key(self) -> Optional[str]:
|
||||
"""Trouve une clé SSH privée valide."""
|
||||
# Essayer le chemin configuré
|
||||
if self.ssh_key_path:
|
||||
key_path = Path(self.ssh_key_path)
|
||||
if key_path.exists():
|
||||
return str(key_path)
|
||||
|
||||
# Chercher dans les emplacements standard
|
||||
candidates = [
|
||||
Path.home() / ".ssh" / "id_rsa",
|
||||
Path.home() / ".ssh" / "id_ed25519",
|
||||
Path.home() / ".ssh" / "id_ecdsa",
|
||||
Path("/app/docker/ssh_keys/id_automation_ansible"),
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Instance singleton du service
|
||||
ansible_service = AnsibleService()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Authentication service with JWT and password hashing.
|
||||
|
||||
Uses:
|
||||
- python-jose for JWT encoding/decoding
|
||||
- bcrypt for password hashing
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import TokenData
|
||||
|
||||
# Configuration from environment variables
|
||||
SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "homelab-secret-key-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("JWT_EXPIRE_MINUTES", "1440")) # 24 hours default
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""Service for authentication operations."""
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode('utf-8'),
|
||||
hashed_password.encode('utf-8')
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password for storage."""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(
|
||||
data: dict,
|
||||
expires_delta: Optional[timedelta] = None
|
||||
) -> tuple[str, int]:
|
||||
"""Create a JWT access token.
|
||||
|
||||
Returns:
|
||||
Tuple of (token_string, expires_in_seconds)
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
expires_in = int(expires_delta.total_seconds())
|
||||
else:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expires_in = ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
|
||||
to_encode.update({
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
})
|
||||
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt, expires_in
|
||||
|
||||
@staticmethod
|
||||
def decode_token(token: str) -> Optional[TokenData]:
|
||||
"""Decode and validate a JWT token.
|
||||
|
||||
Returns:
|
||||
TokenData if valid, None if invalid or expired.
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
user_id: int = payload.get("user_id")
|
||||
role: str = payload.get("role")
|
||||
|
||||
if username is None:
|
||||
return None
|
||||
|
||||
return TokenData(username=username, user_id=user_id, role=role)
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def create_token_for_user(user: User) -> tuple[str, int]:
|
||||
"""Create a JWT token for a user.
|
||||
|
||||
Returns:
|
||||
Tuple of (token_string, expires_in_seconds)
|
||||
"""
|
||||
token_data = {
|
||||
"sub": user.username,
|
||||
"user_id": user.id,
|
||||
"role": user.role,
|
||||
}
|
||||
return AuthService.create_access_token(token_data)
|
||||
|
||||
|
||||
# Convenience functions for direct use
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return AuthService.verify_password(plain_password, hashed_password)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password for storage."""
|
||||
return AuthService.hash_password(password)
|
||||
|
||||
|
||||
def create_access_token(
|
||||
data: dict,
|
||||
expires_delta: Optional[timedelta] = None
|
||||
) -> tuple[str, int]:
|
||||
"""Create a JWT access token."""
|
||||
return AuthService.create_access_token(data, expires_delta)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[TokenData]:
|
||||
"""Decode and validate a JWT token."""
|
||||
return AuthService.decode_token(token)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
auth_service = AuthService()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Service de gestion du statut de bootstrap des hôtes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.database import async_session_maker
|
||||
|
||||
|
||||
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 app.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 app.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 sqlalchemy import select
|
||||
from app.models.bootstrap_status import BootstrapStatus
|
||||
from app.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}")
|
||||
|
||||
|
||||
# Instance singleton du service
|
||||
bootstrap_status_service = BootstrapStatusService()
|
||||
@@ -0,0 +1,526 @@
|
||||
"""
|
||||
Service de gestion des Builtin Playbooks.
|
||||
|
||||
Ce service gère les playbooks intégrés à l'application pour la collecte
|
||||
automatique d'informations sur les hôtes (métriques système, disque, mémoire, etc.).
|
||||
|
||||
Les résultats sont stockés dans la table host_metrics et visibles dans les Logs,
|
||||
mais pas dans la section Tasks (pour éviter de polluer l'interface).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.host_metrics import (
|
||||
BuiltinPlaybookDefinition,
|
||||
HostMetricsCreate,
|
||||
HostMetricsSummary,
|
||||
)
|
||||
|
||||
|
||||
# Définitions des builtin playbooks
|
||||
BUILTIN_PLAYBOOKS: Dict[str, BuiltinPlaybookDefinition] = {
|
||||
"install_base_tools": BuiltinPlaybookDefinition(
|
||||
id="install_base_tools",
|
||||
name="Installer les outils de base",
|
||||
description="Installe les commandes requises pour la collecte et l'affichage des métriques (df, lsblk, python3, etc.)",
|
||||
playbook_file="_builtin_install_base_tools.yml",
|
||||
category="maintenance",
|
||||
icon="fas fa-tools",
|
||||
color="blue",
|
||||
collect_metrics=False,
|
||||
schedule_enabled=False,
|
||||
visible_in_ui=True,
|
||||
),
|
||||
"collect_system_info": BuiltinPlaybookDefinition(
|
||||
id="collect_system_info",
|
||||
name="Collecte Info Système",
|
||||
description="Collecte les informations système complètes (CPU, mémoire, disque, OS)",
|
||||
playbook_file="_builtin_collect_system_info.yml",
|
||||
category="metrics",
|
||||
icon="fas fa-microchip",
|
||||
color="cyan",
|
||||
collect_metrics=True,
|
||||
schedule_enabled=True,
|
||||
visible_in_ui=True,
|
||||
),
|
||||
"collect_disk_usage": BuiltinPlaybookDefinition(
|
||||
id="collect_disk_usage",
|
||||
name="Espace Disque",
|
||||
description="Collecte l'utilisation de l'espace disque sur tous les points de montage",
|
||||
playbook_file="_builtin_collect_disk_usage.yml",
|
||||
category="metrics",
|
||||
icon="fas fa-hdd",
|
||||
color="amber",
|
||||
collect_metrics=True,
|
||||
schedule_enabled=True,
|
||||
visible_in_ui=True,
|
||||
),
|
||||
"collect_memory_info": BuiltinPlaybookDefinition(
|
||||
id="collect_memory_info",
|
||||
name="Utilisation Mémoire",
|
||||
description="Collecte les informations de mémoire RAM et swap",
|
||||
playbook_file="_builtin_collect_memory_info.yml",
|
||||
category="metrics",
|
||||
icon="fas fa-memory",
|
||||
color="purple",
|
||||
collect_metrics=True,
|
||||
schedule_enabled=True,
|
||||
visible_in_ui=True,
|
||||
),
|
||||
"collect_cpu_info": BuiltinPlaybookDefinition(
|
||||
id="collect_cpu_info",
|
||||
name="Informations CPU",
|
||||
description="Collecte les informations CPU (charge, température, modèle)",
|
||||
playbook_file="_builtin_collect_cpu_info.yml",
|
||||
category="metrics",
|
||||
icon="fas fa-tachometer-alt",
|
||||
color="red",
|
||||
collect_metrics=True,
|
||||
schedule_enabled=True,
|
||||
visible_in_ui=True,
|
||||
),
|
||||
"collect_network_info": BuiltinPlaybookDefinition(
|
||||
id="collect_network_info",
|
||||
name="Informations Réseau",
|
||||
description="Collecte les informations des interfaces réseau",
|
||||
playbook_file="_builtin_collect_network_info.yml",
|
||||
category="metrics",
|
||||
icon="fas fa-network-wired",
|
||||
color="green",
|
||||
collect_metrics=True,
|
||||
schedule_enabled=True,
|
||||
visible_in_ui=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class BuiltinPlaybookService:
|
||||
"""Service pour gérer et exécuter les builtin playbooks."""
|
||||
|
||||
def __init__(self, ansible_dir: Path, ansible_service=None):
|
||||
"""
|
||||
Args:
|
||||
ansible_dir: Répertoire racine Ansible (contenant playbooks/)
|
||||
ansible_service: Instance du service Ansible pour l'exécution
|
||||
"""
|
||||
self.ansible_dir = ansible_dir
|
||||
self.playbooks_dir = ansible_dir / "playbooks"
|
||||
self.builtin_dir = ansible_dir / "playbooks" / "builtin"
|
||||
self.ansible_service = ansible_service
|
||||
|
||||
# Créer le répertoire builtin s'il n'existe pas
|
||||
self.builtin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_all_definitions(self) -> List[BuiltinPlaybookDefinition]:
|
||||
"""Retourne toutes les définitions de builtin playbooks."""
|
||||
return list(BUILTIN_PLAYBOOKS.values())
|
||||
|
||||
def get_definition(self, builtin_id: str) -> Optional[BuiltinPlaybookDefinition]:
|
||||
"""Retourne la définition d'un builtin playbook par son ID."""
|
||||
return BUILTIN_PLAYBOOKS.get(builtin_id)
|
||||
|
||||
def get_playbook_path(self, builtin_id: str) -> Optional[Path]:
|
||||
"""Retourne le chemin complet du fichier playbook."""
|
||||
definition = self.get_definition(builtin_id)
|
||||
if not definition:
|
||||
return None
|
||||
return self.builtin_dir / definition.playbook_file
|
||||
|
||||
def is_builtin_playbook(self, filename: str) -> bool:
|
||||
"""Vérifie si un fichier est un builtin playbook (commence par _builtin_)."""
|
||||
return filename.startswith("_builtin_")
|
||||
|
||||
async def execute_builtin(
|
||||
self,
|
||||
builtin_id: str,
|
||||
target: str,
|
||||
extra_vars: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Exécute un builtin playbook et retourne les résultats.
|
||||
|
||||
Args:
|
||||
builtin_id: ID du builtin playbook
|
||||
target: Cible (hostname ou groupe)
|
||||
extra_vars: Variables supplémentaires pour Ansible
|
||||
|
||||
Returns:
|
||||
Dict avec success, stdout, stderr, parsed_metrics, etc.
|
||||
"""
|
||||
definition = self.get_definition(builtin_id)
|
||||
if not definition:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Builtin playbook '{builtin_id}' non trouvé",
|
||||
"parsed_metrics": {},
|
||||
"stdout": "",
|
||||
"stderr": f"Builtin playbook '{builtin_id}' non trouvé",
|
||||
}
|
||||
|
||||
playbook_path = self.builtin_dir / definition.playbook_file
|
||||
if not playbook_path.exists():
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Fichier playbook '{definition.playbook_file}' non trouvé à {playbook_path}",
|
||||
"parsed_metrics": {},
|
||||
"stdout": "",
|
||||
"stderr": f"Fichier playbook '{definition.playbook_file}' non trouvé à {playbook_path}",
|
||||
}
|
||||
|
||||
if not self.ansible_service:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Service Ansible non initialisé",
|
||||
"parsed_metrics": {},
|
||||
"stdout": "",
|
||||
"stderr": "Service Ansible non initialisé",
|
||||
}
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Exécuter le playbook via le service Ansible
|
||||
# Le playbook doit être dans le sous-dossier builtin/
|
||||
playbook_relative = f"builtin/{definition.playbook_file}"
|
||||
|
||||
result = await self.ansible_service.execute_playbook(
|
||||
playbook=playbook_relative,
|
||||
target=target,
|
||||
extra_vars=extra_vars or {},
|
||||
check_mode=False,
|
||||
verbose=False,
|
||||
)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
# Parser les métriques depuis la sortie JSON
|
||||
parsed_metrics = {}
|
||||
if result.get("success") and definition.collect_metrics:
|
||||
parsed_metrics = self._parse_metrics_from_output(
|
||||
result.get("stdout", ""),
|
||||
builtin_id
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result.get("success", False),
|
||||
"stdout": result.get("stdout", ""),
|
||||
"stderr": result.get("stderr", ""),
|
||||
"execution_time": execution_time,
|
||||
"execution_time_ms": int(execution_time * 1000),
|
||||
"parsed_metrics": parsed_metrics,
|
||||
"builtin_id": builtin_id,
|
||||
"target": target,
|
||||
"return_code": result.get("return_code", -1),
|
||||
}
|
||||
except Exception as e:
|
||||
execution_time = time.time() - start_time
|
||||
error_msg = f"Exception lors de l'exécution du builtin playbook: {str(e)}"
|
||||
print(f"[BUILTIN] {error_msg}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"stdout": "",
|
||||
"stderr": error_msg,
|
||||
"error": error_msg,
|
||||
"execution_time": execution_time,
|
||||
"execution_time_ms": int(execution_time * 1000),
|
||||
"parsed_metrics": {},
|
||||
"builtin_id": builtin_id,
|
||||
"target": target,
|
||||
"return_code": -1,
|
||||
}
|
||||
|
||||
def _parse_metrics_from_output(
|
||||
self,
|
||||
stdout: str,
|
||||
builtin_id: str
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Parse les métriques JSON depuis la sortie du playbook.
|
||||
|
||||
Les playbooks builtin utilisent le format:
|
||||
METRICS_JSON_START:{"host": "hostname", "data": {...}}:METRICS_JSON_END
|
||||
|
||||
Returns:
|
||||
Dict mapping hostname to metrics data
|
||||
"""
|
||||
metrics_by_host = {}
|
||||
|
||||
print(f"[BUILTIN] Parsing metrics from stdout ({len(stdout)} chars)")
|
||||
|
||||
# Pattern pour extraire les blocs JSON de métriques
|
||||
# Format: METRICS_JSON_START:{...}:METRICS_JSON_END
|
||||
pattern = r'METRICS_JSON_START:(.*?):METRICS_JSON_END'
|
||||
matches = re.findall(pattern, stdout, re.DOTALL)
|
||||
|
||||
print(f"[BUILTIN] Found {len(matches)} METRICS_JSON matches")
|
||||
|
||||
for match in matches:
|
||||
try:
|
||||
data = json.loads(match.strip())
|
||||
host = data.get("host", "unknown")
|
||||
metrics = data.get("data", {})
|
||||
metrics_by_host[host] = metrics
|
||||
print(f"[BUILTIN] Parsed metrics for host: {host}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[BUILTIN] JSON decode error: {e}")
|
||||
continue
|
||||
|
||||
# Fallback: essayer de parser les debug outputs Ansible standards
|
||||
if not metrics_by_host:
|
||||
print("[BUILTIN] No metrics found with primary pattern, trying fallback...")
|
||||
metrics_by_host = self._parse_ansible_debug_output(stdout, builtin_id)
|
||||
|
||||
# Fallback 2: chercher le format "msg": "METRICS_JSON_START:..."
|
||||
if not metrics_by_host:
|
||||
print("[BUILTIN] Trying msg pattern fallback...")
|
||||
# Pattern pour le format Ansible debug: "msg": "METRICS_JSON_START:...:METRICS_JSON_END"
|
||||
msg_pattern = r'"msg":\s*"METRICS_JSON_START:(.*?):METRICS_JSON_END"'
|
||||
msg_matches = re.findall(msg_pattern, stdout, re.DOTALL)
|
||||
print(f"[BUILTIN] Found {len(msg_matches)} msg pattern matches")
|
||||
|
||||
for match in msg_matches:
|
||||
try:
|
||||
# Le JSON est échappé dans le msg, il faut le décoder
|
||||
unescaped = match.replace('\\"', '"').replace('\\n', '\n')
|
||||
data = json.loads(unescaped.strip())
|
||||
host = data.get("host", "unknown")
|
||||
metrics = data.get("data", {})
|
||||
metrics_by_host[host] = metrics
|
||||
print(f"[BUILTIN] Parsed metrics from msg for host: {host}")
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"[BUILTIN] JSON decode error in msg pattern: {e}")
|
||||
continue
|
||||
|
||||
print(f"[BUILTIN] Total hosts with metrics: {len(metrics_by_host)}")
|
||||
if not metrics_by_host and stdout:
|
||||
# Log un extrait du stdout pour debug
|
||||
print(f"[BUILTIN] Stdout sample (first 500 chars): {stdout[:500]}")
|
||||
|
||||
return metrics_by_host
|
||||
|
||||
def _parse_ansible_debug_output(
|
||||
self,
|
||||
stdout: str,
|
||||
builtin_id: str
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Parse les métriques depuis les messages debug Ansible standards.
|
||||
Format attendu: "host | SUCCESS => {...}" ou debug msg avec JSON
|
||||
"""
|
||||
metrics_by_host = {}
|
||||
|
||||
# Pattern pour les résultats ad-hoc ou debug
|
||||
# Ex: hostname | SUCCESS => {"ansible_facts": {...}}
|
||||
pattern = r'(\S+)\s*\|\s*(?:SUCCESS|CHANGED)\s*=>\s*(\{.*?\})\s*(?=\n\S|\Z)'
|
||||
|
||||
for line in stdout.split('\n'):
|
||||
# Chercher les lignes de debug avec JSON
|
||||
if '"metrics":' in line or '"cpu_' in line or '"memory_' in line or '"disk_' in line:
|
||||
try:
|
||||
# Trouver le JSON dans la ligne
|
||||
json_match = re.search(r'\{.*\}', line)
|
||||
if json_match:
|
||||
data = json.loads(json_match.group())
|
||||
# Essayer d'extraire le hostname depuis le contexte
|
||||
host_match = re.search(r'^(\S+)\s*:', line)
|
||||
if host_match:
|
||||
host = host_match.group(1)
|
||||
metrics_by_host[host] = data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return metrics_by_host
|
||||
|
||||
def _clean_numeric_value(self, value: Any) -> Optional[float]:
|
||||
"""Convertit une valeur en float, retourne None si vide ou invalide."""
|
||||
if value is None or value == '' or value == 'null':
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _clean_int_value(self, value: Any) -> Optional[int]:
|
||||
"""Convertit une valeur en int, retourne None si vide ou invalide."""
|
||||
if value is None or value == '' or value == 'null':
|
||||
return None
|
||||
try:
|
||||
return int(float(value)) # float d'abord pour gérer "3.0"
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _clean_string_value(self, value: Any) -> Optional[str]:
|
||||
"""Retourne None si la valeur est vide."""
|
||||
if value is None or value == '' or value == 'null' or value == 'Unknown':
|
||||
return None
|
||||
return str(value)
|
||||
|
||||
def create_metrics_from_parsed(
|
||||
self,
|
||||
host_id: str,
|
||||
parsed_data: Dict[str, Any],
|
||||
builtin_id: str,
|
||||
execution_time_ms: int
|
||||
) -> HostMetricsCreate:
|
||||
"""
|
||||
Crée un objet HostMetricsCreate à partir des données parsées.
|
||||
"""
|
||||
# Mapper le builtin_id vers metric_type
|
||||
metric_type_map = {
|
||||
"collect_system_info": "system_info",
|
||||
"collect_disk_usage": "disk_usage",
|
||||
"collect_memory_info": "memory",
|
||||
"collect_cpu_info": "cpu",
|
||||
"collect_network_info": "network",
|
||||
}
|
||||
|
||||
metric_type = metric_type_map.get(builtin_id, "unknown")
|
||||
|
||||
return HostMetricsCreate(
|
||||
host_id=host_id,
|
||||
metric_type=metric_type,
|
||||
|
||||
# CPU
|
||||
cpu_count=self._clean_int_value(parsed_data.get("cpu_count")),
|
||||
cpu_model=self._clean_string_value(parsed_data.get("cpu_model")),
|
||||
cpu_cores=self._clean_int_value(parsed_data.get("cpu_cores")),
|
||||
cpu_threads=self._clean_int_value(parsed_data.get("cpu_threads")),
|
||||
cpu_threads_per_core=self._clean_int_value(parsed_data.get("cpu_threads_per_core")),
|
||||
cpu_sockets=self._clean_int_value(parsed_data.get("cpu_sockets")),
|
||||
cpu_mhz=self._clean_numeric_value(parsed_data.get("cpu_mhz")),
|
||||
cpu_max_mhz=self._clean_numeric_value(parsed_data.get("cpu_max_mhz")),
|
||||
cpu_min_mhz=self._clean_numeric_value(parsed_data.get("cpu_min_mhz")),
|
||||
cpu_load_1m=self._clean_numeric_value(parsed_data.get("cpu_load_1m")),
|
||||
cpu_load_5m=self._clean_numeric_value(parsed_data.get("cpu_load_5m")),
|
||||
cpu_load_15m=self._clean_numeric_value(parsed_data.get("cpu_load_15m")),
|
||||
cpu_usage_percent=self._clean_numeric_value(parsed_data.get("cpu_usage_percent")),
|
||||
cpu_temperature=self._clean_numeric_value(parsed_data.get("cpu_temperature")),
|
||||
|
||||
# Memory
|
||||
memory_total_mb=self._clean_int_value(parsed_data.get("memory_total_mb")),
|
||||
memory_used_mb=self._clean_int_value(parsed_data.get("memory_used_mb")),
|
||||
memory_free_mb=self._clean_int_value(parsed_data.get("memory_free_mb")),
|
||||
memory_usage_percent=self._clean_numeric_value(parsed_data.get("memory_usage_percent")),
|
||||
swap_total_mb=self._clean_int_value(parsed_data.get("swap_total_mb")),
|
||||
swap_used_mb=self._clean_int_value(parsed_data.get("swap_used_mb")),
|
||||
swap_usage_percent=self._clean_numeric_value(parsed_data.get("swap_usage_percent")),
|
||||
|
||||
# Disk
|
||||
disk_info=parsed_data.get("disk_info"),
|
||||
disk_devices=parsed_data.get("disk_devices"),
|
||||
disk_root_total_gb=self._clean_numeric_value(parsed_data.get("disk_root_total_gb")),
|
||||
disk_root_used_gb=self._clean_numeric_value(parsed_data.get("disk_root_used_gb")),
|
||||
disk_root_usage_percent=self._clean_numeric_value(parsed_data.get("disk_root_usage_percent")),
|
||||
|
||||
# Storage stacks
|
||||
lvm_info=parsed_data.get("lvm_info"),
|
||||
zfs_info=parsed_data.get("zfs_info"),
|
||||
|
||||
# System
|
||||
os_name=self._clean_string_value(parsed_data.get("os_name")),
|
||||
os_version=self._clean_string_value(parsed_data.get("os_version")),
|
||||
kernel_version=self._clean_string_value(parsed_data.get("kernel_version")),
|
||||
hostname=self._clean_string_value(parsed_data.get("hostname")),
|
||||
uptime_seconds=self._clean_int_value(parsed_data.get("uptime_seconds")),
|
||||
uptime_human=self._clean_string_value(parsed_data.get("uptime_human")),
|
||||
|
||||
# Network
|
||||
network_info=parsed_data.get("network_info"),
|
||||
|
||||
# Metadata
|
||||
raw_data=parsed_data,
|
||||
collection_source=builtin_id,
|
||||
collection_duration_ms=execution_time_ms,
|
||||
)
|
||||
|
||||
def metrics_to_summary(
|
||||
self,
|
||||
metrics: Any, # HostMetrics model
|
||||
host_name: Optional[str] = None
|
||||
) -> HostMetricsSummary:
|
||||
"""Convertit un objet HostMetrics en HostMetricsSummary pour l'UI."""
|
||||
if not metrics:
|
||||
return HostMetricsSummary(
|
||||
host_id="unknown",
|
||||
host_name=host_name,
|
||||
collection_status="unknown"
|
||||
)
|
||||
# Normaliser le timestamp en heure locale (UTC-5) pour l'affichage
|
||||
collected_at = metrics.collected_at
|
||||
if collected_at is not None:
|
||||
if getattr(collected_at, "tzinfo", None) is None:
|
||||
collected_at = collected_at.replace(tzinfo=timezone.utc)
|
||||
app_tz = timezone(timedelta(hours=-5))
|
||||
collected_at = collected_at.astimezone(app_tz)
|
||||
|
||||
return HostMetricsSummary(
|
||||
host_id=metrics.host_id,
|
||||
host_name=host_name,
|
||||
last_collected=collected_at,
|
||||
|
||||
# CPU
|
||||
cpu_usage_percent=metrics.cpu_usage_percent,
|
||||
cpu_load_1m=metrics.cpu_load_1m,
|
||||
cpu_temperature=metrics.cpu_temperature,
|
||||
cpu_model=metrics.cpu_model,
|
||||
cpu_count=metrics.cpu_count,
|
||||
cpu_cores=getattr(metrics, "cpu_cores", None),
|
||||
cpu_threads=getattr(metrics, "cpu_threads", None),
|
||||
cpu_max_mhz=getattr(metrics, "cpu_max_mhz", None),
|
||||
|
||||
# Memory
|
||||
memory_usage_percent=metrics.memory_usage_percent,
|
||||
memory_total_mb=metrics.memory_total_mb,
|
||||
memory_used_mb=metrics.memory_used_mb,
|
||||
|
||||
# Disk
|
||||
disk_root_usage_percent=metrics.disk_root_usage_percent,
|
||||
disk_root_total_gb=metrics.disk_root_total_gb,
|
||||
disk_root_used_gb=metrics.disk_root_used_gb,
|
||||
disk_info=metrics.disk_info if getattr(metrics, "disk_info", None) else None,
|
||||
disk_devices=getattr(metrics, "disk_devices", None),
|
||||
|
||||
# Storage stacks
|
||||
lvm_info=getattr(metrics, "lvm_info", None),
|
||||
zfs_info=getattr(metrics, "zfs_info", None),
|
||||
|
||||
# System
|
||||
os_name=metrics.os_name,
|
||||
uptime_human=metrics.uptime_human,
|
||||
|
||||
# Status
|
||||
collection_status="success" if not metrics.error_message else "failed",
|
||||
error_message=metrics.error_message,
|
||||
)
|
||||
|
||||
|
||||
# Instance globale (sera initialisée au démarrage de l'application)
|
||||
builtin_playbook_service: Optional[BuiltinPlaybookService] = None
|
||||
|
||||
|
||||
def get_builtin_playbook_service() -> BuiltinPlaybookService:
|
||||
"""Retourne l'instance du service builtin playbooks."""
|
||||
global builtin_playbook_service
|
||||
if builtin_playbook_service is None:
|
||||
raise RuntimeError("BuiltinPlaybookService not initialized")
|
||||
return builtin_playbook_service
|
||||
|
||||
|
||||
def init_builtin_playbook_service(ansible_dir: Path, ansible_service=None) -> BuiltinPlaybookService:
|
||||
"""Initialise le service builtin playbooks."""
|
||||
global builtin_playbook_service
|
||||
builtin_playbook_service = BuiltinPlaybookService(ansible_dir, ansible_service)
|
||||
return builtin_playbook_service
|
||||
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Service de capture des logs console (stdout/stderr).
|
||||
Capture les logs de l'application en temps réel pour les afficher dans l'UI.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import io
|
||||
import re
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from collections import deque
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsoleLogEntry:
|
||||
"""Entrée de log console."""
|
||||
id: int
|
||||
timestamp: str
|
||||
level: str
|
||||
message: str
|
||||
source: str = "console"
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ConsoleLogCapture:
|
||||
"""
|
||||
Capture les logs console (stdout/stderr) et les stocke en mémoire.
|
||||
Utilise un buffer circulaire pour limiter l'utilisation mémoire.
|
||||
"""
|
||||
|
||||
def __init__(self, max_entries: int = 2000):
|
||||
self.max_entries = max_entries
|
||||
self._logs: deque = deque(maxlen=max_entries)
|
||||
self._lock = threading.Lock()
|
||||
self._id_counter = 0
|
||||
self._original_stdout = sys.stdout
|
||||
self._original_stderr = sys.stderr
|
||||
self._capturing = False
|
||||
|
||||
# Patterns pour détecter le niveau de log
|
||||
self._level_patterns = [
|
||||
(re.compile(r'\bERROR\b', re.IGNORECASE), 'ERROR'),
|
||||
(re.compile(r'\bWARN(?:ING)?\b', re.IGNORECASE), 'WARN'),
|
||||
(re.compile(r'\bDEBUG\b', re.IGNORECASE), 'DEBUG'),
|
||||
(re.compile(r'\b(INFO|Started|Waiting|Application)\b', re.IGNORECASE), 'INFO'),
|
||||
(re.compile(r'[✅🚀📋📦⏰🔔]'), 'INFO'),
|
||||
(re.compile(r'[⚠️❌]'), 'WARN'),
|
||||
]
|
||||
|
||||
def _detect_level(self, message: str) -> str:
|
||||
"""Détecte le niveau de log à partir du message."""
|
||||
for pattern, level in self._level_patterns:
|
||||
if pattern.search(message):
|
||||
return level
|
||||
return 'INFO'
|
||||
|
||||
def add_log(self, message: str, level: Optional[str] = None, source: str = "console"):
|
||||
"""Ajoute un log au buffer."""
|
||||
if not message or not message.strip():
|
||||
return
|
||||
|
||||
message = message.strip()
|
||||
if not level:
|
||||
level = self._detect_level(message)
|
||||
|
||||
with self._lock:
|
||||
# Éviter les doublons consécutifs (même message dans les 2 dernières entrées)
|
||||
if len(self._logs) > 0:
|
||||
recent = list(self._logs)[-2:] if len(self._logs) >= 2 else list(self._logs)
|
||||
for recent_log in recent:
|
||||
if recent_log.message == message and recent_log.source == source:
|
||||
return # Doublon, ignorer
|
||||
|
||||
self._id_counter += 1
|
||||
entry = ConsoleLogEntry(
|
||||
id=self._id_counter,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
level=level,
|
||||
message=message,
|
||||
source=source
|
||||
)
|
||||
self._logs.append(entry)
|
||||
|
||||
def get_logs(self, limit: int = 500, offset: int = 0, level: Optional[str] = None) -> List[dict]:
|
||||
"""Récupère les logs avec pagination."""
|
||||
with self._lock:
|
||||
logs = list(self._logs)
|
||||
|
||||
# Filtrer par niveau si spécifié
|
||||
if level:
|
||||
logs = [l for l in logs if l.level.upper() == level.upper()]
|
||||
|
||||
# Trier par ID décroissant (plus récent en premier)
|
||||
logs = sorted(logs, key=lambda x: x.id, reverse=True)
|
||||
|
||||
# Pagination
|
||||
start = offset
|
||||
end = offset + limit
|
||||
paginated = logs[start:end]
|
||||
|
||||
return [l.to_dict() for l in paginated]
|
||||
|
||||
def get_count(self) -> int:
|
||||
"""Retourne le nombre total de logs."""
|
||||
with self._lock:
|
||||
return len(self._logs)
|
||||
|
||||
def clear(self):
|
||||
"""Vide le buffer de logs."""
|
||||
with self._lock:
|
||||
self._logs.clear()
|
||||
|
||||
def start_capture(self):
|
||||
"""Démarre la capture des logs stdout/stderr et uvicorn."""
|
||||
if self._capturing:
|
||||
return
|
||||
|
||||
self._capturing = True
|
||||
log_service = self
|
||||
|
||||
# Wrapper pour stdout
|
||||
class StdoutWrapper:
|
||||
def __init__(wrapper_self, original):
|
||||
wrapper_self._original = original
|
||||
wrapper_self._buffer = ""
|
||||
|
||||
def write(wrapper_self, text):
|
||||
wrapper_self._original.write(text)
|
||||
wrapper_self._original.flush()
|
||||
|
||||
# Accumuler et traiter les lignes complètes
|
||||
wrapper_self._buffer += text
|
||||
while '\n' in wrapper_self._buffer:
|
||||
line, wrapper_self._buffer = wrapper_self._buffer.split('\n', 1)
|
||||
if line.strip():
|
||||
log_service.add_log(line, source="stdout")
|
||||
return len(text)
|
||||
|
||||
def flush(wrapper_self):
|
||||
wrapper_self._original.flush()
|
||||
|
||||
def __getattr__(wrapper_self, name):
|
||||
return getattr(wrapper_self._original, name)
|
||||
|
||||
# Wrapper pour stderr
|
||||
class StderrWrapper:
|
||||
def __init__(wrapper_self, original):
|
||||
wrapper_self._original = original
|
||||
wrapper_self._buffer = ""
|
||||
|
||||
def write(wrapper_self, text):
|
||||
wrapper_self._original.write(text)
|
||||
wrapper_self._original.flush()
|
||||
|
||||
wrapper_self._buffer += text
|
||||
while '\n' in wrapper_self._buffer:
|
||||
line, wrapper_self._buffer = wrapper_self._buffer.split('\n', 1)
|
||||
if line.strip():
|
||||
log_service.add_log(line, source="stderr")
|
||||
return len(text)
|
||||
|
||||
def flush(wrapper_self):
|
||||
wrapper_self._original.flush()
|
||||
|
||||
def __getattr__(wrapper_self, name):
|
||||
return getattr(wrapper_self._original, name)
|
||||
|
||||
sys.stdout = StdoutWrapper(self._original_stdout)
|
||||
sys.stderr = StderrWrapper(self._original_stderr)
|
||||
|
||||
# Handler pour capturer les logs uvicorn/logging
|
||||
class LogCaptureHandler(logging.Handler):
|
||||
def emit(handler_self, record):
|
||||
try:
|
||||
msg = handler_self.format(record)
|
||||
level_map = {
|
||||
logging.DEBUG: 'DEBUG',
|
||||
logging.INFO: 'INFO',
|
||||
logging.WARNING: 'WARN',
|
||||
logging.ERROR: 'ERROR',
|
||||
logging.CRITICAL: 'ERROR',
|
||||
}
|
||||
level = level_map.get(record.levelno, 'INFO')
|
||||
log_service.add_log(msg, level=level, source=record.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ajouter le handler aux loggers uvicorn
|
||||
self._log_handler = LogCaptureHandler()
|
||||
self._log_handler.setFormatter(logging.Formatter('%(message)s'))
|
||||
|
||||
for logger_name in ['uvicorn', 'uvicorn.access', 'uvicorn.error']:
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.addHandler(self._log_handler)
|
||||
|
||||
def stop_capture(self):
|
||||
"""Arrête la capture des logs."""
|
||||
if not self._capturing:
|
||||
return
|
||||
|
||||
self._capturing = False
|
||||
sys.stdout = self._original_stdout
|
||||
sys.stderr = self._original_stderr
|
||||
|
||||
# Retirer le handler des loggers uvicorn
|
||||
if hasattr(self, '_log_handler'):
|
||||
for logger_name in ['uvicorn', 'uvicorn.access', 'uvicorn.error']:
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.removeHandler(self._log_handler)
|
||||
|
||||
|
||||
# Instance globale
|
||||
console_log_service = ConsoleLogCapture()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Service de gestion du statut runtime des hôtes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
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] = None,
|
||||
os_info: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""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[str, Any]:
|
||||
"""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[str, Any]]:
|
||||
"""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
|
||||
|
||||
def clear(self):
|
||||
"""Efface tous les statuts."""
|
||||
self._hosts.clear()
|
||||
|
||||
|
||||
# Instance singleton du service
|
||||
host_status_service = HostStatusService()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Base de données hybride combinant données Ansible et données runtime.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.schemas.host_api import Host
|
||||
from app.schemas.task_api import Task
|
||||
from app.schemas.common import LogEntry, SystemMetrics
|
||||
|
||||
|
||||
class HybridDB:
|
||||
"""Base de données hybride combinant l'inventaire Ansible avec les données runtime.
|
||||
|
||||
Cette classe agit comme un agrégateur de données provenant de plusieurs sources:
|
||||
- Inventaire Ansible (via AnsibleService)
|
||||
- Statuts bootstrap (via BootstrapStatusService)
|
||||
- Statuts runtime (via HostStatusService)
|
||||
- Tâches et logs en mémoire
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Cache des hôtes
|
||||
self._hosts_cache: Optional[List[Host]] = None
|
||||
self._hosts_cache_time: float = 0
|
||||
self._cache_ttl = 60 # secondes
|
||||
|
||||
# Données en mémoire
|
||||
self.tasks: List[Task] = []
|
||||
self.logs: List[LogEntry] = []
|
||||
|
||||
# Compteurs pour les IDs
|
||||
self._id_counters: Dict[str, int] = {
|
||||
"tasks": 0,
|
||||
"logs": 0,
|
||||
}
|
||||
|
||||
def get_next_id(self, entity: str) -> int:
|
||||
"""Génère un nouvel ID pour une entité."""
|
||||
self._id_counters[entity] = self._id_counters.get(entity, 0) + 1
|
||||
return self._id_counters[entity]
|
||||
|
||||
@property
|
||||
def hosts(self) -> List[Host]:
|
||||
"""Retourne la liste des hôtes, en la mettant à jour si nécessaire."""
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
if self._hosts_cache and (current_time - self._hosts_cache_time) < self._cache_ttl:
|
||||
return self._hosts_cache
|
||||
|
||||
return self.refresh_hosts()
|
||||
|
||||
def refresh_hosts(self) -> List[Host]:
|
||||
"""Rafraîchit la liste des hôtes depuis l'inventaire Ansible."""
|
||||
import time
|
||||
from app.services.ansible_service import ansible_service
|
||||
from app.services.bootstrap_status_service import bootstrap_status_service
|
||||
from app.services.host_status_service import host_status_service
|
||||
|
||||
hosts = []
|
||||
inventory_hosts = ansible_service.get_hosts_from_inventory()
|
||||
|
||||
for inv_host in inventory_hosts:
|
||||
# Récupérer le statut bootstrap
|
||||
bs_status = bootstrap_status_service.get_bootstrap_status(inv_host.name)
|
||||
|
||||
# Récupérer le statut runtime
|
||||
rt_status = host_status_service.get_status(inv_host.name)
|
||||
|
||||
host = Host(
|
||||
id=inv_host.name, # Utiliser le nom comme ID
|
||||
name=inv_host.name,
|
||||
ip=inv_host.ansible_host or inv_host.name,
|
||||
status=rt_status.get("status") or "unknown",
|
||||
os=rt_status.get("os") or "Linux",
|
||||
last_seen=rt_status.get("last_seen"),
|
||||
groups=inv_host.groups or [inv_host.group] if inv_host.group else [],
|
||||
bootstrap_ok=bs_status.get("bootstrap_ok", False),
|
||||
bootstrap_date=bs_status.get("bootstrap_date")
|
||||
)
|
||||
hosts.append(host)
|
||||
|
||||
self._hosts_cache = hosts
|
||||
self._hosts_cache_time = time.time()
|
||||
|
||||
return hosts
|
||||
|
||||
def invalidate_hosts_cache(self):
|
||||
"""Invalide le cache des hôtes."""
|
||||
self._hosts_cache = None
|
||||
|
||||
def get_host(self, host_id: str) -> Optional[Host]:
|
||||
"""Récupère un hôte par son ID ou nom."""
|
||||
for host in self.hosts:
|
||||
if host.id == host_id or host.name == host_id or host.ip == host_id:
|
||||
return host
|
||||
return None
|
||||
|
||||
def update_host_status(
|
||||
self,
|
||||
host_name: str,
|
||||
status: str,
|
||||
os_info: str = None
|
||||
):
|
||||
"""Met à jour le statut d'un hôte."""
|
||||
from app.services.host_status_service import host_status_service
|
||||
|
||||
host_status_service.set_status(
|
||||
host_name=host_name,
|
||||
status=status,
|
||||
last_seen=datetime.now(timezone.utc),
|
||||
os_info=os_info
|
||||
)
|
||||
|
||||
# Invalider le cache pour forcer le rechargement
|
||||
self.invalidate_hosts_cache()
|
||||
|
||||
@property
|
||||
def metrics(self) -> SystemMetrics:
|
||||
"""Calcule et retourne les métriques système."""
|
||||
hosts = self.hosts
|
||||
|
||||
online_count = sum(1 for h in hosts if h.status == "online")
|
||||
total_tasks = len(self.tasks)
|
||||
|
||||
# Calculer le taux de succès
|
||||
completed = sum(1 for t in self.tasks if t.status == "completed")
|
||||
failed = sum(1 for t in self.tasks if t.status == "failed")
|
||||
total_finished = completed + failed
|
||||
success_rate = (completed / total_finished * 100) if total_finished > 0 else 100.0
|
||||
|
||||
return SystemMetrics(
|
||||
online_hosts=online_count,
|
||||
total_tasks=total_tasks,
|
||||
success_rate=round(success_rate, 1),
|
||||
uptime=99.9, # TODO: calculer depuis le démarrage
|
||||
cpu_usage=0.0,
|
||||
memory_usage=0.0,
|
||||
disk_usage=0.0
|
||||
)
|
||||
|
||||
def add_task(self, task: Task):
|
||||
"""Ajoute une tâche à la liste."""
|
||||
self.tasks.insert(0, task)
|
||||
|
||||
# Limiter la taille de la liste
|
||||
if len(self.tasks) > 1000:
|
||||
self.tasks = self.tasks[:1000]
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[Task]:
|
||||
"""Récupère une tâche par son ID."""
|
||||
for task in self.tasks:
|
||||
if str(task.id) == str(task_id):
|
||||
return task
|
||||
return None
|
||||
|
||||
def update_task(self, task_id: str, **kwargs):
|
||||
"""Met à jour une tâche existante."""
|
||||
task = self.get_task(task_id)
|
||||
if task:
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(task, key):
|
||||
setattr(task, key, value)
|
||||
|
||||
def add_log(self, log: LogEntry):
|
||||
"""Ajoute une entrée de log."""
|
||||
if log.id == 0:
|
||||
log.id = self.get_next_id("logs")
|
||||
self.logs.insert(0, log)
|
||||
|
||||
# Limiter la taille de la liste
|
||||
if len(self.logs) > 5000:
|
||||
self.logs = self.logs[:5000]
|
||||
|
||||
def get_recent_logs(self, limit: int = 50, level: str = None, source: str = None) -> List[LogEntry]:
|
||||
"""Récupère les logs récents avec filtrage optionnel."""
|
||||
logs = self.logs
|
||||
|
||||
if level:
|
||||
logs = [l for l in logs if l.level == level]
|
||||
|
||||
if source:
|
||||
logs = [l for l in logs if l.source == source]
|
||||
|
||||
return logs[:limit]
|
||||
|
||||
def clear_logs(self):
|
||||
"""Efface tous les logs."""
|
||||
self.logs.clear()
|
||||
|
||||
def clear_tasks(self):
|
||||
"""Efface toutes les tâches."""
|
||||
self.tasks.clear()
|
||||
|
||||
|
||||
# Instance singleton de la base de données hybride
|
||||
db = HybridDB()
|
||||
@@ -29,13 +29,22 @@ from base64 import b64encode
|
||||
|
||||
import httpx
|
||||
|
||||
from schemas.notification import (
|
||||
NtfyConfig,
|
||||
NtfyAction,
|
||||
NotificationRequest,
|
||||
NotificationResponse,
|
||||
NotificationTemplates,
|
||||
)
|
||||
try:
|
||||
from schemas.notification import (
|
||||
NtfyConfig,
|
||||
NtfyAction,
|
||||
NotificationRequest,
|
||||
NotificationResponse,
|
||||
NotificationTemplates,
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
from app.schemas.notification import (
|
||||
NtfyConfig,
|
||||
NtfyAction,
|
||||
NotificationRequest,
|
||||
NotificationResponse,
|
||||
NotificationTemplates,
|
||||
)
|
||||
|
||||
# Logger dédié pour le service de notification
|
||||
logger = logging.getLogger("homelab.notifications")
|
||||
@@ -110,6 +119,49 @@ class NotificationService:
|
||||
|
||||
return headers
|
||||
|
||||
def _build_headers(
|
||||
self,
|
||||
title: Optional[str] = None,
|
||||
priority: Optional[int] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
click: Optional[str] = None,
|
||||
attach: Optional[str] = None,
|
||||
delay: Optional[str] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Construit les headers ntfy (ASCII-only) pour tests/compat.
|
||||
|
||||
Note: en prod on envoie en JSON pour supporter UTF-8 dans title/tags,
|
||||
mais les tests unitaires valident encore cette méthode.
|
||||
"""
|
||||
headers: Dict[str, str] = {}
|
||||
if title:
|
||||
headers["Title"] = title
|
||||
|
||||
if priority is not None:
|
||||
mapping = {
|
||||
1: "min",
|
||||
2: "low",
|
||||
3: "default",
|
||||
4: "high",
|
||||
5: "urgent",
|
||||
}
|
||||
headers["Priority"] = mapping.get(int(priority), "default")
|
||||
|
||||
if tags:
|
||||
headers["Tags"] = ",".join(tags)
|
||||
|
||||
if click:
|
||||
headers["Click"] = click
|
||||
|
||||
if attach:
|
||||
headers["Attach"] = attach
|
||||
|
||||
if delay:
|
||||
headers["Delay"] = delay
|
||||
|
||||
headers.update(self._build_auth_headers())
|
||||
return headers
|
||||
|
||||
def _should_send(self, level: str) -> bool:
|
||||
"""Détermine si une notification d'un certain niveau doit être envoyée.
|
||||
|
||||
@@ -241,8 +293,8 @@ class NotificationService:
|
||||
# Utiliser le topic par défaut si non spécifié
|
||||
target_topic = topic or self._config.default_topic
|
||||
|
||||
# Construire l'URL de base (sans le topic, car il est dans le JSON)
|
||||
url = self._config.base_url.rstrip('/')
|
||||
# Construire l'URL (les tests attendent /<topic>)
|
||||
url = f"{self._config.base_url.rstrip('/')}/{target_topic}"
|
||||
|
||||
# Construire le payload JSON (supporte UTF-8 dans le titre et les tags)
|
||||
payload = self._build_json_payload(
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
"""
|
||||
Service de planification des tâches avec APScheduler.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.database import async_session_maker
|
||||
from app.schemas.schedule_api import (
|
||||
Schedule,
|
||||
ScheduleRecurrence,
|
||||
ScheduleRun,
|
||||
ScheduleCreateRequest,
|
||||
ScheduleUpdateRequest,
|
||||
ScheduleStats,
|
||||
)
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
"""Service pour gérer les schedules avec APScheduler."""
|
||||
|
||||
def __init__(self):
|
||||
self._scheduler: Optional[AsyncIOScheduler] = None
|
||||
self._schedules_cache: Dict[str, Schedule] = {}
|
||||
self._timezone = pytz.timezone(settings.scheduler_timezone)
|
||||
self._started = False
|
||||
|
||||
@property
|
||||
def scheduler(self) -> AsyncIOScheduler:
|
||||
"""Retourne l'instance du scheduler, le créant si nécessaire."""
|
||||
if self._scheduler is None:
|
||||
self._scheduler = AsyncIOScheduler(
|
||||
timezone=self._timezone,
|
||||
job_defaults={
|
||||
'coalesce': True,
|
||||
'max_instances': 1,
|
||||
'misfire_grace_time': settings.scheduler_misfire_grace_time
|
||||
}
|
||||
)
|
||||
return self._scheduler
|
||||
|
||||
async def start_async(self):
|
||||
"""Démarre le scheduler et charge les schedules depuis la BD."""
|
||||
if self._started:
|
||||
return
|
||||
|
||||
await self._load_active_schedules_from_db()
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
print(f"⏰ Scheduler démarré avec {len(self._schedules_cache)} schedule(s)")
|
||||
|
||||
def shutdown(self):
|
||||
"""Arrête le scheduler proprement."""
|
||||
if self._scheduler and self._started:
|
||||
self._scheduler.shutdown(wait=False)
|
||||
self._started = False
|
||||
print("⏰ Scheduler arrêté")
|
||||
|
||||
async def _load_active_schedules_from_db(self):
|
||||
"""Charge les schedules actifs depuis la base de données."""
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
from app.crud.schedule import ScheduleRepository
|
||||
repo = ScheduleRepository(session)
|
||||
db_schedules = await repo.list_active()
|
||||
|
||||
for db_sched in db_schedules:
|
||||
pydantic_sched = self._db_to_pydantic(db_sched)
|
||||
self._schedules_cache[pydantic_sched.id] = pydantic_sched
|
||||
|
||||
if pydantic_sched.enabled:
|
||||
self._add_job_for_schedule(pydantic_sched)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Erreur chargement schedules: {e}")
|
||||
|
||||
def _db_to_pydantic(self, db_sched) -> Schedule:
|
||||
"""Convertit un modèle DB en modèle Pydantic."""
|
||||
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 settings.scheduler_timezone,
|
||||
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, recurrence: ScheduleRecurrence, tz: pytz.timezone) -> CronTrigger:
|
||||
"""Construit un trigger cron à partir de la récurrence."""
|
||||
hour, minute = 2, 0
|
||||
if recurrence.time:
|
||||
parts = recurrence.time.split(':')
|
||||
hour = int(parts[0])
|
||||
minute = int(parts[1]) if len(parts) > 1 else 0
|
||||
|
||||
if recurrence.type == "custom" and recurrence.cron_expression:
|
||||
return CronTrigger.from_crontab(recurrence.cron_expression, timezone=tz)
|
||||
|
||||
elif recurrence.type == "daily":
|
||||
return CronTrigger(hour=hour, minute=minute, timezone=tz)
|
||||
|
||||
elif recurrence.type == "weekly":
|
||||
days = recurrence.days or [1] # Lundi par défaut
|
||||
day_of_week = ','.join(str((d - 1) % 7) for d in days) # Convertir 1-7 en 0-6
|
||||
return CronTrigger(day_of_week=day_of_week, hour=hour, minute=minute, timezone=tz)
|
||||
|
||||
elif recurrence.type == "monthly":
|
||||
day = recurrence.day_of_month or 1
|
||||
return CronTrigger(day=day, hour=hour, minute=minute, timezone=tz)
|
||||
|
||||
else:
|
||||
return CronTrigger(hour=hour, minute=minute, timezone=tz)
|
||||
|
||||
def _add_job_for_schedule(self, schedule: Schedule):
|
||||
"""Ajoute un job APScheduler pour un schedule."""
|
||||
job_id = f"schedule_{schedule.id}"
|
||||
|
||||
# Supprimer l'ancien job s'il existe
|
||||
existing = self.scheduler.get_job(job_id)
|
||||
if existing:
|
||||
self.scheduler.remove_job(job_id)
|
||||
|
||||
tz = pytz.timezone(schedule.timezone)
|
||||
|
||||
if schedule.schedule_type == "once":
|
||||
if schedule.start_at:
|
||||
trigger = DateTrigger(run_date=schedule.start_at, timezone=tz)
|
||||
else:
|
||||
return # Pas de date définie
|
||||
else:
|
||||
if not schedule.recurrence:
|
||||
return
|
||||
trigger = self._build_cron_trigger(schedule.recurrence, tz)
|
||||
|
||||
self.scheduler.add_job(
|
||||
self._execute_schedule,
|
||||
trigger=trigger,
|
||||
id=job_id,
|
||||
args=[schedule.id],
|
||||
name=schedule.name,
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
# Mettre à jour next_run_at
|
||||
job = self.scheduler.get_job(job_id)
|
||||
if job and job.next_run_time:
|
||||
schedule.next_run_at = job.next_run_time
|
||||
self._schedules_cache[schedule.id] = schedule
|
||||
|
||||
async def _execute_schedule(self, schedule_id: str):
|
||||
"""Exécute un schedule (appelé par APScheduler)."""
|
||||
schedule = self._schedules_cache.get(schedule_id)
|
||||
if not schedule:
|
||||
return
|
||||
|
||||
run_id = f"run_{uuid.uuid4().hex[:12]}"
|
||||
start_time = datetime.now(timezone.utc)
|
||||
|
||||
# Créer l'entrée de run
|
||||
run = ScheduleRun(
|
||||
id=run_id,
|
||||
schedule_id=schedule_id,
|
||||
started_at=start_time,
|
||||
status="running"
|
||||
)
|
||||
|
||||
try:
|
||||
# Importer les services nécessaires
|
||||
from app.services.ansible_service import ansible_service
|
||||
from app.services.websocket_service import ws_manager
|
||||
from app.services.notification_service import notification_service
|
||||
|
||||
# Mettre à jour le statut
|
||||
schedule.last_status = "running"
|
||||
self._schedules_cache[schedule_id] = schedule
|
||||
|
||||
# Notifier via WebSocket
|
||||
await ws_manager.broadcast({
|
||||
"type": "schedule_started",
|
||||
"data": {
|
||||
"schedule_id": schedule_id,
|
||||
"schedule_name": schedule.name,
|
||||
"run_id": run_id
|
||||
}
|
||||
})
|
||||
|
||||
# Exécuter le playbook
|
||||
result = await ansible_service.execute_playbook(
|
||||
playbook=schedule.playbook,
|
||||
target=schedule.target,
|
||||
extra_vars=schedule.extra_vars,
|
||||
check_mode=False,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Mettre à jour le run
|
||||
end_time = datetime.now(timezone.utc)
|
||||
duration = (end_time - start_time).total_seconds()
|
||||
|
||||
run.finished_at = end_time
|
||||
run.duration_seconds = duration
|
||||
run.status = "success" if result["success"] else "failed"
|
||||
run.error_message = result.get("stderr") if not result["success"] else None
|
||||
|
||||
# Mettre à jour le schedule
|
||||
schedule.last_run_at = end_time
|
||||
schedule.last_status = run.status
|
||||
schedule.run_count += 1
|
||||
if result["success"]:
|
||||
schedule.success_count += 1
|
||||
else:
|
||||
schedule.failure_count += 1
|
||||
|
||||
self._schedules_cache[schedule_id] = schedule
|
||||
|
||||
# Persister en BD
|
||||
await self._persist_run(run)
|
||||
await self._update_schedule_stats_in_db(schedule)
|
||||
|
||||
# Mettre à jour next_run
|
||||
job = self.scheduler.get_job(f"schedule_{schedule_id}")
|
||||
if job and job.next_run_time:
|
||||
schedule.next_run_at = job.next_run_time
|
||||
|
||||
# Notifier via WebSocket
|
||||
await ws_manager.broadcast({
|
||||
"type": "schedule_completed",
|
||||
"data": {
|
||||
"schedule_id": schedule_id,
|
||||
"schedule_name": schedule.name,
|
||||
"run_id": run_id,
|
||||
"status": run.status,
|
||||
"duration": duration
|
||||
}
|
||||
})
|
||||
|
||||
# Envoyer notification selon la configuration
|
||||
if schedule.notification_type != "none":
|
||||
if result["success"] and schedule.notification_type == "all":
|
||||
await notification_service.notify_task_completed(
|
||||
task_name=f"[Planifié] {schedule.name}",
|
||||
target=schedule.target,
|
||||
duration=f"{duration:.1f}s"
|
||||
)
|
||||
elif not result["success"]:
|
||||
await notification_service.notify_task_failed(
|
||||
task_name=f"[Planifié] {schedule.name}",
|
||||
target=schedule.target,
|
||||
error=result.get("stderr", "Erreur inconnue")[:200]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
end_time = datetime.now(timezone.utc)
|
||||
duration = (end_time - start_time).total_seconds()
|
||||
|
||||
run.finished_at = end_time
|
||||
run.duration_seconds = duration
|
||||
run.status = "failed"
|
||||
run.error_message = str(e)
|
||||
|
||||
schedule.last_run_at = end_time
|
||||
schedule.last_status = "failed"
|
||||
schedule.run_count += 1
|
||||
schedule.failure_count += 1
|
||||
self._schedules_cache[schedule_id] = schedule
|
||||
|
||||
await self._persist_run(run)
|
||||
await self._update_schedule_stats_in_db(schedule)
|
||||
|
||||
print(f"Erreur exécution schedule {schedule_id}: {e}")
|
||||
|
||||
async def _persist_run(self, run: ScheduleRun):
|
||||
"""Persiste un run dans la base de données."""
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
from app.crud.schedule_run import ScheduleRunRepository
|
||||
repo = ScheduleRunRepository(session)
|
||||
await repo.create(
|
||||
schedule_id=run.schedule_id,
|
||||
task_id=run.task_id,
|
||||
status=run.status,
|
||||
started_at=run.started_at,
|
||||
completed_at=run.finished_at,
|
||||
duration=run.duration_seconds,
|
||||
error_message=run.error_message,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
print(f"Erreur persistance run: {e}")
|
||||
|
||||
async def _update_schedule_stats_in_db(self, schedule: Schedule):
|
||||
"""Met à jour les stats du schedule en BD."""
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
from app.crud.schedule import ScheduleRepository
|
||||
repo = ScheduleRepository(session)
|
||||
db_sched = await repo.get(schedule.id)
|
||||
if db_sched:
|
||||
await repo.update(
|
||||
db_sched,
|
||||
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,
|
||||
next_run=schedule.next_run_at
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
print(f"Erreur mise à jour stats schedule: {e}")
|
||||
|
||||
# ===== API PUBLIQUE =====
|
||||
|
||||
def get_all_schedules(
|
||||
self,
|
||||
enabled: bool = None,
|
||||
playbook: str = None,
|
||||
tag: str = None
|
||||
) -> List[Schedule]:
|
||||
"""Récupère tous les schedules avec filtrage optionnel."""
|
||||
schedules = list(self._schedules_cache.values())
|
||||
|
||||
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 in s.playbook]
|
||||
|
||||
if tag:
|
||||
schedules = [s for s in schedules if tag in s.tags]
|
||||
|
||||
# Trier par prochaine exécution
|
||||
schedules.sort(key=lambda s: s.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 son ID."""
|
||||
return self._schedules_cache.get(schedule_id)
|
||||
|
||||
def add_schedule_to_cache(self, schedule: Schedule):
|
||||
"""Ajoute un schedule au cache et crée le job."""
|
||||
self._schedules_cache[schedule.id] = schedule
|
||||
if schedule.enabled:
|
||||
self._add_job_for_schedule(schedule)
|
||||
|
||||
def remove_schedule_from_cache(self, schedule_id: str):
|
||||
"""Supprime un schedule du cache et son job."""
|
||||
if schedule_id in self._schedules_cache:
|
||||
del self._schedules_cache[schedule_id]
|
||||
|
||||
job_id = f"schedule_{schedule_id}"
|
||||
if self.scheduler.get_job(job_id):
|
||||
self.scheduler.remove_job(job_id)
|
||||
|
||||
def update_schedule(self, schedule_id: str, update: ScheduleUpdateRequest) -> Optional[Schedule]:
|
||||
"""Met à jour un schedule."""
|
||||
schedule = self._schedules_cache.get(schedule_id)
|
||||
if not schedule:
|
||||
return None
|
||||
|
||||
# Appliquer les mises à jour
|
||||
if update.name is not None:
|
||||
schedule.name = update.name
|
||||
if update.description is not None:
|
||||
schedule.description = update.description
|
||||
if update.playbook is not None:
|
||||
schedule.playbook = update.playbook
|
||||
if update.target is not None:
|
||||
schedule.target = update.target
|
||||
if update.schedule_type is not None:
|
||||
schedule.schedule_type = update.schedule_type
|
||||
if update.recurrence is not None:
|
||||
schedule.recurrence = update.recurrence
|
||||
if update.timezone is not None:
|
||||
schedule.timezone = update.timezone
|
||||
if update.enabled is not None:
|
||||
schedule.enabled = update.enabled
|
||||
if update.notification_type is not None:
|
||||
schedule.notification_type = update.notification_type
|
||||
if update.tags is not None:
|
||||
schedule.tags = update.tags
|
||||
|
||||
schedule.updated_at = datetime.now(timezone.utc)
|
||||
self._schedules_cache[schedule_id] = schedule
|
||||
|
||||
# Recréer le job si activé
|
||||
job_id = f"schedule_{schedule_id}"
|
||||
if self.scheduler.get_job(job_id):
|
||||
self.scheduler.remove_job(job_id)
|
||||
|
||||
if schedule.enabled:
|
||||
self._add_job_for_schedule(schedule)
|
||||
|
||||
return schedule
|
||||
|
||||
def delete_schedule(self, schedule_id: str) -> bool:
|
||||
"""Supprime un schedule."""
|
||||
self.remove_schedule_from_cache(schedule_id)
|
||||
return True
|
||||
|
||||
def pause_schedule(self, schedule_id: str) -> bool:
|
||||
"""Met un schedule en pause."""
|
||||
schedule = self._schedules_cache.get(schedule_id)
|
||||
if not schedule:
|
||||
return False
|
||||
|
||||
schedule.enabled = False
|
||||
self._schedules_cache[schedule_id] = schedule
|
||||
|
||||
job_id = f"schedule_{schedule_id}"
|
||||
job = self.scheduler.get_job(job_id)
|
||||
if job:
|
||||
self.scheduler.pause_job(job_id)
|
||||
|
||||
return True
|
||||
|
||||
def resume_schedule(self, schedule_id: str) -> bool:
|
||||
"""Reprend un schedule en pause."""
|
||||
schedule = self._schedules_cache.get(schedule_id)
|
||||
if not schedule:
|
||||
return False
|
||||
|
||||
schedule.enabled = True
|
||||
self._schedules_cache[schedule_id] = schedule
|
||||
|
||||
job_id = f"schedule_{schedule_id}"
|
||||
job = self.scheduler.get_job(job_id)
|
||||
if job:
|
||||
self.scheduler.resume_job(job_id)
|
||||
else:
|
||||
self._add_job_for_schedule(schedule)
|
||||
|
||||
return True
|
||||
|
||||
async def run_now(self, schedule_id: str) -> Optional[ScheduleRun]:
|
||||
"""Exécute immédiatement un schedule."""
|
||||
schedule = self._schedules_cache.get(schedule_id)
|
||||
if not schedule:
|
||||
return None
|
||||
|
||||
# Exécuter dans une tâche séparée
|
||||
asyncio.create_task(self._execute_schedule(schedule_id))
|
||||
|
||||
return ScheduleRun(
|
||||
id=f"run_{uuid.uuid4().hex[:12]}",
|
||||
schedule_id=schedule_id,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
status="running"
|
||||
)
|
||||
|
||||
def get_stats(self) -> ScheduleStats:
|
||||
"""Récupère les statistiques globales."""
|
||||
schedules = list(self._schedules_cache.values())
|
||||
|
||||
active = sum(1 for s in schedules if s.enabled)
|
||||
paused = len(schedules) - active
|
||||
|
||||
# Trouver la prochaine exécution
|
||||
next_exec = None
|
||||
next_name = None
|
||||
for s in schedules:
|
||||
if s.enabled and s.next_run_at:
|
||||
if next_exec is None or s.next_run_at < next_exec:
|
||||
next_exec = s.next_run_at
|
||||
next_name = s.name
|
||||
|
||||
# Statistiques 24h
|
||||
failures_24h = sum(1 for s in schedules if s.last_status == "failed" and s.last_run_at and s.last_run_at > datetime.now(timezone.utc) - timedelta(hours=24))
|
||||
executions_24h = sum(1 for s in schedules if s.last_run_at and s.last_run_at > datetime.now(timezone.utc) - timedelta(hours=24))
|
||||
|
||||
# Taux de succès 7 jours
|
||||
total_runs = sum(s.run_count for s in schedules)
|
||||
total_success = sum(s.success_count for s in schedules)
|
||||
success_rate = (total_success / total_runs * 100) if total_runs > 0 else 0.0
|
||||
|
||||
return ScheduleStats(
|
||||
total=len(schedules),
|
||||
active=active,
|
||||
paused=paused,
|
||||
expired=0,
|
||||
next_execution=next_exec,
|
||||
next_schedule_name=next_name,
|
||||
failures_24h=failures_24h,
|
||||
executions_24h=executions_24h,
|
||||
success_rate_7d=success_rate
|
||||
)
|
||||
|
||||
def get_upcoming_executions(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Récupère les prochaines exécutions planifiées."""
|
||||
upcoming = []
|
||||
|
||||
for schedule in self._schedules_cache.values():
|
||||
if schedule.enabled and schedule.next_run_at:
|
||||
upcoming.append({
|
||||
"schedule_id": schedule.id,
|
||||
"schedule_name": schedule.name,
|
||||
"playbook": schedule.playbook,
|
||||
"target": schedule.target,
|
||||
"next_run_at": schedule.next_run_at.isoformat() if schedule.next_run_at else None,
|
||||
"tags": schedule.tags
|
||||
})
|
||||
|
||||
# Trier par date
|
||||
upcoming.sort(key=lambda x: x["next_run_at"] or "")
|
||||
|
||||
return upcoming[:limit]
|
||||
|
||||
def validate_cron_expression(self, expression: str) -> Dict[str, Any]:
|
||||
"""Valide une expression cron et retourne les prochaines exécutions."""
|
||||
try:
|
||||
trigger = CronTrigger.from_crontab(expression, timezone=self._timezone)
|
||||
|
||||
# Calculer les 5 prochaines exécutions
|
||||
next_runs = []
|
||||
next_time = datetime.now(self._timezone)
|
||||
for _ in range(5):
|
||||
next_time = trigger.get_next_fire_time(None, next_time)
|
||||
if next_time:
|
||||
next_runs.append(next_time.isoformat())
|
||||
next_time = next_time + timedelta(seconds=1)
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"expression": expression,
|
||||
"next_runs": next_runs,
|
||||
"error": None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"valid": False,
|
||||
"expression": expression,
|
||||
"next_runs": None,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
# Instance singleton du service
|
||||
scheduler_service = SchedulerService()
|
||||
@@ -0,0 +1,681 @@
|
||||
"""
|
||||
Service de vérification des prérequis au démarrage de l'application.
|
||||
Valide les dépendances externes, les clés SSH, et le fonctionnement d'Ansible.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class CheckStatus(Enum):
|
||||
"""Statut d'une vérification"""
|
||||
OK = "ok"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
"""Résultat d'une vérification individuelle"""
|
||||
name: str
|
||||
status: CheckStatus
|
||||
message: str
|
||||
details: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StartupCheckReport:
|
||||
"""Rapport complet des vérifications au démarrage"""
|
||||
results: List[CheckResult] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
return any(r.status == CheckStatus.ERROR for r in self.results)
|
||||
|
||||
@property
|
||||
def has_warnings(self) -> bool:
|
||||
return any(r.status == CheckStatus.WARNING for r in self.results)
|
||||
|
||||
@property
|
||||
def all_ok(self) -> bool:
|
||||
return all(r.status in (CheckStatus.OK, CheckStatus.SKIPPED) for r in self.results)
|
||||
|
||||
def add(self, result: CheckResult):
|
||||
self.results.append(result)
|
||||
|
||||
def print_report(self):
|
||||
"""Affiche le rapport des vérifications dans la console"""
|
||||
print("\n" + "=" * 60)
|
||||
print("🔍 VÉRIFICATION DES PRÉREQUIS AU DÉMARRAGE")
|
||||
print("=" * 60)
|
||||
|
||||
for result in self.results:
|
||||
icon = self._get_status_icon(result.status)
|
||||
print(f"{icon} {result.name}: {result.message}")
|
||||
if result.details:
|
||||
# Indenter les détails
|
||||
for line in result.details.split('\n'):
|
||||
if line.strip():
|
||||
print(f" └─ {line}")
|
||||
|
||||
print("-" * 60)
|
||||
if self.all_ok:
|
||||
print("✅ Tous les prérequis sont satisfaits")
|
||||
elif self.has_errors:
|
||||
print("❌ Des erreurs critiques ont été détectées")
|
||||
else:
|
||||
print("⚠️ Des avertissements ont été détectés")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
def _get_status_icon(self, status: CheckStatus) -> str:
|
||||
icons = {
|
||||
CheckStatus.OK: "✅",
|
||||
CheckStatus.WARNING: "⚠️ ",
|
||||
CheckStatus.ERROR: "❌",
|
||||
CheckStatus.SKIPPED: "⏭️ ",
|
||||
}
|
||||
return icons.get(status, "❓")
|
||||
|
||||
|
||||
class StartupChecksService:
|
||||
"""Service de vérification des prérequis au démarrage"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ansible_dir: Path,
|
||||
ssh_key_path: str,
|
||||
ssh_user: str = "automation",
|
||||
test_host: str = "localhost",
|
||||
):
|
||||
self.ansible_dir = ansible_dir
|
||||
self.ssh_key_path = Path(ssh_key_path)
|
||||
self.ssh_user = ssh_user
|
||||
self.test_host = test_host
|
||||
self.report = StartupCheckReport()
|
||||
|
||||
async def run_all_checks(self) -> StartupCheckReport:
|
||||
"""Exécute toutes les vérifications et retourne le rapport"""
|
||||
self.report = StartupCheckReport()
|
||||
|
||||
# 1. Vérification des packages Python requis
|
||||
await self._check_python_packages()
|
||||
|
||||
# 2. Vérification des variables d'environnement
|
||||
await self._check_env_vars()
|
||||
|
||||
# 3. Vérification des outils système (ansible, ssh)
|
||||
await self._check_system_tools()
|
||||
|
||||
# 4. Vérification de la clé SSH
|
||||
await self._check_ssh_key()
|
||||
|
||||
# 5. Vérification de la configuration Ansible
|
||||
await self._check_ansible_config()
|
||||
|
||||
# 6. Vérification de l'inventaire Ansible
|
||||
await self._check_ansible_inventory()
|
||||
|
||||
# 7. Test de connexion SSH vers localhost
|
||||
await self._check_ssh_connection()
|
||||
|
||||
# 8. Test d'exécution Ansible (ping localhost)
|
||||
await self._check_ansible_ping()
|
||||
|
||||
return self.report
|
||||
|
||||
async def _check_python_packages(self):
|
||||
"""Vérifie que les packages Python requis sont installés"""
|
||||
required_packages = [
|
||||
("ansible", "ansible"),
|
||||
("yaml", "pyyaml"),
|
||||
("aiosqlite", "aiosqlite"),
|
||||
("sqlalchemy", "sqlalchemy"),
|
||||
("fastapi", "fastapi"),
|
||||
("uvicorn", "uvicorn"),
|
||||
("httpx", "httpx"),
|
||||
("apscheduler", "apscheduler"),
|
||||
]
|
||||
|
||||
missing = []
|
||||
installed = []
|
||||
|
||||
for import_name, package_name in required_packages:
|
||||
try:
|
||||
__import__(import_name)
|
||||
installed.append(package_name)
|
||||
except ImportError:
|
||||
missing.append(package_name)
|
||||
|
||||
if missing:
|
||||
self.report.add(CheckResult(
|
||||
name="Packages Python",
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"{len(missing)} package(s) manquant(s)",
|
||||
details=f"Manquants: {', '.join(missing)}"
|
||||
))
|
||||
else:
|
||||
self.report.add(CheckResult(
|
||||
name="Packages Python",
|
||||
status=CheckStatus.OK,
|
||||
message=f"{len(installed)} packages requis installés"
|
||||
))
|
||||
|
||||
async def _check_env_vars(self):
|
||||
"""Vérifie les variables d'environnement importantes et affiche leurs valeurs (sensibles masquées)."""
|
||||
# Définition des variables à contrôler
|
||||
# required=True indique qu'elles sont importantes pour la sécurité ou la config,
|
||||
# même si le code a une valeur par défaut.
|
||||
env_defs = [
|
||||
# Sécurité / Auth
|
||||
{"key": "API_KEY", "required": True, "sensitive": True, "dev_default": "dev-key-12345"},
|
||||
{"key": "JWT_SECRET_KEY", "required": True, "sensitive": True, "dev_default": "homelab-secret-key-change-in-production"},
|
||||
{"key": "JWT_EXPIRE_MINUTES", "required": False, "sensitive": False, "dev_default": "1440"},
|
||||
# Base de données
|
||||
{"key": "DATABASE_URL", "required": False, "sensitive": False, "dev_default": None},
|
||||
{"key": "DB_PATH", "required": False, "sensitive": False, "dev_default": None},
|
||||
# Logs et chemins
|
||||
{"key": "LOGS_DIR", "required": False, "sensitive": False, "dev_default": "/logs"},
|
||||
{"key": "DIR_LOGS_TASKS", "required": False, "sensitive": False, "dev_default": "./tasks_logs"},
|
||||
# SSH / Ansible
|
||||
{"key": "SSH_USER", "required": False, "sensitive": False, "dev_default": "automation"},
|
||||
{"key": "SSH_REMOTE_USER", "required": False, "sensitive": False, "dev_default": "root"},
|
||||
{"key": "SSH_KEY_PATH", "required": False, "sensitive": False, "dev_default": None},
|
||||
{"key": "ANSIBLE_INVENTORY", "required": False, "sensitive": False, "dev_default": "./ansible/inventory"},
|
||||
{"key": "ANSIBLE_PLAYBOOKS", "required": False, "sensitive": False, "dev_default": "./ansible/playbooks"},
|
||||
{"key": "ANSIBLE_GROUP_VARS", "required": False, "sensitive": False, "dev_default": "./ansible/inventory/group_vars"},
|
||||
# Notifications ntfy
|
||||
{"key": "NTFY_BASE_URL", "required": False, "sensitive": False, "dev_default": "http://localhost:8150"},
|
||||
{"key": "NTFY_DEFAULT_TOPIC", "required": False, "sensitive": False, "dev_default": "homelab-events"},
|
||||
{"key": "NTFY_ENABLED", "required": False, "sensitive": False, "dev_default": "true"},
|
||||
{"key": "NTFY_TIMEOUT", "required": False, "sensitive": False, "dev_default": "5"},
|
||||
{"key": "NTFY_MSG_TYPE", "required": False, "sensitive": False, "dev_default": "ALL"},
|
||||
{"key": "NTFY_USERNAME", "required": False, "sensitive": True, "dev_default": None},
|
||||
{"key": "NTFY_PASSWORD", "required": False, "sensitive": True, "dev_default": None},
|
||||
{"key": "NTFY_TOKEN", "required": False, "sensitive": True, "dev_default": None},
|
||||
]
|
||||
|
||||
details_lines: List[str] = []
|
||||
warnings = 0
|
||||
errors = 0
|
||||
|
||||
for env_def in env_defs:
|
||||
key = env_def["key"]
|
||||
required = env_def["required"]
|
||||
sensitive = env_def["sensitive"]
|
||||
dev_default = env_def["dev_default"]
|
||||
|
||||
value = os.environ.get(key)
|
||||
|
||||
if value is None or value == "":
|
||||
if required:
|
||||
# Valeur manquante mais le code a généralement un fallback interne
|
||||
warnings += 1
|
||||
details_lines.append(f"{key}=<non défini> (valeur par défaut interne utilisée)")
|
||||
else:
|
||||
details_lines.append(f"{key}=<non défini>")
|
||||
continue
|
||||
|
||||
# Il y a une valeur définie
|
||||
display_value: str
|
||||
if sensitive:
|
||||
# Masquer les valeurs sensibles (clés, tokens, mots de passe)
|
||||
if len(value) <= 4:
|
||||
masked = "*" * len(value)
|
||||
else:
|
||||
masked = value[:2] + "***" + value[-2:]
|
||||
display_value = masked
|
||||
else:
|
||||
display_value = value
|
||||
|
||||
# Détecter l'utilisation de valeurs de développement connues
|
||||
if dev_default is not None and value == dev_default and required:
|
||||
warnings += 1
|
||||
details_lines.append(f"{key}={display_value} (valeur de DEV, à changer en production)")
|
||||
else:
|
||||
details_lines.append(f"{key}={display_value}")
|
||||
|
||||
# Si aucune ligne (cas improbable), éviter un message vide
|
||||
if not details_lines:
|
||||
details_lines.append("Aucune variable d'environnement spécifique détectée")
|
||||
|
||||
if errors > 0:
|
||||
status = CheckStatus.ERROR
|
||||
message = f"{errors} variable(s) d'environnement critique(s) manquante(s)"
|
||||
elif warnings > 0:
|
||||
status = CheckStatus.WARNING
|
||||
message = f"{warnings} avertissement(s) de configuration d'environnement"
|
||||
else:
|
||||
status = CheckStatus.OK
|
||||
message = "Variables d'environnement principales définies"
|
||||
|
||||
self.report.add(CheckResult(
|
||||
name="Variables d'environnement",
|
||||
status=status,
|
||||
message=message,
|
||||
details="\n".join(details_lines),
|
||||
))
|
||||
|
||||
async def _check_system_tools(self):
|
||||
"""Vérifie que les outils système requis sont disponibles"""
|
||||
tools = {
|
||||
"ansible": "ansible --version",
|
||||
"ansible-playbook": "ansible-playbook --version",
|
||||
"ssh": "ssh -V",
|
||||
}
|
||||
|
||||
results = []
|
||||
for tool, cmd in tools.items():
|
||||
path = shutil.which(tool)
|
||||
if path:
|
||||
# Récupérer la version
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
cmd.split(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
# Combiner stdout et stderr pour trouver la version
|
||||
output = result.stdout + result.stderr
|
||||
# Chercher une ligne contenant une version
|
||||
version_line = ""
|
||||
# Patterns à ignorer (code Python, tracebacks, etc.)
|
||||
skip_starts = ('Traceback', 'File', ' ', 'from ', 'import ', '~', '^',
|
||||
'if ', 'def ', 'class ', 'return ', 'raise ', 'OSError', 'WinError')
|
||||
for line in output.split('\n'):
|
||||
line = line.strip()
|
||||
# Ignorer les lignes de traceback, import, code Python, etc.
|
||||
if line and not any(line.startswith(x) for x in skip_starts):
|
||||
# Chercher des patterns de version
|
||||
if any(x in line.lower() for x in ['version', 'openssh', 'core [']):
|
||||
version_line = line[:60]
|
||||
break
|
||||
# Pattern spécifique pour ansible
|
||||
if tool.startswith('ansible') and 'ansible' in line.lower() and '[' in line:
|
||||
version_line = line[:60]
|
||||
break
|
||||
if not version_line:
|
||||
# Prendre la première ligne non vide qui n'est pas du code
|
||||
for line in output.split('\n'):
|
||||
line = line.strip()
|
||||
if line and not any(line.startswith(x) for x in skip_starts) and not any(x in line for x in ['(', ')', ':', '=']):
|
||||
version_line = line[:60]
|
||||
break
|
||||
# Si toujours pas de version, juste indiquer que c'est installé
|
||||
results.append((tool, True, version_line if version_line else f"installé à {path}"))
|
||||
except Exception as e:
|
||||
results.append((tool, True, f"installé à {path}"))
|
||||
else:
|
||||
results.append((tool, False, "non trouvé"))
|
||||
|
||||
missing = [r[0] for r in results if not r[1]]
|
||||
if missing:
|
||||
self.report.add(CheckResult(
|
||||
name="Outils système",
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"{len(missing)} outil(s) manquant(s): {', '.join(missing)}",
|
||||
details="\n".join([f"{r[0]}: {r[2]}" for r in results])
|
||||
))
|
||||
else:
|
||||
self.report.add(CheckResult(
|
||||
name="Outils système",
|
||||
status=CheckStatus.OK,
|
||||
message="ansible, ansible-playbook, ssh disponibles",
|
||||
details="\n".join([f"{r[0]}: {r[2]}" for r in results if r[1]])
|
||||
))
|
||||
|
||||
async def _check_ssh_key(self):
|
||||
"""Vérifie que la clé SSH est disponible et valide"""
|
||||
# Vérifier si le fichier existe
|
||||
if not self.ssh_key_path.exists():
|
||||
self.report.add(CheckResult(
|
||||
name="Clé SSH",
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Clé SSH non trouvée",
|
||||
details=f"Chemin: {self.ssh_key_path}"
|
||||
))
|
||||
return
|
||||
|
||||
# Vérifier les permissions (sur Linux/Mac)
|
||||
if os.name != 'nt': # Non-Windows
|
||||
stat_info = self.ssh_key_path.stat()
|
||||
mode = oct(stat_info.st_mode)[-3:]
|
||||
if mode not in ('600', '400'):
|
||||
self.report.add(CheckResult(
|
||||
name="Clé SSH",
|
||||
status=CheckStatus.WARNING,
|
||||
message=f"Permissions incorrectes ({mode})",
|
||||
details=f"Chemin: {self.ssh_key_path}\nPermissions recommandées: 600"
|
||||
))
|
||||
return
|
||||
|
||||
# Vérifier que c'est une clé valide
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
["ssh-keygen", "-l", "-f", str(self.ssh_key_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
key_info = result.stdout.strip()
|
||||
self.report.add(CheckResult(
|
||||
name="Clé SSH",
|
||||
status=CheckStatus.OK,
|
||||
message="Clé SSH valide",
|
||||
details=f"Chemin: {self.ssh_key_path}\n{key_info}"
|
||||
))
|
||||
else:
|
||||
self.report.add(CheckResult(
|
||||
name="Clé SSH",
|
||||
status=CheckStatus.ERROR,
|
||||
message="Clé SSH invalide",
|
||||
details=result.stderr.strip()
|
||||
))
|
||||
except FileNotFoundError:
|
||||
# ssh-keygen non disponible (Windows sans OpenSSH)
|
||||
self.report.add(CheckResult(
|
||||
name="Clé SSH",
|
||||
status=CheckStatus.OK,
|
||||
message="Clé SSH présente (validation partielle)",
|
||||
details=f"Chemin: {self.ssh_key_path}\nTaille: {self.ssh_key_path.stat().st_size} bytes"
|
||||
))
|
||||
except Exception as e:
|
||||
self.report.add(CheckResult(
|
||||
name="Clé SSH",
|
||||
status=CheckStatus.WARNING,
|
||||
message=f"Impossible de valider la clé: {str(e)}",
|
||||
details=f"Chemin: {self.ssh_key_path}"
|
||||
))
|
||||
|
||||
async def _check_ansible_config(self):
|
||||
"""Vérifie la configuration Ansible"""
|
||||
ansible_cfg = self.ansible_dir / "ansible.cfg"
|
||||
|
||||
if not ansible_cfg.exists():
|
||||
self.report.add(CheckResult(
|
||||
name="Configuration Ansible",
|
||||
status=CheckStatus.WARNING,
|
||||
message="Fichier ansible.cfg non trouvé",
|
||||
details=f"Chemin attendu: {ansible_cfg}"
|
||||
))
|
||||
return
|
||||
|
||||
# Vérifier que le fichier est lisible et contient les sections essentielles
|
||||
try:
|
||||
content = ansible_cfg.read_text()
|
||||
has_defaults = "[defaults]" in content
|
||||
has_inventory = "inventory" in content
|
||||
|
||||
if has_defaults and has_inventory:
|
||||
self.report.add(CheckResult(
|
||||
name="Configuration Ansible",
|
||||
status=CheckStatus.OK,
|
||||
message="ansible.cfg valide",
|
||||
details=f"Chemin: {ansible_cfg}"
|
||||
))
|
||||
else:
|
||||
self.report.add(CheckResult(
|
||||
name="Configuration Ansible",
|
||||
status=CheckStatus.WARNING,
|
||||
message="Configuration Ansible incomplète",
|
||||
details=f"[defaults]: {'✓' if has_defaults else '✗'}, inventory: {'✓' if has_inventory else '✗'}"
|
||||
))
|
||||
except Exception as e:
|
||||
self.report.add(CheckResult(
|
||||
name="Configuration Ansible",
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Erreur lecture ansible.cfg: {str(e)}"
|
||||
))
|
||||
|
||||
async def _check_ansible_inventory(self):
|
||||
"""Vérifie l'inventaire Ansible"""
|
||||
inventory_path = self.ansible_dir / "inventory" / "hosts.yml"
|
||||
|
||||
if not inventory_path.exists():
|
||||
self.report.add(CheckResult(
|
||||
name="Inventaire Ansible",
|
||||
status=CheckStatus.ERROR,
|
||||
message="Fichier d'inventaire non trouvé",
|
||||
details=f"Chemin attendu: {inventory_path}"
|
||||
))
|
||||
return
|
||||
|
||||
try:
|
||||
import yaml
|
||||
content = inventory_path.read_text()
|
||||
inventory = yaml.safe_load(content)
|
||||
|
||||
# Compter les hôtes
|
||||
host_count = 0
|
||||
group_count = 0
|
||||
|
||||
def count_hosts(data, depth=0):
|
||||
nonlocal host_count, group_count
|
||||
if isinstance(data, dict):
|
||||
if 'hosts' in data and isinstance(data['hosts'], dict):
|
||||
host_count += len(data['hosts'])
|
||||
if 'children' in data:
|
||||
group_count += len(data['children'])
|
||||
for child in data['children'].values():
|
||||
count_hosts(child, depth + 1)
|
||||
|
||||
count_hosts(inventory.get('all', {}))
|
||||
|
||||
self.report.add(CheckResult(
|
||||
name="Inventaire Ansible",
|
||||
status=CheckStatus.OK,
|
||||
message=f"{host_count} hôte(s) dans {group_count} groupe(s)",
|
||||
details=f"Chemin: {inventory_path}"
|
||||
))
|
||||
except Exception as e:
|
||||
self.report.add(CheckResult(
|
||||
name="Inventaire Ansible",
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Erreur lecture inventaire: {str(e)}"
|
||||
))
|
||||
|
||||
async def _check_ssh_connection(self):
|
||||
"""Teste la connexion SSH vers l'hôte de test"""
|
||||
# Pour localhost, on utilise la connexion locale Ansible, pas SSH
|
||||
if self.test_host == "localhost":
|
||||
self.report.add(CheckResult(
|
||||
name="Connexion SSH",
|
||||
status=CheckStatus.SKIPPED,
|
||||
message="Test SSH ignoré pour localhost",
|
||||
details="Utilisation de la connexion locale Ansible"
|
||||
))
|
||||
return
|
||||
|
||||
# Vérifier d'abord que la clé SSH existe
|
||||
if not self.ssh_key_path.exists():
|
||||
self.report.add(CheckResult(
|
||||
name="Connexion SSH",
|
||||
status=CheckStatus.SKIPPED,
|
||||
message="Test SSH ignoré (clé SSH non disponible)",
|
||||
details=f"Clé manquante: {self.ssh_key_path}"
|
||||
))
|
||||
return
|
||||
|
||||
try:
|
||||
# Test SSH avec timeout court
|
||||
cmd = [
|
||||
"ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-i", str(self.ssh_key_path),
|
||||
f"{self.ssh_user}@{self.test_host}",
|
||||
"echo", "SSH_OK"
|
||||
]
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if result.returncode == 0 and "SSH_OK" in result.stdout:
|
||||
self.report.add(CheckResult(
|
||||
name="Connexion SSH",
|
||||
status=CheckStatus.OK,
|
||||
message=f"Connexion SSH vers {self.test_host} réussie",
|
||||
details=f"Utilisateur: {self.ssh_user}"
|
||||
))
|
||||
else:
|
||||
error_msg = result.stderr.strip() if result.stderr else "Erreur inconnue"
|
||||
self.report.add(CheckResult(
|
||||
name="Connexion SSH",
|
||||
status=CheckStatus.WARNING,
|
||||
message=f"Connexion SSH vers {self.test_host} échouée",
|
||||
details=f"Erreur: {error_msg[:100]}"
|
||||
))
|
||||
except subprocess.TimeoutExpired:
|
||||
self.report.add(CheckResult(
|
||||
name="Connexion SSH",
|
||||
status=CheckStatus.WARNING,
|
||||
message=f"Timeout connexion SSH vers {self.test_host}",
|
||||
details="La connexion a dépassé le délai de 15 secondes"
|
||||
))
|
||||
except Exception as e:
|
||||
self.report.add(CheckResult(
|
||||
name="Connexion SSH",
|
||||
status=CheckStatus.WARNING,
|
||||
message=f"Test SSH non effectué: {str(e)}"
|
||||
))
|
||||
|
||||
async def _check_ansible_ping(self):
|
||||
"""Teste le ping Ansible vers l'hôte de test"""
|
||||
try:
|
||||
# Pour localhost, utiliser connexion locale (pas besoin de SSH)
|
||||
if self.test_host == "localhost":
|
||||
cmd = [
|
||||
"ansible",
|
||||
self.test_host,
|
||||
"-m", "ping",
|
||||
"-i", str(self.ansible_dir / "inventory" / "hosts.yml"),
|
||||
"-c", "local", # Connexion locale
|
||||
"-o", # One-line output
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
"ansible",
|
||||
self.test_host,
|
||||
"-m", "ping",
|
||||
"-i", str(self.ansible_dir / "inventory" / "hosts.yml"),
|
||||
"--private-key", str(self.ssh_key_path),
|
||||
"-u", self.ssh_user,
|
||||
"-o", # One-line output
|
||||
]
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=str(self.ansible_dir)
|
||||
)
|
||||
|
||||
if result.returncode == 0 and "SUCCESS" in result.stdout:
|
||||
self.report.add(CheckResult(
|
||||
name="Ansible Ping",
|
||||
status=CheckStatus.OK,
|
||||
message=f"Ansible ping vers {self.test_host} réussi",
|
||||
details="Module ping exécuté avec succès"
|
||||
))
|
||||
else:
|
||||
# Extraire le message d'erreur pertinent (filtrer les tracebacks Python)
|
||||
error_output = result.stdout + result.stderr
|
||||
|
||||
# Détecter les erreurs Windows spécifiques
|
||||
if 'WinError' in error_output or 'blocking_io' in error_output.lower():
|
||||
self.report.add(CheckResult(
|
||||
name="Ansible Ping",
|
||||
status=CheckStatus.WARNING,
|
||||
message=f"Ansible non compatible avec cet environnement Windows",
|
||||
details="Ansible fonctionne mieux sous WSL ou Linux"
|
||||
))
|
||||
return
|
||||
|
||||
# Filtrer les lignes de traceback et garder les messages utiles
|
||||
useful_lines = []
|
||||
skip_patterns = ('Traceback', 'File ', ' File', ' ', 'from ', 'import ',
|
||||
'~', '^', 'check_', 'if ', 'def ', 'OSError', 'raise ')
|
||||
for line in error_output.split('\n'):
|
||||
line = line.strip()
|
||||
if line and not any(line.startswith(p) for p in skip_patterns):
|
||||
# Garder les lignes d'erreur Ansible ou messages pertinents
|
||||
if any(x in line.lower() for x in ['error', 'failed', 'unreachable', 'fatal', 'msg:', 'permission']):
|
||||
useful_lines.append(line[:80])
|
||||
if len(useful_lines) >= 2:
|
||||
break
|
||||
|
||||
error_detail = "\n".join(useful_lines) if useful_lines else "Vérifiez la configuration Ansible"
|
||||
|
||||
self.report.add(CheckResult(
|
||||
name="Ansible Ping",
|
||||
status=CheckStatus.WARNING,
|
||||
message=f"Ansible ping vers {self.test_host} échoué",
|
||||
details=error_detail
|
||||
))
|
||||
except subprocess.TimeoutExpired:
|
||||
self.report.add(CheckResult(
|
||||
name="Ansible Ping",
|
||||
status=CheckStatus.WARNING,
|
||||
message="Timeout Ansible ping",
|
||||
details="L'exécution a dépassé 30 secondes"
|
||||
))
|
||||
except Exception as e:
|
||||
self.report.add(CheckResult(
|
||||
name="Ansible Ping",
|
||||
status=CheckStatus.WARNING,
|
||||
message=f"Test Ansible non effectué: {str(e)}"
|
||||
))
|
||||
|
||||
|
||||
# Instance globale du service (sera configurée au démarrage)
|
||||
startup_checks_service: Optional[StartupChecksService] = None
|
||||
|
||||
|
||||
async def run_startup_checks(
|
||||
ansible_dir: Path,
|
||||
ssh_key_path: str,
|
||||
ssh_user: str = "automation",
|
||||
test_host: str = "localhost",
|
||||
) -> StartupCheckReport:
|
||||
"""
|
||||
Fonction utilitaire pour exécuter les vérifications au démarrage.
|
||||
|
||||
Args:
|
||||
ansible_dir: Chemin vers le répertoire Ansible
|
||||
ssh_key_path: Chemin vers la clé SSH privée
|
||||
ssh_user: Utilisateur SSH pour les tests
|
||||
test_host: Hôte de test pour les connexions SSH/Ansible
|
||||
|
||||
Returns:
|
||||
StartupCheckReport: Rapport des vérifications
|
||||
"""
|
||||
global startup_checks_service
|
||||
startup_checks_service = StartupChecksService(
|
||||
ansible_dir=ansible_dir,
|
||||
ssh_key_path=ssh_key_path,
|
||||
ssh_user=ssh_user,
|
||||
test_host=test_host,
|
||||
)
|
||||
|
||||
report = await startup_checks_service.run_all_checks()
|
||||
report.print_report()
|
||||
|
||||
return report
|
||||
@@ -0,0 +1,649 @@
|
||||
"""
|
||||
Service de gestion des logs de tâches en fichiers markdown.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
import uuid
|
||||
|
||||
import pytz
|
||||
|
||||
from app.schemas.task_api import TaskLogFile
|
||||
|
||||
|
||||
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():
|
||||
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:
|
||||
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 Exception:
|
||||
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)
|
||||
|
||||
# Utiliser le fuseau horaire local pour les dossiers
|
||||
local_tz = pytz.timezone("America/Montreal")
|
||||
|
||||
if dt.tzinfo is None:
|
||||
dt_local = local_tz.localize(dt)
|
||||
else:
|
||||
dt_local = dt.astimezone(local_tz)
|
||||
|
||||
year = dt_local.strftime("%Y")
|
||||
month = dt_local.strftime("%m")
|
||||
day = dt_local.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."""
|
||||
return f"task_{datetime.now(timezone.utc).strftime('%H%M%S')}_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
def save_task_log(self, task, output: str = "", error: str = "", source_type: str = None) -> str:
|
||||
"""Sauvegarde un log de tâche en markdown et retourne le chemin."""
|
||||
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
|
||||
metadata['duration_seconds'] = self._parse_duration_to_seconds(dur_val)
|
||||
|
||||
# Extraire les hôtes depuis la sortie Ansible
|
||||
host_patterns = [
|
||||
r'^([a-zA-Z0-9][a-zA-Z0-9._-]+)\s*:\s*ok=',
|
||||
r'^\s*([a-zA-Z0-9][a-zA-Z0-9._-]+)\s*\|\s*(SUCCESS|CHANGED|FAILED|UNREACHABLE)',
|
||||
]
|
||||
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'
|
||||
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
|
||||
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
|
||||
s_clean = duration_str.strip()
|
||||
|
||||
# Gérer les secondes seules
|
||||
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
|
||||
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."""
|
||||
self._build_index()
|
||||
|
||||
# Convertir les heures de filtrage en minutes
|
||||
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 Exception:
|
||||
pass
|
||||
if hour_end:
|
||||
try:
|
||||
h, m = map(int, hour_end.split(':'))
|
||||
hour_end_minutes = h * 60 + m
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Filtrer l'index
|
||||
filtered = []
|
||||
for entry in self._logs_index:
|
||||
if year and entry['year'] != year:
|
||||
continue
|
||||
if month and entry['month'] != month:
|
||||
continue
|
||||
if day and entry['day'] != day:
|
||||
continue
|
||||
if status and status != "all" and entry['status'] != status:
|
||||
continue
|
||||
|
||||
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 Exception:
|
||||
pass
|
||||
|
||||
if target and target != "all":
|
||||
file_target = entry.get('target', '')
|
||||
if file_target and target.lower() not in file_target.lower():
|
||||
continue
|
||||
|
||||
if category and category != "all":
|
||||
file_category = entry.get('category', '')
|
||||
if file_category and category.lower() not in file_category.lower():
|
||||
continue
|
||||
|
||||
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 index_log_file(self, file_path: str) -> Optional[TaskLogFile]:
|
||||
md_file = Path(file_path)
|
||||
if not md_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
entry = self._index_file(md_file)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
try:
|
||||
return TaskLogFile(
|
||||
id=entry['id'],
|
||||
filename=entry['filename'],
|
||||
path=entry['path'],
|
||||
task_name=entry['task_name'],
|
||||
target=entry['target'],
|
||||
status=entry['status'],
|
||||
date=entry['date'],
|
||||
year=entry['year'],
|
||||
month=entry['month'],
|
||||
day=entry['day'],
|
||||
created_at=datetime.fromtimestamp(entry['created_at'], tz=timezone.utc),
|
||||
size_bytes=entry['size_bytes'],
|
||||
start_time=entry.get('start_time'),
|
||||
end_time=entry.get('end_time'),
|
||||
duration=entry.get('duration'),
|
||||
duration_seconds=entry.get('duration_seconds'),
|
||||
hosts=entry.get('hosts', []),
|
||||
category=entry.get('category'),
|
||||
subcategory=entry.get('subcategory'),
|
||||
target_type=entry.get('target_type'),
|
||||
source_type=entry.get('source_type')
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _detect_source_type(self, task_name: str, content: str) -> str:
|
||||
"""Détecte le type de source d'une tâche."""
|
||||
task_name_lower = task_name.lower()
|
||||
content_lower = content.lower()
|
||||
|
||||
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'
|
||||
|
||||
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'
|
||||
if re.search(r'\|\s*\*\*Module\*\*\s*\|', content):
|
||||
return 'adhoc'
|
||||
|
||||
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}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,60 @@
|
||||
"""
|
||||
Service WebSocket pour les mises à jour en temps réel.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from threading import Lock
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
"""Gestionnaire des connexions WebSocket."""
|
||||
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
self.lock = Lock()
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
"""Accepte et enregistre une nouvelle connexion WebSocket."""
|
||||
await websocket.accept()
|
||||
with self.lock:
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
"""Déconnecte un client WebSocket."""
|
||||
with self.lock:
|
||||
if websocket in self.active_connections:
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Envoie un message à tous les clients connectés."""
|
||||
with self.lock:
|
||||
disconnected = []
|
||||
for connection in self.active_connections:
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
disconnected.append(connection)
|
||||
|
||||
# Nettoyer les connexions déconnectées
|
||||
for conn in disconnected:
|
||||
if conn in self.active_connections:
|
||||
self.active_connections.remove(conn)
|
||||
|
||||
async def send_to_client(self, websocket: WebSocket, message: dict):
|
||||
"""Envoie un message à un client spécifique."""
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
except Exception:
|
||||
self.disconnect(websocket)
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
"""Retourne le nombre de connexions actives."""
|
||||
with self.lock:
|
||||
return len(self.active_connections)
|
||||
|
||||
|
||||
# Instance singleton du gestionnaire WebSocket
|
||||
ws_manager = WebSocketManager()
|
||||
Reference in New Issue
Block a user