298 lines
12 KiB
Python
298 lines
12 KiB
Python
"""语言无关的专业软件 capability 契约加载与查询。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from dataclasses import dataclass
|
||
from hashlib import sha256
|
||
from pathlib import Path, PurePosixPath
|
||
from typing import Any
|
||
|
||
from jsonschema import Draft202012Validator, FormatChecker
|
||
|
||
|
||
CONTRACT_ROOT = Path(__file__).resolve().parents[1] / "software-contracts"
|
||
|
||
|
||
class SoftwareContractError(ValueError):
|
||
pass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class OutputSpec:
|
||
output_id: str
|
||
filename: str
|
||
media_type: str
|
||
relative_path: str
|
||
publish: bool
|
||
required: bool
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CapabilityContract:
|
||
capability: str
|
||
display_name: str
|
||
default_enrollment: bool
|
||
output_namespace: str
|
||
request_schema: dict[str, Any]
|
||
input_policy: dict[str, Any]
|
||
outputs: dict[str, OutputSpec]
|
||
feature_path: tuple[str, ...]
|
||
features: dict[str, str]
|
||
summary: dict[str, Any]
|
||
legacy_runtime: dict[str, Any] | None
|
||
|
||
def normalize_request(self, request: object) -> tuple[dict[str, Any], str]:
|
||
errors = sorted(
|
||
Draft202012Validator(
|
||
self.request_schema, format_checker=FormatChecker()
|
||
).iter_errors(request),
|
||
key=lambda item: list(item.absolute_path),
|
||
)
|
||
if errors:
|
||
first = errors[0]
|
||
location = ".".join(str(item) for item in first.absolute_path)
|
||
prefix = f"{location}: " if location else ""
|
||
raise SoftwareContractError(f"invalid {self.capability} request: {prefix}{first.message}")
|
||
encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||
max_bytes = int(self.request_schema.get("x-maxBytes") or 256 * 1024)
|
||
if len(encoded.encode("utf-8")) > max_bytes:
|
||
raise SoftwareContractError(f"{self.capability} request is too large")
|
||
normalized = json.loads(encoded)
|
||
return normalized, sha256(encoded.encode("utf-8")).hexdigest()
|
||
|
||
def input_bindings(self, request: dict[str, Any]) -> list[dict[str, Any]]:
|
||
return list(request.get("inputs") or [])
|
||
|
||
def expected_outputs(self, request: dict[str, Any]) -> dict[str, OutputSpec]:
|
||
requested = {
|
||
item.get("key")
|
||
for item in request.get("outputs") or []
|
||
if isinstance(item, dict) and isinstance(item.get("key"), str)
|
||
}
|
||
expected = {
|
||
output_id: spec
|
||
for output_id, spec in self.outputs.items()
|
||
if spec.required or output_id in requested
|
||
}
|
||
if requested - self.outputs.keys():
|
||
raise SoftwareContractError(f"{self.capability} request contains unknown outputs")
|
||
return expected
|
||
|
||
def output_spec(self, output_id: str) -> OutputSpec:
|
||
try:
|
||
return self.outputs[output_id]
|
||
except KeyError as exc:
|
||
raise SoftwareContractError("unsupported output artifact identity") from exc
|
||
|
||
def feature(self, request: dict[str, Any]) -> str:
|
||
value: Any = request
|
||
for part in self.feature_path:
|
||
if not isinstance(value, dict):
|
||
return ""
|
||
value = value.get(part)
|
||
return value if isinstance(value, str) else ""
|
||
|
||
def required_adapter_version(self, request: dict[str, Any]) -> str:
|
||
return self.features.get(self.feature(request), "0.0.0")
|
||
|
||
def summarize(self, request: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"display_name": self.display_name,
|
||
"title": str(_value_at(request, self.summary.get("title_path") or []) or ""),
|
||
"formats": [
|
||
item.get("format")
|
||
for item in request.get("outputs") or []
|
||
if isinstance(item, dict)
|
||
],
|
||
}
|
||
|
||
def submission_schema(self) -> dict[str, Any]:
|
||
definitions = self.request_schema.get("$defs") or {}
|
||
properties = _resolve_local_refs(
|
||
dict(self.request_schema.get("properties") or {}), definitions
|
||
)
|
||
properties.pop("schema_version", None)
|
||
required = [
|
||
item for item in self.request_schema.get("required") or []
|
||
if item != "schema_version"
|
||
]
|
||
return {
|
||
"type": "object",
|
||
"properties": properties,
|
||
"required": required,
|
||
"additionalProperties": False,
|
||
}
|
||
|
||
|
||
def _value_at(value: object, path: list[str]) -> object:
|
||
current = value
|
||
for part in path:
|
||
if not isinstance(current, dict):
|
||
return None
|
||
current = current.get(part)
|
||
return current
|
||
|
||
|
||
def _resolve_local_refs(value: Any, definitions: dict[str, Any]) -> Any:
|
||
if isinstance(value, list):
|
||
return [_resolve_local_refs(item, definitions) for item in value]
|
||
if not isinstance(value, dict):
|
||
return value
|
||
if set(value) == {"$ref"} and isinstance(value["$ref"], str):
|
||
prefix = "#/$defs/"
|
||
if not value["$ref"].startswith(prefix):
|
||
raise RuntimeError("software contract contains a non-local schema reference")
|
||
name = value["$ref"][len(prefix):]
|
||
if name not in definitions:
|
||
raise RuntimeError("software contract contains an unknown schema reference")
|
||
return _resolve_local_refs(definitions[name], definitions)
|
||
return {
|
||
key: _resolve_local_refs(item, definitions)
|
||
for key, item in value.items()
|
||
}
|
||
|
||
|
||
def _load_contract(path: Path) -> CapabilityContract:
|
||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||
required = {
|
||
"capability", "display_name", "default_enrollment", "output_namespace", "request_schema",
|
||
"input_policy", "outputs", "feature_path", "features", "summary", "legacy_runtime",
|
||
}
|
||
if not isinstance(raw, dict) or set(raw) != required:
|
||
raise RuntimeError(f"invalid software contract fields: {path.name}")
|
||
capability = raw["capability"]
|
||
namespace = raw["output_namespace"]
|
||
if not isinstance(capability, str) or not re.fullmatch(r"[a-z][a-z0-9_.-]+@v[1-9][0-9]*", capability):
|
||
raise RuntimeError(f"invalid software capability: {path.name}")
|
||
if not isinstance(namespace, str) or not re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", namespace):
|
||
raise RuntimeError(f"invalid output namespace: {path.name}")
|
||
outputs: dict[str, OutputSpec] = {}
|
||
for output_id, item in raw["outputs"].items():
|
||
if not isinstance(item, dict) or set(item) != {
|
||
"filename", "media_type", "relative_path", "publish", "required"
|
||
}:
|
||
raise RuntimeError(f"invalid output spec: {path.name}:{output_id}")
|
||
filename = item["filename"]
|
||
relative_path = item["relative_path"]
|
||
parsed_path = PurePosixPath(relative_path) if isinstance(relative_path, str) else None
|
||
if (
|
||
not isinstance(output_id, str)
|
||
or not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", output_id)
|
||
or not isinstance(filename, str)
|
||
or PurePosixPath(filename).name != filename
|
||
or parsed_path is None
|
||
or parsed_path.is_absolute()
|
||
or ".." in parsed_path.parts
|
||
or parsed_path.name != filename
|
||
or parsed_path.as_posix() != relative_path
|
||
or not isinstance(item["media_type"], str)
|
||
or not item["media_type"]
|
||
or not isinstance(item["publish"], bool)
|
||
or not isinstance(item["required"], bool)
|
||
):
|
||
raise RuntimeError(f"unsafe output spec: {path.name}:{output_id}")
|
||
outputs[output_id] = OutputSpec(output_id=output_id, **item)
|
||
if len({item.relative_path for item in outputs.values()}) != len(outputs):
|
||
raise RuntimeError(f"duplicate output paths: {path.name}")
|
||
Draft202012Validator.check_schema(raw["request_schema"])
|
||
return CapabilityContract(
|
||
capability=capability,
|
||
display_name=raw["display_name"],
|
||
default_enrollment=raw["default_enrollment"],
|
||
output_namespace=namespace,
|
||
request_schema=raw["request_schema"],
|
||
input_policy=raw["input_policy"],
|
||
outputs=outputs,
|
||
feature_path=tuple(raw["feature_path"]),
|
||
features=raw["features"],
|
||
summary=raw["summary"],
|
||
legacy_runtime=raw["legacy_runtime"],
|
||
)
|
||
|
||
|
||
_loaded_contracts = [_load_contract(path) for path in sorted(CONTRACT_ROOT.glob("*.json"))]
|
||
CONTRACTS = {contract.capability: contract for contract in _loaded_contracts}
|
||
if not CONTRACTS or len(CONTRACTS) != len(_loaded_contracts):
|
||
raise RuntimeError("software contracts are missing or contain duplicate capabilities")
|
||
SUPPORTED_CAPABILITIES = frozenset(CONTRACTS)
|
||
DEFAULT_CAPABILITIES = tuple(
|
||
item.capability for item in CONTRACTS.values() if item.default_enrollment
|
||
)
|
||
|
||
|
||
def get_contract(capability: str) -> CapabilityContract:
|
||
try:
|
||
return CONTRACTS[capability]
|
||
except KeyError as exc:
|
||
raise SoftwareContractError("unsupported capability") from exc
|
||
|
||
|
||
def version_at_least(actual: str, required: str) -> bool:
|
||
def parts(value: str) -> tuple[int, ...]:
|
||
match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?", value.strip())
|
||
return tuple(int(item or 0) for item in match.groups(default="0")) if match else (0, 0, 0)
|
||
|
||
return parts(actual) >= parts(required)
|
||
|
||
|
||
def node_supports_request(
|
||
contract: CapabilityContract,
|
||
request: dict[str, Any],
|
||
runtime: dict[str, Any],
|
||
) -> bool:
|
||
capability_runtime = (runtime.get("capability_runtime") or {}).get(contract.capability)
|
||
if isinstance(capability_runtime, dict):
|
||
if capability_runtime.get("health") != "ready":
|
||
return False
|
||
if int(capability_runtime.get("available_slots") or 0) <= 0:
|
||
return False
|
||
actual_version = str(capability_runtime.get("adapter_version") or "0.0.0")
|
||
advertised_features = capability_runtime.get("features")
|
||
feature = contract.feature(request)
|
||
if isinstance(advertised_features, list) and feature not in advertised_features:
|
||
return False
|
||
return version_at_least(actual_version, contract.required_adapter_version(request))
|
||
# 兼容尚未升级 capability_runtime 的 Node;兼容路径由版本化 contract 声明。
|
||
legacy_config = contract.legacy_runtime or {}
|
||
if not legacy_config:
|
||
return False
|
||
legacy = _value_at(runtime, legacy_config.get("detail_path") or [])
|
||
assumed_version = str(legacy_config.get("assumed_adapter_version") or "0.0.0")
|
||
slots = int(_value_at(runtime, legacy_config.get("slots_path") or []) or 0)
|
||
if legacy is None:
|
||
return (
|
||
slots > 0
|
||
and version_at_least(assumed_version, contract.required_adapter_version(request))
|
||
)
|
||
return (
|
||
isinstance(legacy, dict)
|
||
and legacy.get("health") == "ready"
|
||
and slots > 0
|
||
and version_at_least(
|
||
str(legacy.get("adapter_version") or "0.0.0"),
|
||
contract.required_adapter_version(request),
|
||
)
|
||
)
|
||
|
||
|
||
def node_available_slots(capability: str, runtime: dict[str, Any]) -> int:
|
||
contract = get_contract(capability)
|
||
item = (runtime.get("capability_runtime") or {}).get(capability)
|
||
if isinstance(item, dict) and item.get("health") == "ready":
|
||
return max(0, int(item.get("available_slots") or 0))
|
||
legacy_config = contract.legacy_runtime or {}
|
||
if not legacy_config:
|
||
return 0
|
||
legacy = _value_at(runtime, legacy_config.get("detail_path") or [])
|
||
slots = max(0, int(_value_at(runtime, legacy_config.get("slots_path") or []) or 0))
|
||
if legacy is None:
|
||
return slots
|
||
if (
|
||
isinstance(legacy, dict)
|
||
and legacy.get("health") == "ready"
|
||
):
|
||
return slots
|
||
return 0
|