feat: add imported PVE contract and core endpoints

This commit is contained in:
Sergey Antropoff
2026-07-13 00:27:07 +03:00
parent adf9fb9108
commit d141994540
16 changed files with 71594 additions and 6 deletions
+3 -1
View File
@@ -8,8 +8,10 @@ DB_COMMAND_TIMEOUT_SECONDS=30
LOG_LEVEL=INFO
REQUEST_ID_HEADER=X-Request-ID
PVE_API_VERSION=9.2.3
CONTRACT_SNAPSHOT=/app/contracts/pve-9.2.3.json
CONTRACT_FALLBACK=error
TICKET_SIGNING_KEY=development-only-signing-key-change-me
SIMULATION_SEED=42
SIMULATION_TIME_SCALE=10
SIMULATOR_ADMIN_ENABLED=false
SIMULATOR_ADMIN_TOKEN=replace-with-a-long-random-secret
+1
View File
@@ -25,6 +25,7 @@ ENV PATH="/opt/venv/bin:$PATH" \
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
COPY contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json /app/contracts/pve-9.2.3.json
WORKDIR /app
USER 10001:10001
EXPOSE 8006
+8
View File
@@ -8,6 +8,11 @@ Imported methods can be registered dynamically, but no stateful Proxmox method
is claimed as compatible yet; the vertical slice is tracked in
[the implementation plan](docs/implementation-plan.md).
The bundled PVE 9.2.3 declared contract contains 444 paths and 675 methods.
Implemented semantics currently include version, ticket login, node listing and
status, and cluster resources; all other declared methods return an explicit
unsupported error.
## Development
Python 3.13 is required.
@@ -26,6 +31,9 @@ curl http://localhost:8006/health/live
curl http://localhost:8006/health/ready
make db-migrate
make seed
curl http://localhost:8006/api2/json/version
curl -X POST -d 'username=root@pam&password=secret' \
http://localhost:8006/api2/json/access/ticket
```
Database migrations are ordered SQL files applied transactionally and recorded
+1
View File
@@ -34,6 +34,7 @@ class Settings(BaseSettings):
request_id_header: str = "X-Request-ID"
contract_snapshot: Path | None = None
contract_fallback: Literal["error", "schema-default", "fixture"] = "error"
ticket_signing_key: SecretStr = SecretStr("development-only-signing-key-change-me")
@lru_cache(maxsize=1)
+3 -3
View File
@@ -53,7 +53,7 @@ def _json(value: Any) -> JsonValue:
def normalize_schema(raw: Mapping[str, Any] | None) -> Schema:
source = raw or {}
properties = source.get("properties", {})
properties = source.get("properties") or {}
normalized_properties = {
str(name): normalize_schema(cast(Mapping[str, Any], schema))
for name, schema in cast(Mapping[str, Any], properties).items()
@@ -67,7 +67,7 @@ def normalize_schema(raw: Mapping[str, Any] | None) -> Schema:
items=normalize_schema(cast(Mapping[str, Any], items))
if isinstance(items, Mapping)
else None,
enum=tuple(_json(value) for value in source.get("enum", ())),
enum=tuple(_json(value) for value in (source.get("enum") or ())),
optional=bool(source["optional"]) if "optional" in source else None,
default=_json(source.get("default")),
minimum=source.get("minimum"),
@@ -96,7 +96,7 @@ def normalize_permissions(raw: Mapping[str, Any] | None) -> Permissions | None:
def normalize_method(verb: str, raw: Mapping[str, Any]) -> Method:
parameters_raw = cast(Mapping[str, Any], raw.get("parameters", {})).get("properties", {})
parameters_raw = cast(Mapping[str, Any], raw.get("parameters") or {}).get("properties") or {}
parameters = tuple(
Parameter(name=str(name), definition=normalize_schema(cast(Mapping[str, Any], schema)))
for name, schema in sorted(cast(Mapping[str, Any], parameters_raw).items())
+1
View File
@@ -0,0 +1 @@
"""Semantic handlers for implemented Proxmox methods."""
+95
View File
@@ -0,0 +1,95 @@
"""First read/login semantic service handlers."""
from __future__ import annotations
import json
from typing import Any, cast
from fastapi import Request
from app.api.errors import ApiError
from app.api.registry import HandlerRegistry
from app.config import Settings
from app.db.pool import AsyncpgDatabase
from app.security.auth import csrf_token, issue_ticket, verify_secret
def _database(request: Request) -> AsyncpgDatabase:
return cast(AsyncpgDatabase, request.app.state.database)
def build_core_handlers(settings: Settings) -> HandlerRegistry:
registry = HandlerRegistry()
async def version(_request: Request, _inputs: dict[str, Any]) -> dict[str, str]:
return {"version": "9.2.3", "release": "9.2", "repoid": "simulator"}
async def login(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
values = cast(dict[str, Any], inputs["values"])
username = str(values["username"])
password = str(values["password"])
row = await _database(request).pool.fetchrow(
"SELECT name, password_hash FROM principals WHERE name=$1", username
)
if (
row is None
or row["password_hash"] is None
or not verify_secret(password, str(row["password_hash"]))
):
raise ApiError(401, "authentication failure")
key = settings.ticket_signing_key.get_secret_value().encode()
ticket = issue_ticket(username, key)
return {
"username": username,
"ticket": ticket,
"CSRFPreventionToken": csrf_token(ticket, key),
"cap": {"vms": {"VM.Audit": 1, "VM.PowerMgmt": 1}},
}
async def nodes(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await _database(request).pool.fetch(
"SELECT name AS node, status FROM nodes ORDER BY name"
)
return [{"node": str(row["node"]), "status": str(row["status"])} for row in rows]
async def node_status(request: Request, inputs: dict[str, Any]) -> dict[str, Any]:
node = str(cast(dict[str, Any], inputs["values"])["node"])
row = await _database(request).pool.fetchrow(
"SELECT name, status FROM nodes WHERE name=$1", node
)
if row is None:
raise ApiError(404, "node does not exist")
return {
"status": str(row["status"]),
"node": str(row["name"]),
"uptime": 0,
"cpu": 0.0,
"memory": {"used": 0, "total": 0},
}
async def resources(request: Request, _inputs: dict[str, Any]) -> list[dict[str, Any]]:
rows = await _database(request).pool.fetch(
"""SELECT r.kind AS type, r.external_id, r.state, n.name AS node
FROM resources r JOIN nodes n ON n.id=r.node_id
ORDER BY r.kind, r.external_id"""
)
result: list[dict[str, Any]] = []
for row in rows:
raw_state = row["state"]
state = json.loads(raw_state) if isinstance(raw_state, str) else dict(raw_state)
result.append(
{
"type": str(row["type"]),
"id": f"{row['type']}/{row['external_id']}",
"node": str(row["node"]),
**state,
}
)
return result
registry.register("/version", "GET", version)
registry.register("/access/ticket", "POST", login)
registry.register("/nodes", "GET", nodes)
registry.register("/nodes/{node}/status", "GET", node_status)
registry.register("/cluster/resources", "GET", resources)
return registry
+2 -1
View File
@@ -10,6 +10,7 @@ from app.api.registry import HandlerRegistry, register_contract_routes
from app.compatibility import build_report
from app.config import Settings, get_settings
from app.contracts.model import Snapshot
from app.handlers.core import build_core_handlers
from app.lifespan import DatabaseFactory, WorkerFactory, create_lifespan, default_database_factory
from app.logging import configure_logging
from app.observability.health import router as health_router
@@ -36,7 +37,7 @@ def create_app(
app.include_router(health_router)
if resolved.contract_snapshot is not None:
snapshot = Snapshot.model_validate_json(resolved.contract_snapshot.read_bytes())
resolved_handlers = handlers or HandlerRegistry()
resolved_handlers = handlers or build_core_handlers(resolved)
register_contract_routes(
app,
snapshot,
+10
View File
@@ -9,6 +9,8 @@ from dataclasses import dataclass
import asyncpg # type: ignore[import-untyped]
from asyncpg import Connection
from app.security.auth import hash_secret
NAMESPACE = uuid.UUID("c9040a72-b391-4a7e-9864-3ae46291a531")
@@ -89,6 +91,14 @@ async def apply_seed(connection: Connection, profile: SeedProfile) -> None:
for resource in profile.resources
],
)
await connection.execute(
"""INSERT INTO principals(id, name, password_hash, realm_name)
VALUES($1, 'root@pam', $2, 'pam')
ON CONFLICT (name) DO UPDATE SET password_hash=EXCLUDED.password_hash,
realm_name=EXCLUDED.realm_name""",
stable_id("principal:root@pam"),
hash_secret("secret", salt=b"pve-simulator-v1"),
)
async def seed_url(database_url: str) -> dict[str, object]:
@@ -0,0 +1 @@
{"method_count":675,"path_count":444,"raw_sha256":"f2b77b57c71f3781a0993cc5062940ef31e0843fd9a6bcfdb4de4dd2001d6d9e","snapshot_sha256":"e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1","source_version":"9.2.3"}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1
View File
@@ -24,6 +24,7 @@ services:
required: false
environment:
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
CONTRACT_SNAPSHOT: /app/contracts/pve-9.2.3.json
depends_on:
postgres:
condition: service_healthy
+17
View File
@@ -68,6 +68,23 @@ def test_unknown_schema_fields_are_retained() -> None:
assert snapshot.paths[0].methods[0].returns.extra["futureKeyword"] == {"x": 1}
def test_nullable_source_collections_normalize_as_empty() -> None:
raw = (
b'[{"path":"/nullable","info":{"GET":{"parameters":{"properties":null},'
b'"returns":{"type":"string","enum":null}}}}]'
)
snapshot, _ = normalize_snapshot(
ApiViewerParser().parse(raw),
raw=raw,
source_version="test",
retrieved_at=RETRIEVED_AT,
)
method = snapshot.paths[0].methods[0]
assert method.parameters == ()
assert method.returns.enum == ()
@given(st.dictionaries(st.text(min_size=1), st.integers(), max_size=10))
def test_canonical_json_is_independent_of_mapping_order(values: dict[str, int]) -> None:
reversed_values = dict(reversed(tuple(values.items())))
+120
View File
@@ -0,0 +1,120 @@
"""First vertical read/login handler tests."""
from datetime import UTC, datetime
from pathlib import Path
from httpx import ASGITransport, AsyncClient
from app.config import Settings
from app.contracts.model import Method, Parameter, PathContract, Schema, Snapshot
from app.main import create_app
from app.security.auth import hash_secret
class FakePool:
async def fetchrow(self, sql: str, *args: object) -> dict[str, object] | None:
if "principals" in sql and args[0] == "root@pam":
return {
"name": "root@pam",
"password_hash": hash_secret("secret", salt=b"pve-simulator-v1"),
}
if "FROM nodes" in sql and args[0] == "pve1":
return {"name": "pve1", "status": "online"}
return None
async def fetch(self, sql: str, *args: object) -> list[dict[str, object]]:
del args
if "FROM nodes" in sql:
return [{"node": "pve1", "status": "online"}]
return [
{
"type": "qemu",
"external_id": "100",
"state": '{"status":"stopped"}',
"node": "pve1",
}
]
class FakeDatabase:
pool = FakePool()
async def connect(self) -> None:
pass
async def close(self) -> None:
pass
async def is_ready(self) -> bool:
return True
def method(verb: str, name: str, parameters: tuple[Parameter, ...] = ()) -> Method:
return Method(
verb=verb,
name=name,
parameters=parameters,
returns=Schema(type="object"),
checksum=(name[0] * 64),
)
def write_snapshot(path: Path) -> None:
string = Schema(type="string")
paths = (
PathContract(path="/version", methods=(method("GET", "version"),)),
PathContract(
path="/access/ticket",
methods=(
method(
"POST",
"ticket",
(
Parameter(name="username", definition=string),
Parameter(name="password", definition=string),
),
),
),
),
PathContract(path="/nodes", methods=(method("GET", "nodes"),)),
PathContract(
path="/nodes/{node}/status",
methods=(method("GET", "status", (Parameter(name="node", definition=string),)),),
),
PathContract(path="/cluster/resources", methods=(method("GET", "resources"),)),
)
snapshot = Snapshot(
source_version="test",
retrieved_at=datetime(2026, 1, 1, tzinfo=UTC),
raw_sha256="0" * 64,
paths=paths,
path_count=len(paths),
method_count=5,
)
path.write_bytes(snapshot.canonical_bytes())
async def test_core_login_and_read_endpoints(tmp_path: Path) -> None:
snapshot_path = tmp_path / "snapshot.json"
write_snapshot(snapshot_path)
database = FakeDatabase()
app = create_app(Settings(contract_snapshot=snapshot_path), lambda _settings: database)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
login = await client.post(
"/api2/json/access/ticket",
content="username=root%40pam&password=secret",
headers={"content-type": "application/x-www-form-urlencoded"},
)
version = await client.get("/api2/json/version")
nodes = await client.get("/api2/json/nodes")
status = await client.get("/api2/json/nodes/pve1/status")
resources = await client.get("/api2/json/cluster/resources")
assert login.status_code == 200
assert login.json()["data"]["username"] == "root@pam"
assert "ticket" in login.json()["data"]
assert version.json()["data"]["release"] == "9.2"
assert nodes.json()["data"][0]["node"] == "pve1"
assert status.json()["data"]["status"] == "online"
assert resources.json()["data"][0]["type"] == "qemu"
+5 -1
View File
@@ -44,7 +44,11 @@ async def request_app(
snapshot_path.write_bytes(contract_snapshot(get_method()).canonical_bytes())
settings = Settings(contract_snapshot=snapshot_path, contract_fallback=fallback)
database = FakeDatabase(True)
app = create_app(settings, lambda _settings: database, handlers)
app = create_app(
settings,
lambda _settings: database,
handlers if handlers is not None else HandlerRegistry(),
)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
json_response = await client.get("/api2/json/version")