feat: add runnable simulator foundation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Proxmox API simulator application package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP adapters."""
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Base external error representation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Log internal failures and return a stable non-FastAPI error envelope."""
|
||||
|
||||
logger.exception(
|
||||
"unhandled request error",
|
||||
extra={"request_id": getattr(request.state, "request_id", None), "path": request.url.path},
|
||||
)
|
||||
body: dict[str, Any] = {
|
||||
"data": None,
|
||||
"errors": {"internal": "internal server error"},
|
||||
}
|
||||
return JSONResponse(status_code=500, content=body)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Request correlation and access logging middleware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RequestHandler = Callable[[Request], Awaitable[Response]]
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
"""Attach a bounded request ID and log one structured completion event."""
|
||||
|
||||
def __init__(self, app: object, header_name: str) -> None:
|
||||
super().__init__(app) # type: ignore[arg-type]
|
||||
self._header_name = header_name
|
||||
|
||||
async def dispatch(self, request: Request, call_next: RequestHandler) -> Response:
|
||||
supplied = request.headers.get(self._header_name, "")
|
||||
request_id = supplied if 0 < len(supplied) <= 128 else str(uuid.uuid4())
|
||||
request.state.request_id = request_id
|
||||
started = time.monotonic()
|
||||
response = await call_next(request)
|
||||
response.headers[self._header_name] = request_id
|
||||
logger.info(
|
||||
"request completed",
|
||||
extra={
|
||||
"request_id": request_id,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status": response.status_code,
|
||||
"duration_ms": round((time.monotonic() - started) * 1000, 3),
|
||||
},
|
||||
)
|
||||
return response
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Typed application configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field, SecretStr
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Runtime settings loaded from environment variables and an optional `.env`."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
frozen=True,
|
||||
)
|
||||
|
||||
app_name: str = "proxmox-api-simulator"
|
||||
app_host: str = "0.0.0.0" # noqa: S104 - the container must accept external traffic
|
||||
app_port: int = Field(default=8006, ge=1, le=65535)
|
||||
database_url: SecretStr = SecretStr(
|
||||
"postgresql://proxmox:proxmox@localhost:5432/proxmox_simulator"
|
||||
)
|
||||
db_pool_min_size: int = Field(default=1, ge=1, le=100)
|
||||
db_pool_max_size: int = Field(default=10, ge=1, le=100)
|
||||
db_connect_timeout_seconds: float = Field(default=10.0, gt=0, le=60)
|
||||
db_command_timeout_seconds: float = Field(default=30.0, gt=0, le=300)
|
||||
log_level: str = "INFO"
|
||||
request_id_header: str = "X-Request-ID"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Return the immutable process configuration."""
|
||||
|
||||
return Settings()
|
||||
@@ -0,0 +1 @@
|
||||
"""PostgreSQL infrastructure."""
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Small typed asyncpg pool boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, Self, cast
|
||||
|
||||
import asyncpg # type: ignore[import-untyped]
|
||||
from asyncpg import Pool
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
class Database(Protocol):
|
||||
"""Application-facing database lifecycle and health interface."""
|
||||
|
||||
async def connect(self) -> None: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
async def is_ready(self) -> bool: ...
|
||||
|
||||
|
||||
class AsyncpgDatabase:
|
||||
"""Own an asyncpg pool without exposing it as global mutable state."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._pool: Pool | None = None
|
||||
|
||||
@property
|
||||
def pool(self) -> Pool:
|
||||
"""Return the initialized pool to repository factories."""
|
||||
|
||||
if self._pool is None:
|
||||
message = "database pool is not initialized"
|
||||
raise RuntimeError(message)
|
||||
return self._pool
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Create the pool and verify the first connection."""
|
||||
|
||||
if self._pool is not None:
|
||||
return
|
||||
settings = self._settings
|
||||
pool = await asyncpg.create_pool(
|
||||
dsn=settings.database_url.get_secret_value(),
|
||||
min_size=settings.db_pool_min_size,
|
||||
max_size=settings.db_pool_max_size,
|
||||
timeout=settings.db_connect_timeout_seconds,
|
||||
command_timeout=settings.db_command_timeout_seconds,
|
||||
)
|
||||
if pool is None: # pragma: no cover - asyncpg types allow this for legacy reasons
|
||||
message = "asyncpg did not create a pool"
|
||||
raise RuntimeError(message)
|
||||
self._pool = cast(Pool, pool)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close all pooled connections; repeated close is safe."""
|
||||
|
||||
pool, self._pool = self._pool, None
|
||||
if pool is not None:
|
||||
await pool.close()
|
||||
|
||||
async def is_ready(self) -> bool:
|
||||
"""Check that PostgreSQL accepts a trivial query."""
|
||||
|
||||
if self._pool is None:
|
||||
return False
|
||||
try:
|
||||
return bool(await self._pool.fetchval("SELECT 1") == 1)
|
||||
except asyncpg.PostgresError:
|
||||
return False
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
||||
await self.close()
|
||||
@@ -0,0 +1,14 @@
|
||||
"""FastAPI dependency adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.db.pool import Database
|
||||
|
||||
|
||||
def get_database(request: Request) -> Database:
|
||||
"""Resolve the lifespan-owned database from application state."""
|
||||
|
||||
database: Database = request.app.state.database
|
||||
return database
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Application resource ownership."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.pool import AsyncpgDatabase, Database
|
||||
|
||||
DatabaseFactory = Callable[[Settings], Database]
|
||||
Lifespan = Callable[[FastAPI], AbstractAsyncContextManager[None]]
|
||||
|
||||
|
||||
def create_lifespan(settings: Settings, database_factory: DatabaseFactory) -> Lifespan:
|
||||
"""Build a lifespan context so tests can inject a database implementation."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
database = database_factory(settings)
|
||||
await database.connect()
|
||||
app.state.database = database
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await database.close()
|
||||
|
||||
return lifespan
|
||||
|
||||
|
||||
def default_database_factory(settings: Settings) -> Database:
|
||||
"""Create the production asyncpg adapter."""
|
||||
|
||||
return AsyncpgDatabase(settings)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Structured logging configuration with safe JSON output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""Serialize standard records and selected structured attributes as JSON."""
|
||||
|
||||
_fields = ("request_id", "method", "path", "status", "duration_ms")
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
}
|
||||
for field in self._fields:
|
||||
value = getattr(record, field, None)
|
||||
if value is not None:
|
||||
payload[field] = value
|
||||
if record.exc_info is not None:
|
||||
payload["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
"""Configure the root logger once for the process."""
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(JsonFormatter())
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
root.setLevel(level.upper())
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
"""FastAPI application factory and ASGI entry point."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.errors import unhandled_exception_handler
|
||||
from app.api.middleware import RequestContextMiddleware
|
||||
from app.config import Settings, get_settings
|
||||
from app.lifespan import DatabaseFactory, create_lifespan, default_database_factory
|
||||
from app.logging import configure_logging
|
||||
from app.observability.health import router as health_router
|
||||
|
||||
|
||||
def create_app(
|
||||
settings: Settings | None = None,
|
||||
database_factory: DatabaseFactory = default_database_factory,
|
||||
) -> FastAPI:
|
||||
"""Create an isolated application instance with explicit resource factories."""
|
||||
|
||||
resolved = settings or get_settings()
|
||||
configure_logging(resolved.log_level)
|
||||
app = FastAPI(
|
||||
title=resolved.app_name,
|
||||
version="0.0.1",
|
||||
lifespan=create_lifespan(resolved, database_factory),
|
||||
)
|
||||
app.add_middleware(RequestContextMiddleware, header_name=resolved.request_id_header)
|
||||
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||
app.include_router(health_router)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1 @@
|
||||
"""Health, metrics, and tracing adapters."""
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Kubernetes-compatible health endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db.pool import Database
|
||||
from app.dependencies import get_database
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
@router.get("/live", response_model=HealthResponse)
|
||||
async def live() -> HealthResponse:
|
||||
"""Report process liveness without checking dependencies."""
|
||||
|
||||
return HealthResponse(status="ok")
|
||||
|
||||
|
||||
@router.get("/ready", response_model=HealthResponse)
|
||||
async def ready(
|
||||
response: Response,
|
||||
database: Annotated[Database, Depends(get_database)],
|
||||
) -> HealthResponse:
|
||||
"""Report whether the required database dependency is usable."""
|
||||
|
||||
if await database.is_ready():
|
||||
return HealthResponse(status="ok")
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return HealthResponse(status="unavailable")
|
||||
Reference in New Issue
Block a user