"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics. Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici (plan.md sect. 4: un worker mort nu trebuie sa lase containerul "sanatos"). Pornire dev: uvicorn app.main:app --reload """ from __future__ import annotations from contextlib import asynccontextmanager from datetime import datetime, timezone from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, PlainTextResponse from . import __version__ from .api.v1.router import router as api_v1_router from .config import get_settings from .db import get_connection, init_db, queue_depth, read_heartbeat from .security import install_log_redaction from .web.routes import router as web_router @asynccontextmanager async def lifespan(app: FastAPI): install_log_redaction() init_db() yield app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan) @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: """422 fara echo de credentiale. Pydantic include implicit `input` (+ uneori `ctx`) in fiecare eroare — pe /v1/prezentari asta ar reflecta inapoi `rar_credentials.password`. Pastram type/loc/msg (clientul stie ce camp e gresit) si DROP-am input/ctx. Defense in depth pe TOATE rutele, nu doar prezentari. """ cleaned = [{"type": e.get("type"), "loc": e.get("loc"), "msg": e.get("msg")} for e in exc.errors()] return JSONResponse(status_code=422, content={"detail": cleaned}) app.include_router(api_v1_router) app.include_router(web_router) @app.get("/healthz") def healthz() -> dict: """Sanatate: worker viu + ultimul login RAR reusit + adancime coada. Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort -> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii; orchestratorul decide pe campul `worker_alive`. """ settings = get_settings() conn = get_connection() try: hb = read_heartbeat(conn) depth = queue_depth(conn) finally: conn.close() worker_alive = False last_beat = hb["last_beat"] if hb else None if last_beat: try: age = (datetime.now(timezone.utc) - datetime.fromisoformat(last_beat)).total_seconds() worker_alive = age <= settings.worker_heartbeat_stale_s except ValueError: worker_alive = False return { "ok": True, "version": __version__, "rar_env": settings.rar_env, "worker_alive": worker_alive, "last_beat": last_beat, "last_rar_login_ok": hb["last_rar_login_ok"] if hb else None, "queue_depth": depth, } @app.get("/metrics", response_class=PlainTextResponse) def metrics() -> str: """Metrici text simplu (submissions pe status + backlog). Format Prometheus-lite.""" conn = get_connection() try: rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall() finally: conn.close() lines = ["# submissions pe status"] for r in rows: lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}') return "\n".join(lines) + "\n"