6033967e6a
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
135 lines
3.9 KiB
Python
135 lines
3.9 KiB
Python
"""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
|