feat: add runnable simulator foundation

This commit is contained in:
Sergey Antropoff
2026-07-12 23:06:25 +03:00
parent 8804a3ec47
commit e11014ea26
25 changed files with 739 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Proxmox API simulator application package."""
+1
View File
@@ -0,0 +1 @@
"""HTTP adapters."""
+25
View File
@@ -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)
+42
View File
@@ -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
+39
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""PostgreSQL infrastructure."""
+79
View File
@@ -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()
+14
View File
@@ -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
+36
View File
@@ -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)
+40
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Health, metrics, and tracing adapters."""
+37
View File
@@ -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")