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