feat: add runnable simulator foundation
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.mypy_cache
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.env
|
||||||
|
htmlcov
|
||||||
|
tests
|
||||||
|
docs
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8006
|
||||||
|
DATABASE_URL=postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
|
||||||
|
DB_POOL_MIN_SIZE=1
|
||||||
|
DB_POOL_MAX_SIZE=10
|
||||||
|
DB_CONNECT_TIMEOUT_SECONDS=10
|
||||||
|
DB_COMMAND_TIMEOUT_SECONDS=30
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
REQUEST_ID_HEADER=X-Request-ID
|
||||||
|
PVE_API_VERSION=9.2.3
|
||||||
|
SIMULATION_SEED=42
|
||||||
|
SIMULATION_TIME_SCALE=10
|
||||||
|
SIMULATOR_ADMIN_ENABLED=false
|
||||||
|
SIMULATOR_ADMIN_TOKEN=replace-with-a-long-random-secret
|
||||||
|
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
.mypy_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
FROM python:3.13-slim-bookworm AS builder
|
||||||
|
|
||||||
|
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
VIRTUAL_ENV=/opt/venv
|
||||||
|
RUN python -m venv "$VIRTUAL_ENV"
|
||||||
|
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||||
|
WORKDIR /build
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY app ./app
|
||||||
|
RUN pip install --upgrade "pip>=25.1,<26" && pip install .
|
||||||
|
|
||||||
|
FROM python:3.13-slim-bookworm AS runtime
|
||||||
|
|
||||||
|
ARG APP_VERSION=0.0.1
|
||||||
|
LABEL org.opencontainers.image.title="proxmox-api-simulator" \
|
||||||
|
org.opencontainers.image.version="$APP_VERSION" \
|
||||||
|
org.opencontainers.image.source="https://github.com/example/proxmox-api-simulator"
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH" \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
APP_HOST=0.0.0.0 \
|
||||||
|
APP_PORT=8006
|
||||||
|
RUN groupadd --system --gid 10001 simulator \
|
||||||
|
&& useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator
|
||||||
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
WORKDIR /app
|
||||||
|
USER 10001:10001
|
||||||
|
EXPOSE 8006
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8006/health/live', timeout=2)"]
|
||||||
|
ENTRYPOINT ["uvicorn", "app.main:app"]
|
||||||
|
CMD ["--host", "0.0.0.0", "--port", "8006"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Copyright 2026 proxmox-api-simulator contributors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
PYTHON ?= python3.13
|
||||||
|
VENV ?= .venv
|
||||||
|
BIN := $(VENV)/bin
|
||||||
|
COMPOSE ?= docker compose
|
||||||
|
|
||||||
|
.PHONY: help install format lint typecheck test test-unit test-integration test-contract coverage run dev docker-build docker-up docker-down docker-logs db-up db-down db-migrate db-reset api-import api-diff seed clean ci
|
||||||
|
|
||||||
|
help: ## Show available commands
|
||||||
|
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||||
|
|
||||||
|
install: ## Create the Python 3.13 environment and install development dependencies
|
||||||
|
$(PYTHON) -m venv $(VENV)
|
||||||
|
$(BIN)/python -m pip install --upgrade "pip>=25.1,<26"
|
||||||
|
$(BIN)/python -m pip install -e '.[dev]'
|
||||||
|
|
||||||
|
format: ## Format Python sources
|
||||||
|
$(BIN)/ruff format .
|
||||||
|
|
||||||
|
lint: ## Run Ruff lint checks
|
||||||
|
$(BIN)/ruff check .
|
||||||
|
|
||||||
|
typecheck: ## Run strict mypy checks
|
||||||
|
$(BIN)/mypy
|
||||||
|
|
||||||
|
test: ## Run all offline tests
|
||||||
|
$(BIN)/pytest
|
||||||
|
|
||||||
|
test-unit: ## Run unit tests
|
||||||
|
$(BIN)/pytest tests/unit
|
||||||
|
|
||||||
|
test-integration: ## Run tests that require PostgreSQL
|
||||||
|
$(BIN)/pytest -m integration
|
||||||
|
|
||||||
|
test-contract: ## Run offline API contract tests
|
||||||
|
$(BIN)/pytest -m contract
|
||||||
|
|
||||||
|
coverage: ## Run tests with coverage enforcement
|
||||||
|
$(BIN)/pytest --cov=app --cov-report=term-missing --cov-report=xml
|
||||||
|
|
||||||
|
run: ## Run the application
|
||||||
|
$(BIN)/uvicorn app.main:app --host "$${APP_HOST:-0.0.0.0}" --port "$${APP_PORT:-8006}"
|
||||||
|
|
||||||
|
dev: ## Run with auto-reload
|
||||||
|
$(BIN)/uvicorn app.main:app --reload --host "$${APP_HOST:-0.0.0.0}" --port "$${APP_PORT:-8006}"
|
||||||
|
|
||||||
|
docker-build: ## Build the runtime image
|
||||||
|
$(COMPOSE) build simulator
|
||||||
|
|
||||||
|
docker-up: ## Start PostgreSQL and simulator
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
$(COMPOSE) up -d --build
|
||||||
|
|
||||||
|
docker-down: ## Stop local services
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
docker-logs: ## Follow simulator logs
|
||||||
|
$(COMPOSE) logs -f simulator
|
||||||
|
|
||||||
|
db-up: ## Start PostgreSQL only
|
||||||
|
$(COMPOSE) up -d postgres
|
||||||
|
|
||||||
|
db-down: ## Stop PostgreSQL
|
||||||
|
$(COMPOSE) stop postgres
|
||||||
|
|
||||||
|
db-migrate: ## Apply database migrations
|
||||||
|
@echo "Database migrations are scheduled for milestone D1" >&2; exit 2
|
||||||
|
|
||||||
|
db-reset: ## Recreate the local database volume
|
||||||
|
$(COMPOSE) down -v
|
||||||
|
$(COMPOSE) up -d postgres
|
||||||
|
|
||||||
|
api-import: ## Import an API snapshot
|
||||||
|
@echo "API import is scheduled for milestone B4" >&2; exit 2
|
||||||
|
|
||||||
|
api-diff: ## Compare API snapshots
|
||||||
|
@echo "API diff is scheduled for milestone B5" >&2; exit 2
|
||||||
|
|
||||||
|
seed: ## Seed simulation data
|
||||||
|
@echo "Database seed is scheduled for milestone D2" >&2; exit 2
|
||||||
|
|
||||||
|
clean: ## Remove generated local artifacts
|
||||||
|
rm -rf $(VENV) .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
|
||||||
|
|
||||||
|
ci: format lint typecheck coverage ## Run the complete local quality gate
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# proxmox-api-simulator
|
||||||
|
|
||||||
|
Stateful asynchronous Proxmox VE API simulator for testing API clients and
|
||||||
|
infrastructure tooling without a real hypervisor cluster.
|
||||||
|
|
||||||
|
The project is in its foundation stage. Only liveness and PostgreSQL-backed
|
||||||
|
readiness endpoints exist. No Proxmox endpoint is claimed as compatible yet; the
|
||||||
|
official contract importer and stateful vertical slice are tracked in
|
||||||
|
[the implementation plan](docs/implementation-plan.md).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Python 3.13 is required.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install
|
||||||
|
make ci
|
||||||
|
```
|
||||||
|
|
||||||
|
Local services use plain HTTP at this stage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
make docker-up
|
||||||
|
curl http://localhost:8006/health/live
|
||||||
|
curl http://localhost:8006/health/ready
|
||||||
|
```
|
||||||
|
|
||||||
|
See [the architecture](docs/architecture.md) for component boundaries and
|
||||||
|
durability decisions. Commands for not-yet-implemented milestones intentionally
|
||||||
|
return a non-zero status instead of pretending to succeed.
|
||||||
|
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17.5-bookworm
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: proxmox_simulator
|
||||||
|
POSTGRES_USER: proxmox
|
||||||
|
POSTGRES_PASSWORD: proxmox
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U proxmox -d proxmox_simulator"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
|
||||||
|
simulator:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: runtime
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8006/health/ready', timeout=2)"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
ports:
|
||||||
|
- "8006:8006"
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling>=1.27,<2"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "proxmox-api-simulator"
|
||||||
|
version = "0.0.1"
|
||||||
|
description = "Stateful asynchronous Proxmox VE API simulator"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13,<3.14"
|
||||||
|
license = { text = "Apache-2.0" }
|
||||||
|
authors = [{ name = "proxmox-api-simulator contributors" }]
|
||||||
|
dependencies = [
|
||||||
|
"asyncpg>=0.30,<0.31",
|
||||||
|
"fastapi>=0.116,<0.117",
|
||||||
|
"pydantic>=2.11,<3",
|
||||||
|
"pydantic-settings>=2.10,<3",
|
||||||
|
"uvicorn[standard]>=0.35,<0.36",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"httpx>=0.28,<0.29",
|
||||||
|
"mypy>=1.17,<1.18",
|
||||||
|
"pytest>=8.4,<9",
|
||||||
|
"pytest-asyncio>=1.1,<2",
|
||||||
|
"pytest-cov>=6.2,<7",
|
||||||
|
"ruff>=0.12,<0.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["app"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py313"
|
||||||
|
line-length = 100
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "ASYNC", "S", "RUF"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**/*.py" = ["S101"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.13"
|
||||||
|
strict = true
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
files = ["app", "tests"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
markers = [
|
||||||
|
"integration: requires PostgreSQL or another external service",
|
||||||
|
"contract: validates imported API contracts",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
branch = true
|
||||||
|
source = ["app"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
fail_under = 80
|
||||||
|
show_missing = true
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Test package."""
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.db.pool import Database
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDatabase:
|
||||||
|
def __init__(self, ready: bool) -> None:
|
||||||
|
self.ready = ready
|
||||||
|
self.connected = False
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
self.connected = True
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
async def is_ready(self) -> bool:
|
||||||
|
return self.ready
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("database_ready", "status_code"), [(True, 200), (False, 503)])
|
||||||
|
async def test_health_endpoints(database_ready: bool, status_code: int) -> None:
|
||||||
|
database = FakeDatabase(database_ready)
|
||||||
|
|
||||||
|
def factory(settings: Settings) -> Database:
|
||||||
|
del settings
|
||||||
|
return database
|
||||||
|
|
||||||
|
application = create_app(Settings(), factory)
|
||||||
|
async with application.router.lifespan_context(application):
|
||||||
|
async with AsyncClient(
|
||||||
|
transport=ASGITransport(app=application, raise_app_exceptions=False),
|
||||||
|
base_url="http://test",
|
||||||
|
) as client:
|
||||||
|
live = await client.get("/health/live")
|
||||||
|
ready = await client.get("/health/ready", headers={"X-Request-ID": "test-request"})
|
||||||
|
|
||||||
|
assert live.status_code == 200
|
||||||
|
assert live.json() == {"status": "ok"}
|
||||||
|
assert ready.status_code == status_code
|
||||||
|
assert ready.headers["X-Request-ID"] == "test-request"
|
||||||
|
assert database.connected
|
||||||
|
assert database.closed
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.logging import JsonFormatter
|
||||||
|
|
||||||
|
|
||||||
|
def test_json_formatter_emits_structured_fields() -> None:
|
||||||
|
record = logging.LogRecord("test", logging.INFO, __file__, 1, "hello %s", ("world",), None)
|
||||||
|
record.request_id = "request-1"
|
||||||
|
|
||||||
|
payload = json.loads(JsonFormatter().format(record))
|
||||||
|
|
||||||
|
assert payload["message"] == "hello world"
|
||||||
|
assert payload["request_id"] == "request-1"
|
||||||
|
assert payload["level"] == "INFO"
|
||||||
Reference in New Issue
Block a user