L'API exposait /metrics, mais aucun collecteur ne le lisait : monitoring/ ne contenait que des .gitkeep. Sous le profil Compose `monitoring` : prometheus, alertmanager, grafana, postgres-exporter, node-exporter et cadvisor. Tous ont un mem_limit, pour environ 700 Mo au total sur la VM de 8 Go, et leurs interfaces n'écoutent que sur 127.0.0.1. Le profil est actif en prod via COMPOSE_PROFILES, donc à chaque déploiement, et se lance à la demande ailleurs (make monitoring-up). - Neuf règles d'alerte (API, base, hôte, cibles). Chacune a un cas dans les tests joués par `promtool test rules`, en CI comme par make monitoring-check. - Alertmanager route les alertes par courriel vers Mailpit ; un critical masque le warning de la même cible. - Grafana est provisionné : sources Prometheus et TimescaleDB, et trois tableaux de bord (API, données et dérive du modèle, infrastructure). - Le rôle PostgreSQL `supervision` est en lecture seule sur les seules tables métier (db/roles/supervision.sql), posé par make db-ensure-supervision et par stack-up quand le profil est actif. - Le jeton de /metrics passe à Prometheus en secret Compose (APP_METRICS_TOKEN) ; provision-host.sh génère ce secret et les deux autres. Backend : - un APP_METRICS_TOKEN vide vaut absent ; - les sondes de santé ne comptent plus dans les métriques ; - seaux de latence fins autour de 500 ms ; - un registre Prometheus par application, sans quoi toute application créée après la première (dans les tests) ne mesurait rien. Réf : #26
136 lines
5.3 KiB
Python
136 lines
5.3 KiB
Python
from functools import lru_cache
|
|
from typing import Literal, Self
|
|
|
|
from pydantic import Field, SecretStr, field_validator, model_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
Environment = Literal["local", "dev", "staging", "prod"]
|
|
SameSite = Literal["lax", "strict", "none"]
|
|
|
|
SECRET_KEY_MIN_LENGTH = 32
|
|
REFRESH_COOKIE_DEFAUT = "ev_refresh"
|
|
SENTINELLES_INTERDITES = frozenset(
|
|
{"change_me", "changeme", "secret", "secret-de-test", "changez-moi", "todo"}
|
|
)
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
model_config = SettingsConfigDict(
|
|
env_file=".env",
|
|
env_prefix="APP_",
|
|
env_file_encoding="utf-8",
|
|
extra="ignore",
|
|
)
|
|
|
|
name: str = "EnerVision API"
|
|
version: str = "0.1.0"
|
|
env: Environment = "local"
|
|
debug: bool = False
|
|
log_level: str = "INFO"
|
|
api_prefix: str = "/api/v1"
|
|
secret_key: SecretStr
|
|
cors_origins: str = ""
|
|
database_url: str = Field(validation_alias="DATABASE_URL")
|
|
database_pool_size: int = 5
|
|
database_max_overflow: int = 10
|
|
|
|
mock_api_base_url: str = "https://api-mock.charlieandre.fr"
|
|
mock_api_username: str | None = None
|
|
mock_api_password: SecretStr | None = None
|
|
mock_api_timeout_seconds: float = Field(default=10.0, gt=0)
|
|
|
|
jwt_issuer: str = "enervision-api"
|
|
jwt_audience: str = "enervision-web"
|
|
access_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
|
|
refresh_token_ttl_seconds: int = Field(default=604800, ge=3600, le=2592000)
|
|
|
|
refresh_cookie_name: str = REFRESH_COOKIE_DEFAUT
|
|
cookie_path: str = "/api/v1/auth"
|
|
cookie_samesite: SameSite = "strict"
|
|
cookie_secure: bool | None = None
|
|
|
|
argon2_time_cost: int = Field(default=2, ge=1, le=10)
|
|
argon2_memory_cost_kib: int = Field(default=19456, ge=8192)
|
|
argon2_parallelism: int = Field(default=1, ge=1, le=4)
|
|
argon2_max_concurrency: int = Field(default=4, ge=1, le=32)
|
|
|
|
login_window_seconds: int = Field(default=900, ge=60)
|
|
login_max_failures_per_identifier_and_ip: int = Field(default=5, ge=1)
|
|
login_max_failures_per_ip: int = Field(default=20, ge=1)
|
|
login_max_failures_per_identifier: int = Field(default=50, ge=1)
|
|
|
|
password_reset_ttl_seconds: int = Field(default=900, ge=60, le=3600)
|
|
password_reset_window_seconds: int = Field(default=900, ge=60)
|
|
password_reset_max_requests_per_identifier: int = Field(default=3, ge=1)
|
|
password_reset_max_requests_per_ip: int = Field(default=10, ge=1)
|
|
|
|
smtp_host: str = "localhost"
|
|
smtp_port: int = Field(default=587, ge=1, le=65535)
|
|
smtp_username: str | None = None
|
|
smtp_password: SecretStr | None = None
|
|
smtp_use_tls: bool = False
|
|
smtp_from_address: str = "no-reply@enervision.fr"
|
|
frontend_reset_password_url: str = "http://localhost:4200/reset-password" # noqa: S105
|
|
|
|
trust_proxy_headers: bool = False
|
|
expose_api_docs: bool | None = None
|
|
metrics_token: SecretStr | None = None
|
|
|
|
# Compose passe `APP_METRICS_TOKEN` vide quand aucun jeton n'est posé : vide vaut absent, sinon
|
|
# `/metrics` exigerait un `Bearer` sans valeur et plus rien ne pourrait le scruter.
|
|
@field_validator("metrics_token", mode="before")
|
|
@classmethod
|
|
def _jeton_vide_vaut_absent(cls, valeur: object) -> object:
|
|
return None if valeur == "" else valeur
|
|
|
|
@property
|
|
def allowed_origins(self) -> list[str]:
|
|
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
|
|
|
@property
|
|
def is_production(self) -> bool:
|
|
return self.env == "prod"
|
|
|
|
@property
|
|
def cookies_are_secure(self) -> bool:
|
|
return self.env != "local" if self.cookie_secure is None else self.cookie_secure
|
|
|
|
@property
|
|
def api_docs_are_exposed(self) -> bool:
|
|
if self.expose_api_docs is not None:
|
|
return self.expose_api_docs
|
|
return self.env not in ("staging", "prod")
|
|
|
|
@model_validator(mode="after")
|
|
def _refuse_les_configurations_dangereuses(self) -> Self:
|
|
secret = self.secret_key.get_secret_value()
|
|
if len(secret) < SECRET_KEY_MIN_LENGTH:
|
|
raise ValueError(
|
|
f"APP_SECRET_KEY doit faire au moins {SECRET_KEY_MIN_LENGTH} caractères"
|
|
)
|
|
if secret.strip().lower() in SENTINELLES_INTERDITES:
|
|
raise ValueError("APP_SECRET_KEY est une valeur d'exemple, il faut en générer une")
|
|
|
|
# Piège : `create_app()` passe `debug` à FastAPI, qui renvoie alors la trace complète
|
|
# au client, et à l'engine, qui journalise le SQL et ses paramètres.
|
|
if self.debug and self.env in ("staging", "prod"):
|
|
raise ValueError("APP_DEBUG doit rester faux hors des environnements locaux")
|
|
|
|
if "*" in self.cors_origins:
|
|
raise ValueError("APP_CORS_ORIGINS n'accepte pas de joker, les origines sont listées")
|
|
|
|
# Sans origines, aucun middleware CORS n'est monté et la vérification d'`Origin` des
|
|
# routes d'authentification n'a plus de référentiel auquel comparer.
|
|
if self.env != "local" and not self.allowed_origins:
|
|
raise ValueError("APP_CORS_ORIGINS doit lister au moins une origine hors local")
|
|
|
|
if self.cookie_samesite == "none" and not self.cookies_are_secure:
|
|
raise ValueError("Un cookie SameSite=None est rejeté par les navigateurs sans Secure")
|
|
|
|
return self
|
|
|
|
|
|
@lru_cache
|
|
def get_settings() -> Settings:
|
|
return Settings()
|