Initial commit: stateful OpenStack API laboratory simulator.
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Authoritative API contract ingestion and normalization."""
|
||||
@@ -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())))
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"))
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user