feat(device-plugins): add profiled K1 lifecycle and canonical data plane
This commit is contained in:
+15
-2
@@ -6,7 +6,9 @@ from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import ValidationError
|
||||
|
||||
@@ -23,6 +25,7 @@ from k1link.web.plugin_runtime import (
|
||||
)
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
||||
|
||||
|
||||
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||
@@ -48,6 +51,16 @@ app = FastAPI(
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def request_validation_error_handler(
|
||||
_: Request,
|
||||
__: RequestValidationError,
|
||||
) -> JSONResponse:
|
||||
"""Return validation failures without reflecting request values or credentials."""
|
||||
|
||||
return JSONResponse(status_code=422, content={"detail": INVALID_REQUEST_DETAIL})
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, Any]:
|
||||
return {
|
||||
@@ -86,7 +99,7 @@ async def invoke_device_plugin_action(
|
||||
except (PluginNotFoundError, PluginActionNotFoundError) as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=422, detail=INVALID_REQUEST_DETAIL) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except PluginExecutionError as exc:
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
OperationStatus = Literal[
|
||||
"accepted",
|
||||
"running",
|
||||
"operator_action_required",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"timed_out",
|
||||
"interrupted",
|
||||
]
|
||||
AcquisitionState = Literal[
|
||||
"preparing",
|
||||
"prepared",
|
||||
"awaiting_external_start",
|
||||
"starting",
|
||||
"acquiring",
|
||||
"awaiting_external_stop",
|
||||
"stopping",
|
||||
"finalizing",
|
||||
"completed",
|
||||
"failed",
|
||||
"aborted",
|
||||
"interrupted",
|
||||
]
|
||||
ControlMode = Literal["operator-manual", "plugin-commanded", "observe-only"]
|
||||
|
||||
TERMINAL_OPERATION_STATUSES: frozenset[OperationStatus] = frozenset(
|
||||
{"succeeded", "failed", "cancelled", "timed_out", "interrupted"}
|
||||
)
|
||||
TERMINAL_ACQUISITION_STATES: frozenset[AcquisitionState] = frozenset(
|
||||
{"completed", "failed", "aborted", "interrupted"}
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
return value.isoformat().replace("+00:00", "Z") if value is not None else None
|
||||
|
||||
|
||||
def _identifier(value: str | None, *, prefix: str) -> str:
|
||||
if value is None:
|
||||
return f"{prefix}-{uuid4()}"
|
||||
candidate = value.strip()
|
||||
if not candidate or len(candidate) > 128:
|
||||
raise ValueError(f"{prefix} id must contain 1..128 characters")
|
||||
try:
|
||||
UUID(candidate.removeprefix(f"{prefix}-"))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{prefix} id must be a generated UUID identifier") from exc
|
||||
return candidate
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OperationRecord:
|
||||
operation_id: str
|
||||
action: str
|
||||
status: OperationStatus
|
||||
accepted_at: datetime
|
||||
device_id: str | None = None
|
||||
device_session_id: str | None = None
|
||||
idempotency_key: str | None = None
|
||||
deadline_at: datetime | None = None
|
||||
stage_code: str = "accepted"
|
||||
message_code: str = "operation.accepted"
|
||||
sequence: int = 1
|
||||
state_revision: int = 1
|
||||
completed_at: datetime | None = None
|
||||
cancellable: bool = False
|
||||
cancel_requested: bool = False
|
||||
result: dict[str, Any] | None = None
|
||||
error: dict[str, Any] | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
# A keyed, non-reversible digest supplied by the service. It is deliberately
|
||||
# excluded from API snapshots: callers only need mismatch detection, while
|
||||
# the journal must never retain action inputs or secret material.
|
||||
request_fingerprint: str | None = field(default=None, repr=False)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.operation-snapshot/v1alpha2",
|
||||
"operation_id": self.operation_id,
|
||||
"action": self.action,
|
||||
"status": self.status,
|
||||
"accepted_at": _iso(self.accepted_at),
|
||||
"completed_at": _iso(self.completed_at),
|
||||
"deadline_at": _iso(self.deadline_at),
|
||||
"device_id": self.device_id,
|
||||
"device_session_id": self.device_session_id,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"stage_code": self.stage_code,
|
||||
"message_code": self.message_code,
|
||||
"sequence": self.sequence,
|
||||
"state_revision": self.state_revision,
|
||||
"cancellable": self.cancellable,
|
||||
"cancel_requested": self.cancel_requested,
|
||||
"result": dict(self.result) if self.result is not None else None,
|
||||
"error": dict(self.error) if self.error is not None else None,
|
||||
"evidence_refs": list(self.evidence_refs),
|
||||
}
|
||||
|
||||
|
||||
class OperationJournal:
|
||||
"""Bounded, secret-free operation journal for one in-process plugin runtime.
|
||||
|
||||
The journal deliberately stores lifecycle metadata only. Action inputs, BLE
|
||||
frames, MQTT payloads and credentials never enter operation events.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_records: int = 128,
|
||||
clock: Callable[[], datetime] = utc_now,
|
||||
) -> None:
|
||||
if max_records < 1:
|
||||
raise ValueError("max_records must be positive")
|
||||
self._max_records = max_records
|
||||
self._clock = clock
|
||||
self._lock = threading.Lock()
|
||||
self._records: dict[str, OperationRecord] = {}
|
||||
self._order: list[str] = []
|
||||
self._idempotency: dict[str, str] = {}
|
||||
|
||||
def begin(
|
||||
self,
|
||||
action: str,
|
||||
*,
|
||||
operation_id: str | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
device_id: str | None = None,
|
||||
device_session_id: str | None = None,
|
||||
deadline_seconds: float | None = None,
|
||||
cancellable: bool = False,
|
||||
request_fingerprint: str | None = None,
|
||||
) -> tuple[OperationRecord, bool]:
|
||||
action = action.strip()
|
||||
if not action:
|
||||
raise ValueError("operation action cannot be blank")
|
||||
if idempotency_key is not None:
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise ValueError("idempotency key must contain 1..160 characters")
|
||||
if deadline_seconds is not None and not 0 < deadline_seconds <= 86_400:
|
||||
raise ValueError("operation deadline must be within 1..86400 seconds")
|
||||
|
||||
with self._lock:
|
||||
if idempotency_key is not None and idempotency_key in self._idempotency:
|
||||
existing_idempotent = self._records[self._idempotency[idempotency_key]]
|
||||
if existing_idempotent.action != action:
|
||||
raise ValueError("idempotency key is already bound to another action")
|
||||
if existing_idempotent.request_fingerprint != request_fingerprint:
|
||||
raise ValueError("idempotency key is already bound to a different request")
|
||||
return existing_idempotent, False
|
||||
|
||||
resolved_id = _identifier(operation_id, prefix="op")
|
||||
existing_by_id = self._records.get(resolved_id)
|
||||
if existing_by_id is not None:
|
||||
if existing_by_id.action != action:
|
||||
raise ValueError("operation id is already bound to another action")
|
||||
if existing_by_id.request_fingerprint != request_fingerprint:
|
||||
raise ValueError("operation id is already bound to a different request")
|
||||
return existing_by_id, False
|
||||
|
||||
now = self._clock()
|
||||
record = OperationRecord(
|
||||
operation_id=resolved_id,
|
||||
action=action,
|
||||
status="accepted",
|
||||
accepted_at=now,
|
||||
device_id=device_id,
|
||||
device_session_id=device_session_id,
|
||||
idempotency_key=idempotency_key,
|
||||
deadline_at=(
|
||||
now + timedelta(seconds=deadline_seconds)
|
||||
if deadline_seconds is not None
|
||||
else None
|
||||
),
|
||||
cancellable=cancellable,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
self._records[resolved_id] = record
|
||||
self._order.append(resolved_id)
|
||||
if idempotency_key is not None:
|
||||
self._idempotency[idempotency_key] = resolved_id
|
||||
self._trim_locked()
|
||||
return record, True
|
||||
|
||||
def transition(
|
||||
self,
|
||||
operation_id: str,
|
||||
status: OperationStatus,
|
||||
*,
|
||||
stage_code: str,
|
||||
message_code: str,
|
||||
result: Mapping[str, Any] | None = None,
|
||||
error: Mapping[str, Any] | None = None,
|
||||
evidence_refs: Iterable[str] = (),
|
||||
) -> OperationRecord:
|
||||
with self._lock:
|
||||
record = self._require_locked(operation_id)
|
||||
if record.status in TERMINAL_OPERATION_STATUSES:
|
||||
if record.status == status:
|
||||
return record
|
||||
raise ValueError(f"operation {operation_id} is already terminal")
|
||||
record.status = status
|
||||
record.stage_code = stage_code
|
||||
record.message_code = message_code
|
||||
record.sequence += 1
|
||||
record.state_revision += 1
|
||||
record.result = dict(result) if result is not None else None
|
||||
record.error = dict(error) if error is not None else None
|
||||
record.evidence_refs = tuple(evidence_refs)
|
||||
if status in TERMINAL_OPERATION_STATUSES:
|
||||
record.completed_at = self._clock()
|
||||
self._trim_locked()
|
||||
return record
|
||||
|
||||
def request_cancel(self, operation_id: str) -> OperationRecord:
|
||||
with self._lock:
|
||||
record = self._require_locked(operation_id)
|
||||
if record.status in TERMINAL_OPERATION_STATUSES:
|
||||
return record
|
||||
if not record.cancellable:
|
||||
raise ValueError(f"operation {operation_id} is not cancellable")
|
||||
record.cancel_requested = True
|
||||
record.sequence += 1
|
||||
record.state_revision += 1
|
||||
record.stage_code = "cancellation-requested"
|
||||
record.message_code = "operation.cancellation_requested"
|
||||
return record
|
||||
|
||||
def transition_if_pending(
|
||||
self,
|
||||
operation_id: str | None,
|
||||
status: OperationStatus,
|
||||
*,
|
||||
stage_code: str,
|
||||
message_code: str,
|
||||
result: Mapping[str, Any] | None = None,
|
||||
error: Mapping[str, Any] | None = None,
|
||||
evidence_refs: Iterable[str] = (),
|
||||
) -> OperationRecord | None:
|
||||
"""Atomically transition an existing non-terminal operation.
|
||||
|
||||
Lifecycle reconciliation and explicit stop/abort paths can race. This
|
||||
helper makes terminalization idempotent without exposing mutable journal
|
||||
records or turning an already-completed operation into an error.
|
||||
"""
|
||||
|
||||
with self._lock:
|
||||
if operation_id is None:
|
||||
return None
|
||||
record = self._records.get(operation_id)
|
||||
if record is None or record.status in TERMINAL_OPERATION_STATUSES:
|
||||
return record
|
||||
record.status = status
|
||||
record.stage_code = stage_code
|
||||
record.message_code = message_code
|
||||
record.sequence += 1
|
||||
record.state_revision += 1
|
||||
record.result = dict(result) if result is not None else None
|
||||
record.error = dict(error) if error is not None else None
|
||||
record.evidence_refs = tuple(evidence_refs)
|
||||
if status in TERMINAL_OPERATION_STATUSES:
|
||||
record.completed_at = self._clock()
|
||||
self._trim_locked()
|
||||
return record
|
||||
|
||||
def get(self, operation_id: str) -> OperationRecord:
|
||||
with self._lock:
|
||||
return self._require_locked(operation_id)
|
||||
|
||||
def latest(self) -> OperationRecord | None:
|
||||
with self._lock:
|
||||
return self._records[self._order[-1]] if self._order else None
|
||||
|
||||
def snapshot(self, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
if limit < 1:
|
||||
return []
|
||||
with self._lock:
|
||||
return [self._records[item].as_dict() for item in self._order[-limit:]]
|
||||
|
||||
def _require_locked(self, operation_id: str) -> OperationRecord:
|
||||
try:
|
||||
return self._records[operation_id]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"unknown operation: {operation_id}") from exc
|
||||
|
||||
def _trim_locked(self) -> None:
|
||||
while len(self._order) > self._max_records:
|
||||
oldest_id = next(
|
||||
(
|
||||
operation_id
|
||||
for operation_id in self._order
|
||||
if self._records[operation_id].status in TERMINAL_OPERATION_STATUSES
|
||||
),
|
||||
None,
|
||||
)
|
||||
# Never evict an operation that still needs reconciliation. A brief
|
||||
# overrun is safer than turning a later device observation into an
|
||||
# unknown-operation failure.
|
||||
if oldest_id is None:
|
||||
return
|
||||
self._order.remove(oldest_id)
|
||||
oldest = self._records.pop(oldest_id)
|
||||
if oldest.idempotency_key is not None:
|
||||
self._idempotency.pop(oldest.idempotency_key, None)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AcquisitionRecord:
|
||||
acquisition_id: str
|
||||
device_id: str
|
||||
device_session_id: str
|
||||
compatibility_profile_id: str
|
||||
control_mode: ControlMode
|
||||
requested_streams: tuple[str, ...]
|
||||
target_host: str
|
||||
duration_seconds: float
|
||||
evidence_policy: Literal["required", "best-effort", "disabled"]
|
||||
state: AcquisitionState = "preparing"
|
||||
state_revision: int = 1
|
||||
created_at: datetime = field(default_factory=utc_now)
|
||||
updated_at: datetime = field(default_factory=utc_now)
|
||||
message_code: str = "acquisition.preparing"
|
||||
operator_instructions: tuple[str, ...] = ()
|
||||
result: dict[str, Any] | None = None
|
||||
|
||||
def transition(
|
||||
self,
|
||||
state: AcquisitionState,
|
||||
*,
|
||||
message_code: str,
|
||||
operator_instructions: Iterable[str] = (),
|
||||
result: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
if self.state in TERMINAL_ACQUISITION_STATES:
|
||||
if self.state == state:
|
||||
return
|
||||
raise ValueError(f"acquisition {self.acquisition_id} is already terminal")
|
||||
self.state = state
|
||||
self.state_revision += 1
|
||||
self.updated_at = utc_now()
|
||||
self.message_code = message_code
|
||||
self.operator_instructions = tuple(operator_instructions)
|
||||
self.result = dict(result) if result is not None else None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.acquisition-snapshot/v1alpha2",
|
||||
"acquisition_id": self.acquisition_id,
|
||||
"device_id": self.device_id,
|
||||
"device_session_id": self.device_session_id,
|
||||
"compatibility_profile_id": self.compatibility_profile_id,
|
||||
"control_mode": self.control_mode,
|
||||
"requested_streams": list(self.requested_streams),
|
||||
"target_host": self.target_host,
|
||||
"duration_seconds": self.duration_seconds,
|
||||
"evidence_policy": self.evidence_policy,
|
||||
"state": self.state,
|
||||
"state_revision": self.state_revision,
|
||||
"created_at": _iso(self.created_at),
|
||||
"updated_at": _iso(self.updated_at),
|
||||
"message_code": self.message_code,
|
||||
"operator_instructions": list(self.operator_instructions),
|
||||
"result": dict(self.result) if self.result is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def new_acquisition_id() -> str:
|
||||
return f"acq-{uuid4()}"
|
||||
|
||||
|
||||
def new_device_id() -> str:
|
||||
return f"device-{uuid4()}"
|
||||
|
||||
|
||||
def new_device_session_id() -> str:
|
||||
return f"device-session-{uuid4()}"
|
||||
@@ -1,13 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, ValidationError
|
||||
from pydantic import (
|
||||
AfterValidator,
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
ValidationError,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from k1link.web.plugin_runtime import STATE_READ_ACTION_ID
|
||||
|
||||
PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha1"
|
||||
PLUGIN_API_VERSION = "missioncore.nodedc/v1alpha2"
|
||||
SUPPORTED_PLUGIN_API_VERSIONS = frozenset(
|
||||
{
|
||||
"missioncore.nodedc/v1alpha1",
|
||||
PLUGIN_API_VERSION,
|
||||
}
|
||||
)
|
||||
_V1ALPHA2_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$")
|
||||
|
||||
|
||||
def _reject_blank(value: str) -> str:
|
||||
@@ -16,6 +32,11 @@ def _reject_blank(value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def _validate_v1alpha2_identifier(value: str, path: str) -> None:
|
||||
if _V1ALPHA2_IDENTIFIER.fullmatch(value) is None:
|
||||
raise ValueError(f"{path} must be a v1alpha2 identifier")
|
||||
|
||||
|
||||
ShortText = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=160),
|
||||
@@ -31,6 +52,11 @@ EntrypointText = Annotated[
|
||||
Field(min_length=1, max_length=256),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
ProfilePathText = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=512),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
|
||||
|
||||
class CapabilityManifest(BaseModel):
|
||||
@@ -75,6 +101,14 @@ class PluginActionManifest(BaseModel):
|
||||
secretFields: list[ShortText]
|
||||
|
||||
|
||||
class CompatibilityProfileLinkManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
profileId: ShortText
|
||||
path: ProfilePathText
|
||||
modelId: ShortText
|
||||
|
||||
|
||||
class PluginMetadata(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -94,26 +128,164 @@ class PluginMetadata(BaseModel):
|
||||
class PluginSpec(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
hostApiRange: Literal["v1alpha1"]
|
||||
hostApiRange: Literal["v1alpha1", "v1alpha2"]
|
||||
runtime: PluginRuntimeManifest
|
||||
permissions: list[ShortText]
|
||||
actions: list[PluginActionManifest]
|
||||
models: list[DeviceModelManifest] = Field(min_length=1, max_length=1)
|
||||
models: list[DeviceModelManifest] = Field(min_length=1)
|
||||
compatibilityProfiles: list[CompatibilityProfileLinkManifest] | None = None
|
||||
|
||||
|
||||
class DevicePluginManifest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
apiVersion: Literal["missioncore.nodedc/v1alpha1"]
|
||||
apiVersion: Literal[
|
||||
"missioncore.nodedc/v1alpha1",
|
||||
"missioncore.nodedc/v1alpha2",
|
||||
]
|
||||
kind: Literal["DevicePlugin"]
|
||||
metadata: PluginMetadata
|
||||
spec: PluginSpec
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_contract_version(self) -> DevicePluginManifest:
|
||||
expected_host_range = self.apiVersion.rsplit("/", maxsplit=1)[-1]
|
||||
if self.spec.hostApiRange != expected_host_range:
|
||||
raise ValueError("apiVersion and hostApiRange must declare the same contract")
|
||||
if self.apiVersion == "missioncore.nodedc/v1alpha1":
|
||||
if len(self.spec.models) != 1:
|
||||
raise ValueError("v1alpha1 must declare exactly one device model")
|
||||
if self.spec.compatibilityProfiles is not None:
|
||||
raise ValueError("v1alpha1 must not declare compatibilityProfiles")
|
||||
else:
|
||||
if not self.spec.compatibilityProfiles:
|
||||
raise ValueError("v1alpha2 must declare at least one compatibility profile")
|
||||
identifiers: list[tuple[str, str]] = [
|
||||
("metadata.id", self.metadata.id),
|
||||
*(
|
||||
(f"spec.permissions[{index}]", permission)
|
||||
for index, permission in enumerate(self.spec.permissions)
|
||||
),
|
||||
]
|
||||
for action_index, action in enumerate(self.spec.actions):
|
||||
identifiers.append((f"spec.actions[{action_index}].id", action.id))
|
||||
identifiers.extend(
|
||||
(
|
||||
f"spec.actions[{action_index}].secretFields[{field_index}]",
|
||||
field,
|
||||
)
|
||||
for field_index, field in enumerate(action.secretFields)
|
||||
)
|
||||
for model_index, model in enumerate(self.spec.models):
|
||||
identifiers.append((f"spec.models[{model_index}].id", model.id))
|
||||
identifiers.extend(
|
||||
(
|
||||
f"spec.models[{model_index}].capabilities[{capability_index}].id",
|
||||
capability.id,
|
||||
)
|
||||
for capability_index, capability in enumerate(model.capabilities)
|
||||
)
|
||||
for profile_index, profile in enumerate(self.spec.compatibilityProfiles):
|
||||
identifiers.extend(
|
||||
(
|
||||
(
|
||||
f"spec.compatibilityProfiles[{profile_index}].profileId",
|
||||
profile.profileId,
|
||||
),
|
||||
(
|
||||
f"spec.compatibilityProfiles[{profile_index}].modelId",
|
||||
profile.modelId,
|
||||
),
|
||||
)
|
||||
)
|
||||
for path, value in identifiers:
|
||||
_validate_v1alpha2_identifier(value, path)
|
||||
return self
|
||||
|
||||
|
||||
class PluginCatalogError(RuntimeError):
|
||||
"""An installed manifest is invalid or conflicts with another manifest."""
|
||||
|
||||
|
||||
def _reject_duplicate_profile_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise PluginCatalogError(f"Duplicate JSON key in compatibility profile: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _validate_profile_links(
|
||||
manifest: DevicePluginManifest,
|
||||
manifest_path: Path,
|
||||
) -> None:
|
||||
links = manifest.spec.compatibilityProfiles
|
||||
if links is None:
|
||||
return
|
||||
|
||||
plugin_directory = manifest_path.parent.resolve()
|
||||
model_ids = {model.id for model in manifest.spec.models}
|
||||
linked_model_ids: set[str] = set()
|
||||
profile_ids: set[str] = set()
|
||||
profile_paths: set[Path] = set()
|
||||
|
||||
for link in links:
|
||||
if link.profileId in profile_ids:
|
||||
raise PluginCatalogError(
|
||||
f"Duplicate compatibility profile id in {manifest.metadata.id}: {link.profileId}"
|
||||
)
|
||||
profile_ids.add(link.profileId)
|
||||
|
||||
if link.modelId not in model_ids:
|
||||
raise PluginCatalogError(
|
||||
f"Compatibility profile {link.profileId} references unknown model {link.modelId}"
|
||||
)
|
||||
linked_model_ids.add(link.modelId)
|
||||
|
||||
relative_path = Path(link.path)
|
||||
if relative_path.is_absolute():
|
||||
raise PluginCatalogError(
|
||||
f"Compatibility profile path must be plugin-relative: {link.path}"
|
||||
)
|
||||
profile_path = (plugin_directory / relative_path).resolve()
|
||||
try:
|
||||
profile_path.relative_to(plugin_directory)
|
||||
except ValueError as exc:
|
||||
raise PluginCatalogError(
|
||||
f"Compatibility profile path escapes plugin directory: {link.path}"
|
||||
) from exc
|
||||
if profile_path in profile_paths:
|
||||
raise PluginCatalogError(
|
||||
f"Duplicate compatibility profile path in {manifest.metadata.id}: {link.path}"
|
||||
)
|
||||
profile_paths.add(profile_path)
|
||||
if not profile_path.is_file():
|
||||
raise PluginCatalogError(f"Compatibility profile does not exist: {link.path}")
|
||||
|
||||
try:
|
||||
profile_document = json.loads(
|
||||
profile_path.read_text(encoding="utf-8"),
|
||||
object_pairs_hook=_reject_duplicate_profile_keys,
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise PluginCatalogError(
|
||||
f"Invalid compatibility profile {profile_path}: {exc}"
|
||||
) from exc
|
||||
if not isinstance(profile_document, dict):
|
||||
raise PluginCatalogError(
|
||||
f"Compatibility profile must contain a JSON object: {link.path}"
|
||||
)
|
||||
if profile_document.get("profile_id") != link.profileId:
|
||||
raise PluginCatalogError(
|
||||
f"Compatibility profile id mismatch for {link.path}: expected {link.profileId}"
|
||||
)
|
||||
|
||||
if linked_model_ids != model_ids:
|
||||
missing = ", ".join(sorted(model_ids - linked_model_ids))
|
||||
raise PluginCatalogError(f"Device models without compatibility profiles: {missing}")
|
||||
|
||||
|
||||
class DevicePluginCatalog:
|
||||
"""Read-only catalog of statically reviewed device-plugin manifests."""
|
||||
|
||||
@@ -141,6 +313,8 @@ class DevicePluginCatalog:
|
||||
raise PluginCatalogError(f"Duplicate device-plugin id: {plugin_id}")
|
||||
plugin_ids.add(plugin_id)
|
||||
|
||||
_validate_profile_links(manifest, path)
|
||||
|
||||
action_ids: set[str] = set()
|
||||
for action in manifest.spec.actions:
|
||||
if action.id in action_ids:
|
||||
@@ -178,7 +352,9 @@ class DevicePluginCatalog:
|
||||
return list(self._validated_manifests)
|
||||
|
||||
def plugin_documents(self) -> list[dict[str, Any]]:
|
||||
return [manifest.model_dump(mode="json") for manifest in self.manifests()]
|
||||
return [
|
||||
manifest.model_dump(mode="json", exclude_none=True) for manifest in self.manifests()
|
||||
]
|
||||
|
||||
def model_documents(self) -> list[dict[str, Any]]:
|
||||
models: list[dict[str, Any]] = []
|
||||
|
||||
+1127
-19
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user