feat: add imported PVE contract and core endpoints
This commit is contained in:
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user