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
+76
View File
@@ -0,0 +1,76 @@
"""Generate example values from Proxmox contract schemas."""
from __future__ import annotations
from app.contracts.model import Schema
_PATH_PARAM_EXAMPLES: dict[str, object] = {
"node": "pve01",
"vmid": 100,
"storage": "local",
"pool": "testpool",
"userid": "root@pam",
"tokenid": "automation",
"realm": "pam",
"group": "admins",
"role": "Administrator",
"upid": "UPID:pve01:00000001:00000001:65000001:qmstart:100:root@pam:",
"snapname": "snap1",
"volume": "local:100/vm-100-disk-0.qcow2",
"disk": "scsi0",
"iface": "net0",
"key": "cpu",
"digest": "00000000",
"name": "example",
}
def path_param_example(name: str) -> object | None:
"""Return a realistic placeholder for a common Proxmox path parameter."""
return _PATH_PARAM_EXAMPLES.get(name)
def schema_example(schema: Schema, *, name: str | None = None) -> object:
"""Build a representative example value for a contract schema."""
if schema.default is not None:
return schema.default
if schema.enum:
return schema.enum[0]
if name is not None:
hinted = path_param_example(name)
if hinted is not None:
return hinted
if "[n]" in name:
indexed = name.replace("[n]", "0")
hinted = path_param_example(indexed.rstrip("0123456789"))
if hinted is not None:
return hinted
if schema.type == "array":
if schema.items is not None:
return [schema_example(schema.items)]
return []
if schema.type == "object":
return {
key: schema_example(definition, name=key)
for key, definition in schema.properties.items()
if not definition.optional
}
if schema.type == "boolean":
return False
if schema.type == "integer":
if schema.minimum is not None:
return int(schema.minimum)
return 1
if schema.type == "number":
if schema.minimum is not None:
return float(schema.minimum)
return 1.0
if schema.type == "string" or schema.type is None:
if schema.format == "email":
return "user@example.com"
if schema.format == "uri":
return "https://example.com"
return "example"
return None
+16 -1
View File
@@ -36,12 +36,27 @@ def validate_remote_url(url: str, allowed_hosts: frozenset[str]) -> str:
return host
# Fake-IP pools used by local proxies (Clash, Surge, etc.) still route to public hosts.
_FAKE_IP_NETWORK = ipaddress.ip_network("198.18.0.0/15")
def _is_allowed_resolved_address(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
if address.is_global:
return True
mapped = address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) else None
if mapped is not None and mapped in _FAKE_IP_NETWORK:
return True
if isinstance(address, ipaddress.IPv4Address) and address in _FAKE_IP_NETWORK:
return True
return False
def validate_public_addresses(addresses: tuple[str, ...]) -> None:
if not addresses:
raise SourceError("remote host did not resolve")
for value in addresses:
address = ipaddress.ip_address(value)
if not address.is_global:
if not _is_allowed_resolved_address(address):
raise SourceError(f"remote host resolved to a non-public address: {value}")
+210
View File
@@ -0,0 +1,210 @@
"""In-memory runtime contract hot-swap helpers."""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any, cast
from fastapi import FastAPI, Request, Response
from starlette.routing import Route
from app.api.registry import (
FallbackMode,
HandlerRegistry,
register_contract_routes,
register_legacy_handler_routes,
)
from app.compatibility import (
CompatibilityDimension,
CompatibilityReport,
build_report,
load_evidence_manifest,
resolve_evidence_path,
)
from app.config import Settings
from app.contracts.model import Snapshot
_ADMIN_ROUTE_NAMES = frozenset(
{
"admin:compatibility",
"admin:compatibility.md",
"admin:compatibility.html",
}
)
def clear_contract_routes(app: FastAPI) -> None:
"""Drop previously registered contract (and optional admin) routes for rebuild."""
app.router.routes = [route for route in app.router.routes if not _is_swappable_route(route)]
app.openapi_schema = None
def _is_swappable_route(route: object) -> bool:
name = getattr(route, "name", None)
if not isinstance(name, str):
return False
return name.startswith("contract:") or name in _ADMIN_ROUTE_NAMES
def build_compatibility_for_snapshot(
snapshot: Snapshot,
handlers: HandlerRegistry,
settings: Settings,
*,
require_evidence_match: bool = False,
) -> CompatibilityReport:
"""Build a compatibility report for the active primary snapshot.
Evidence is resolved per ``snapshot.source_version``
(``evidence/pve-{version}.json``). When ``require_evidence_match`` is true
(cold start) a missing or mismatched ledger raises.
"""
declared = frozenset(
(path.path, method.verb.upper()) for path in snapshot.paths for method in path.methods
)
dimensions: dict[CompatibilityDimension, frozenset[tuple[str, str]]] = {
CompatibilityDimension.ROUTE_METHOD: declared,
}
observed: frozenset[tuple[str, str]] = frozenset()
verified: frozenset[tuple[str, str]] = frozenset()
evidence_path = resolve_evidence_path(snapshot.source_version, settings)
if evidence_path is not None:
evidence = load_evidence_manifest(evidence_path)
if evidence.source_version != snapshot.source_version:
if require_evidence_match:
raise ValueError("compatibility evidence version does not match contract")
else:
dimensions.update(evidence.dimension_map())
dimensions[CompatibilityDimension.ROUTE_METHOD] = declared
observed = evidence.observed_methods() & declared
verified = evidence.verified_methods() & declared
implemented_all = frozenset(handlers.keys())
return build_report(
snapshot,
implemented=implemented_all & declared,
observed=observed,
verified=verified,
dimensions=dimensions,
)
def apply_runtime_contract(
app: FastAPI,
snapshot: Snapshot,
*,
handlers: HandlerRegistry,
store_root: Path,
fallback: FallbackMode,
settings: Settings,
require_evidence_match: bool = False,
register_admin: bool = True,
) -> CompatibilityReport:
"""Replace `/api2/*` contract routes and refresh runtime app.state fields."""
clear_contract_routes(app)
registered = register_contract_routes(app, snapshot, handlers, fallback)
register_legacy_handler_routes(
app,
handlers,
store_root,
fallback,
primary_version=snapshot.source_version,
existing=registered,
)
report = build_compatibility_for_snapshot(
snapshot,
handlers,
settings,
require_evidence_match=require_evidence_match,
)
implemented_all = frozenset(handlers.keys())
app.state.runtime_snapshot = snapshot
app.state.runtime_source_version = snapshot.source_version
app.state.handlers = handlers
app.state.contract_store_root = store_root
app.state.implemented_methods = implemented_all
app.state.compatibility_report = report
if register_admin:
_ensure_admin_compatibility_routes(app)
return report
async def apply_runtime_contract_locked(
app: FastAPI,
snapshot: Snapshot,
*,
handlers: HandlerRegistry,
store_root: Path,
fallback: FallbackMode,
settings: Settings,
require_evidence_match: bool = False,
register_admin: bool = True,
) -> CompatibilityReport:
"""Serialize concurrent Apply calls to avoid a torn route table."""
lock = getattr(app.state, "contract_swap_lock", None)
if lock is None:
lock = asyncio.Lock()
app.state.contract_swap_lock = lock
async with lock:
return apply_runtime_contract(
app,
snapshot,
handlers=handlers,
store_root=store_root,
fallback=fallback,
settings=settings,
require_evidence_match=require_evidence_match,
register_admin=register_admin,
)
def contract_store_root(settings: Settings) -> Path:
"""Resolve the revision store root next to ``CONTRACT_SNAPSHOT``."""
if settings.contract_snapshot is None:
return Path("contracts")
snapshot_path = settings.contract_snapshot.resolve()
if snapshot_path.name == "snapshot.json" and (snapshot_path.parent / "manifest.json").is_file():
return snapshot_path.parent.parent
return snapshot_path.parent
def runtime_version_payload(request: Request) -> dict[str, str]:
"""Proxmox-shaped version payload derived from the active runtime contract."""
version = getattr(request.app.state, "runtime_source_version", None) or "0.0"
release = str(version).split("-", 1)[0]
if release.count(".") >= 2:
release = ".".join(release.split(".")[:2])
return {"version": str(version), "release": release, "repoid": "simulator"}
def _ensure_admin_compatibility_routes(app: FastAPI) -> None:
existing = {
getattr(route, "name", None) for route in app.router.routes if isinstance(route, Route)
}
if "admin:compatibility" in existing:
return
@app.get("/admin/compatibility", include_in_schema=False, name="admin:compatibility")
async def compatibility_report(request: Request) -> dict[str, Any]:
report = getattr(request.app.state, "compatibility_report", None)
if report is None:
return {}
return cast(dict[str, Any], report.as_json())
@app.get("/admin/compatibility.md", include_in_schema=False, name="admin:compatibility.md")
async def compatibility_report_markdown(request: Request) -> Response:
report = getattr(request.app.state, "compatibility_report", None)
body = report.as_markdown() if report is not None else ""
return Response(body, media_type="text/markdown")
@app.get("/admin/compatibility.html", include_in_schema=False, name="admin:compatibility.html")
async def compatibility_report_html(request: Request) -> Response:
report = getattr(request.app.state, "compatibility_report", None)
body = report.as_html() if report is not None else ""
return Response(body, media_type="text/html")
+16 -13
View File
@@ -47,9 +47,9 @@ class LocalFileImporter:
class ApiViewerParser:
"""Extract the JSON-compatible ``apiSchema`` value without executing JS."""
"""Extract the JSON-compatible schema value without executing JS."""
declaration = b"const apiSchema"
declarations = (b"const apiSchema", b"var pveapi")
known_node_fields = frozenset({"children", "info", "leaf", "path", "text"})
def parse(self, raw: bytes) -> ParsedSource:
@@ -84,18 +84,21 @@ class ApiViewerParser:
if stripped.startswith((b"[", b"{")):
return stripped
declaration_at = raw.find(self.declaration)
if declaration_at < 0:
raise SourceError("apiSchema declaration was not found")
equals_at = raw.find(b"=", declaration_at + len(self.declaration))
if equals_at < 0:
raise SourceError("apiSchema declaration has no assignment")
for declaration in self.declarations:
declaration_at = raw.find(declaration)
if declaration_at < 0:
continue
equals_at = raw.find(b"=", declaration_at + len(declaration))
if equals_at < 0:
raise SourceError("apiSchema declaration has no assignment")
start = self._next_non_space(raw, equals_at + 1)
if start >= len(raw) or raw[start] not in b"[{":
raise SourceError("apiSchema assignment must start with an array or object")
end = self._matching_end(raw, start)
return raw[start : end + 1]
start = self._next_non_space(raw, equals_at + 1)
if start >= len(raw) or raw[start] not in b"[{":
raise SourceError("apiSchema assignment must start with an array or object")
end = self._matching_end(raw, start)
return raw[start : end + 1]
raise SourceError("apiSchema declaration was not found")
@staticmethod
def _next_non_space(raw: bytes, start: int) -> int: