feat(device-plugins): add profiled K1 lifecycle and canonical data plane

This commit is contained in:
DCCONSTRUCTIONS
2026-07-16 19:44:06 +03:00
parent 19ab973110
commit e6f7648b84
45 changed files with 6401 additions and 440 deletions
+15 -2
View File
@@ -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:
+391
View File
@@ -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()}"
+182 -6
View File
@@ -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]] = []
File diff suppressed because it is too large Load Diff