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:
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Core module - Configuration, constantes, exceptions et dépendances.
|
||||
"""
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.constants import (
|
||||
HostStatus,
|
||||
TaskStatus,
|
||||
LogLevel,
|
||||
ScheduleStatus,
|
||||
NotificationType,
|
||||
ACTION_PLAYBOOK_MAP,
|
||||
)
|
||||
from app.core.exceptions import (
|
||||
HomelabException,
|
||||
HostNotFoundException,
|
||||
TaskNotFoundException,
|
||||
ScheduleNotFoundException,
|
||||
PlaybookNotFoundException,
|
||||
GroupNotFoundException,
|
||||
ValidationException,
|
||||
AnsibleExecutionException,
|
||||
BootstrapException,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"settings",
|
||||
"HostStatus",
|
||||
"TaskStatus",
|
||||
"LogLevel",
|
||||
"ScheduleStatus",
|
||||
"NotificationType",
|
||||
"ACTION_PLAYBOOK_MAP",
|
||||
"HomelabException",
|
||||
"HostNotFoundException",
|
||||
"TaskNotFoundException",
|
||||
"ScheduleNotFoundException",
|
||||
"PlaybookNotFoundException",
|
||||
"GroupNotFoundException",
|
||||
"ValidationException",
|
||||
"AnsibleExecutionException",
|
||||
"BootstrapException",
|
||||
]
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Configuration centralisée de l'application.
|
||||
|
||||
Toutes les variables d'environnement et paramètres sont centralisés ici.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration de l'application Homelab Automation."""
|
||||
|
||||
# === Chemins ===
|
||||
base_dir: Path = Field(default_factory=lambda: Path(__file__).resolve().parent.parent)
|
||||
logs_dir: Path = Field(default_factory=lambda: Path(os.environ.get("LOGS_DIR", "/logs")))
|
||||
|
||||
@property
|
||||
def ansible_dir(self) -> Path:
|
||||
"""Répertoire Ansible (relatif à base_dir.parent)"""
|
||||
return self.base_dir.parent / "ansible"
|
||||
|
||||
@property
|
||||
def tasks_logs_dir(self) -> Path:
|
||||
"""Répertoire des logs de tâches markdown"""
|
||||
return Path(os.environ.get("DIR_LOGS_TASKS", str(self.base_dir.parent / "tasks_logs")))
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
"""Chemin de la base de données SQLite"""
|
||||
return self.logs_dir / "homelab.db"
|
||||
|
||||
# === SSH ===
|
||||
ssh_key_path: str = Field(
|
||||
default_factory=lambda: os.environ.get("SSH_KEY_PATH", str(Path.home() / ".ssh" / "id_rsa"))
|
||||
)
|
||||
ssh_user: str = Field(default_factory=lambda: os.environ.get("SSH_USER", "automation"))
|
||||
ssh_remote_user: str = Field(default_factory=lambda: os.environ.get("SSH_REMOTE_USER", "root"))
|
||||
|
||||
# === API ===
|
||||
api_key: str = Field(default_factory=lambda: os.environ.get("API_KEY", "dev-key-12345"))
|
||||
api_title: str = "Homelab Automation Dashboard API"
|
||||
api_version: str = "1.0.0"
|
||||
api_description: str = "API REST moderne pour la gestion automatique d'homelab"
|
||||
|
||||
# === JWT Authentication ===
|
||||
jwt_secret_key: str = Field(
|
||||
default_factory=lambda: os.environ.get("JWT_SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
)
|
||||
jwt_expire_minutes: int = Field(
|
||||
default_factory=lambda: int(os.environ.get("JWT_EXPIRE_MINUTES", "1440"))
|
||||
)
|
||||
jwt_algorithm: str = "HS256"
|
||||
|
||||
# === Database ===
|
||||
database_url: Optional[str] = Field(default=None)
|
||||
|
||||
@property
|
||||
def async_database_url(self) -> str:
|
||||
"""URL de connexion async pour SQLAlchemy"""
|
||||
if self.database_url:
|
||||
return self.database_url
|
||||
return f"sqlite+aiosqlite:///{self.db_path}"
|
||||
|
||||
# === CORS ===
|
||||
cors_origins: list = Field(default=["*"])
|
||||
cors_allow_credentials: bool = True
|
||||
cors_allow_methods: list = Field(default=["*"])
|
||||
cors_allow_headers: list = Field(default=["*"])
|
||||
|
||||
# === Notifications ntfy ===
|
||||
ntfy_enabled: bool = Field(
|
||||
default_factory=lambda: os.environ.get("NTFY_ENABLED", "true").lower() == "true"
|
||||
)
|
||||
ntfy_base_url: str = Field(
|
||||
default_factory=lambda: os.environ.get("NTFY_BASE_URL", "https://ntfy.sh")
|
||||
)
|
||||
ntfy_default_topic: str = Field(
|
||||
default_factory=lambda: os.environ.get("NTFY_TOPIC", "homelab-automation")
|
||||
)
|
||||
ntfy_timeout: int = Field(
|
||||
default_factory=lambda: int(os.environ.get("NTFY_TIMEOUT", "10"))
|
||||
)
|
||||
ntfy_username: Optional[str] = Field(
|
||||
default_factory=lambda: os.environ.get("NTFY_USERNAME")
|
||||
)
|
||||
ntfy_password: Optional[str] = Field(
|
||||
default_factory=lambda: os.environ.get("NTFY_PASSWORD")
|
||||
)
|
||||
ntfy_token: Optional[str] = Field(
|
||||
default_factory=lambda: os.environ.get("NTFY_TOKEN")
|
||||
)
|
||||
|
||||
# === Scheduler ===
|
||||
scheduler_timezone: str = Field(
|
||||
default_factory=lambda: os.environ.get("SCHEDULER_TIMEZONE", "America/Montreal")
|
||||
)
|
||||
scheduler_misfire_grace_time: int = 300
|
||||
|
||||
# === Cache ===
|
||||
hosts_cache_ttl: int = 60 # secondes
|
||||
inventory_cache_ttl: int = 60 # secondes
|
||||
logs_index_rebuild_interval: int = 60 # secondes
|
||||
|
||||
# === Server ===
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8008
|
||||
reload: bool = Field(
|
||||
default_factory=lambda: os.environ.get("RELOAD", "true").lower() == "true"
|
||||
)
|
||||
log_level: str = "info"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
# Instance singleton de la configuration
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Constantes et énumérations de l'application.
|
||||
|
||||
Centralise toutes les valeurs constantes pour éviter les magic strings.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class HostStatus(str, Enum):
|
||||
"""Statuts possibles d'un hôte."""
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
WARNING = "warning"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""Statuts possibles d'une tâche."""
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class LogLevel(str, Enum):
|
||||
"""Niveaux de log."""
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARN = "WARN"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
|
||||
|
||||
class ScheduleStatus(str, Enum):
|
||||
"""Statuts possibles d'un schedule."""
|
||||
NEVER = "never"
|
||||
RUNNING = "running"
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
CANCELED = "canceled"
|
||||
|
||||
|
||||
class ScheduleType(str, Enum):
|
||||
"""Types de schedule."""
|
||||
ONCE = "once"
|
||||
RECURRING = "recurring"
|
||||
|
||||
|
||||
class RecurrenceType(str, Enum):
|
||||
"""Types de récurrence."""
|
||||
DAILY = "daily"
|
||||
WEEKLY = "weekly"
|
||||
MONTHLY = "monthly"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class TargetType(str, Enum):
|
||||
"""Types de cible pour les schedules."""
|
||||
GROUP = "group"
|
||||
HOST = "host"
|
||||
|
||||
|
||||
class NotificationType(str, Enum):
|
||||
"""Types de notification pour les schedules."""
|
||||
NONE = "none"
|
||||
ALL = "all"
|
||||
ERRORS = "errors"
|
||||
|
||||
|
||||
class SourceType(str, Enum):
|
||||
"""Types de source pour les tâches."""
|
||||
SCHEDULED = "scheduled"
|
||||
MANUAL = "manual"
|
||||
ADHOC = "adhoc"
|
||||
|
||||
|
||||
class AnsibleModule(str, Enum):
|
||||
"""Modules Ansible courants pour les commandes ad-hoc."""
|
||||
SHELL = "shell"
|
||||
COMMAND = "command"
|
||||
RAW = "raw"
|
||||
PING = "ping"
|
||||
SETUP = "setup"
|
||||
|
||||
|
||||
class GroupType(str, Enum):
|
||||
"""Types de groupes Ansible."""
|
||||
ENV = "env"
|
||||
ROLE = "role"
|
||||
|
||||
|
||||
# === Mapping Actions → Playbooks ===
|
||||
ACTION_PLAYBOOK_MAP: Dict[str, str] = {
|
||||
"upgrade": "vm-upgrade.yml",
|
||||
"reboot": "vm-reboot.yml",
|
||||
"health-check": "health-check.yml",
|
||||
"backup": "backup-config.yml",
|
||||
"bootstrap": "bootstrap-host.yml",
|
||||
}
|
||||
|
||||
# === Noms lisibles des actions ===
|
||||
ACTION_DISPLAY_NAMES: Dict[str, str] = {
|
||||
"upgrade": "Mise à jour système",
|
||||
"reboot": "Redémarrage système",
|
||||
"health-check": "Vérification de santé",
|
||||
"backup": "Sauvegarde",
|
||||
"deploy": "Déploiement",
|
||||
"rollback": "Rollback",
|
||||
"maintenance": "Maintenance",
|
||||
"bootstrap": "Bootstrap Ansible",
|
||||
}
|
||||
|
||||
# === Actions valides ===
|
||||
VALID_ACTIONS = list(ACTION_DISPLAY_NAMES.keys())
|
||||
|
||||
# === Emojis pour les statuts de tâches ===
|
||||
TASK_STATUS_EMOJIS: Dict[str, str] = {
|
||||
TaskStatus.COMPLETED: "✅",
|
||||
TaskStatus.FAILED: "❌",
|
||||
TaskStatus.RUNNING: "🔄",
|
||||
TaskStatus.PENDING: "⏳",
|
||||
TaskStatus.CANCELLED: "🚫",
|
||||
}
|
||||
|
||||
# === Labels pour les types de source ===
|
||||
SOURCE_TYPE_LABELS: Dict[str, str] = {
|
||||
SourceType.SCHEDULED: "Planifié",
|
||||
SourceType.MANUAL: "Manuel",
|
||||
SourceType.ADHOC: "Ad-hoc",
|
||||
}
|
||||
|
||||
# === Catégories ad-hoc par défaut ===
|
||||
DEFAULT_ADHOC_CATEGORIES = [
|
||||
{"name": "default", "description": "Commandes générales", "color": "#7c3aed", "icon": "fa-terminal"},
|
||||
{"name": "diagnostic", "description": "Commandes de diagnostic", "color": "#10b981", "icon": "fa-stethoscope"},
|
||||
{"name": "maintenance", "description": "Commandes de maintenance", "color": "#f59e0b", "icon": "fa-wrench"},
|
||||
{"name": "deployment", "description": "Commandes de déploiement", "color": "#3b82f6", "icon": "fa-rocket"},
|
||||
]
|
||||
|
||||
# === Timeouts par défaut ===
|
||||
DEFAULT_ADHOC_TIMEOUT = 60
|
||||
DEFAULT_SCHEDULE_TIMEOUT = 3600
|
||||
SSH_CONNECT_TIMEOUT = 10
|
||||
|
||||
# === Limites de pagination ===
|
||||
DEFAULT_PAGE_LIMIT = 50
|
||||
MAX_PAGE_LIMIT = 1000
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Dépendances FastAPI pour l'injection de dépendances.
|
||||
|
||||
Centralise toutes les dépendances communes utilisées dans les routes.
|
||||
"""
|
||||
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.exceptions import AuthenticationException
|
||||
from app.models.database import get_db as get_db_session
|
||||
|
||||
|
||||
# === Schémas de sécurité ===
|
||||
|
||||
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
# === Dépendance: Session de base de données ===
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""
|
||||
Fournit une session de base de données async.
|
||||
|
||||
Utilisée comme dépendance dans les endpoints:
|
||||
```python
|
||||
@app.get("/api/example")
|
||||
async def example(db: AsyncSession = Depends(get_db)):
|
||||
...
|
||||
```
|
||||
"""
|
||||
async for session in get_db_session():
|
||||
yield session
|
||||
|
||||
|
||||
# === Dépendance: Vérification clé API ===
|
||||
|
||||
async def verify_api_key(
|
||||
api_key: Optional[str] = Depends(api_key_header),
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
) -> bool:
|
||||
"""
|
||||
Vérifie l'authentification par clé API ou JWT Bearer token.
|
||||
|
||||
Raises:
|
||||
HTTPException 401 si non authentifié
|
||||
|
||||
Returns:
|
||||
True si authentifié
|
||||
"""
|
||||
# Vérifier la clé API
|
||||
if api_key and api_key == settings.api_key:
|
||||
return True
|
||||
|
||||
# Vérifier le token JWT
|
||||
if token:
|
||||
try:
|
||||
from app.services.auth_service import decode_token
|
||||
token_data = decode_token(token)
|
||||
if token_data:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentification requise (clé API ou JWT)",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
# === Dépendance: Vérification JWT (optionnelle) ===
|
||||
|
||||
async def get_current_user_optional(
|
||||
token: Optional[str] = Depends(oauth2_scheme),
|
||||
api_key: Optional[str] = Depends(api_key_header),
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Vérifie l'authentification par JWT ou clé API (optionnel).
|
||||
|
||||
Returns:
|
||||
Dictionnaire utilisateur si authentifié, None sinon
|
||||
"""
|
||||
# Vérifier d'abord la clé API (compatibilité legacy)
|
||||
if api_key and api_key == settings.api_key:
|
||||
return {"type": "api_key", "authenticated": True}
|
||||
|
||||
# Vérifier le token JWT
|
||||
if token:
|
||||
try:
|
||||
from app.services.auth_service import decode_token
|
||||
token_data = decode_token(token)
|
||||
if token_data:
|
||||
return {
|
||||
"type": "jwt",
|
||||
"authenticated": True,
|
||||
"user_id": token_data.user_id,
|
||||
"username": token_data.username,
|
||||
"role": token_data.role,
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
user: Optional[dict] = Depends(get_current_user_optional)
|
||||
) -> dict:
|
||||
"""
|
||||
Vérifie l'authentification par JWT ou clé API (obligatoire).
|
||||
|
||||
Raises:
|
||||
HTTPException 401 si non authentifié
|
||||
|
||||
Returns:
|
||||
Dictionnaire utilisateur
|
||||
"""
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentification requise",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
# === Dépendance: Vérification rôle admin ===
|
||||
|
||||
async def require_admin(
|
||||
user: dict = Depends(get_current_user)
|
||||
) -> dict:
|
||||
"""
|
||||
Vérifie que l'utilisateur est admin.
|
||||
|
||||
Raises:
|
||||
HTTPException 403 si l'utilisateur n'est pas admin
|
||||
|
||||
Returns:
|
||||
Dictionnaire utilisateur
|
||||
"""
|
||||
# La clé API a tous les droits
|
||||
if user.get("type") == "api_key":
|
||||
return user
|
||||
|
||||
# Vérifier le rôle dans le payload JWT
|
||||
payload = user.get("payload", {})
|
||||
role = payload.get("role", "viewer")
|
||||
|
||||
if role != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Droits administrateur requis"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
# === Dépendance combinée: Auth + DB ===
|
||||
|
||||
class AuthenticatedDB:
|
||||
"""Conteneur pour la session DB et l'info utilisateur."""
|
||||
|
||||
def __init__(self, db: AsyncSession, user: dict):
|
||||
self.db = db
|
||||
self.user = user
|
||||
|
||||
|
||||
async def get_authenticated_db(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: dict = Depends(get_current_user)
|
||||
) -> AuthenticatedDB:
|
||||
"""
|
||||
Fournit une session DB authentifiée.
|
||||
|
||||
Returns:
|
||||
AuthenticatedDB avec session et info utilisateur
|
||||
"""
|
||||
return AuthenticatedDB(db=db, user=user)
|
||||
|
||||
|
||||
# === Dépendance: Pagination ===
|
||||
|
||||
class PaginationParams:
|
||||
"""Paramètres de pagination communs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
):
|
||||
from app.core.constants import MAX_PAGE_LIMIT
|
||||
self.limit = min(limit, MAX_PAGE_LIMIT)
|
||||
self.offset = max(offset, 0)
|
||||
|
||||
|
||||
def get_pagination(
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> PaginationParams:
|
||||
"""
|
||||
Extrait les paramètres de pagination des query params.
|
||||
|
||||
Returns:
|
||||
PaginationParams avec limit et offset validés
|
||||
"""
|
||||
return PaginationParams(limit=limit, offset=offset)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Exceptions personnalisées de l'application.
|
||||
|
||||
Centralise la gestion des erreurs avec des exceptions typées.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
class HomelabException(Exception):
|
||||
"""Exception de base pour l'application Homelab."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
status_code: int = 500,
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convertit l'exception en dictionnaire pour la réponse API."""
|
||||
return {
|
||||
"error": self.__class__.__name__,
|
||||
"message": self.message,
|
||||
"details": self.details,
|
||||
}
|
||||
|
||||
|
||||
# === Exceptions 404 Not Found ===
|
||||
|
||||
class NotFoundException(HomelabException):
|
||||
"""Exception de base pour les ressources non trouvées."""
|
||||
|
||||
def __init__(self, resource_type: str, identifier: str):
|
||||
super().__init__(
|
||||
message=f"{resource_type} '{identifier}' non trouvé(e)",
|
||||
status_code=404,
|
||||
details={"resource_type": resource_type, "identifier": identifier}
|
||||
)
|
||||
|
||||
|
||||
class HostNotFoundException(NotFoundException):
|
||||
"""Hôte non trouvé."""
|
||||
|
||||
def __init__(self, identifier: str):
|
||||
super().__init__("Hôte", identifier)
|
||||
|
||||
|
||||
class TaskNotFoundException(NotFoundException):
|
||||
"""Tâche non trouvée."""
|
||||
|
||||
def __init__(self, identifier: str):
|
||||
super().__init__("Tâche", identifier)
|
||||
|
||||
|
||||
class ScheduleNotFoundException(NotFoundException):
|
||||
"""Schedule non trouvé."""
|
||||
|
||||
def __init__(self, identifier: str):
|
||||
super().__init__("Schedule", identifier)
|
||||
|
||||
|
||||
class PlaybookNotFoundException(NotFoundException):
|
||||
"""Playbook non trouvé."""
|
||||
|
||||
def __init__(self, identifier: str):
|
||||
super().__init__("Playbook", identifier)
|
||||
|
||||
|
||||
class GroupNotFoundException(NotFoundException):
|
||||
"""Groupe non trouvé."""
|
||||
|
||||
def __init__(self, identifier: str):
|
||||
super().__init__("Groupe", identifier)
|
||||
|
||||
|
||||
class LogNotFoundException(NotFoundException):
|
||||
"""Log non trouvé."""
|
||||
|
||||
def __init__(self, identifier: str):
|
||||
super().__init__("Log", identifier)
|
||||
|
||||
|
||||
# === Exceptions 400 Bad Request ===
|
||||
|
||||
class ValidationException(HomelabException):
|
||||
"""Erreur de validation des données."""
|
||||
|
||||
def __init__(self, message: str, field: Optional[str] = None):
|
||||
details = {"field": field} if field else {}
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=400,
|
||||
details=details
|
||||
)
|
||||
|
||||
|
||||
class DuplicateResourceException(HomelabException):
|
||||
"""Ressource déjà existante."""
|
||||
|
||||
def __init__(self, resource_type: str, identifier: str):
|
||||
super().__init__(
|
||||
message=f"{resource_type} '{identifier}' existe déjà",
|
||||
status_code=400,
|
||||
details={"resource_type": resource_type, "identifier": identifier}
|
||||
)
|
||||
|
||||
|
||||
class InvalidOperationException(HomelabException):
|
||||
"""Opération non valide dans l'état actuel."""
|
||||
|
||||
def __init__(self, message: str, current_state: Optional[str] = None):
|
||||
details = {"current_state": current_state} if current_state else {}
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=400,
|
||||
details=details
|
||||
)
|
||||
|
||||
|
||||
class IncompatiblePlaybookException(HomelabException):
|
||||
"""Playbook incompatible avec la cible."""
|
||||
|
||||
def __init__(self, playbook: str, target: str, playbook_hosts: str):
|
||||
super().__init__(
|
||||
message=f"Le playbook '{playbook}' (hosts: {playbook_hosts}) n'est pas compatible avec la cible '{target}'",
|
||||
status_code=400,
|
||||
details={
|
||||
"playbook": playbook,
|
||||
"target": target,
|
||||
"playbook_hosts": playbook_hosts,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# === Exceptions 401/403 Auth ===
|
||||
|
||||
class AuthenticationException(HomelabException):
|
||||
"""Erreur d'authentification."""
|
||||
|
||||
def __init__(self, message: str = "Authentification requise"):
|
||||
super().__init__(message=message, status_code=401)
|
||||
|
||||
|
||||
class AuthorizationException(HomelabException):
|
||||
"""Erreur d'autorisation."""
|
||||
|
||||
def __init__(self, message: str = "Accès non autorisé"):
|
||||
super().__init__(message=message, status_code=403)
|
||||
|
||||
|
||||
# === Exceptions 500 Server Error ===
|
||||
|
||||
class AnsibleExecutionException(HomelabException):
|
||||
"""Erreur lors de l'exécution Ansible."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
return_code: int = -1,
|
||||
stdout: str = "",
|
||||
stderr: str = ""
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=500,
|
||||
details={
|
||||
"return_code": return_code,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BootstrapException(HomelabException):
|
||||
"""Erreur lors du bootstrap d'un hôte."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
message: str,
|
||||
return_code: int = -1,
|
||||
stdout: str = "",
|
||||
stderr: str = ""
|
||||
):
|
||||
super().__init__(
|
||||
message=f"Échec bootstrap pour {host}: {message}",
|
||||
status_code=500,
|
||||
details={
|
||||
"host": host,
|
||||
"return_code": return_code,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SSHConnectionException(HomelabException):
|
||||
"""Erreur de connexion SSH."""
|
||||
|
||||
def __init__(self, host: str, message: str):
|
||||
super().__init__(
|
||||
message=f"Connexion SSH échouée vers {host}: {message}",
|
||||
status_code=500,
|
||||
details={"host": host}
|
||||
)
|
||||
|
||||
|
||||
class DatabaseException(HomelabException):
|
||||
"""Erreur de base de données."""
|
||||
|
||||
def __init__(self, message: str, operation: Optional[str] = None):
|
||||
details = {"operation": operation} if operation else {}
|
||||
super().__init__(
|
||||
message=f"Erreur base de données: {message}",
|
||||
status_code=500,
|
||||
details=details
|
||||
)
|
||||
|
||||
|
||||
class SchedulerException(HomelabException):
|
||||
"""Erreur du service de planification."""
|
||||
|
||||
def __init__(self, message: str, schedule_id: Optional[str] = None):
|
||||
details = {"schedule_id": schedule_id} if schedule_id else {}
|
||||
super().__init__(
|
||||
message=f"Erreur scheduler: {message}",
|
||||
status_code=500,
|
||||
details=details
|
||||
)
|
||||
|
||||
|
||||
class NotificationException(HomelabException):
|
||||
"""Erreur lors de l'envoi de notification."""
|
||||
|
||||
def __init__(self, message: str, topic: Optional[str] = None):
|
||||
details = {"topic": topic} if topic else {}
|
||||
super().__init__(
|
||||
message=f"Erreur notification: {message}",
|
||||
status_code=500,
|
||||
details=details
|
||||
)
|
||||
|
||||
|
||||
class FileOperationException(HomelabException):
|
||||
"""Erreur lors d'une opération sur fichier."""
|
||||
|
||||
def __init__(self, message: str, path: Optional[str] = None):
|
||||
details = {"path": path} if path else {}
|
||||
super().__init__(
|
||||
message=message,
|
||||
status_code=500,
|
||||
details=details
|
||||
)
|
||||
Reference in New Issue
Block a user