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:
@@ -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
|
||||
@@ -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}")
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user