Add a stateful Proxmox API console and broad handler coverage beyond the

initial QEMU slice, backed by imported contracts for majors 6–9.
- Implement durable handlers for access/auth, cluster, LXC, storage, HA,
  firewall, Ceph, SDN, ACME, notifications, pools, mapping, and node ops
- Serve an interactive Web UI with catalog browsing, demo seed controls,
  and OpenAPI/help surfaces
- Bundle PVE 6.4-15, 7.4-16, and 8.4.5 contract revisions alongside 9.2.3
- Support in-memory runtime contract Apply (POST /ui/api/contract/apply)
  so /version and /api2 routes follow the selected major until restart
- Expand seed profiles (including demo-cluster), migrations 007–008, TLS
  gateway config, Compose/Makefile tooling, and compatibility evidence
- Tighten .gitignore for macOS, hidden directories (.*/), and local secrets
This commit is contained in:
Sergey Antropoff
2026-07-16 01:08:01 +03:00
parent 003ee5d634
commit 777926487b
189 changed files with 241501 additions and 944 deletions
+109
View File
@@ -0,0 +1,109 @@
"""OpenAPI tag resolution for contract-driven routes."""
from __future__ import annotations
_NODE_SECTION_LABELS: dict[str, str] = {
"qemu": "QEMU",
"lxc": "LXC",
"ceph": "Ceph",
"storage": "Storage",
"sdn": "SDN",
"firewall": "Firewall",
"apt": "APT",
"certificates": "Certificates",
"scan": "Scan",
"network": "Network",
"services": "Services",
"capabilities": "Capabilities",
"hardware": "Hardware",
"replication": "Replication",
"tasks": "Tasks",
"subscription": "Subscription",
"vzdump": "Backup",
"disks": "Disks",
"config": "Config",
"dns": "DNS",
"hosts": "Hosts",
"status": "Status",
"time": "Time",
"aplinfo": "Appliance",
}
_CLUSTER_SECTION_LABELS: dict[str, str] = {
"sdn": "SDN",
"firewall": "Firewall",
"notifications": "Notifications",
"ha": "HA",
"mapping": "Mapping",
"acme": "ACME",
"config": "Config",
"ceph": "Ceph",
"jobs": "Jobs",
"metrics": "Metrics",
"qemu": "QEMU",
"backup": "Backup",
"bulk-action": "Bulk Action",
"replication": "Replication",
"backup-info": "Backup Info",
"options": "Options",
"log": "Log",
"nextid": "Next ID",
"resources": "Resources",
"status": "Status",
"tasks": "Tasks",
}
def contract_openapi_tag(path: str) -> str:
"""Map a semantic contract path to a Swagger UI category."""
parts = [part for part in path.strip("/").split("/") if part]
if not parts or parts == ["version"]:
return "Core"
root = parts[0]
if root == "access":
return "Access"
if root == "nodes":
if len(parts) >= 3 and parts[1] == "{node}":
section = parts[2]
label = _NODE_SECTION_LABELS.get(section, section.replace("-", " ").title())
return f"Nodes · {label}"
return "Nodes"
if root == "cluster":
if len(parts) >= 2:
section = parts[1]
label = _CLUSTER_SECTION_LABELS.get(section, section.replace("-", " ").title())
return f"Cluster · {label}"
return "Cluster"
if root == "storage":
return "Storage"
if root == "pools":
return "Pools"
return root.replace("-", " ").title()
def contract_openapi_tags(path: str, renderer: str) -> list[str]:
"""Return OpenAPI tags for a contract route, including the API renderer."""
renderer_label = "API2 JSON" if renderer == "json" else "API2 ExtJS"
return [contract_openapi_tag(path), renderer_label]
def openapi_tag_metadata() -> list[dict[str, str]]:
"""Descriptions shown in Swagger UI for each tag group."""
descriptions: dict[str, str] = {
"Core": "Version and global simulator metadata.",
"Access": "Authentication, users, groups, roles, ACLs, and API tokens.",
"Nodes": "Node inventory and node-level endpoints without a resource section.",
"Storage": "Cluster-wide and node storage definitions and content.",
"Pools": "Resource pools and membership.",
"API2 JSON": "Proxmox `/api2/json` renderer routes.",
"API2 ExtJS": "Proxmox `/api2/extjs` renderer routes.",
"Simulator": "Health checks, compatibility reports, and the web console.",
}
for label in _NODE_SECTION_LABELS.values():
descriptions.setdefault(f"Nodes · {label}", f"Node-level {label} API.")
for label in _CLUSTER_SECTION_LABELS.values():
descriptions.setdefault(f"Cluster · {label}", f"Cluster-level {label} API.")
return [{"name": name, "description": text} for name, text in sorted(descriptions.items())]
+73 -20
View File
@@ -5,6 +5,8 @@ from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Literal, cast
from urllib.parse import parse_qsl
@@ -12,7 +14,9 @@ from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api.errors import ApiError, ContractValidationError
from app.api.openapi import contract_openapi_tags
from app.config import Settings
from app.contracts.examples import schema_example
from app.contracts.model import Method, Schema, Snapshot
from app.db.pool import AsyncpgDatabase
from app.security.acl import AclEntry, CapabilityRequirement, authorize, requirement_from_contract
@@ -48,18 +52,35 @@ def register_contract_routes(
snapshot: Snapshot,
handlers: HandlerRegistry,
fallback: FallbackMode = "error",
) -> None:
seen: set[tuple[str, str, str]] = set()
*,
existing: set[tuple[str, str, str]] | None = None,
require_handler: bool = False,
allow_existing: bool = False,
) -> set[tuple[str, str, str]]:
"""Register `/api2/{json,extjs}` routes for a contract snapshot.
When ``allow_existing`` is true, path/verb pairs already present in
``existing`` are skipped (used to merge older majors onto a primary contract).
When ``require_handler`` is true, only methods with a registered semantic
handler are added — used for legacy-path aliases.
"""
seen = existing if existing is not None else set()
for contract_path in snapshot.paths:
for contract_method in contract_path.methods:
if require_handler and handlers.get(contract_path.path, contract_method.verb) is None:
continue
for renderer in ("json", "extjs"):
route = f"/api2/{renderer}{contract_path.path}"
key = (route, contract_method.verb, renderer)
if key in seen:
if allow_existing:
continue
raise RouteCollisionError(
f"duplicate contract route: {contract_method.verb} {route}"
)
seen.add(key)
implemented = handlers.get(contract_path.path, contract_method.verb) is not None
endpoint = _endpoint(
contract_path.path,
contract_method,
@@ -72,8 +93,48 @@ def register_contract_routes(
endpoint,
methods=[contract_method.verb],
name=f"contract:{renderer}:{contract_method.verb}:{contract_path.path}",
openapi_extra={"x-proxmox-method-checksum": contract_method.checksum},
tags=cast(
list[str | Enum], contract_openapi_tags(contract_path.path, renderer)
),
openapi_extra={
"x-proxmox-method-checksum": contract_method.checksum,
"x-proxmox-implementation": "implemented" if implemented else "unsupported",
},
)
return seen
def register_legacy_handler_routes(
app: FastAPI,
handlers: HandlerRegistry,
store_root: Path,
fallback: FallbackMode = "error",
*,
primary_version: str | None = None,
existing: set[tuple[str, str, str]] | None = None,
) -> set[tuple[str, str, str]]:
"""Expose handler-backed paths declared only in older cached contracts."""
seen = existing if existing is not None else set()
if not store_root.is_dir():
return seen
for revision_dir in sorted(store_root.iterdir()):
snapshot_path = revision_dir / "snapshot.json"
if not snapshot_path.is_file():
continue
snapshot = Snapshot.model_validate_json(snapshot_path.read_bytes())
if primary_version and snapshot.source_version == primary_version:
continue
seen = register_contract_routes(
app,
snapshot,
handlers,
fallback,
existing=seen,
require_handler=True,
allow_existing=True,
)
return seen
def _endpoint(
@@ -90,13 +151,13 @@ def _endpoint(
if handler is not None:
data = await handler(request, inputs)
elif fallback == "schema-default":
data = _schema_default(method.returns)
data = schema_example(method.returns)
elif fallback == "fixture" and "fixture" in method.extra:
data = method.extra["fixture"]
else:
return JSONResponse(
status_code=501,
content={"data": None, "errors": "method semantics are not implemented"},
content={"data": None, "errors": "handler pending for this contract method"},
)
content = {"data": data, "success": True} if renderer == "extjs" else {"data": data}
response = JSONResponse(content)
@@ -177,6 +238,8 @@ async def _authorize(
)
if requirement is None and semantic_path == "/nodes/{node}/qemu" and method.verb == "POST":
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
if requirement is None and semantic_path == "/nodes/{node}/lxc" and method.verb == "POST":
requirement = CapabilityRequirement(f"/vms/{values['vmid']}", frozenset({"VM.Allocate"}))
if requirement is None:
return
rows = await database.pool.fetch(
@@ -264,6 +327,10 @@ async def _parse_inputs(request: Request, method: Method) -> dict[str, Any]:
errors[name] = "property is not defined in schema"
if errors:
raise ContractValidationError(dict(sorted(errors.items())))
# Path params are always available to handlers even when omitted from the
# method property schema (common for Proxmox nested resources).
for name, value in request.path_params.items():
parsed.setdefault(name, value)
return {
"values": parsed,
"path": dict(request.path_params),
@@ -305,18 +372,4 @@ def _coerce(value: Any, schema: Schema) -> Any:
def _schema_default(schema: Schema) -> Any:
if schema.default is not None:
return schema.default
if schema.type == "array":
return []
if schema.type == "object":
return {
name: _schema_default(definition)
for name, definition in schema.properties.items()
if not definition.optional
}
if schema.type == "boolean":
return False
if schema.type in {"integer", "number"}:
return 0
return None
return schema_example(schema)