Initial commit: VMware vSphere API simulator scaffold.

Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API
contracts, docs, client examples, and the unit/integration/compatibility
test suite for local client and tooling labs without a real vCenter.
This commit is contained in:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Authoritative API contract ingestion and normalization."""
+87
View File
@@ -0,0 +1,87 @@
"""Command-line interface for contract imports and inspection."""
from __future__ import annotations
import argparse
import asyncio
import json
from datetime import UTC, datetime
from pathlib import Path
from app.contracts.diff import (
compare_snapshots,
has_breaking_changes,
render_html,
render_json,
render_markdown,
render_text,
)
from app.contracts.importer import RemoteSourceImporter
from app.contracts.model import Snapshot
from app.contracts.normalize import normalize_snapshot
from app.contracts.source import ApiViewerParser, LocalFileImporter, SourceImporter
from app.contracts.store import RevisionStore
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(prog="proxmox-api-contract")
root.add_argument("--store", type=Path, default=Path("contracts"))
commands = root.add_subparsers(dest="command", required=True)
import_command = commands.add_parser("import")
source = import_command.add_mutually_exclusive_group(required=True)
source.add_argument("--file", type=Path)
source.add_argument("--url")
import_command.add_argument("--version", required=True)
validate = commands.add_parser("validate")
validate.add_argument("file", type=Path)
commands.add_parser("list")
show = commands.add_parser("show")
show.add_argument("revision")
diff = commands.add_parser("diff")
diff.add_argument("before", type=Path)
diff.add_argument("after", type=Path)
diff.add_argument("--format", choices=("text", "json", "markdown", "html"), default="text")
return root
async def run(arguments: argparse.Namespace) -> int:
store = RevisionStore(arguments.store)
if arguments.command == "list":
for revision in store.list():
print(revision)
return 0
if arguments.command == "show":
print(json.dumps(store.manifest(arguments.revision).model_dump(mode="json"), indent=2))
return 0
if arguments.command == "diff":
before = Snapshot.model_validate_json(arguments.before.read_bytes())
after = Snapshot.model_validate_json(arguments.after.read_bytes())
changes = compare_snapshots(before, after)
renderers = {
"text": render_text,
"json": render_json,
"markdown": render_markdown,
"html": render_html,
}
print(renderers[arguments.format](changes))
return 1 if has_breaking_changes(changes) else 0
if arguments.command == "validate":
parsed = ApiViewerParser().parse(arguments.file.read_bytes())
print(json.dumps({"nodes": len(parsed.nodes), "warnings": len(parsed.warnings)}))
return 0
importer: SourceImporter
if arguments.file is not None:
importer = LocalFileImporter(arguments.file)
else:
importer = RemoteSourceImporter(arguments.url)
raw = await importer.load()
parsed = ApiViewerParser().parse(raw)
snapshot, manifest = normalize_snapshot(
parsed, raw=raw, source_version=arguments.version, retrieved_at=datetime.now(UTC)
)
print(store.save(raw, snapshot, manifest))
return 0
def main() -> None:
raise SystemExit(asyncio.run(run(parser().parse_args())))
+224
View File
@@ -0,0 +1,224 @@
"""Deterministic semantic differences between normalized snapshots."""
from __future__ import annotations
import html
import json
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from app.contracts.model import Method, Parameter, PathContract, Snapshot
class Severity(StrEnum):
BREAKING = "breaking"
NON_BREAKING = "non-breaking"
DOCUMENTATION = "documentation"
@dataclass(frozen=True, slots=True, order=True)
class Change:
path: str
method: str
category: str
severity: Severity
detail: str
before: str | None = None
after: str | None = None
def _stable(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def _methods(snapshot: Snapshot) -> dict[tuple[str, str], Method]:
return {(path.path, method.verb): method for path in snapshot.paths for method in path.methods}
def _paths(snapshot: Snapshot) -> dict[str, PathContract]:
return {path.path: path for path in snapshot.paths}
def compare_snapshots(before: Snapshot, after: Snapshot) -> tuple[Change, ...]:
changes: list[Change] = []
old_paths, new_paths = _paths(before), _paths(after)
for path in sorted(old_paths.keys() - new_paths.keys()):
changes.append(Change(path, "", "path", Severity.BREAKING, "path removed"))
for path in sorted(new_paths.keys() - old_paths.keys()):
changes.append(Change(path, "", "path", Severity.NON_BREAKING, "path added"))
old_methods, new_methods = _methods(before), _methods(after)
for path, verb in sorted(old_methods.keys() - new_methods.keys()):
changes.append(Change(path, verb, "method", Severity.BREAKING, "method removed"))
for path, verb in sorted(new_methods.keys() - old_methods.keys()):
changes.append(Change(path, verb, "method", Severity.NON_BREAKING, "method added"))
for key in sorted(old_methods.keys() & new_methods.keys()):
_compare_method(key, old_methods[key], new_methods[key], changes)
return tuple(sorted(changes))
def _compare_method(
key: tuple[str, str], before: Method, after: Method, changes: list[Change]
) -> None:
path, verb = key
if before.description != after.description:
changes.append(
Change(
path,
verb,
"documentation",
Severity.DOCUMENTATION,
"description changed",
before.description,
after.description,
)
)
if before.permissions != after.permissions:
changes.append(
Change(
path,
verb,
"permissions",
Severity.BREAKING,
"permissions changed",
_stable(before.permissions.model_dump(mode="json") if before.permissions else None),
_stable(after.permissions.model_dump(mode="json") if after.permissions else None),
)
)
_compare_parameters(path, verb, before.parameters, after.parameters, changes)
_compare_schema(
path,
verb,
"returns",
before.returns.model_dump(mode="json"),
after.returns.model_dump(mode="json"),
changes,
)
def _compare_parameters(
path: str,
verb: str,
before: tuple[Parameter, ...],
after: tuple[Parameter, ...],
changes: list[Change],
) -> None:
old = {parameter.name: parameter for parameter in before}
new = {parameter.name: parameter for parameter in after}
for name in sorted(old.keys() - new.keys()):
changes.append(Change(path, verb, "parameter", Severity.BREAKING, f"removed: {name}"))
for name in sorted(new.keys() - old.keys()):
severity = Severity.NON_BREAKING if new[name].definition.optional else Severity.BREAKING
changes.append(Change(path, verb, "parameter", severity, f"added: {name}"))
for name in sorted(old.keys() & new.keys()):
_compare_schema(
path,
verb,
f"parameter:{name}",
old[name].definition.model_dump(mode="json"),
new[name].definition.model_dump(mode="json"),
changes,
)
def _compare_schema(
path: str,
verb: str,
label: str,
before: dict[str, Any],
after: dict[str, Any],
changes: list[Change],
) -> None:
groups = {
"schema": {"type", "properties", "items", "enum", "format", "pattern"},
"default": {"default", "optional"},
"constraint": {"minimum", "maximum", "min_length", "max_length"},
"documentation": {"description"},
}
for category, fields in groups.items():
old = {field: before.get(field) for field in fields}
new = {field: after.get(field) for field in fields}
if old != new:
severity = Severity.DOCUMENTATION if category == "documentation" else Severity.BREAKING
changes.append(
Change(
path,
verb,
category,
severity,
f"{label} {category} changed",
_stable(old),
_stable(new),
)
)
def render_json(changes: tuple[Change, ...]) -> str:
return json.dumps(
[
{
"after": change.after,
"before": change.before,
"category": change.category,
"detail": change.detail,
"method": change.method,
"path": change.path,
"severity": change.severity,
}
for change in changes
],
ensure_ascii=False,
indent=2,
)
def render_text(changes: tuple[Change, ...]) -> str:
return "\n".join(
(
f"{change.severity}: {change.method} {change.path} [{change.category}] {change.detail}"
).strip()
for change in changes
)
def render_markdown(changes: tuple[Change, ...]) -> str:
lines = [
"# API contract diff",
"",
"| Severity | Method | Path | Category | Detail |",
"|---|---|---|---|---|",
]
lines.extend(
(
f"| {change.severity} | {change.method} | `{change.path}` | "
f"{change.category} | {change.detail} |"
)
for change in changes
)
return "\n".join(lines)
def render_html(changes: tuple[Change, ...]) -> str:
rows = "".join(
"<tr>"
+ "".join(
f"<td>{html.escape(str(value))}</td>"
for value in (
change.severity,
change.method,
change.path,
change.category,
change.detail,
)
)
+ "</tr>"
for change in changes
)
return (
f"<!doctype html><meta charset=utf-8><title>API contract diff</title><table>{rows}</table>"
)
def has_breaking_changes(changes: tuple[Change, ...]) -> bool:
return any(change.severity is Severity.BREAKING for change in changes)
+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
+110
View File
@@ -0,0 +1,110 @@
"""Network-constrained remote contract retrieval."""
from __future__ import annotations
import asyncio
import ipaddress
import socket
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from urllib.parse import urljoin, urlsplit
import httpx
from app.contracts.source import SourceError
Resolver = Callable[[str], Awaitable[tuple[str, ...]]]
async def resolve_host(host: str) -> tuple[str, ...]:
loop = asyncio.get_running_loop()
results = await loop.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
return tuple(sorted({str(result[4][0]) for result in results}))
def validate_remote_url(url: str, allowed_hosts: frozenset[str]) -> str:
parsed = urlsplit(url)
if parsed.scheme != "https":
raise SourceError("remote imports require HTTPS")
if parsed.username or parsed.password or parsed.port not in (None, 443):
raise SourceError("remote URL contains forbidden authority components")
host = (parsed.hostname or "").rstrip(".").lower()
if host not in allowed_hosts:
raise SourceError("remote host is not in the official-domain allowlist")
if parsed.fragment:
raise SourceError("remote URL fragments are not allowed")
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 _is_allowed_resolved_address(address):
raise SourceError(f"remote host resolved to a non-public address: {value}")
@dataclass(frozen=True, slots=True)
class RemoteSourceImporter:
url: str
allowed_hosts: frozenset[str] = frozenset({"pve.proxmox.com"})
max_bytes: int = 16 * 1024 * 1024
max_redirects: int = 3
retries: int = 2
timeout_seconds: float = 20.0
resolver: Resolver = resolve_host
transport: httpx.AsyncBaseTransport | None = None
async def load(self) -> bytes:
current = self.url
timeout = httpx.Timeout(self.timeout_seconds)
async with httpx.AsyncClient(
follow_redirects=False, timeout=timeout, transport=self.transport
) as client:
for redirect_count in range(self.max_redirects + 1):
host = validate_remote_url(current, self.allowed_hosts)
validate_public_addresses(await self.resolver(host))
response = await self._request(client, current)
if response.is_redirect:
if redirect_count == self.max_redirects:
raise SourceError("remote import exceeded redirect limit")
location = response.headers.get("location")
if not location:
raise SourceError("remote redirect has no location")
current = urljoin(current, location)
continue
response.raise_for_status()
content_length = response.headers.get("content-length")
if content_length and int(content_length) > self.max_bytes:
raise SourceError("remote artifact exceeds size limit")
content = response.content
if len(content) > self.max_bytes:
raise SourceError("remote artifact exceeds size limit")
return content
raise SourceError("remote import failed")
async def _request(self, client: httpx.AsyncClient, url: str) -> httpx.Response:
for attempt in range(self.retries + 1):
try:
return await client.get(url)
except (httpx.TimeoutException, httpx.NetworkError):
if attempt == self.retries:
raise
await asyncio.sleep(0.1 * (2**attempt))
raise SourceError("remote import retry loop exhausted")
+134
View File
@@ -0,0 +1,134 @@
"""Immutable normalized representation of Proxmox API contracts."""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any, Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
type JsonValue = None | bool | int | float | str | list[JsonValue] | dict[str, JsonValue]
def canonical_json(value: BaseModel | Mapping[str, Any] | Sequence[Any]) -> bytes:
"""Serialize a JSON-compatible value deterministically as UTF-8."""
data: Any
if isinstance(value, BaseModel):
data = value.model_dump(mode="json", exclude_none=True)
else:
data = value
return json.dumps(
data,
default=_json_default,
ensure_ascii=False,
allow_nan=False,
sort_keys=True,
separators=(",", ":"),
).encode()
def _json_default(value: object) -> Any:
if isinstance(value, BaseModel):
return value.model_dump(mode="json", exclude_none=True)
if isinstance(value, datetime):
return value.isoformat()
raise TypeError(f"{type(value).__name__} is not JSON serializable")
def sha256(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
class FrozenModel(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
class Schema(FrozenModel):
"""Proxmox's JSON-Schema-like dialect with retained extensions."""
type: str | None = None
description: str | None = None
properties: dict[str, Schema] = Field(default_factory=dict)
items: Schema | None = None
enum: tuple[JsonValue, ...] = ()
optional: bool | None = None
default: JsonValue = None
minimum: int | float | None = None
maximum: int | float | None = None
min_length: int | None = None
max_length: int | None = None
pattern: str | None = None
format: str | dict[str, JsonValue] | None = None
extra: dict[str, JsonValue] = Field(default_factory=dict)
class Parameter(FrozenModel):
name: str
definition: Schema
class Permissions(FrozenModel):
user: str | None = None
description: str | None = None
expression: dict[str, JsonValue] = Field(default_factory=dict)
extra: dict[str, JsonValue] = Field(default_factory=dict)
class Method(FrozenModel):
verb: str
name: str
description: str | None = None
parameters: tuple[Parameter, ...] = ()
returns: Schema = Field(default_factory=Schema)
permissions: Permissions | None = None
protected: bool = False
allow_token: bool | None = None
extra: dict[str, JsonValue] = Field(default_factory=dict)
checksum: str
class PathContract(FrozenModel):
path: str
methods: tuple[Method, ...]
extra: dict[str, JsonValue] = Field(default_factory=dict)
class Snapshot(FrozenModel):
format_version: int = 1
source_version: str
retrieved_at: datetime
raw_sha256: str
paths: tuple[PathContract, ...]
path_count: int
method_count: int
extra: dict[str, JsonValue] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_counts_and_uniqueness(self) -> Self:
if self.path_count != len(self.paths):
raise ValueError("path_count does not match paths")
methods = sum(len(path.methods) for path in self.paths)
if self.method_count != methods:
raise ValueError("method_count does not match methods")
keys = [(path.path, method.verb) for path in self.paths for method in path.methods]
if len(keys) != len(set(keys)):
raise ValueError("duplicate path and method")
return self
def canonical_bytes(self) -> bytes:
return canonical_json(self)
def checksum(self) -> str:
return sha256(self.canonical_bytes())
class Manifest(FrozenModel):
source_version: str
raw_sha256: str
snapshot_sha256: str
path_count: int
method_count: int
+168
View File
@@ -0,0 +1,168 @@
"""Normalize parsed API Viewer trees into stable contract models."""
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
from typing import Any, cast
from app.contracts.model import (
JsonValue,
Manifest,
Method,
Parameter,
PathContract,
Permissions,
Schema,
Snapshot,
canonical_json,
sha256,
)
from app.contracts.source import ParsedSource
SCHEMA_FIELDS = {
"type",
"description",
"properties",
"items",
"enum",
"optional",
"default",
"minimum",
"maximum",
"minLength",
"maxLength",
"pattern",
"format",
}
METHOD_FIELDS = {
"allowtoken",
"description",
"method",
"name",
"parameters",
"permissions",
"protected",
"returns",
}
def _json(value: Any) -> JsonValue:
return cast(JsonValue, value)
def normalize_schema(raw: Mapping[str, Any] | None) -> Schema:
source = raw or {}
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()
}
items = source.get("items")
extra = {key: _json(value) for key, value in source.items() if key not in SCHEMA_FIELDS}
return Schema(
type=source.get("type"),
description=source.get("description"),
properties=normalized_properties,
items=normalize_schema(cast(Mapping[str, Any], items))
if isinstance(items, Mapping)
else None,
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"),
maximum=source.get("maximum"),
min_length=source.get("minLength"),
max_length=source.get("maxLength"),
pattern=source.get("pattern"),
format=_json(source.get("format")),
extra=extra,
)
def normalize_permissions(raw: Mapping[str, Any] | None) -> Permissions | None:
if raw is None:
return None
known = {"user", "description"}
expression_keys = {"and", "or", "check", "userParam"}
return Permissions(
user=raw.get("user"),
description=raw.get("description"),
expression={key: _json(raw[key]) for key in expression_keys if key in raw},
extra={
key: _json(value) for key, value in raw.items() if key not in known | expression_keys
},
)
def normalize_method(verb: str, raw: Mapping[str, Any]) -> Method:
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())
)
values: dict[str, Any] = {
"verb": verb.upper(),
"name": str(raw.get("name", verb.lower())),
"description": raw.get("description"),
"parameters": parameters,
"returns": normalize_schema(cast(Mapping[str, Any] | None, raw.get("returns"))),
"permissions": normalize_permissions(
cast(Mapping[str, Any] | None, raw.get("permissions"))
),
"protected": bool(raw.get("protected", False)),
"allow_token": bool(raw["allowtoken"]) if "allowtoken" in raw else None,
"extra": {key: _json(value) for key, value in raw.items() if key not in METHOD_FIELDS},
}
checksum = sha256(canonical_json(values))
return Method(**values, checksum=checksum)
def _walk(nodes: tuple[dict[str, Any], ...]) -> list[PathContract]:
paths: list[PathContract] = []
def visit(node: Mapping[str, Any]) -> None:
info = node.get("info")
path = node.get("path")
if isinstance(info, Mapping) and isinstance(path, str):
methods = tuple(
normalize_method(str(verb), cast(Mapping[str, Any], method))
for verb, method in sorted(info.items())
if isinstance(method, Mapping)
)
extra = {
key: _json(value)
for key, value in node.items()
if key not in {"children", "info", "leaf", "path", "text"}
}
paths.append(PathContract(path=path, methods=methods, extra=extra))
for child in node.get("children", ()):
if isinstance(child, Mapping):
visit(child)
for root in nodes:
visit(root)
return sorted(paths, key=lambda item: item.path)
def normalize_snapshot(
parsed: ParsedSource, *, raw: bytes, source_version: str, retrieved_at: datetime
) -> tuple[Snapshot, Manifest]:
paths = tuple(_walk(parsed.nodes))
snapshot = Snapshot(
source_version=source_version,
retrieved_at=retrieved_at,
raw_sha256=sha256(raw),
paths=paths,
path_count=len(paths),
method_count=sum(len(path.methods) for path in paths),
extra={"warning_count": len(parsed.warnings)},
)
manifest = Manifest(
source_version=source_version,
raw_sha256=snapshot.raw_sha256,
snapshot_sha256=snapshot.checksum(),
path_count=snapshot.path_count,
method_count=snapshot.method_count,
)
return snapshot, manifest
+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")
+154
View File
@@ -0,0 +1,154 @@
"""Safe source adapters for Proxmox API Viewer artifacts."""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol, cast
class SourceError(ValueError):
"""Raised when an API source cannot be parsed safely."""
@dataclass(frozen=True, slots=True)
class ParseWarning:
"""A recoverable variation found in a source artifact."""
code: str
path: str
message: str
@dataclass(frozen=True, slots=True)
class ParsedSource:
"""Parsed source tree with non-fatal diagnostics."""
nodes: tuple[dict[str, Any], ...]
warnings: tuple[ParseWarning, ...] = ()
class SourceImporter(Protocol):
"""Asynchronous boundary for obtaining source artifact bytes."""
async def load(self) -> bytes: ...
@dataclass(frozen=True, slots=True)
class LocalFileImporter:
"""Load an artifact from a caller-selected local path."""
path: Path
async def load(self) -> bytes:
return self.path.read_bytes()
class ApiViewerParser:
"""Extract the JSON-compatible schema value without executing JS."""
declarations = (b"const apiSchema", b"var pveapi")
known_node_fields = frozenset({"children", "info", "leaf", "path", "text"})
def parse(self, raw: bytes) -> ParsedSource:
if not raw.strip():
raise SourceError("source artifact is empty")
payload = self._extract_payload(raw)
try:
decoded = json.loads(payload)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SourceError(f"invalid apiSchema JSON: {exc}") from exc
if isinstance(decoded, Mapping):
raw_nodes = [decoded]
elif isinstance(decoded, list):
raw_nodes = decoded
else:
raise SourceError("apiSchema must be an object or array of objects")
nodes: list[dict[str, Any]] = []
warnings: list[ParseWarning] = []
for index, value in enumerate(raw_nodes):
if not isinstance(value, Mapping):
raise SourceError(f"apiSchema node /{index} must be an object")
node = cast(dict[str, Any], dict(value))
nodes.append(node)
self._inspect_node(node, f"/{index}", warnings)
return ParsedSource(tuple(nodes), tuple(warnings))
def _extract_payload(self, raw: bytes) -> bytes:
stripped = raw.strip()
if stripped.startswith((b"[", b"{")):
return stripped
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]
raise SourceError("apiSchema declaration was not found")
@staticmethod
def _next_non_space(raw: bytes, start: int) -> int:
while start < len(raw) and raw[start] in b" \t\r\n":
start += 1
return start
@staticmethod
def _matching_end(raw: bytes, start: int) -> int:
opening = raw[start]
closing = ord("]") if opening == ord("[") else ord("}")
depth = 0
in_string = False
escaped = False
for index in range(start, len(raw)):
byte = raw[index]
if in_string:
if escaped:
escaped = False
elif byte == ord("\\"):
escaped = True
elif byte == ord('"'):
in_string = False
continue
if byte == ord('"'):
in_string = True
elif byte == opening:
depth += 1
elif byte == closing:
depth -= 1
if depth == 0:
return index
raise SourceError("apiSchema assignment is truncated")
def _inspect_node(
self, node: Mapping[str, Any], path: str, warnings: list[ParseWarning]
) -> None:
for field in sorted(node.keys() - self.known_node_fields):
warnings.append(
ParseWarning("unknown-node-field", f"{path}/{field}", "field was preserved")
)
children = node.get("children", [])
if not isinstance(children, list):
warnings.append(
ParseWarning("invalid-children", f"{path}/children", "expected an array")
)
return
for index, child in enumerate(children):
child_path = f"{path}/children/{index}"
if isinstance(child, Mapping):
self._inspect_node(child, child_path, warnings)
else:
warnings.append(ParseWarning("invalid-child", child_path, "expected an object"))
+48
View File
@@ -0,0 +1,48 @@
"""Immutable filesystem storage for imported contract revisions."""
from __future__ import annotations
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from app.contracts.model import Manifest, Snapshot, canonical_json
@dataclass(frozen=True, slots=True)
class RevisionStore:
root: Path
def save(self, raw: bytes, snapshot: Snapshot, manifest: Manifest) -> Path:
revision = self.root / manifest.snapshot_sha256
if revision.exists():
return revision
self.root.mkdir(parents=True, exist_ok=True)
temporary = Path(tempfile.mkdtemp(prefix=".import-", dir=self.root))
try:
self._write(temporary / "raw.js", raw)
self._write(temporary / "snapshot.json", snapshot.canonical_bytes())
self._write(temporary / "manifest.json", canonical_json(manifest))
os.replace(temporary, revision)
except BaseException:
for child in temporary.iterdir():
child.unlink()
temporary.rmdir()
raise
return revision
def list(self) -> tuple[str, ...]:
if not self.root.exists():
return ()
return tuple(sorted(path.name for path in self.root.iterdir() if path.is_dir()))
def manifest(self, revision: str) -> Manifest:
return Manifest.model_validate_json((self.root / revision / "manifest.json").read_bytes())
@staticmethod
def _write(path: Path, content: bytes) -> None:
with path.open("xb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())