feat: add imported PVE contract and core endpoints
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Semantic handlers for implemented Proxmox methods."""
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
|
||||
+1
@@ -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
+1
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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())))
|
||||
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user