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
+11
View File
@@ -0,0 +1,11 @@
.git
.venv
__pycache__
.mypy_cache
.pytest_cache
.ruff_cache
.env
htmlcov
tests
docs
+15
View File
@@ -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
View File
@@ -0,0 +1,14 @@
.env
.venv/
__pycache__/
*.py[cod]
.coverage
coverage.xml
htmlcov/
.mypy_cache/
.pytest_cache/
.ruff_cache/
dist/
build/
*.egg-info/
+34
View File
@@ -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"]
+13
View File
@@ -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.
+85
View File
@@ -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
+32
View File
@@ -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.
+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")
+44
View File
@@ -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:
+65
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Test package."""
+58
View File
@@ -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
+17
View File
@@ -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"