Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
504dd1a2e9 | ||
|
|
e2f7183ecf | ||
|
|
eb4e1a9ea1 | ||
|
|
9055f2522c | ||
|
|
130087a1ba | ||
|
|
0e53e04e95 | ||
|
|
82afce1f81 |
@@ -0,0 +1,7 @@
|
||||
"""Credential-free Sense control-plane connector for Brain."""
|
||||
|
||||
from .consumer import ApplyResult, SourceConfigConsumer, SourceConfigError
|
||||
from .replay import SQLiteReplayStore
|
||||
from .status import RuntimeStatusPublisher
|
||||
|
||||
__all__ = ["ApplyResult", "SourceConfigConsumer", "SourceConfigError", "SQLiteReplayStore", "RuntimeStatusPublisher"]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Machine-authenticated adapters with bounded timeout/backoff and a kill switch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from yovision_brain.integration.machine_identity.token import Signer, Verifier, bearer_token
|
||||
|
||||
from .consumer import ApplyResult, SourceConfigConsumer
|
||||
|
||||
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorResponse:
|
||||
status: int
|
||||
body: bytes
|
||||
correlation_id: str
|
||||
|
||||
|
||||
class SourceConfigEndpoint:
|
||||
def __init__(self, consumer: SourceConfigConsumer, verifier: Verifier, *, enabled: bool = True, max_body_bytes: int = 10 * 1024 * 1024) -> None:
|
||||
self._consumer, self._verifier, self._enabled, self._max = consumer, verifier, enabled, max_body_bytes
|
||||
|
||||
def receive(self, authorization: str, body: bytes, correlation_id: str) -> ApplyResult:
|
||||
if not self._enabled: raise RuntimeError("CONNECTOR_DISABLED")
|
||||
if not _REQUEST_ID.fullmatch(correlation_id): raise ValueError("INVALID_CORRELATION_ID")
|
||||
if len(body) > self._max: raise ValueError("REQUEST_TOO_LARGE")
|
||||
token=bearer_token(authorization)
|
||||
self._verifier.verify(token,"yovision-brain","source-config:write","POST","/machine/v1/source-config",body)
|
||||
return self._consumer.apply(body)
|
||||
|
||||
|
||||
class StatusSender:
|
||||
def __init__(self, signer: Signer, send: Callable[[str, bytes, str, float], ConnectorResponse], *, enabled: bool = True, timeout_seconds: float = 5.0, max_attempts: int = 4, sleeper: Callable[[float], None] = time.sleep) -> None:
|
||||
if timeout_seconds <= 0 or max_attempts < 1: raise ValueError("invalid connector retry policy")
|
||||
self._signer,self._send,self._enabled,self._timeout,self._attempts,self._sleep=signer,send,enabled,timeout_seconds,max_attempts,sleeper
|
||||
|
||||
def publish(self, body: bytes, correlation_id: str) -> ConnectorResponse:
|
||||
if not self._enabled: raise RuntimeError("CONNECTOR_DISABLED")
|
||||
if not _REQUEST_ID.fullmatch(correlation_id): raise ValueError("INVALID_CORRELATION_ID")
|
||||
last: Exception|None=None
|
||||
for attempt in range(self._attempts):
|
||||
try:
|
||||
# A retry gets a fresh jti: the previous request may have been
|
||||
# accepted even when its response was lost.
|
||||
token=self._signer.mint("yovision-sense",("runtime-status:write",),"POST","/machine/v1/runtime-status",body)
|
||||
response=self._send("Bearer "+token,body,correlation_id,self._timeout)
|
||||
if 200<=response.status<300:return response
|
||||
if response.status<500:raise RuntimeError(f"STATUS_REJECTED_{response.status}")
|
||||
last=RuntimeError(f"STATUS_REMOTE_{response.status}")
|
||||
except (TimeoutError,ConnectionError) as exc:last=exc
|
||||
if attempt+1<self._attempts:self._sleep(min(2**attempt,30))
|
||||
raise RuntimeError("STATUS_DELIVERY_EXHAUSTED") from last
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Strict source-config/v1 validation and atomic last-known-good application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Mapping
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from yovision_brain.rules.models import AreaDefinition, DirectionalLineDefinition, NormalizedPoint, RuleSet
|
||||
|
||||
VERSION = "yovision.source-config/v1"
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$")
|
||||
_EXTENSION_NAMESPACE = re.compile(r"^[A-Za-z][A-Za-z0-9.-]{0,127}$")
|
||||
_SECRET = re.compile(r"password|secret|credential|cookie|jwt|username|stream_uri", re.I)
|
||||
|
||||
|
||||
class SourceConfigError(ValueError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppliedConfig:
|
||||
config_id: str
|
||||
revision: int
|
||||
logical_device_id: str
|
||||
media_ref: str
|
||||
profile_encoding: str
|
||||
frame_rate: float
|
||||
rule_state: str
|
||||
rules: RuleSet | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplyResult:
|
||||
config_id: str
|
||||
revision: int
|
||||
state: str
|
||||
config: AppliedConfig | None
|
||||
|
||||
|
||||
class SourceConfigConsumer:
|
||||
"""Persists validated snapshots before atomically changing the active pointer."""
|
||||
|
||||
def __init__(self, state_path: str | Path, *, clock: Callable[[], float] | None = None) -> None:
|
||||
self._path = str(state_path)
|
||||
self._clock = clock or time.time
|
||||
self._lock = threading.RLock()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS source_snapshots (
|
||||
config_id TEXT NOT NULL, revision INTEGER NOT NULL, effective_at INTEGER NOT NULL,
|
||||
state TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(config_id, revision));
|
||||
CREATE TABLE IF NOT EXISTS source_active (
|
||||
config_id TEXT PRIMARY KEY, revision INTEGER NOT NULL,
|
||||
FOREIGN KEY(config_id, revision) REFERENCES source_snapshots(config_id, revision));
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self._path, timeout=5)
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
return connection
|
||||
|
||||
def apply(self, body: bytes) -> ApplyResult:
|
||||
document = _parse_and_validate(body)
|
||||
config_id, revision = document["config_id"], document["revision"]
|
||||
mapped = _map(document)
|
||||
effective_at = int(_timestamp(document["effective_at"]))
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
latest = connection.execute(
|
||||
"SELECT revision, payload, state, effective_at FROM source_snapshots WHERE config_id=? ORDER BY revision DESC LIMIT 1",
|
||||
(config_id,),
|
||||
).fetchone()
|
||||
if latest and revision < latest[0]:
|
||||
connection.rollback()
|
||||
raise SourceConfigError("STALE_REVISION")
|
||||
canonical = body.decode("utf-8")
|
||||
if latest and revision == latest[0]:
|
||||
if json.loads(latest[1]) != document:
|
||||
connection.rollback()
|
||||
raise SourceConfigError("REVISION_CONFLICT")
|
||||
connection.rollback()
|
||||
return ApplyResult(config_id, revision, "idempotent", self.get_active(config_id))
|
||||
connection.execute(
|
||||
"INSERT INTO source_snapshots(config_id, revision, effective_at, state, payload) VALUES (?, ?, ?, ?, ?)",
|
||||
(config_id, revision, effective_at, document["rule_set"]["state"], canonical),
|
||||
)
|
||||
if effective_at <= int(self._clock()):
|
||||
connection.execute(
|
||||
"INSERT INTO source_active(config_id, revision) VALUES (?, ?) ON CONFLICT(config_id) DO UPDATE SET revision=excluded.revision",
|
||||
(config_id, revision),
|
||||
)
|
||||
state = "applied"
|
||||
else:
|
||||
state = "scheduled"
|
||||
connection.commit()
|
||||
return ApplyResult(config_id, revision, state, mapped if state == "applied" else self.get_active(config_id))
|
||||
|
||||
def activate_due(self) -> tuple[AppliedConfig, ...]:
|
||||
now = int(self._clock())
|
||||
activated: list[AppliedConfig] = []
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT s.payload FROM source_snapshots s JOIN (SELECT config_id, MAX(revision) revision FROM source_snapshots WHERE effective_at<=? GROUP BY config_id) d ON d.config_id=s.config_id AND d.revision=s.revision",
|
||||
(now,),
|
||||
).fetchall()
|
||||
for (payload,) in rows:
|
||||
document = json.loads(payload)
|
||||
connection.execute(
|
||||
"INSERT INTO source_active(config_id, revision) VALUES (?, ?) ON CONFLICT(config_id) DO UPDATE SET revision=excluded.revision",
|
||||
(document["config_id"], document["revision"]),
|
||||
)
|
||||
activated.append(_map(document))
|
||||
connection.commit()
|
||||
return tuple(activated)
|
||||
|
||||
def get_active(self, config_id: str) -> AppliedConfig | None:
|
||||
with closing(self._connect()) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT s.payload FROM source_active a JOIN source_snapshots s ON s.config_id=a.config_id AND s.revision=a.revision WHERE a.config_id=?",
|
||||
(config_id,),
|
||||
).fetchone()
|
||||
return _map(json.loads(row[0])) if row else None
|
||||
|
||||
|
||||
def _parse_and_validate(body: bytes) -> dict[str, object]:
|
||||
try:
|
||||
document = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise SourceConfigError("CONFIG_INVALID") from None
|
||||
if not isinstance(document, dict):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
if document.get("schema_version") != VERSION:
|
||||
raise SourceConfigError("UNSUPPORTED_SCHEMA_VERSION")
|
||||
if _contains_secret(document):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
required = {"schema_version", "config_id", "revision", "published_at", "effective_at", "site", "logical_device", "profile", "media", "rule_set", "integrity"}
|
||||
if set(document) - (required | {"extensions"}) or not required <= set(document):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
extensions = document.get("extensions", {})
|
||||
if not isinstance(extensions, dict) or any(
|
||||
not isinstance(namespace, str)
|
||||
or not _EXTENSION_NAMESPACE.fullmatch(namespace)
|
||||
or not isinstance(value, dict)
|
||||
for namespace, value in extensions.items()
|
||||
):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
integrity = document.get("integrity")
|
||||
if not isinstance(integrity, dict) or set(integrity) != {"algorithm", "value"} or integrity.get("algorithm") != "sha256":
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
unsigned = dict(document); unsigned.pop("integrity")
|
||||
digest = hashlib.sha256(json.dumps(unsigned, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode()).hexdigest()
|
||||
if not hmac.compare_digest(digest, str(integrity.get("value", ""))):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
try:
|
||||
if not _ID.fullmatch(document["config_id"]) or isinstance(document["revision"], bool) or document["revision"] < 1:
|
||||
raise ValueError
|
||||
published, effective = _timestamp(document["published_at"]), _timestamp(document["effective_at"])
|
||||
if effective < published:
|
||||
raise ValueError
|
||||
for field in ("site", "logical_device"):
|
||||
if not isinstance(document[field], dict) or set(document[field]) != {"id"} or not _ID.fullmatch(document[field]["id"]): raise ValueError
|
||||
_validate_profile(document["profile"])
|
||||
media = document["media"]
|
||||
if not isinstance(media, dict) or set(media) != {"ref", "transport"} or media["transport"] != "rtsp" or not isinstance(media["ref"], str) or not media["ref"].startswith("media:") or any(marker in media["ref"] for marker in ("?", "#", "@", "\\", "://")): raise ValueError
|
||||
_validate_rules(document["rule_set"], document["profile"])
|
||||
except (KeyError, TypeError, ValueError, AttributeError):
|
||||
raise SourceConfigError("CONFIG_INVALID") from None
|
||||
return document
|
||||
|
||||
|
||||
def _validate_profile(profile: object) -> None:
|
||||
if not isinstance(profile, dict) or set(profile) != {"id", "width", "height", "encoding", "frame_rate"}: raise ValueError
|
||||
if not _ID.fullmatch(profile["id"]) or profile["encoding"] not in {"H264", "H265", "MJPEG"}: raise ValueError
|
||||
for field in ("width", "height"):
|
||||
if isinstance(profile[field], bool) or not isinstance(profile[field], int) or profile[field] < 1: raise ValueError
|
||||
if isinstance(profile["frame_rate"], bool) or not isinstance(profile["frame_rate"], (int, float)) or profile["frame_rate"] <= 0: raise ValueError
|
||||
|
||||
|
||||
def _validate_rules(rules: object, profile: Mapping[str, object]) -> None:
|
||||
if not isinstance(rules, dict) or set(rules) != {"version", "state", "profile_binding", "areas", "directional_lines"}: raise ValueError
|
||||
if not _ID.fullmatch(rules["version"]) or rules["state"] not in {"active", "disabled", "recalibration_required"}: raise ValueError
|
||||
binding = rules["profile_binding"]
|
||||
if binding != {"profile_id": profile["id"], "width": profile["width"], "height": profile["height"]}: raise ValueError
|
||||
if not isinstance(rules["areas"], list) or not isinstance(rules["directional_lines"], list) or len(rules["areas"]) > 1024 or len(rules["directional_lines"]) > 1024: raise ValueError
|
||||
identifiers: set[str] = set()
|
||||
for area in rules["areas"]:
|
||||
if not isinstance(area, dict) or set(area) != {"id", "version", "kind", "enabled", "points"} or area["kind"] != "danger_area" or not isinstance(area["enabled"], bool) or not 3 <= len(area["points"]) <= 256: raise ValueError
|
||||
_rule_identity(area, identifiers); points = tuple(_point(value) for value in area["points"])
|
||||
polygon = sum(a[0]*b[1]-b[0]*a[1] for a,b in zip(points, points[1:]+points[:1])) / 2
|
||||
if abs(polygon) < 1e-9: raise ValueError
|
||||
for line in rules["directional_lines"]:
|
||||
if not isinstance(line, dict) or set(line) != {"id", "version", "kind", "enabled", "start", "end", "trigger_direction"} or line["kind"] != "directional_line" or not isinstance(line["enabled"], bool) or line["trigger_direction"] not in {"left_to_right", "right_to_left"}: raise ValueError
|
||||
_rule_identity(line, identifiers)
|
||||
if _point(line["start"]) == _point(line["end"]): raise ValueError
|
||||
|
||||
|
||||
def _rule_identity(rule: Mapping[str, object], identifiers: set[str]) -> None:
|
||||
if not isinstance(rule["id"], str) or not _ID.fullmatch(rule["id"]) or rule["id"] in identifiers or isinstance(rule["version"], bool) or not isinstance(rule["version"], int) or rule["version"] < 1: raise ValueError
|
||||
identifiers.add(rule["id"])
|
||||
|
||||
|
||||
def _point(value: object) -> tuple[float, float]:
|
||||
if not isinstance(value, dict) or set(value) != {"x", "y"}: raise ValueError
|
||||
x, y = value["x"], value["y"]
|
||||
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int,float)) or not isinstance(y,(int,float)) or not 0 <= x <= 1 or not 0 <= y <= 1: raise ValueError
|
||||
return float(x), float(y)
|
||||
|
||||
|
||||
def _map(document: Mapping[str, object]) -> AppliedConfig:
|
||||
profile, rules = document["profile"], document["rule_set"]
|
||||
rule_set = None
|
||||
if rules["state"] == "active":
|
||||
rule_set = RuleSet(
|
||||
version=rules["version"], profile_id=profile["id"], width=profile["width"], height=profile["height"],
|
||||
areas=tuple(AreaDefinition(a["id"], tuple(NormalizedPoint(**p) for p in a["points"])) for a in rules["areas"] if a["enabled"]),
|
||||
directional_lines=tuple(DirectionalLineDefinition(l["id"], NormalizedPoint(**l["start"]), NormalizedPoint(**l["end"]), l["trigger_direction"]) for l in rules["directional_lines"] if l["enabled"]),
|
||||
)
|
||||
return AppliedConfig(document["config_id"], document["revision"], document["logical_device"]["id"], document["media"]["ref"], profile["encoding"], float(profile["frame_rate"]), rules["state"], rule_set)
|
||||
|
||||
|
||||
def _timestamp(value: object) -> float:
|
||||
if not isinstance(value, str) or not value.endswith("Z"): raise ValueError
|
||||
return datetime.fromisoformat(value[:-1] + "+00:00").astimezone(timezone.utc).timestamp()
|
||||
|
||||
|
||||
def _contains_secret(value: object) -> bool:
|
||||
if isinstance(value, dict): return any(_SECRET.search(str(k)) or _contains_secret(v) for k,v in value.items())
|
||||
if isinstance(value, list): return any(_contains_secret(item) for item in value)
|
||||
if isinstance(value, str):
|
||||
split=urlsplit(value)
|
||||
return bool(split.username or split.password or value.startswith("file:") or re.match(r"^[A-Za-z]:[\\/]", value))
|
||||
return False
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Brain-owned durable replay storage; never shared with Sense or Bell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SQLiteReplayStore:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self._path = str(path)
|
||||
self._lock = threading.Lock()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS machine_replay (principal TEXT NOT NULL, token_id TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY(principal, token_id))"
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self._path, timeout=5, isolation_level=None)
|
||||
connection.execute("PRAGMA busy_timeout=5000")
|
||||
return connection
|
||||
|
||||
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
|
||||
if not principal or not token_id or expires_at <= now:
|
||||
return False
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute("DELETE FROM machine_replay WHERE expires_at <= ?", (now,))
|
||||
connection.execute(
|
||||
"INSERT INTO machine_replay(principal, token_id, expires_at) VALUES (?, ?, ?)",
|
||||
(principal, token_id, expires_at),
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
connection.execute("ROLLBACK")
|
||||
return False
|
||||
except Exception:
|
||||
connection.execute("ROLLBACK")
|
||||
return False
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Persistent sequence allocation and safe runtime-status/v1 production."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
_LOGICAL_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$")
|
||||
_ERROR_CODE = re.compile(r"^[A-Z][A-Z0-9_]{2,63}$")
|
||||
_RUNTIME_STATES = {"unconfigured", "starting", "running", "degraded", "failed", "stopped"}
|
||||
|
||||
|
||||
class RuntimeStatusPublisher:
|
||||
def __init__(self, state_path: str | Path, brain_instance_ref: str) -> None:
|
||||
self._path, self._instance, self._lock = str(state_path), brain_instance_ref, threading.Lock()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute("CREATE TABLE IF NOT EXISTS runtime_sequence(instance_ref TEXT PRIMARY KEY, sequence INTEGER NOT NULL)")
|
||||
connection.commit()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self._path, timeout=5)
|
||||
|
||||
def build(self, *, runtime_state: str, runtime_version: str, started_at: datetime | None, model_ref: str, model_version: str, configurations: Sequence[Mapping[str, object]], health: Mapping[str, object], inputs: Sequence[Mapping[str, object]], observed_at: datetime | None = None) -> bytes:
|
||||
_validate(runtime_state, runtime_version, self._instance, model_ref, model_version, configurations, health, inputs)
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row=connection.execute("SELECT sequence FROM runtime_sequence WHERE instance_ref=?",(self._instance,)).fetchone();sequence=(row[0]+1) if row else 0
|
||||
connection.execute("INSERT INTO runtime_sequence(instance_ref,sequence) VALUES(?,?) ON CONFLICT(instance_ref) DO UPDATE SET sequence=excluded.sequence",(self._instance,sequence));connection.commit()
|
||||
observed=(observed_at or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
document={"schema_version":"yovision.runtime-status/v1","status_id":str(uuid.uuid4()),"brain_instance_ref":self._instance,"sequence":sequence,"observed_at":_utc(observed),"runtime":{"state":runtime_state,"version":runtime_version,"started_at":_utc(started_at) if started_at else None},"model":{"model_ref":model_ref,"version":model_version},"configurations":list(configurations),"health":dict(health),"inputs":list(inputs)}
|
||||
raw=json.dumps(document,separators=(",",":"),sort_keys=True).encode()
|
||||
lowered=raw.lower();
|
||||
for marker in (b"password",b"credential",b"stream_uri",b"cookie",b"jwt",b"file://"):
|
||||
if marker in lowered: raise ValueError("runtime status contains sensitive field")
|
||||
return raw
|
||||
|
||||
|
||||
def _utc(value: datetime) -> str:
|
||||
if value.tzinfo is None: raise ValueError("runtime timestamp must be timezone-aware")
|
||||
return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00","Z")
|
||||
|
||||
|
||||
def _validate(runtime_state: str, runtime_version: str, instance: str, model_ref: str, model_version: str, configurations: Sequence[Mapping[str, object]], health: Mapping[str, object], inputs: Sequence[Mapping[str, object]]) -> None:
|
||||
if runtime_state not in _RUNTIME_STATES or not _VERSION.fullmatch(runtime_version) or not _LOGICAL_REF.fullmatch(instance) or not _LOGICAL_REF.fullmatch(model_ref) or not _VERSION.fullmatch(model_version):
|
||||
raise ValueError("invalid runtime identity or version")
|
||||
if len(configurations) > 4096 or len(inputs) > 4096:
|
||||
raise ValueError("runtime status collection too large")
|
||||
seen: set[str] = set()
|
||||
for item in configurations:
|
||||
if set(item) != {"config_id", "apply_state", "applied_revision", "error_code"} or not isinstance(item["config_id"], str) or not _LOGICAL_REF.fullmatch(item["config_id"]) or item["config_id"] in seen:
|
||||
raise ValueError("invalid configuration status")
|
||||
seen.add(item["config_id"]); state=item["apply_state"]; revision=item["applied_revision"]; error=item["error_code"]
|
||||
if state not in {"not_configured","applying","applied","rejected"} or (state=="not_configured" and revision is not None) or (state=="applied" and (isinstance(revision,bool) or not isinstance(revision,int) or revision<1)) or (state=="rejected" and (not isinstance(error,str) or not _ERROR_CODE.fullmatch(error))):
|
||||
raise ValueError("invalid configuration status")
|
||||
if set(health) != {"overall","error_codes","metrics"} or health["overall"] not in {"healthy","degraded","unhealthy"} or not _codes(health["error_codes"],32) or not _metrics(health["metrics"]):
|
||||
raise ValueError("invalid health status")
|
||||
for item in inputs:
|
||||
if set(item) != {"input_ref","state","error_codes","metrics"} or not isinstance(item["input_ref"],str) or not _LOGICAL_REF.fullmatch(item["input_ref"]) or item["state"] not in _RUNTIME_STATES or not _codes(item["error_codes"],16) or not _metrics(item["metrics"]):
|
||||
raise ValueError("invalid input status")
|
||||
|
||||
|
||||
def _codes(value: object, limit: int) -> bool:
|
||||
return isinstance(value,list) and len(value)<=limit and len(set(value))==len(value) and all(isinstance(code,str) and _ERROR_CODE.fullmatch(code) for code in value)
|
||||
|
||||
|
||||
def _metrics(value: object) -> bool:
|
||||
if not isinstance(value,Mapping) or set(value)!={"load_percent","queue_depth","latency_ms"}: return False
|
||||
load,queue,latency=value["load_percent"],value["queue_depth"],value["latency_ms"]
|
||||
return not isinstance(load,bool) and isinstance(load,(int,float)) and 0<=load<=100 and not isinstance(queue,bool) and isinstance(queue,int) and queue>=0 and not isinstance(latency,bool) and isinstance(latency,(int,float)) and latency>=0
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from yovision_brain.integration.machine_identity.token import KeyRecord, Registry, Signer, Verifier
|
||||
from yovision_brain.integration.sense_control.connector import ConnectorResponse, SourceConfigEndpoint, StatusSender
|
||||
from yovision_brain.integration.sense_control.consumer import SourceConfigConsumer, SourceConfigError
|
||||
from yovision_brain.integration.sense_control.replay import SQLiteReplayStore
|
||||
from yovision_brain.integration.sense_control.status import RuntimeStatusPublisher
|
||||
|
||||
|
||||
def source_document(revision: int = 1, *, state: str = "active", effective: int = 0) -> bytes:
|
||||
now=datetime(2026,8,31,tzinfo=timezone.utc)
|
||||
document={"schema_version":"yovision.source-config/v1","config_id":"gate-primary","revision":revision,"published_at":now.isoformat().replace("+00:00","Z"),"effective_at":(now+timedelta(seconds=effective)).isoformat().replace("+00:00","Z"),"site":{"id":"site-east"},"logical_device":{"id":"camera-1"},"profile":{"id":"main","width":1920,"height":1080,"encoding":"H264","frame_rate":25},"media":{"ref":"media:site-east/camera-1/main","transport":"rtsp"},"rule_set":{"version":f"rules-{revision}","state":state,"profile_binding":{"profile_id":"main","width":1920,"height":1080},"areas":[{"id":"danger","version":1,"kind":"danger_area","enabled":True,"points":[{"x":.1,"y":.1},{"x":.8,"y":.1},{"x":.5,"y":.8}]}],"directional_lines":[]}}
|
||||
digest=hashlib.sha256(json.dumps(document,separators=(",",":"),sort_keys=True).encode()).hexdigest();document["integrity"]={"algorithm":"sha256","value":digest}
|
||||
return json.dumps(document,separators=(",",":"),sort_keys=True).encode()
|
||||
|
||||
|
||||
def with_extension(body: bytes, extensions: object) -> bytes:
|
||||
document=json.loads(body);document["extensions"]=extensions;unsigned=dict(document);unsigned.pop("integrity");document["integrity"]["value"]=hashlib.sha256(json.dumps(unsigned,separators=(",",":"),sort_keys=True).encode()).hexdigest();return json.dumps(document,separators=(",",":"),sort_keys=True).encode()
|
||||
|
||||
|
||||
def identity(tmp_path, now: int):
|
||||
private=Ed25519PrivateKey.generate();signer=Signer("yv:sense:east","sense-key-01",private,clock=lambda:now)
|
||||
registry=Registry([KeyRecord("yv:sense:east","sense-key-01",private.public_key(),"yovision-brain",frozenset({"source-config:write"}))])
|
||||
replay=SQLiteReplayStore(tmp_path/"replay.sqlite")
|
||||
return signer,Verifier(registry,replay,clock=lambda:now)
|
||||
|
||||
|
||||
def test_authenticated_apply_is_idempotent_and_replay_survives_restart(tmp_path):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());signer,verifier=identity(tmp_path,now);body=source_document();consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now);endpoint=SourceConfigEndpoint(consumer,verifier)
|
||||
token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body)
|
||||
result=endpoint.receive("Bearer "+token,body,"corr-request-0001");assert result.state=="applied" and result.config.rules is not None
|
||||
with pytest.raises(ValueError,match="machine_token_replayed"):endpoint.receive("Bearer "+token,body,"corr-request-0001")
|
||||
restarted=SourceConfigEndpoint(SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now),Verifier(verifier._registry,SQLiteReplayStore(tmp_path/"replay.sqlite"),clock=lambda:now))
|
||||
with pytest.raises(ValueError,match="machine_token_replayed"):restarted.receive("Bearer "+token,body,"corr-request-0002")
|
||||
new_token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body);assert restarted.receive("Bearer "+new_token,body,"corr-request-0003").state=="idempotent"
|
||||
|
||||
|
||||
def test_atomic_replay_accepts_once_under_concurrency(tmp_path):
|
||||
store=SQLiteReplayStore(tmp_path/"atomic.sqlite");results=[]
|
||||
threads=[threading.Thread(target=lambda:results.append(store.consume("yv:sense:east","token-id",200,100))) for _ in range(12)]
|
||||
for thread in threads:thread.start()
|
||||
for thread in threads:thread.join()
|
||||
assert results.count(True)==1
|
||||
|
||||
|
||||
def test_stale_unknown_profile_and_recalibration_are_safe(tmp_path):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
|
||||
assert consumer.apply(source_document(2)).config.rules is not None
|
||||
with pytest.raises(SourceConfigError,match="STALE_REVISION"):consumer.apply(source_document(1))
|
||||
invalid=json.loads(source_document(3));invalid["profile"]["width"]=1280;invalid["integrity"]["value"]="0"*64
|
||||
with pytest.raises(SourceConfigError,match="CONFIG_INVALID"):consumer.apply(json.dumps(invalid).encode())
|
||||
unknown=json.loads(source_document(3));unknown["schema_version"]="yovision.source-config/v2"
|
||||
with pytest.raises(SourceConfigError,match="UNSUPPORTED_SCHEMA_VERSION"):consumer.apply(json.dumps(unknown).encode())
|
||||
safe=consumer.apply(source_document(3,state="recalibration_required"));assert safe.config.rule_state=="recalibration_required" and safe.config.rules is None
|
||||
|
||||
|
||||
def test_future_effective_snapshot_activates_atomically_after_restart(tmp_path):
|
||||
base=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());current=[base];path=tmp_path/"state.sqlite";consumer=SourceConfigConsumer(path,clock=lambda:current[0])
|
||||
assert consumer.apply(source_document(1)).state=="applied";scheduled=consumer.apply(source_document(2,effective=60));assert scheduled.state=="scheduled" and scheduled.config.revision==1
|
||||
current[0]+=61;restarted=SourceConfigConsumer(path,clock=lambda:current[0]);activated=restarted.activate_due();assert activated[0].revision==2 and restarted.get_active("gate-primary").revision==2
|
||||
|
||||
|
||||
def test_unknown_valid_extension_namespace_is_ignored(tmp_path):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
|
||||
result=consumer.apply(with_extension(source_document(),{"vendor.example":{"feature":"safe"}}));assert result.state=="applied" and result.config.revision==1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extensions", [[], {"1invalid":{}}, {"vendor_ok":{}}, {"vendor.example":"not-an-object"}])
|
||||
def test_invalid_extensions_are_rejected(tmp_path, extensions):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
|
||||
with pytest.raises(SourceConfigError,match="CONFIG_INVALID"):consumer.apply(with_extension(source_document(),extensions))
|
||||
|
||||
|
||||
def test_status_sequence_restart_retry_timeout_and_disable(tmp_path):
|
||||
path=tmp_path/"status.sqlite";publisher=RuntimeStatusPublisher(path,"brain-east-01");health={"overall":"healthy","error_codes":[],"metrics":{"load_percent":1.0,"queue_depth":0,"latency_ms":2.0}}
|
||||
one=json.loads(publisher.build(runtime_state="running",runtime_version="1.0.0",started_at=datetime.now(timezone.utc),model_ref="people-detection",model_version="1",configurations=[],health=health,inputs=[]));two=json.loads(RuntimeStatusPublisher(path,"brain-east-01").build(runtime_state="running",runtime_version="1.0.0",started_at=None,model_ref="people-detection",model_version="1",configurations=[],health=health,inputs=[]));assert (one["sequence"],two["sequence"])==(0,1)
|
||||
private=Ed25519PrivateKey.generate();signer=Signer("yv:brain:east","brain-key-01",private,clock=lambda:1_787_000_000);attempts=[]
|
||||
def send(_auth,_body,_corr,timeout):attempts.append(timeout);raise TimeoutError
|
||||
sender=StatusSender(signer,send,max_attempts=3,sleeper=lambda _:None)
|
||||
with pytest.raises(RuntimeError,match="STATUS_DELIVERY_EXHAUSTED"):sender.publish(b"{}","corr-request-0001")
|
||||
assert attempts==[5.0,5.0,5.0]
|
||||
disabled=StatusSender(signer,lambda *_:ConnectorResponse(204,b"","corr-request-0001"),enabled=False)
|
||||
with pytest.raises(RuntimeError,match="CONNECTOR_DISABLED"):disabled.publish(b"{}","corr-request-0001")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_id", ["short", "0123456789abcde\n", "0123456789abcde!", "a"*129])
|
||||
def test_connector_rejects_unsafe_request_ids(tmp_path, request_id):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());signer,verifier=identity(tmp_path,now);body=source_document();endpoint=SourceConfigEndpoint(SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now),verifier)
|
||||
token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body)
|
||||
with pytest.raises(ValueError,match="INVALID_CORRELATION_ID"):endpoint.receive("Bearer "+token,body,request_id)
|
||||
status_signer=Signer("yv:brain:east","brain-key-01",Ed25519PrivateKey.generate(),clock=lambda:now)
|
||||
sender=StatusSender(status_signer,lambda *_:ConnectorResponse(204,b"",request_id))
|
||||
with pytest.raises(ValueError,match="INVALID_CORRELATION_ID"):sender.publish(b"{}",request_id)
|
||||
@@ -0,0 +1,118 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
)
|
||||
|
||||
const (
|
||||
sourceConfigPath = "/machine/v1/source-config"
|
||||
runtimeStatusPath = "/machine/v1/runtime-status"
|
||||
)
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type SendResponse struct {
|
||||
StatusCode int
|
||||
Body []byte
|
||||
CorrelationID string
|
||||
}
|
||||
type SendFunc func(authorization string, body []byte, correlationID string, timeout time.Duration) (SendResponse, error)
|
||||
|
||||
type ConfigSender struct {
|
||||
Signer mi.Signer
|
||||
Send SendFunc
|
||||
Enabled bool
|
||||
Timeout time.Duration
|
||||
MaxAttempts int
|
||||
Sleep func(time.Duration)
|
||||
}
|
||||
|
||||
func (s ConfigSender) Publish(config SourceConfig, correlationID string) (SendResponse, error) {
|
||||
if !s.Enabled {
|
||||
return SendResponse{}, errors.New("CONNECTOR_DISABLED")
|
||||
}
|
||||
if s.Send == nil || !requestIDPattern.MatchString(correlationID) {
|
||||
return SendResponse{}, errors.New("invalid connector configuration")
|
||||
}
|
||||
if s.Timeout <= 0 {
|
||||
s.Timeout = 5 * time.Second
|
||||
}
|
||||
if s.MaxAttempts == 0 {
|
||||
s.MaxAttempts = 4
|
||||
}
|
||||
if s.MaxAttempts < 1 {
|
||||
return SendResponse{}, errors.New("invalid connector retry policy")
|
||||
}
|
||||
if s.Sleep == nil {
|
||||
s.Sleep = time.Sleep
|
||||
}
|
||||
if err := ValidateSourceConfig(config); err != nil {
|
||||
return SendResponse{}, err
|
||||
}
|
||||
body, err := MarshalSourceConfig(config)
|
||||
if err != nil {
|
||||
return SendResponse{}, err
|
||||
}
|
||||
var last error
|
||||
for attempt := 0; attempt < s.MaxAttempts; attempt++ {
|
||||
token, mintErr := s.Signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", sourceConfigPath, body)
|
||||
if mintErr != nil {
|
||||
return SendResponse{}, mintErr
|
||||
}
|
||||
response, sendErr := s.Send("Bearer "+token, body, correlationID, s.Timeout)
|
||||
if sendErr == nil && response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
return response, nil
|
||||
}
|
||||
if sendErr == nil && response.StatusCode < 500 {
|
||||
return SendResponse{}, fmt.Errorf("source config rejected: %d", response.StatusCode)
|
||||
}
|
||||
if sendErr != nil {
|
||||
last = sendErr
|
||||
} else {
|
||||
last = fmt.Errorf("source config remote status: %d", response.StatusCode)
|
||||
}
|
||||
if attempt+1 < s.MaxAttempts {
|
||||
delay := time.Second << attempt
|
||||
if delay > 30*time.Second {
|
||||
delay = 30 * time.Second
|
||||
}
|
||||
s.Sleep(delay)
|
||||
}
|
||||
}
|
||||
return SendResponse{}, fmt.Errorf("source config delivery exhausted: %w", last)
|
||||
}
|
||||
|
||||
type RuntimeStatusEndpoint struct {
|
||||
Verifier mi.Verifier
|
||||
Store ProjectionStore
|
||||
Enabled bool
|
||||
MaxBodyBytes int
|
||||
}
|
||||
|
||||
func (e RuntimeStatusEndpoint) Receive(authorization string, body []byte, correlationID string, expected map[string]int64) (ProjectionView, error) {
|
||||
if !e.Enabled {
|
||||
return ProjectionView{}, errors.New("CONNECTOR_DISABLED")
|
||||
}
|
||||
if !requestIDPattern.MatchString(correlationID) {
|
||||
return ProjectionView{}, errors.New("INVALID_CORRELATION_ID")
|
||||
}
|
||||
if e.MaxBodyBytes == 0 {
|
||||
e.MaxBodyBytes = 10 * 1024 * 1024
|
||||
}
|
||||
if len(body) > e.MaxBodyBytes {
|
||||
return ProjectionView{}, errors.New("REQUEST_TOO_LARGE")
|
||||
}
|
||||
token, err := mi.BearerToken(authorization)
|
||||
if err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
if _, err = e.Verifier.Verify(token, "yovision-sense", "runtime-status:write", "POST", runtimeStatusPath, body); err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
return e.Store.Ingest(body, expected)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func memoryDB(t *testing.T, name string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ReplayToken{}, &RuntimeProjection{}, &SourceRevision{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSourceMapperAndRecalibration(t *testing.T) {
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
facts := SourceFacts{ConfigID: "gate-primary", SiteID: "site-east", LogicalDeviceID: "camera-1", MediaPath: "site/camera/main", Revision: 1, PublishedAt: now, EffectiveAt: now, Profile: Profile{ID: "main", Width: 1920, Height: 1080, Encoding: "h264", FrameRate: 25}, RuleSetVersion: "rules-1", Areas: []AreaRule{{ID: "danger", Version: 1, Kind: "danger_area", Enabled: true, Points: []Point{{.1, .1}, {.8, .1}, {.5, .8}}}}}
|
||||
config, err := MapSourceConfig(facts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ValidateSourceConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
facts.Revision = 2
|
||||
facts.NeedsRecalibration = true
|
||||
config, err = MapSourceConfig(facts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.RuleSet.State != "recalibration_required" || config.RuleSet.Areas[0].Enabled {
|
||||
t.Fatal("recalibration did not disable rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayAtomicAndRestart(t *testing.T) {
|
||||
db := memoryDB(t, "replay-package")
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
registry, _ := mi.NewRegistry(mi.KeyRecord{Principal: "yv:brain:east", KeyID: "brain-key-01", PublicKey: pub, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true})
|
||||
signer := mi.Signer{Principal: "yv:brain:east", KeyID: "brain-key-01", PrivateKey: priv, Now: func() time.Time { return now }}
|
||||
body := []byte("{}")
|
||||
token, _ := signer.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
accepted := 0
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v := mi.Verifier{Registry: registry, Replay: GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
if _, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
|
||||
mu.Lock()
|
||||
accepted++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if accepted != 1 {
|
||||
t.Fatalf("accepted %d", accepted)
|
||||
}
|
||||
v := mi.Verifier{Registry: registry, Replay: GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
if _, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
|
||||
t.Fatal("restart replay accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectionStaleRecoveryAndMismatch(t *testing.T) {
|
||||
db := memoryDB(t, "projection-package")
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
store := ProjectionStore{DB: db, Clock: func() time.Time { return now }, StaleAfter: 90 * time.Second}
|
||||
raw := runtimeFixture("018f4d6a-8d1b-4a25-8b37-9085f9c0d101", 1, now, 2)
|
||||
view, err := store.Ingest(raw, map[string]int64{"gate": 3})
|
||||
if err != nil || !view.RevisionMismatch {
|
||||
t.Fatalf("view %+v err %v", view, err)
|
||||
}
|
||||
now = now.Add(91 * time.Second)
|
||||
view, _ = store.View("brain-east-01")
|
||||
if !view.Offline {
|
||||
t.Fatal("not offline")
|
||||
}
|
||||
raw = runtimeFixture("018f4d6a-8d1b-4a25-8b37-9085f9c0d102", 2, now, 3)
|
||||
view, err = store.Ingest(raw, map[string]int64{"gate": 3})
|
||||
if err != nil || !view.Recovered {
|
||||
t.Fatalf("recovery %+v err %v", view, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorRejectsUnsafeRequestIDs(t *testing.T) {
|
||||
for _, value := range []string{"short", "0123456789abcde\n", "0123456789abcde!", strings.Repeat("a", 129)} {
|
||||
sender := ConfigSender{Enabled: true, Send: func(string, []byte, string, time.Duration) (SendResponse, error) {
|
||||
t.Fatal("unsafe request id reached transport")
|
||||
return SendResponse{}, nil
|
||||
}}
|
||||
if _, err := sender.Publish(SourceConfig{}, value); err == nil {
|
||||
t.Fatalf("sender accepted request id %q", value)
|
||||
}
|
||||
endpoint := RuntimeStatusEndpoint{Enabled: true}
|
||||
if _, err := endpoint.Receive("Bearer ignored", nil, value, nil); err == nil || err.Error() != "INVALID_CORRELATION_ID" {
|
||||
t.Fatalf("endpoint accepted request id %q: %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeFixture(id string, seq int64, observed time.Time, revision int64) []byte {
|
||||
value := map[string]any{"schema_version": RuntimeStatusVersion, "status_id": id, "brain_instance_ref": "brain-east-01", "sequence": seq, "observed_at": observed.Format(time.RFC3339), "runtime": map[string]any{"state": "running", "version": "1.0.0", "started_at": observed.Format(time.RFC3339)}, "model": map[string]any{"model_ref": "people", "version": "1"}, "configurations": []any{map[string]any{"config_id": "gate", "apply_state": "applied", "applied_revision": revision, "error_code": nil}}, "health": map[string]any{"overall": "healthy", "error_codes": []any{}, "metrics": map[string]any{"load_percent": 1.0, "queue_depth": 0, "latency_ms": 1.0}}, "inputs": []any{}}
|
||||
raw, _ := json.Marshal(value)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var stableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`)
|
||||
|
||||
func MapSourceConfig(f SourceFacts) (SourceConfig, error) {
|
||||
if !stableID.MatchString(f.ConfigID) || !stableID.MatchString(f.SiteID) || !stableID.MatchString(f.LogicalDeviceID) || !stableID.MatchString(f.Profile.ID) || f.Revision < 1 || f.Profile.Width < 1 || f.Profile.Height < 1 || f.Profile.FrameRate <= 0 {
|
||||
return SourceConfig{}, errors.New("invalid source configuration facts")
|
||||
}
|
||||
encoding := strings.ToUpper(f.Profile.Encoding)
|
||||
if encoding != "H264" && encoding != "H265" && encoding != "MJPEG" {
|
||||
return SourceConfig{}, errors.New("unsupported profile encoding")
|
||||
}
|
||||
if f.PublishedAt.IsZero() || f.EffectiveAt.Before(f.PublishedAt) {
|
||||
return SourceConfig{}, errors.New("invalid source configuration time")
|
||||
}
|
||||
if !stableID.MatchString(f.RuleSetVersion) {
|
||||
return SourceConfig{}, errors.New("invalid rule set version")
|
||||
}
|
||||
if strings.ContainsAny(f.MediaPath, "?#@\\") || strings.Contains(f.MediaPath, "://") || f.MediaPath == "" {
|
||||
return SourceConfig{}, errors.New("media path must be opaque and credential-free")
|
||||
}
|
||||
if len(f.Areas) > 1024 || len(f.DirectionalLines) > 1024 {
|
||||
return SourceConfig{}, errors.New("too many rules")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, a := range f.Areas {
|
||||
if !stableID.MatchString(a.ID) || seen[a.ID] || a.Version < 1 || a.Kind != "danger_area" || len(a.Points) < 3 || len(a.Points) > 256 || !validPoints(a.Points) || polygonArea(a.Points) == 0 {
|
||||
return SourceConfig{}, errors.New("invalid area rule")
|
||||
}
|
||||
seen[a.ID] = true
|
||||
}
|
||||
for _, l := range f.DirectionalLines {
|
||||
if !stableID.MatchString(l.ID) || seen[l.ID] || l.Version < 1 || l.Kind != "directional_line" || (l.TriggerDirection != "left_to_right" && l.TriggerDirection != "right_to_left") || !validPoints([]Point{l.Start, l.End}) || l.Start == l.End {
|
||||
return SourceConfig{}, errors.New("invalid directional line rule")
|
||||
}
|
||||
seen[l.ID] = true
|
||||
}
|
||||
var out SourceConfig
|
||||
out.SchemaVersion, out.ConfigID, out.Revision = SourceConfigVersion, f.ConfigID, f.Revision
|
||||
out.PublishedAt, out.EffectiveAt = f.PublishedAt.UTC(), f.EffectiveAt.UTC()
|
||||
out.Site.ID, out.LogicalDevice.ID = f.SiteID, f.LogicalDeviceID
|
||||
out.Profile = f.Profile
|
||||
out.Profile.Encoding = encoding
|
||||
out.Media.Ref, out.Media.Transport = "media:"+strings.TrimPrefix(f.MediaPath, "/"), "rtsp"
|
||||
out.RuleSet.Version = f.RuleSetVersion
|
||||
out.RuleSet.State = "active"
|
||||
if f.Disabled {
|
||||
out.RuleSet.State = "disabled"
|
||||
}
|
||||
if f.NeedsRecalibration {
|
||||
out.RuleSet.State = "recalibration_required"
|
||||
}
|
||||
out.RuleSet.ProfileBinding.ProfileID, out.RuleSet.ProfileBinding.Width, out.RuleSet.ProfileBinding.Height = f.Profile.ID, f.Profile.Width, f.Profile.Height
|
||||
out.RuleSet.Areas = append([]AreaRule(nil), f.Areas...)
|
||||
out.RuleSet.DirectionalLines = append([]DirectionalLineRule(nil), f.DirectionalLines...)
|
||||
if out.RuleSet.State != "active" {
|
||||
for i := range out.RuleSet.Areas {
|
||||
out.RuleSet.Areas[i].Enabled = false
|
||||
}
|
||||
for i := range out.RuleSet.DirectionalLines {
|
||||
out.RuleSet.DirectionalLines[i].Enabled = false
|
||||
}
|
||||
}
|
||||
digest, err := sourceDigest(out)
|
||||
if err != nil {
|
||||
return SourceConfig{}, err
|
||||
}
|
||||
out.Integrity.Algorithm, out.Integrity.Value = "sha256", digest
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func MarshalSourceConfig(config SourceConfig) ([]byte, error) { return json.Marshal(config) }
|
||||
|
||||
func sourceDigest(config SourceConfig) (string, error) {
|
||||
raw, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var value map[string]any
|
||||
if err = json.Unmarshal(raw, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
delete(value, "integrity")
|
||||
canonical, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(canonical)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func validPoints(points []Point) bool {
|
||||
for _, p := range points {
|
||||
if p.X < 0 || p.X > 1 || p.Y < 0 || p.Y > 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func polygonArea(p []Point) float64 {
|
||||
var a float64
|
||||
for i := range p {
|
||||
n := p[(i+1)%len(p)]
|
||||
a += p[i].X*n.Y - n.X*p[i].Y
|
||||
}
|
||||
if a < 0 {
|
||||
a = -a
|
||||
}
|
||||
return a / 2
|
||||
}
|
||||
|
||||
func ValidateSourceConfig(config SourceConfig) error {
|
||||
if config.SchemaVersion != SourceConfigVersion {
|
||||
return fmt.Errorf("unsupported source config version")
|
||||
}
|
||||
digest, err := sourceDigest(config)
|
||||
if err != nil || config.Integrity.Algorithm != "sha256" || digest != config.Integrity.Value {
|
||||
return errors.New("source config integrity mismatch")
|
||||
}
|
||||
_, err = MapSourceConfig(SourceFacts{ConfigID: config.ConfigID, SiteID: config.Site.ID, LogicalDeviceID: config.LogicalDevice.ID, MediaPath: strings.TrimPrefix(config.Media.Ref, "media:"), Revision: config.Revision, PublishedAt: config.PublishedAt, EffectiveAt: config.EffectiveAt, Profile: config.Profile, RuleSetVersion: config.RuleSet.Version, Disabled: config.RuleSet.State == "disabled", NeedsRecalibration: config.RuleSet.State == "recalibration_required", Areas: config.RuleSet.Areas, DirectionalLines: config.RuleSet.DirectionalLines})
|
||||
return err
|
||||
}
|
||||
|
||||
func UTCNow() time.Time { return time.Now().UTC() }
|
||||
@@ -0,0 +1,122 @@
|
||||
package brain_control
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
SourceConfigVersion = "yovision.source-config/v1"
|
||||
RuntimeStatusVersion = "yovision.runtime-status/v1"
|
||||
)
|
||||
|
||||
type Point struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
type Profile struct {
|
||||
ID string `json:"id"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Encoding string `json:"encoding"`
|
||||
FrameRate float64 `json:"frame_rate"`
|
||||
}
|
||||
type AreaRule struct {
|
||||
ID string `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Points []Point `json:"points"`
|
||||
}
|
||||
type DirectionalLineRule struct {
|
||||
ID string `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Start Point `json:"start"`
|
||||
End Point `json:"end"`
|
||||
TriggerDirection string `json:"trigger_direction"`
|
||||
}
|
||||
|
||||
type SourceConfig struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ConfigID string `json:"config_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
EffectiveAt time.Time `json:"effective_at"`
|
||||
Site struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"site"`
|
||||
LogicalDevice struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"logical_device"`
|
||||
Profile Profile `json:"profile"`
|
||||
Media struct {
|
||||
Ref string `json:"ref"`
|
||||
Transport string `json:"transport"`
|
||||
} `json:"media"`
|
||||
RuleSet struct {
|
||||
Version string `json:"version"`
|
||||
State string `json:"state"`
|
||||
ProfileBinding struct {
|
||||
ProfileID string `json:"profile_id"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"profile_binding"`
|
||||
Areas []AreaRule `json:"areas"`
|
||||
DirectionalLines []DirectionalLineRule `json:"directional_lines"`
|
||||
} `json:"rule_set"`
|
||||
Integrity struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Value string `json:"value"`
|
||||
} `json:"integrity"`
|
||||
}
|
||||
|
||||
// SourceFacts is an explicit, credential-free boundary DTO. Callers map their
|
||||
// GORM entities into it; database models are never serialized as a contract.
|
||||
type SourceFacts struct {
|
||||
ConfigID, SiteID, LogicalDeviceID, MediaRouteID, MediaPath string
|
||||
Revision int64
|
||||
PublishedAt, EffectiveAt time.Time
|
||||
Profile Profile
|
||||
RuleSetVersion string
|
||||
Disabled, NeedsRecalibration bool
|
||||
Areas []AreaRule
|
||||
DirectionalLines []DirectionalLineRule
|
||||
}
|
||||
|
||||
type ReplayToken struct {
|
||||
Principal string `gorm:"size:128;primaryKey"`
|
||||
TokenID string `gorm:"size:96;primaryKey"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ReplayToken) TableName() string { return "sense_brain_runtime_replay_tokens" }
|
||||
|
||||
type RuntimeProjection struct {
|
||||
BrainInstanceRef string `gorm:"size:128;primaryKey"`
|
||||
StatusID string `gorm:"size:36;not null;uniqueIndex"`
|
||||
Sequence int64 `gorm:"not null"`
|
||||
ObservedAt time.Time `gorm:"not null;index"`
|
||||
ReceivedAt time.Time `gorm:"not null"`
|
||||
RuntimeState string `gorm:"size:32;not null"`
|
||||
RuntimeVersion string `gorm:"size:64;not null"`
|
||||
ModelRef string `gorm:"size:128;not null"`
|
||||
ModelVersion string `gorm:"size:64;not null"`
|
||||
HealthOverall string `gorm:"size:32;not null"`
|
||||
ExpectedRevisionsJSON string `gorm:"type:jsonb;not null"`
|
||||
ConfigurationsJSON string `gorm:"type:jsonb;not null"`
|
||||
HealthJSON string `gorm:"type:jsonb;not null"`
|
||||
InputsJSON string `gorm:"type:jsonb;not null"`
|
||||
WasOffline bool `gorm:"not null;default:false"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (RuntimeProjection) TableName() string { return "sense_brain_runtime_projections" }
|
||||
|
||||
type SourceRevision struct {
|
||||
ConfigID string `gorm:"size:128;primaryKey"`
|
||||
Revision int64 `gorm:"not null"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SourceRevision) TableName() string { return "sense_brain_source_revisions" }
|
||||
@@ -0,0 +1,145 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
type runtimeStatus struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
StatusID string `json:"status_id"`
|
||||
BrainInstanceRef string `json:"brain_instance_ref"`
|
||||
Sequence int64 `json:"sequence"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
Runtime struct {
|
||||
State string `json:"state"`
|
||||
Version string `json:"version"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
} `json:"runtime"`
|
||||
Model struct {
|
||||
ModelRef string `json:"model_ref"`
|
||||
Version string `json:"version"`
|
||||
} `json:"model"`
|
||||
Configurations []configurationStatus `json:"configurations"`
|
||||
Health healthStatus `json:"health"`
|
||||
Inputs []inputStatus `json:"inputs"`
|
||||
}
|
||||
type configurationStatus struct {
|
||||
ConfigID string `json:"config_id"`
|
||||
ApplyState string `json:"apply_state"`
|
||||
AppliedRevision *int64 `json:"applied_revision"`
|
||||
ErrorCode *string `json:"error_code"`
|
||||
}
|
||||
type metrics struct {
|
||||
LoadPercent float64 `json:"load_percent"`
|
||||
QueueDepth int64 `json:"queue_depth"`
|
||||
LatencyMS float64 `json:"latency_ms"`
|
||||
}
|
||||
type healthStatus struct {
|
||||
Overall string `json:"overall"`
|
||||
ErrorCodes []string `json:"error_codes"`
|
||||
Metrics metrics `json:"metrics"`
|
||||
}
|
||||
type inputStatus struct {
|
||||
InputRef string `json:"input_ref"`
|
||||
State string `json:"state"`
|
||||
ErrorCodes []string `json:"error_codes"`
|
||||
Metrics metrics `json:"metrics"`
|
||||
}
|
||||
|
||||
var uuid4 = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||
var errCode = regexp.MustCompile(`^[A-Z][A-Z0-9_]{2,63}$`)
|
||||
|
||||
func parseRuntimeStatus(raw []byte) (runtimeStatus, error) {
|
||||
var s runtimeStatus
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(&s); err != nil {
|
||||
return s, errors.New("CONFIG_INVALID")
|
||||
}
|
||||
if err := d.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return s, errors.New("CONFIG_INVALID")
|
||||
}
|
||||
if s.SchemaVersion != RuntimeStatusVersion {
|
||||
return s, errors.New("UNSUPPORTED_SCHEMA_VERSION")
|
||||
}
|
||||
if !uuid4.MatchString(s.StatusID) || !stableID.MatchString(s.BrainInstanceRef) || s.Sequence < 0 || s.ObservedAt.IsZero() || !validState(s.Runtime.State) || s.Runtime.Version == "" || !stableID.MatchString(s.Model.ModelRef) || s.Model.Version == "" || len(s.Configurations) > 4096 || len(s.Inputs) > 4096 || !validHealth(s.Health) {
|
||||
return s, errors.New("CONFIG_INVALID")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range s.Configurations {
|
||||
if !stableID.MatchString(c.ConfigID) || seen[c.ConfigID] || !validApply(c) {
|
||||
return s, errors.New("CONFIG_INVALID")
|
||||
}
|
||||
seen[c.ConfigID] = true
|
||||
}
|
||||
for _, i := range s.Inputs {
|
||||
if !stableID.MatchString(i.InputRef) || !validState(i.State) || !validMetrics(i.Metrics) || !validCodes(i.ErrorCodes, 16) {
|
||||
return s, errors.New("CONFIG_INVALID")
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func validState(v string) bool {
|
||||
switch v {
|
||||
case "unconfigured", "starting", "running", "degraded", "failed", "stopped":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func validApply(c configurationStatus) bool {
|
||||
switch c.ApplyState {
|
||||
case "not_configured":
|
||||
return c.AppliedRevision == nil
|
||||
case "applying":
|
||||
return true
|
||||
case "applied":
|
||||
return c.AppliedRevision != nil && *c.AppliedRevision >= 1
|
||||
case "rejected":
|
||||
return c.ErrorCode != nil && errCode.MatchString(*c.ErrorCode)
|
||||
}
|
||||
return false
|
||||
}
|
||||
func validMetrics(m metrics) bool {
|
||||
return m.LoadPercent >= 0 && m.LoadPercent <= 100 && m.QueueDepth >= 0 && m.LatencyMS >= 0
|
||||
}
|
||||
func validCodes(v []string, n int) bool {
|
||||
if len(v) > n {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, x := range v {
|
||||
if seen[x] || !errCode.MatchString(x) {
|
||||
return false
|
||||
}
|
||||
seen[x] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
func validHealth(h healthStatus) bool {
|
||||
return (h.Overall == "healthy" || h.Overall == "degraded" || h.Overall == "unhealthy") && validCodes(h.ErrorCodes, 32) && validMetrics(h.Metrics)
|
||||
}
|
||||
func validRuntimeTransition(from, to string) bool {
|
||||
if from == to {
|
||||
return true
|
||||
}
|
||||
switch from {
|
||||
case "unconfigured":
|
||||
return to == "starting" || to == "stopped"
|
||||
case "starting":
|
||||
return to == "running" || to == "degraded" || to == "failed" || to == "stopped"
|
||||
case "running":
|
||||
return to == "degraded" || to == "failed" || to == "stopped"
|
||||
case "degraded":
|
||||
return to == "running" || to == "failed" || to == "stopped"
|
||||
case "failed":
|
||||
return to == "starting" || to == "stopped"
|
||||
case "stopped":
|
||||
return to == "starting"
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type GORMReplayStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s GORMReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
if s.DB == nil || principal == "" || tokenID == "" || !expiresAt.After(now) {
|
||||
return false
|
||||
}
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("expires_at <= ?", now).Delete(&ReplayToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt, CreatedAt: now})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("replayed")
|
||||
}
|
||||
return nil
|
||||
}) == nil
|
||||
}
|
||||
|
||||
type RevisionStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s RevisionStore) Next(configID string) (int64, error) {
|
||||
if s.DB == nil || !stableID.MatchString(configID) {
|
||||
return 0, errors.New("invalid revision store")
|
||||
}
|
||||
var next int64
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var row SourceRevision
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("config_id = ?", configID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
row = SourceRevision{ConfigID: configID, Revision: 1}
|
||||
if err = tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
next = 1
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.Revision++
|
||||
next = row.Revision
|
||||
return tx.Save(&row).Error
|
||||
})
|
||||
return next, err
|
||||
}
|
||||
|
||||
type ProjectionStore struct {
|
||||
DB *gorm.DB
|
||||
Clock func() time.Time
|
||||
StaleAfter time.Duration
|
||||
FutureSkew time.Duration
|
||||
}
|
||||
type ProjectionView struct {
|
||||
Projection RuntimeProjection
|
||||
Offline, Stale, Recovered, RevisionMismatch bool
|
||||
}
|
||||
|
||||
func (s ProjectionStore) Ingest(raw []byte, expected map[string]int64) (ProjectionView, error) {
|
||||
if s.DB == nil {
|
||||
return ProjectionView{}, errors.New("projection database required")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Clock != nil {
|
||||
now = s.Clock().UTC()
|
||||
}
|
||||
if s.StaleAfter == 0 {
|
||||
s.StaleAfter = 90 * time.Second
|
||||
}
|
||||
if s.FutureSkew == 0 {
|
||||
s.FutureSkew = 30 * time.Second
|
||||
}
|
||||
status, err := parseRuntimeStatus(raw)
|
||||
if err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
if status.ObservedAt.After(now.Add(s.FutureSkew)) {
|
||||
return ProjectionView{}, errors.New("FUTURE_OBSERVATION")
|
||||
}
|
||||
var view ProjectionView
|
||||
err = s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var old RuntimeProjection
|
||||
find := tx.Where("brain_instance_ref = ?", status.BrainInstanceRef).First(&old).Error
|
||||
if find == nil {
|
||||
if old.StatusID == status.StatusID {
|
||||
view.Projection = old
|
||||
return nil
|
||||
}
|
||||
if status.Sequence <= old.Sequence {
|
||||
return errors.New("OUT_OF_ORDER_STATUS")
|
||||
}
|
||||
if !validRuntimeTransition(old.RuntimeState, status.Runtime.State) {
|
||||
return errors.New("INVALID_STATUS_TRANSITION")
|
||||
}
|
||||
}
|
||||
if find != nil && !errors.Is(find, gorm.ErrRecordNotFound) {
|
||||
return find
|
||||
}
|
||||
expectedJSON, _ := json.Marshal(expected)
|
||||
configs, _ := json.Marshal(status.Configurations)
|
||||
health, _ := json.Marshal(status.Health)
|
||||
inputs, _ := json.Marshal(status.Inputs)
|
||||
p := RuntimeProjection{BrainInstanceRef: status.BrainInstanceRef, StatusID: status.StatusID, Sequence: status.Sequence, ObservedAt: status.ObservedAt, ReceivedAt: now, RuntimeState: status.Runtime.State, RuntimeVersion: status.Runtime.Version, ModelRef: status.Model.ModelRef, ModelVersion: status.Model.Version, HealthOverall: status.Health.Overall, ExpectedRevisionsJSON: string(expectedJSON), ConfigurationsJSON: string(configs), HealthJSON: string(health), InputsJSON: string(inputs), WasOffline: find == nil && now.Sub(old.ObservedAt) > s.StaleAfter}
|
||||
if find == nil {
|
||||
p.CreatedAt = old.CreatedAt
|
||||
view.Recovered = p.WasOffline
|
||||
}
|
||||
if err := tx.Save(&p).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
view.Projection = p
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
view.Stale = now.Sub(view.Projection.ObservedAt) > s.StaleAfter
|
||||
view.Offline = view.Stale
|
||||
applied := make(map[string]*int64, len(status.Configurations))
|
||||
for _, c := range status.Configurations {
|
||||
applied[c.ConfigID] = c.AppliedRevision
|
||||
}
|
||||
for configID, want := range expected {
|
||||
got, ok := applied[configID]
|
||||
if !ok || got == nil || *got != want {
|
||||
view.RevisionMismatch = true
|
||||
}
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s ProjectionStore) View(instance string) (ProjectionView, error) {
|
||||
var p RuntimeProjection
|
||||
if err := s.DB.First(&p, "brain_instance_ref = ?", instance).Error; err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Clock != nil {
|
||||
now = s.Clock().UTC()
|
||||
}
|
||||
stale := s.StaleAfter
|
||||
if stale == 0 {
|
||||
stale = 90 * time.Second
|
||||
}
|
||||
return ProjectionView{Projection: p, Offline: now.Sub(p.ObservedAt) > stale, Stale: now.Sub(p.ObservedAt) > stale}, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/brain_control"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBrainRuntime)
|
||||
}
|
||||
|
||||
func migrateBrainRuntime(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&brain_control.ReplayToken{}, &brain_control.RuntimeProjection{}, &brain_control.SourceRevision{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/brain_control"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMigrateBrainRuntime(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:brain-runtime-migration?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migrateBrainRuntime(db, "2026083112000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, model := range []any{&brain_control.ReplayToken{}, &brain_control.RuntimeProjection{}, &brain_control.SourceRevision{}} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
t.Fatalf("missing table for %T", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package brain_control_test
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bc "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/brain_control"
|
||||
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T, name string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&bc.ReplayToken{}, &bc.RuntimeProjection{}, &bc.SourceRevision{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMapperProducesCredentialFreeFrozenContract(t *testing.T) {
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
c, err := bc.MapSourceConfig(bc.SourceFacts{ConfigID: "gate-primary", SiteID: "site-east", LogicalDeviceID: "camera-1", MediaPath: "site-east/camera-1/main", Revision: 1, PublishedAt: now, EffectiveAt: now, Profile: bc.Profile{ID: "main", Width: 1920, Height: 1080, Encoding: "h264", FrameRate: 25}, RuleSetVersion: "rules-1", Areas: []bc.AreaRule{{ID: "danger", Version: 1, Kind: "danger_area", Enabled: true, Points: []bc.Point{{X: .1, Y: .1}, {X: .8, Y: .1}, {X: .5, Y: .8}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = bc.ValidateSourceConfig(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(c)
|
||||
text := strings.ToLower(string(raw))
|
||||
for _, secret := range []string{"password", "username", "rtsp://", "stream_uri", "credential"} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Fatalf("leaked %q", secret)
|
||||
}
|
||||
}
|
||||
c2, err := bc.MapSourceConfig(bc.SourceFacts{ConfigID: "gate-primary", SiteID: "site-east", LogicalDeviceID: "camera-1", MediaPath: "site-east/camera-1/main", Revision: 2, PublishedAt: now, EffectiveAt: now, Profile: bc.Profile{ID: "main-v2", Width: 1280, Height: 720, Encoding: "H265", FrameRate: 20}, RuleSetVersion: "rules-2", NeedsRecalibration: true, Areas: []bc.AreaRule{{ID: "danger", Version: 2, Kind: "danger_area", Enabled: true, Points: []bc.Point{{X: .1, Y: .1}, {X: .8, Y: .1}, {X: .5, Y: .8}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c2.RuleSet.State != "recalibration_required" || c2.RuleSet.Areas[0].Enabled {
|
||||
t.Fatal("recalibration must disable rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableReplayIsAtomicAndSurvivesVerifierRestart(t *testing.T) {
|
||||
db := testDB(t, "sense-replay")
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
registry, _ := mi.NewRegistry(mi.KeyRecord{Principal: "yv:brain:east", KeyID: "brain-key-01", PublicKey: pub, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true})
|
||||
signer := mi.Signer{Principal: "yv:brain:east", KeyID: "brain-key-01", PrivateKey: priv, Now: func() time.Time { return now }}
|
||||
body := []byte(`{"ok":true}`)
|
||||
token, _ := signer.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
results := make(chan bool, 8)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v := mi.Verifier{Registry: registry, Replay: bc.GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
_, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body)
|
||||
results <- err == nil
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
accepted := 0
|
||||
for ok := range results {
|
||||
if ok {
|
||||
accepted++
|
||||
}
|
||||
}
|
||||
if accepted != 1 {
|
||||
t.Fatalf("accepted=%d", accepted)
|
||||
}
|
||||
v2 := mi.Verifier{Registry: registry, Replay: bc.GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
if _, err := v2.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
|
||||
t.Fatal("replay accepted after verifier restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionStoreConcurrentAndRestart(t *testing.T) {
|
||||
db := testDB(t, "sense-revisions")
|
||||
store := bc.RevisionStore{DB: db}
|
||||
for want := int64(1); want <= 3; want++ {
|
||||
got, err := store.Next("gate-primary")
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("got %d err %v", got, err)
|
||||
}
|
||||
}
|
||||
restarted := bc.RevisionStore{DB: db}
|
||||
got, err := restarted.Next("gate-primary")
|
||||
if err != nil || got != 4 {
|
||||
t.Fatalf("restart got %d err %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeProjectionStaleRecoveryMismatchAndOrdering(t *testing.T) {
|
||||
db := testDB(t, "sense-projection")
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
store := bc.ProjectionStore{DB: db, Clock: func() time.Time { return now }, StaleAfter: 90 * time.Second}
|
||||
raw := statusJSON("018f4d6a-8d1b-4a25-8b37-9085f9c0d101", 41, now, "running", 20)
|
||||
view, err := store.Ingest(raw, map[string]int64{"gate-primary": 21})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !view.RevisionMismatch || view.Stale {
|
||||
t.Fatalf("bad initial view %+v", view)
|
||||
}
|
||||
now = now.Add(91 * time.Second)
|
||||
view, err = store.View("brain-east-01")
|
||||
if err != nil || !view.Offline || !view.Stale {
|
||||
t.Fatalf("offline %+v %v", view, err)
|
||||
}
|
||||
raw = statusJSON("018f4d6a-8d1b-4a25-8b37-9085f9c0d102", 42, now, "running", 21)
|
||||
view, err = store.Ingest(raw, map[string]int64{"gate-primary": 21})
|
||||
if err != nil || !view.Recovered || view.RevisionMismatch {
|
||||
t.Fatalf("recovery %+v %v", view, err)
|
||||
}
|
||||
if _, err = store.Ingest(statusJSON("018f4d6a-8d1b-4a25-8b37-9085f9c0d103", 41, now, "running", 21), nil); err == nil || err.Error() != "OUT_OF_ORDER_STATUS" {
|
||||
t.Fatalf("expected ordering rejection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func statusJSON(id string, seq int64, observed time.Time, state string, revision int64) []byte {
|
||||
v := map[string]any{"schema_version": bc.RuntimeStatusVersion, "status_id": id, "brain_instance_ref": "brain-east-01", "sequence": seq, "observed_at": observed.Format(time.RFC3339), "runtime": map[string]any{"state": state, "version": "1.0.0", "started_at": observed.Add(-time.Minute).Format(time.RFC3339)}, "model": map[string]any{"model_ref": "people-detection", "version": "2026.08.1"}, "configurations": []any{map[string]any{"config_id": "gate-primary", "apply_state": "applied", "applied_revision": revision, "error_code": nil}}, "health": map[string]any{"overall": "healthy", "error_codes": []any{}, "metrics": map[string]any{"load_percent": 1.0, "queue_depth": 0, "latency_ms": 2.0}}, "inputs": []any{}}
|
||||
raw, _ := json.Marshal(v)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module git.ilapage.cn/ila/yovision/Sense/tests/integration/brain_control
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
git.ilapage.cn/ila/yovision/Sense/server v0.0.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.49 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
|
||||
replace git.ilapage.cn/ila/yovision/Sense/server => ../../../server
|
||||
@@ -0,0 +1,12 @@
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -0,0 +1,40 @@
|
||||
# YoVision 可选协调部署
|
||||
|
||||
本目录只组合已经验收的 Sense、Brain、Bell 交付入口,不复制产品实现,也不成为业务事实源。三个产品仍使用独立包、进程、端口、数据目录、日志、数据库角色、账户空间、浏览器 Cookie 和机器身份。
|
||||
|
||||
## 准备
|
||||
|
||||
1. 将 `coordination.example.json` 复制到仓库外受控配置目录,填写三个已审核交付包的精确版本与绝对路径。
|
||||
2. 分别准备仓库外 `sense.env`、`brain.env`、`bell.env`。Sense 至少提供 `SENSE_DATABASE_URL`、`SENSE_JWT_SECRET`、`SENSE_PORT`,Bell 至少提供 `BELL_DATABASE_URL`、`BELL_JWT_SECRET`、`BELL_PORT`、`BELL_WEB_PORT`;其余配置(包括 connector 开关)仍遵循各产品自己的环境文件说明。不得在清单或仓库中填写密码、JWT、token 或私钥内容。
|
||||
3. 为每个调用实例创建独立 Ed25519 私钥和消费者公钥注册表;清单只引用私钥路径。
|
||||
4. 为 Sense、Bell 创建不同 PostgreSQL 数据库和角色;先使用各自迁移入口完成备份与迁移。
|
||||
5. 确认清单声明的所有端口、包目录、数据目录和日志目录互不重叠。
|
||||
|
||||
编排器会创建并隔离清单中的数据、日志目录;各产品环境文件或产品配置还必须把自身持久化与业务日志指向对应目录。编排器不会猜测或改写产品内部配置。
|
||||
|
||||
示例清单中的地址、版本和标识都是不可直接投产的占位值。默认 16 路只是当前交付配额,不是编排器容量上限。
|
||||
|
||||
## 命令
|
||||
|
||||
从仓库根目录运行;不需要修改 PowerShell 执行策略:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File scripts/runtime/coordination/start-yovision.ps1 -Manifest C:\YoVision\config\coordination.json -ValidateOnly
|
||||
pwsh -NoProfile -File scripts/runtime/coordination/start-yovision.ps1 -Manifest C:\YoVision\config\coordination.json -Product bell,sense,brain
|
||||
pwsh -NoProfile -File scripts/runtime/coordination/status-yovision.ps1 -Manifest C:\YoVision\config\coordination.json -Product all
|
||||
pwsh -NoProfile -File scripts/runtime/coordination/stop-yovision.ps1 -Manifest C:\YoVision\config\coordination.json -Product brain
|
||||
```
|
||||
|
||||
`.bat` 包装器接受相同参数。启动时,`all` 只包含清单中 `enabled=true` 的产品;停止和查看状态时,`all` 会覆盖三个产品,避免产品被停用后遗留进程。启动顺序固定为 Bell → Sense → Brain,停止顺序固定为 Brain → Sense → Bell。单端失败只返回非零并清理该端新进程,不自动停止已经运行的其他端。
|
||||
|
||||
## 状态与日志
|
||||
|
||||
编排状态写入清单的 `runtime_root\state`,每端只保存 PID、启动时间、版本、命令路径和清单摘要,不保存环境变量值。协调启动日志分别写入各产品独立 `log_directory`。产品自己的日志仍由产品入口管理。
|
||||
|
||||
`status` 返回 `running`、`unhealthy`、`stopped` 或 `stale`;仅当所选产品全部健康运行时退出码为 0。PID 与命令归属不匹配时不停止进程,必须人工核对。
|
||||
|
||||
## 升级与回退
|
||||
|
||||
升级顺序为:备份 Sense/Bell → 迁移 Bell → 启动/检查 Bell → 迁移 Sense → 启动/检查 Sense → 更新 Brain → 启用 connector。每次只替换一个独立包并更新清单版本。
|
||||
|
||||
回退时先停用 Brain event export、Sense ingress/relay 与 Bell ingress/evidence connector,再按产品独立入口回退包或恢复各自数据库。不得删除 Sense Outbox、运行投影、Bell Receipt/Event、replay 或审计事实。根级编排故障时直接恢复三个产品原有独立入口。
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"schema_version": "yovision.coordination/v1",
|
||||
"deployment_id": "school-a-yovision",
|
||||
"runtime_root": "C:\\YoVision\\runtime\\coordination",
|
||||
"products": {
|
||||
"sense": {
|
||||
"enabled": true,
|
||||
"version": "replace-with-reviewed-sense-artifact-version",
|
||||
"package_root": "C:\\YoVision\\packages\\sense",
|
||||
"environment_file": "C:\\YoVision\\secrets\\sense.env",
|
||||
"data_directory": "C:\\YoVision\\data\\sense",
|
||||
"log_directory": "C:\\YoVision\\logs\\sense",
|
||||
"ports": [18080, 9997, 8889],
|
||||
"browser_origin": "http://127.0.0.1:18080",
|
||||
"cookie_name": "Sense-Admin-Token",
|
||||
"account_namespace": "sense-users",
|
||||
"database_id": "sense",
|
||||
"database_role": "sense_app",
|
||||
"start": { "executable": "scripts\\runtime\\start-sense.ps1", "arguments": ["-Mode", "production"] },
|
||||
"stop": { "executable": "scripts\\runtime\\stop-sense.ps1", "arguments": ["-Mode", "production"] },
|
||||
"health": { "kind": "http", "url": "http://127.0.0.1:18080/healthz", "timeout_seconds": 60 },
|
||||
"identities": [
|
||||
{ "principal": "yv:sense:school-a", "key_id": "sense-2026-01", "private_key_path": "C:\\YoVision\\secrets\\sense-to-bell.ed25519" }
|
||||
]
|
||||
},
|
||||
"brain": {
|
||||
"enabled": true,
|
||||
"version": "replace-with-reviewed-brain-artifact-version",
|
||||
"package_root": "C:\\YoVision\\packages\\brain",
|
||||
"environment_file": "C:\\YoVision\\secrets\\brain.env",
|
||||
"data_directory": "C:\\YoVision\\data\\brain",
|
||||
"log_directory": "C:\\YoVision\\logs\\brain",
|
||||
"ports": [18100],
|
||||
"browser_origin": "",
|
||||
"cookie_name": "",
|
||||
"account_namespace": "brain-machine-only",
|
||||
"database_id": "",
|
||||
"database_role": "",
|
||||
"start": { "executable": ".venv\\Scripts\\python.exe", "arguments": ["-m", "yovision_brain.app", "--config", "C:\\YoVision\\config\\brain.json", "--output", "-"] },
|
||||
"health": { "kind": "process", "timeout_seconds": 15 },
|
||||
"identities": [
|
||||
{ "principal": "yv:brain:school-a", "key_id": "brain-2026-01", "private_key_path": "C:\\YoVision\\secrets\\brain-to-sense.ed25519" }
|
||||
]
|
||||
},
|
||||
"bell": {
|
||||
"enabled": true,
|
||||
"version": "replace-with-reviewed-bell-artifact-version",
|
||||
"package_root": "C:\\YoVision\\packages\\bell",
|
||||
"environment_file": "C:\\YoVision\\secrets\\bell.env",
|
||||
"data_directory": "C:\\YoVision\\data\\bell",
|
||||
"log_directory": "C:\\YoVision\\logs\\bell",
|
||||
"ports": [18090, 18091],
|
||||
"browser_origin": "http://127.0.0.1:18091",
|
||||
"cookie_name": "Bell-Admin-Token",
|
||||
"account_namespace": "bell-users",
|
||||
"database_id": "bell",
|
||||
"database_role": "bell_app",
|
||||
"start": { "executable": "scripts\\runtime\\start-bell.ps1", "arguments": [] },
|
||||
"stop": { "executable": "scripts\\runtime\\stop-bell.ps1", "arguments": [] },
|
||||
"health": { "kind": "http", "url": "http://127.0.0.1:18090/healthz", "timeout_seconds": 60 },
|
||||
"identities": [
|
||||
{ "principal": "yv:bell:school-a", "key_id": "bell-2026-01", "private_key_path": "C:\\YoVision\\secrets\\bell-to-sense.ed25519" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/schemas/coordination/v1",
|
||||
"title": "YoVision coordination deployment manifest v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema_version", "deployment_id", "runtime_root", "products"],
|
||||
"properties": {
|
||||
"schema_version": { "const": "yovision.coordination/v1" },
|
||||
"deployment_id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$" },
|
||||
"runtime_root": { "type": "string", "minLength": 1 },
|
||||
"products": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["sense", "brain", "bell"],
|
||||
"properties": {
|
||||
"sense": { "$ref": "#/$defs/product" },
|
||||
"brain": { "$ref": "#/$defs/product" },
|
||||
"bell": { "$ref": "#/$defs/product" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"command": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["executable", "arguments"],
|
||||
"properties": {
|
||||
"executable": { "type": "string", "minLength": 1 },
|
||||
"arguments": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"identity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["principal", "key_id", "private_key_path"],
|
||||
"properties": {
|
||||
"principal": { "type": "string", "pattern": "^yv:(sense|brain|bell):[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
|
||||
"key_id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
|
||||
"private_key_path": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"product": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "version", "package_root", "environment_file", "data_directory", "log_directory", "ports", "browser_origin", "cookie_name", "account_namespace", "database_id", "database_role", "start", "health", "identities"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"version": { "type": "string", "minLength": 1 },
|
||||
"package_root": { "type": "string", "minLength": 1 },
|
||||
"environment_file": { "type": "string", "minLength": 1 },
|
||||
"data_directory": { "type": "string", "minLength": 1 },
|
||||
"log_directory": { "type": "string", "minLength": 1 },
|
||||
"ports": { "type": "array", "items": { "type": "integer", "minimum": 1, "maximum": 65535 }, "uniqueItems": true },
|
||||
"browser_origin": { "type": "string" },
|
||||
"cookie_name": { "type": "string" },
|
||||
"account_namespace": { "type": "string", "minLength": 1 },
|
||||
"database_id": { "type": "string" },
|
||||
"database_role": { "type": "string" },
|
||||
"start": { "$ref": "#/$defs/command" },
|
||||
"stop": { "$ref": "#/$defs/command" },
|
||||
"health": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "timeout_seconds"],
|
||||
"properties": {
|
||||
"kind": { "enum": ["process", "http"] },
|
||||
"url": { "type": "string" },
|
||||
"timeout_seconds": { "type": "integer", "minimum": 1, "maximum": 300 }
|
||||
}
|
||||
},
|
||||
"identities": { "type": "array", "items": { "$ref": "#/$defs/identity" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-True {
|
||||
param([Parameter(Mandatory = $true)][bool]$Condition, [Parameter(Mandatory = $true)][string]$Message)
|
||||
if (-not $Condition) { throw $Message }
|
||||
}
|
||||
|
||||
function Get-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
|
||||
}
|
||||
|
||||
function Write-Utf8File {
|
||||
param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
$directory = Split-Path -Parent $Path
|
||||
if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null }
|
||||
[IO.File]::WriteAllText($Path, $Content, [Text.UTF8Encoding]::new($false))
|
||||
}
|
||||
|
||||
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
|
||||
$scriptsRoot = Join-Path $repositoryRoot 'scripts\runtime\coordination'
|
||||
$startScript = Join-Path $scriptsRoot 'start-yovision.ps1'
|
||||
$stopScript = Join-Path $scriptsRoot 'stop-yovision.ps1'
|
||||
$statusScript = Join-Path $scriptsRoot 'status-yovision.ps1'
|
||||
$tempParent = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$testRoot = Join-Path $tempParent ("yovision coordination-" + [guid]::NewGuid().ToString('N'))
|
||||
$manifestPath = Join-Path $testRoot 'config\coordination.json'
|
||||
$manifest = $null
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Force -Path $testRoot,(Join-Path $testRoot 'markers') | Out-Null
|
||||
$ports = @{ sense = Get-FreePort; brain = Get-FreePort; bell = Get-FreePort; bellWeb = Get-FreePort }
|
||||
Assert-True (($ports.Values | Select-Object -Unique).Count -eq 4) 'Dynamic test ports are not unique.'
|
||||
$products = [ordered]@{}
|
||||
foreach ($name in @('sense', 'brain', 'bell')) {
|
||||
$packageRoot = Join-Path $testRoot "packages\$name"
|
||||
$start = Join-Path $packageRoot 'start.ps1'
|
||||
$stop = Join-Path $packageRoot 'stop.ps1'
|
||||
Write-Utf8File -Path $start -Content @'
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($env:FAKE_STARTED_FILE) { [IO.File]::WriteAllText($env:FAKE_STARTED_FILE, $PID.ToString(), [Text.UTF8Encoding]::new($false)) }
|
||||
while ($true) { Start-Sleep -Milliseconds 250 }
|
||||
'@
|
||||
Write-Utf8File -Path $stop -Content @'
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ($env:FAKE_STOPPED_FILE) { [IO.File]::WriteAllText($env:FAKE_STOPPED_FILE, 'stopped', [Text.UTF8Encoding]::new($false)) }
|
||||
$ownedPID = 0
|
||||
if (-not [int]::TryParse($env:YOVISION_COORDINATION_OWNED_PID, [ref]$ownedPID)) { exit 4 }
|
||||
& taskkill.exe /PID $ownedPID /T /F 2>$null | Out-Null
|
||||
exit 0
|
||||
'@
|
||||
$environmentPath = Join-Path $testRoot "secrets\$name.env"
|
||||
$environment = "FAKE_STARTED_FILE=$(Join-Path $testRoot "markers\$name.started")`nFAKE_STOPPED_FILE=$(Join-Path $testRoot "markers\$name.stopped")`n"
|
||||
if ($name -eq 'sense') { $environment += "SENSE_DATABASE_URL=postgres://sense_role@127.0.0.1/sense_db`nSENSE_JWT_SECRET=$(('s' * 40))`nSENSE_PORT=$($ports.sense)`n" }
|
||||
if ($name -eq 'bell') { $environment += "BELL_DATABASE_URL=postgres://bell_role@127.0.0.1/bell_db`nBELL_JWT_SECRET=$(('b' * 40))`nBELL_PORT=$($ports.bell)`nBELL_WEB_PORT=$($ports.bellWeb)`n" }
|
||||
Write-Utf8File -Path $environmentPath -Content $environment
|
||||
$keyPath = Join-Path $testRoot "secrets\$name.ed25519"
|
||||
Write-Utf8File -Path $keyPath -Content ([guid]::NewGuid().ToString('N'))
|
||||
$productPorts = if ($name -eq 'sense') { @($ports.sense) } elseif ($name -eq 'bell') { @($ports.bell, $ports.bellWeb) } else { @($ports.brain) }
|
||||
$products[$name] = [ordered]@{
|
||||
enabled = $true; version = "test-$name-v1"; package_root = $packageRoot; environment_file = $environmentPath
|
||||
data_directory = (Join-Path $testRoot "data\$name"); log_directory = (Join-Path $testRoot "logs\$name"); ports = $productPorts
|
||||
browser_origin = $(if ($name -eq 'sense') { "http://127.0.0.1:$($ports.sense)" } elseif ($name -eq 'bell') { "http://127.0.0.1:$($ports.bellWeb)" } else { '' })
|
||||
cookie_name = $(if ($name -eq 'sense') { 'Sense-Admin-Token' } elseif ($name -eq 'bell') { 'Bell-Admin-Token' } else { '' })
|
||||
account_namespace = "$name-accounts"; database_id = $(if ($name -eq 'brain') { '' } else { "${name}_db" }); database_role = $(if ($name -eq 'brain') { '' } else { "${name}_role" })
|
||||
start = [ordered]@{ executable = 'start.ps1'; arguments = @() }; stop = [ordered]@{ executable = 'stop.ps1'; arguments = @() }
|
||||
health = [ordered]@{ kind = 'process'; timeout_seconds = 5 }
|
||||
identities = @([ordered]@{ principal = "yv:${name}:test"; key_id = "${name}-test-key"; private_key_path = $keyPath })
|
||||
}
|
||||
}
|
||||
$manifest = [ordered]@{ schema_version = 'yovision.coordination/v1'; deployment_id = 'test-coordination'; runtime_root = (Join-Path $testRoot 'runtime'); products = $products }
|
||||
Write-Utf8File -Path $manifestPath -Content ($manifest | ConvertTo-Json -Depth 20)
|
||||
|
||||
& pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -ValidateOnly
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'Manifest validation failed.'
|
||||
|
||||
& pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product sense
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'Sense-only start failed.'
|
||||
& pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product sense -Json
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'Sense-only status is not healthy.'
|
||||
& pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product bell -Json
|
||||
Assert-True ($LASTEXITCODE -eq 3) 'Stopped Bell status did not return exit code 3.'
|
||||
& pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product sense
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'Sense-only stop failed.'
|
||||
|
||||
$occupiedPort = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, $ports.sense)
|
||||
try {
|
||||
$occupiedPort.Start()
|
||||
& pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product sense 2>$null
|
||||
Assert-True ($LASTEXITCODE -eq 1) 'An occupied Sense port was not rejected.'
|
||||
Assert-True (-not (Test-Path -LiteralPath (Join-Path $testRoot 'runtime\state\sense.json'))) 'Occupied-port failure left Sense state behind.'
|
||||
} finally {
|
||||
$occupiedPort.Stop()
|
||||
}
|
||||
|
||||
foreach ($standaloneProduct in @('brain', 'bell')) {
|
||||
& pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product $standaloneProduct
|
||||
Assert-True ($LASTEXITCODE -eq 0) "$standaloneProduct standalone start failed."
|
||||
& pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product $standaloneProduct -Json
|
||||
Assert-True ($LASTEXITCODE -eq 0) "$standaloneProduct standalone status is not healthy."
|
||||
& pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product $standaloneProduct
|
||||
Assert-True ($LASTEXITCODE -eq 0) "$standaloneProduct standalone stop failed."
|
||||
}
|
||||
|
||||
& pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product brain,bell
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'Brain+Bell combination start failed.'
|
||||
$bellStatePath = Join-Path $testRoot 'runtime\state\bell.json'
|
||||
$bellStateText = Get-Content -LiteralPath $bellStatePath -Raw -Encoding UTF8
|
||||
$foreignState = $bellStateText | ConvertFrom-Json
|
||||
$foreignState.pid = $PID
|
||||
Write-Utf8File -Path $bellStatePath -Content ($foreignState | ConvertTo-Json -Depth 10)
|
||||
$ownershipStatus = & pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product bell -Json
|
||||
Assert-True ($LASTEXITCODE -eq 3 -and ($ownershipStatus -join "`n") -match 'ownership-mismatch') 'Foreign PID ownership was not diagnosed.'
|
||||
& pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product bell 2>$null
|
||||
Assert-True ($LASTEXITCODE -eq 1 -and $null -ne (Get-Process -Id $PID -ErrorAction SilentlyContinue)) 'Stop did not protect an unrelated process.'
|
||||
Write-Utf8File -Path $bellStatePath -Content $bellStateText
|
||||
|
||||
$manifestText = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8
|
||||
Write-Utf8File -Path $manifestPath -Content ($manifestText + "`n")
|
||||
$driftStatus = & pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product bell -Json
|
||||
Assert-True ($LASTEXITCODE -eq 3 -and ($driftStatus -join "`n") -match 'manifest-drift') 'Manifest drift was not diagnosed.'
|
||||
& pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product bell
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'Owned Bell could not be stopped after manifest drift.'
|
||||
Write-Utf8File -Path $manifestPath -Content $manifestText
|
||||
& pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product bell
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'Bell restart after manifest restoration failed.'
|
||||
|
||||
& pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product brain
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'Brain-only stop failed.'
|
||||
& pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product bell -Json
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'Stopping Brain damaged Bell.'
|
||||
|
||||
Write-Utf8File -Path (Join-Path $testRoot 'packages\sense\start.ps1') -Content 'exit 7'
|
||||
& pwsh.exe -NoProfile -File $startScript -Manifest $manifestPath -Product sense 2>$null
|
||||
Assert-True ($LASTEXITCODE -eq 1) 'A failing product start did not return exit code 1.'
|
||||
& pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product bell -Json
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'A Sense start failure damaged Bell.'
|
||||
|
||||
$badManifest = $manifest | ConvertTo-Json -Depth 20 | ConvertFrom-Json -Depth 20
|
||||
$badManifest.products.bell.database_id = $badManifest.products.sense.database_id
|
||||
$badPath = Join-Path $testRoot 'config\invalid-isolation.json'
|
||||
Write-Utf8File -Path $badPath -Content ($badManifest | ConvertTo-Json -Depth 20)
|
||||
& pwsh.exe -NoProfile -File $startScript -Manifest $badPath -ValidateOnly 2>$null
|
||||
Assert-True ($LASTEXITCODE -eq 1) 'Shared database identity was not rejected.'
|
||||
|
||||
$badPortManifest = $manifest | ConvertTo-Json -Depth 20 | ConvertFrom-Json -Depth 20
|
||||
$badPortManifest.products.bell.ports[0] = $badPortManifest.products.sense.ports[0]
|
||||
$badBellEnvironmentPath = Join-Path $testRoot 'secrets\bell-duplicate-port.env'
|
||||
$badBellEnvironment = Get-Content -LiteralPath $badPortManifest.products.bell.environment_file -Raw -Encoding UTF8
|
||||
$badBellEnvironment = $badBellEnvironment -replace "(?m)^BELL_PORT=\d+$", "BELL_PORT=$($ports.sense)"
|
||||
Write-Utf8File -Path $badBellEnvironmentPath -Content $badBellEnvironment
|
||||
$badPortManifest.products.bell.environment_file = $badBellEnvironmentPath
|
||||
$badPortPath = Join-Path $testRoot 'config\invalid-port-isolation.json'
|
||||
Write-Utf8File -Path $badPortPath -Content ($badPortManifest | ConvertTo-Json -Depth 20)
|
||||
& pwsh.exe -NoProfile -File $startScript -Manifest $badPortPath -ValidateOnly 2>$null
|
||||
Assert-True ($LASTEXITCODE -eq 1) 'Shared product port was not rejected.'
|
||||
|
||||
& pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product all
|
||||
Assert-True ($LASTEXITCODE -eq 0) 'Final stop failed.'
|
||||
& pwsh.exe -NoProfile -File $statusScript -Manifest $manifestPath -Product all -Json
|
||||
Assert-True ($LASTEXITCODE -eq 3) 'Stopped combination did not report non-running status.'
|
||||
foreach ($name in @('sense', 'brain', 'bell')) {
|
||||
$statePath = Join-Path $testRoot "runtime\state\$name.json"
|
||||
Assert-True (-not (Test-Path -LiteralPath $statePath)) "State was not cleaned for $name."
|
||||
}
|
||||
Write-Host 'Coordination smoke passed: validation, port isolation, independent start/stop, combination, failure isolation, ownership and cleanup.'
|
||||
exit 0
|
||||
} finally {
|
||||
if ($manifest -and (Test-Path -LiteralPath $manifestPath)) { & pwsh.exe -NoProfile -File $stopScript -Manifest $manifestPath -Product all 2>$null | Out-Null }
|
||||
$resolved = [IO.Path]::GetFullPath($testRoot)
|
||||
if ($resolved.StartsWith($tempParent, [StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $resolved)) { Remove-Item -LiteralPath $resolved -Recurse -Force }
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 41c2193b2f1edb37abe1e8994d65d02207549039
|
||||
synchronized_at: 2026-08-31T03:18:12Z
|
||||
wiki_revision: b25498f58c690f787f0b572788617ef6b0d8ea7d
|
||||
synchronized_at: 2026-08-31T07:23:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -298,51 +298,56 @@ Brain 解码层位于 `Brain/src/yovision_brain/decode/`,只依赖 #11 的内
|
||||
<!-- brain-local-events-v1:end -->
|
||||
|
||||
<!-- sense-brain-contracts-v1:start -->
|
||||
## Sense↔Brain v1 契约边界
|
||||
## Sense↔Brain v1 connector 结构
|
||||
|
||||
- Sense→Brain 配置:`contracts/source-config/v1/source-config.schema.json`;版本 `yovision.source-config/v1`。
|
||||
- Brain→Sense 状态:`contracts/runtime-status/v1/runtime-status.schema.json`;版本 `yovision.runtime-status/v1`。
|
||||
- 共同测试:`contracts/tests/source-config-v1/`、`contracts/tests/runtime-status-v1/`。
|
||||
- 生产者/消费者 mapper 责任分别记录在 `mapper-fields.md` 与 `mapping.md`;产品 adapter 后续由 #152 实现。
|
||||
共享协议仍位于 `contracts/source-config/v1/**` 与 `contracts/runtime-status/v1/**`。#152 的产品实现位于:
|
||||
|
||||
数据流固定为:
|
||||
- Sense:`Sense/server/app/sense/integration/brain_control/**`;运行投影迁移为 `2026083112000_brain_runtime.go`。
|
||||
- Brain:`Brain/src/yovision_brain/integration/sense_control/**`。
|
||||
- 双端集成测试:`Sense/tests/integration/brain_control/**`、`Brain/tests/integration/sense_control/**`。
|
||||
|
||||
数据流为:
|
||||
|
||||
```text
|
||||
Sense Device/Profile/Area 内部事实
|
||||
→ source-config/v1 mapper
|
||||
→ Brain adapter(后续 #152)
|
||||
→ Brain 内部配置与运行
|
||||
→ runtime-status/v1 mapper
|
||||
→ Sense 只读运维投影(后续 #152)
|
||||
Sense Device/Profile/Area
|
||||
→ source-config/v1 mapper + revision store
|
||||
→ machine identity transport adapter
|
||||
→ Brain strict consumer + last-known-good + persistent replay
|
||||
→ runtime-status/v1 mapper + monotonic sequence
|
||||
→ Sense read-only runtime projection + stale/offline/recovery
|
||||
```
|
||||
|
||||
共享契约统一使用 snake_case 与 `schema_version: yovision.<contract>/v1`。源配置使用 `config_id + integer revision`;运行状态以 `configurations[]` 按 `config_id` 回报实际应用 revision。未知主版本、重复配置 ID、倒序状态、摘要失败或敏感字段必须拒绝,且不得覆盖最后已知有效配置/投影。
|
||||
配置以 `config_id + integer revision` 唯一标识,状态以 Brain instance + sequence 保证时序。未知主版本、摘要失败、过期 revision、错误 Profile/几何或倒序状态拒绝且不覆盖最后有效事实。双端不共享数据库、用户会话、摄像头凭据或内部模型。
|
||||
|
||||
协议不得包含摄像头凭据、RTSP URL、query token、内部绝对路径、数据库模型、用户/JWT/Cookie 或 Bell Alert 语义。当前只冻结契约,没有新增网络端点、机器身份或跨端 connector。
|
||||
#152 提供可注入传输 adapter 和持久恢复边界;根级进程编排及部署级 HTTP hosting 由 #154 绑定,#155 验证真实跨进程故障隔离。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
<!-- standard-event-evidence-v1:start -->
|
||||
## 标准事件与证据 v1 契约边界
|
||||
## 标准事件与证据 v1 connector 结构
|
||||
|
||||
- Event Schema:`contracts/events/v1/event.schema.json`,版本 `yovision.event/v1`。
|
||||
- Bell 接入描述:`contracts/events/v1/openapi.json`,返回创建、重复、幂等冲突和不支持版本等明确结果。
|
||||
- Evidence Schema/API:`contracts/evidence/v1/evidence-reference.schema.json`、`openapi.json`,版本 `yovision.evidence-reference/v1`。
|
||||
- 共同测试:`contracts/tests/events-v1/`、`contracts/tests/evidence-v1/`。
|
||||
共享协议仍位于 `contracts/events/v1/**`、`contracts/evidence/v1/**`,机器身份位于 `contracts/machine-identity/v1/**`。#153 的产品实现位于:
|
||||
|
||||
后续 #153 的映射流固定为:
|
||||
- Brain producer:`Brain/src/yovision_brain/integration/event_export/**`,由 CLI `app/__main__.py` 按配置启用。
|
||||
- Sense gateway/relay:`Sense/server/app/sense/integration/bell_connector/**`,由 API 启动链注册 `POST /v1/events`、`GET /v1/evidence/:evidence_id` 和 Bell Outbox Worker。
|
||||
- Bell consumer:`Bell/server/app/bell/integration/event_ingress/**`,由 router registry 注册 `POST /v1/events`。
|
||||
- 正式迁移:Sense/Bell 各自的 `2026083112000_*connector*.go` / `*event_ingress.go`。
|
||||
|
||||
默认数据流为:
|
||||
|
||||
```text
|
||||
Brain internal candidate / Sense local event
|
||||
→ yovision.event/v1 producer mapper
|
||||
→ Sense Outbox relay(默认拓扑,保持原 producer/source ID)
|
||||
→ Bell v1 ingress
|
||||
→ Bell private immutable Event + permanent Receipt
|
||||
Brain internal candidate
|
||||
→ canonical yovision.event/v1
|
||||
→ Sense authenticated ingress
|
||||
→ Sense InboundEvent + EvidenceRecord + Bell Outbox(同事务)
|
||||
→ HTTPS relay(原 payload/producer/source ID 不变)
|
||||
→ Bell authenticated ingress
|
||||
→ permanent Receipt + immutable Event + conflict audit
|
||||
→ Bell private Rule / Alert / ack / close
|
||||
```
|
||||
|
||||
规范载荷使用 RFC 8785 JCS 与 SHA-256 形成稳定摘要。同键同摘要返回原 Event;同键不同摘要返回冲突并审计,不覆盖原事实。Evidence 只提供逻辑引用与状态/完整性元数据,不授予访问权限,不包含本机路径、签名 URL 或凭据;取证授权由后续机器身份和 connector 工单实现。
|
||||
规范载荷使用 RFC 8785 JCS 与 SHA-256。同键同摘要返回 duplicate;同键异摘要稳定冲突并审计。机器令牌 replay 与业务幂等分别持久化,传输重试签发新 jti,但不改变业务 ID。证据解析失败或不可用只更新独立降级状态,不允许上游写 Alert 语义。
|
||||
|
||||
Brain candidate、Sense candidate/Outbox 与 Bell Event/Receipt/Alert 继续是各自内部模型。当前没有新增可运行的跨端 ingress/relay,不能把冻结 Schema 解释为端到端链路已完成。
|
||||
Sense 与 Bell 只使用各自默认数据库;连接器关闭时不注册相应入口/Worker,三端继续独立启动。根级部署、真实 PKI/网络和最终 E2E 属于 #154/#155。
|
||||
<!-- standard-event-evidence-v1:end -->
|
||||
|
||||
<!-- machine-identity-v1:start -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 7a91edf3ca35ade3e254937c4a68b53d816a3eea
|
||||
synchronized_at: 2026-08-31T03:18:24Z
|
||||
wiki_revision: c0903448152d7fa88715e902efa99615a6b97083
|
||||
synchronized_at: 2026-08-31T07:23:13Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -274,3 +274,16 @@ synchronized_at: 2026-08-31T03:18:24Z
|
||||
- 轮换先登记新 `kid`,最多并存 24 小时,切换后移除旧 key;禁用 principal 或吊销 `kid` 对每次请求即时生效。
|
||||
- 回退只能关闭 connector 并恢复三端独立运行,不得降级为明文、共享管理员身份、共享 JWT 或跳过签名/TLS 验证。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
<!-- integration-connectors-v1:start -->
|
||||
## Connector 持久性与故障隔离规则
|
||||
|
||||
- **配置事实与运行投影分离**:Sense 的期望 revision 不等于 Brain 已应用 revision;只以 Brain runtime-status 的实际值更新只读投影。
|
||||
- **最后有效配置**:Brain 对过期、未知版本、摘要错误或不安全配置拒绝应用,并保留 last-known-good;不得用失败输入覆盖当前配置。
|
||||
- **机器重放与业务幂等分离**:每次网络重试必须使用新 jti;事件仍沿用原 `producer_id/source_event_id` 与规范载荷。
|
||||
- **Sense 同事务网关**:Brain 事件只有在 Sense 的 InboundEvent、EvidenceRecord 与 Bell Outbox 同一事务成功后才算被 Sense 接受。
|
||||
- **Bell 永久收据**:同键同摘要只形成一个 Receipt/Event;同键异摘要是终止冲突并追加脱敏审计,不能覆盖或删除原事实。
|
||||
- **证据降级独立**:pending、failed、timeout、expired 不改变 Event 不可变性,也不自动 ack/close Alert。
|
||||
- **停用规则**:关闭 connector 只停止新接入或投递;不得清空未投递 Outbox、持久 replay、Receipt、Event、证据元数据或审计。
|
||||
- **独立运行**:Sense、Brain、Bell 不因对端未安装、离线或 connector 关闭而停止各自核心能力;不得以共享数据库/JWT/Cookie 规避故障隔离。
|
||||
<!-- integration-connectors-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 771fdd92eb03e9dff7d3ae20b7cb89f1ca288959
|
||||
synchronized_at: 2026-08-31T03:18:34Z
|
||||
wiki_revision: 59f6eb5816b5d3f78c85ef902053c5fc45b8e6e9
|
||||
synchronized_at: 2026-08-31T07:33:54Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -652,7 +652,7 @@ python dev_scripts/harness.py check --strict
|
||||
git diff --check
|
||||
```
|
||||
|
||||
这些命令只验证冻结契约,不验证 #152 产品 adapter、真实网络传输、机器身份、现场断网恢复或端到端链路。
|
||||
这些命令本身只验证冻结契约;#152 产品 adapter、持久 replay、停用/超时/恢复由本页后续 connector 测试覆盖,真实部署网络与端到端链路仍由 #154/#155 验证。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
<!-- standard-event-evidence-v1:start -->
|
||||
@@ -677,7 +677,7 @@ python dev_scripts/harness.py check --strict
|
||||
git diff --check
|
||||
```
|
||||
|
||||
这些测试只验证冻结契约,不验证 #153 产品 mapper/relay/ingress、#151 机器身份、实际证据存储/授权、网络断线补投或跨项目 E2E。
|
||||
这些命令本身只验证冻结契约;#153 产品 mapper/relay/ingress、机器身份、持久 replay、断线补投与证据降级由本页后续 connector 测试覆盖,真实证据存储、生产网络和跨项目 E2E 仍由 #154/#155 验证。
|
||||
<!-- standard-event-evidence-v1:end -->
|
||||
|
||||
<!-- machine-identity-v1:start -->
|
||||
@@ -704,5 +704,50 @@ cd ../..
|
||||
Brain\.venv\Scripts\python.exe contracts\tests\machine-identity-v1\test_contract.py
|
||||
```
|
||||
|
||||
这些测试只证明 #151 身份和传输基础。真实客户 PKI/网络、现场时钟漂移、业务 endpoint、断网补投以及持久 replay 重启恢复由 #152/#153/#155 验证;不得用进程内 replay store替代生产结论。
|
||||
这些测试只证明 #151 身份和传输基础。#152/#153 已覆盖业务 adapter/endpoint、断网补投和持久 replay 重启恢复;真实客户 PKI/网络、现场时钟漂移与最终故障隔离仍由 #154/#155 验证,不得用进程内 replay store替代生产结论。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
<!-- integration-connectors-v1:start -->
|
||||
## #152/#153 connector 验证
|
||||
|
||||
使用冻结 CPython 3.11.15 与 Go 1.26.5。从仓库根目录按受影响范围执行:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = (Resolve-Path Brain/src)
|
||||
Brain\.venv\Scripts\python.exe -m pytest Brain/tests/integration/sense_control Brain/tests/integration/event_export Brain/tests/app/test_event_export_connector.py -q
|
||||
|
||||
cd Sense/server
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go test -race ./app/sense/integration/brain_control ./app/sense/integration/bell_connector ./app/sense/local_event ./cmd/api ./cmd/migrate/migration/version
|
||||
|
||||
cd ../../Sense/tests/integration/brain_control
|
||||
go test -race ./...
|
||||
cd ../bell_connector
|
||||
go test -race ./...
|
||||
|
||||
cd ../../../../Bell/server
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go test -race ./app/bell/integration/event_ingress ./cmd/api ./cmd/migrate/migration/version
|
||||
cd tests/integration/event_ingress
|
||||
go test -race ./...
|
||||
```
|
||||
|
||||
冻结契约与仓库检查:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File contracts/tests/source-config-v1/run.ps1
|
||||
python contracts/tests/runtime-status-v1/test_contract.py
|
||||
python contracts/tests/events-v1/test_contract.py
|
||||
python contracts/tests/evidence-v1/test_contract.py
|
||||
pwsh -NoProfile -File contracts/tests/machine-identity-v1/run.ps1
|
||||
python -m unittest discover -s tests -v
|
||||
python dev_scripts/harness.py check --strict
|
||||
git diff --check
|
||||
```
|
||||
|
||||
#152 覆盖 revision 幂等、last-known-good、单调状态、陈旧/离线/恢复、持久 replay、停用、超时和退避。#153 覆盖 Brain mapper、Sense 同事务 Outbox、Bell 永久 Receipt/Event、重复/冲突、证据降级、持久 replay、重启、断线恢复和多数据库默认库选择。
|
||||
|
||||
工单验收未使用客户 PKI、生产 PostgreSQL、真实三端网络或生产流量;这些结果只能由 #154 部署和 #155 E2E 补充。Sense 本地候选原子 Outbox 当前没有生产创建 caller,也不得据此声明本地产生链已完整接通。
|
||||
<!-- integration-connectors-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
||||
wiki_revision: 75bf70f375f58356e968b45139b54fcae391d1e8
|
||||
synchronized_at: 2026-08-31T03:18:53Z
|
||||
wiki_revision: fdd44bfe589d65cbce6ec085878abae2702d59e7
|
||||
synchronized_at: 2026-08-31T07:23:47Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -176,3 +176,22 @@ synchronized_at: 2026-08-31T03:18:53Z
|
||||
|
||||
排错日志只记录稳定错误码、已认证 principal/kid 和 correlation ID;不得记录令牌、签名、密钥、完整 Authorization header 或秘密路径。无法安全恢复时关闭 connector,三端保持独立运行。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
<!-- integration-connectors-v1:start -->
|
||||
## #152/#153 connector 排错
|
||||
|
||||
| 现象/错误 | 检查 | 安全处理 |
|
||||
|---|---|---|
|
||||
| connector 启用后提示迁移缺失 | 核对 Sense/Bell `2026083112000_*` 迁移记录和默认数据库 | 停止 connector,先备份并执行正式迁移;不依赖 AutoMigrate |
|
||||
| 配置被 Brain 拒绝 | 核对 schema 主版本、JCS/SHA-256、config_id/revision、Profile/几何和 recalibration 状态 | 修复新 revision;保留 last-known-good,不回写旧 revision |
|
||||
| Sense 显示 Brain 陈旧/离线或 revision mismatch | 核对 Brain sequence、observed_at、实际 applied_revision 和传输错误码 | 修复时钟/传输/配置;不手工改只读投影 |
|
||||
| Sense Outbox 持续积压 | 核对 Bell ingress 开关、HTTPS、机器身份、available_at、lease、attempt 与脱敏错误 | 恢复 Bell 后等待幂等补投;不清空消息或改业务 ID |
|
||||
| Bell 返回 duplicate | 同一 producer/source ID 与相同规范摘要已接收 | 视为成功;不得创建新 source_event_id |
|
||||
| Bell 返回 idempotency conflict | 同一 producer/source ID 对应不同摘要 | 终止重试并调查 producer;保留原 Receipt/Event 与冲突审计 |
|
||||
| evidence unavailable/timeout/expired | 核对 Sense evidence endpoint、`evidence:read` 权限、owner_id、HTTPS 和过期时间 | 保持降级状态;不把失败伪装 success,不改 Alert 生命周期 |
|
||||
| 启动出现重复路由或错误数据库 | 检查 Sense 是否使用默认 DB、是否重复启动实例 | 每实例只注册一次;不让 connector 随机选择 secondary DB |
|
||||
| Request-ID 或机器令牌拒绝 | 检查 16–128 字符 Request-ID、方法/路径/正文绑定、audience/scope/kid 和时钟 | 新签发 token/jti;保持原业务幂等键,不记录 Authorization |
|
||||
| 对端离线导致本端无法启动 | connector 被错误配置成强依赖 | 关闭对应开关并恢复独立运行;保留持久事实后另行排查 |
|
||||
|
||||
日志只记录稳定错误码、request/correlation ID、已认证 principal/kid 和脱敏业务引用;不得记录私钥、令牌、完整 Authorization、摄像头凭据、内部证据路径或事件完整敏感载荷。
|
||||
<!-- integration-connectors-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.-
|
||||
wiki_revision: c9970b0ee8b677b6be13f31af67b3206a3faf956
|
||||
synchronized_at: 2026-08-31T02:00:00Z
|
||||
wiki_revision: 2b110cd34439aca8aef4bc509ba8b50655b18c0a
|
||||
synchronized_at: 2026-08-31T07:24:22Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求
|
||||
@@ -274,21 +274,23 @@ BRN-002 的首个解码阶段已通过工单 #13 验收。Brain 通过可替换
|
||||
<!-- brain-local-events-delivery:end -->
|
||||
|
||||
<!-- sense-brain-contracts-v1:start -->
|
||||
## Sense↔Brain 首批冻结契约
|
||||
## Sense↔Brain 配置与状态 connector
|
||||
|
||||
工单 #148、#149 已于 2026-08-31 通过用户验收并合入 `dev`。Sense→Brain 源/规则配置的唯一共享事实源为 `contracts/source-config/v1/`,版本标识为 `yovision.source-config/v1`;Brain→Sense 运行状态的唯一共享事实源为 `contracts/runtime-status/v1/`,版本标识为 `yovision.runtime-status/v1`。
|
||||
工单 #148、#149 冻结的唯一共享事实源仍为 `contracts/source-config/v1/`(`yovision.source-config/v1`)和 `contracts/runtime-status/v1/`(`yovision.runtime-status/v1`)。工单 #152 已于 2026-08-31 通过用户验收并合入 `dev`。
|
||||
|
||||
源配置按 `config_id + integer revision` 形成不可复用的配置流,携带逻辑站点/设备/Profile、无凭据媒体引用、归一化区域/方向线、规则版本与完整性摘要。运行状态按同一 `config_id` 在 `configurations[]` 中报告实际应用 revision,并包含 Brain 实例、运行/模型版本、健康、输入和稳定错误码。
|
||||
Sense 现在可从 Device/Profile/Area 内部事实生成无凭据完整快照并持久分配 revision;Brain 严格校验版本、JCS/SHA-256、Profile/几何/状态,幂等应用并保留 last-known-good。Brain 以持久单调 sequence 发布实际应用 revision、运行/模型、健康和输入状态;Sense 保存只读投影并区分在线、陈旧、离线、恢复和 revision mismatch。双端持久 replay store 在各自数据库内防止机器令牌重放,重启不清空安全状态。
|
||||
|
||||
这两项只冻结协议和测试,不表示 #152 connector 已实现。Sense 与 Brain 仍可独立运行;Brain 不读取 Sense 数据库,Sense 不读取 Brain 内部状态。既有 `brain.internal.*`、Sense GORM 模型和运维投影继续是项目内部实现,不得直接作为共享协议。
|
||||
connector 可停用,断线或对端未安装不阻断 Sense/Brain 核心能力。#152 交付产品 adapter、持久状态和恢复原语;部署级 HTTP 托管、进程编排、真实双机 TLS 与最终 E2E 仍属于 #154/#155。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
<!-- standard-event-evidence-v1:start -->
|
||||
## 标准事件与证据引用冻结契约
|
||||
## 标准事件、证据与可靠 connector
|
||||
|
||||
工单 #150 已于 2026-08-31 通过用户验收并合入 `dev`。Sense/Brain→Bell 标准匿名安全事件的唯一共享事实源为 `contracts/events/v1/`,版本标识 `yovision.event/v1`;证据逻辑引用的唯一共享事实源为 `contracts/evidence/v1/`,版本标识 `yovision.evidence-reference/v1`。
|
||||
工单 #150 冻结的唯一共享事实源仍为 `contracts/events/v1/`(`yovision.event/v1`)和 `contracts/evidence/v1/`(`yovision.evidence-reference/v1`)。工单 #153 已于 2026-08-31 通过用户验收并合入 `dev`。
|
||||
|
||||
事件以原始 `(producer_id, source_event_id)` 永久幂等,Sense relay 不改变原始身份或业务载荷。事件只携带逻辑站点/设备/Profile、事件类型、发生时间、规则/模型版本、匿名观测、区域和证据逻辑引用,不携带用户会话、摄像头凭据、内部路径、人脸特征或 Alert/ack/close 状态。
|
||||
Brain 将内部匿名候选映射为标准事件;默认拓扑由 Sense 以机器身份接收并在同一事务中写入 InboundEvent、证据元数据和 Bell Outbox,再由后台 Worker 可靠投递到 Bell。Bell 以持久机器令牌 replay、永久 `(producer_id, source_event_id)` Receipt、JCS/SHA-256 摘要和冲突审计写入不可变 Event;Alert/ack/close 仍只由 Bell 私有规则与处置流程产生。
|
||||
|
||||
证据状态为 `pending/processing/success/failed`;`success` 必须包含内容类型和 SHA-256 完整性元数据,失败或过期只降级证据,不改写不可变 Event 或 Bell Alert 生命周期。此工单只冻结契约和测试,#153 可靠 connector、机器身份、证据存储与实际授权取证尚未实现。
|
||||
证据继续使用逻辑引用,状态为 `pending/processing/success/failed`;失败、超时或过期只形成明确降级,不暴露内部路径、长期签名 URL 或凭据,也不改写 Event/Alert 生命周期。三端 connector 均可停用并保持独立运行;停用或故障时保留 Outbox、Receipt、Event、replay 与审计事实。
|
||||
|
||||
Sense 的 `local_event.CreateWithOutbox` 是本地候选与标准事件 Outbox 的正式原子写入口;当前仓库尚无生产本地候选创建调用链,不把不存在的上游路径声明为已接通。真实客户 PKI、生产 PostgreSQL、现场网络与最终故障隔离由 #154/#155 验证。
|
||||
<!-- standard-event-evidence-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 54d720aa07d2c40684da3fc04378393c7f318a04
|
||||
synchronized_at: 2026-08-31T03:20:15Z
|
||||
wiki_revision: fa7e2031338cbca0f669f5d5a72a073670114882
|
||||
synchronized_at: 2026-08-31T07:35:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# YoVision 部署与运维
|
||||
@@ -146,11 +146,45 @@ Sense\start_sense.bat
|
||||
<!-- machine-identity-v1:start -->
|
||||
## 机器身份部署与轮换边界
|
||||
|
||||
#151 已冻结机器身份和传输基础,但 #152/#153 尚未注册业务 connector,因此当前不得手工拼接 endpoint 或临时共享凭据提前打通。
|
||||
#151 已冻结机器身份和传输基础,#152/#153 已验收业务 adapter/connector;运行时仍必须使用正式配置、迁移和独立机器身份,不得手工拼接临时共享凭据。
|
||||
|
||||
部署时为每个调用实例独立生成 Ed25519 私钥,保存到仓库外受 ACL/秘密存储保护的位置;产品配置只记录私钥路径、principal、kid、目标 audience 和最小 scope。消费者从仓库外公钥注册表读取受信 principal/kid。不得把私钥、完整令牌、Authorization header、管理员密码、浏览器 JWT/Cookie 或 query token写入配置样例、日志、工单和备份。
|
||||
|
||||
传输固定使用 HTTPS,TLS 最低 1.2并验证证书链与主机名。轮换按“先登记新公钥 → 调用方切换新 kid → 验证流量 → 24 小时内移除旧 key”执行;应急吊销直接禁用 principal 或 key,并同时停用相关 connector。回退保持 Sense、Brain、Bell 独立运行,保留 Outbox、Receipt、Event 和最后已知状态。
|
||||
|
||||
#152/#153 必须为各产品注入自己的持久原子 `(principal,jti)` replay store并验证重启;内存 replay store 只用于适配测试或不重启的单进程原语。
|
||||
#152/#153 已为各产品接入独立持久原子 `(principal,jti)` replay store并覆盖重启恢复;内存 replay store 仍只用于适配测试或不重启的单进程原语。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
<!-- integration-connectors-v1:start -->
|
||||
## #152/#153 connector 部署与回退
|
||||
|
||||
升级前先备份 Sense、Bell 各自 PostgreSQL,并在停服窗口执行正式迁移:
|
||||
|
||||
- Sense:`2026083112000_brain_runtime.go`、`2026083112000_bell_connector.go`。
|
||||
- Bell:`2026083112000_event_ingress.go`。
|
||||
|
||||
迁移缺失时 connector 必须拒绝启动,不得依赖运行时 AutoMigrate 临时补表。Sense 只使用默认数据库注册 ingress 和 Worker,Bell 使用自己的默认数据库;不得指向共享数据库。
|
||||
|
||||
Brain 的标准事件出口在自身 JSON 配置的 `event_export` 对象中启用。必须配置 HTTPS origin、producer/site/severity、仓库外私钥路径、独立 principal/kid 和严格 transport policy;关闭时配置只保留 `{"enabled": false}`,CLI 恢复原 JSON Lines 独立输出。endpoint 只能是 HTTPS origin,固定追加 `/v1/events`。
|
||||
|
||||
Sense 运行变量:
|
||||
|
||||
- `SENSE_EVENT_INGRESS_ENABLED`:注册 Brain→Sense `POST /v1/events` 与证据读取入口。
|
||||
- `SENSE_MACHINE_PRINCIPAL_REGISTRY`:仓库外 Brain/Bell 公钥注册表。
|
||||
- `SENSE_EVIDENCE_OWNER_ID`:Sense 证据所有者逻辑标识。
|
||||
- `SENSE_BELL_CONNECTOR_ENABLED`:启动 Bell Outbox Worker。
|
||||
- `SENSE_BELL_ENDPOINT`、`SENSE_RELAY_ID`:Bell HTTPS origin 与 relay 实例标识。
|
||||
- `SENSE_BELL_PRINCIPAL_ID`、`SENSE_BELL_KEY_ID`、`SENSE_BELL_PRIVATE_KEY_PATH`:Sense→Bell 独立机器身份。
|
||||
- `SENSE_BELL_RELAY_INTERVAL_MS`:100–60000 ms;未配置时 2000 ms。
|
||||
|
||||
Bell 运行变量:
|
||||
|
||||
- `BELL_EVENT_INGRESS_ENABLED`:注册 `POST /v1/events`。
|
||||
- `BELL_MACHINE_PRINCIPAL_REGISTRY`:仓库外 producer/relay 公钥注册表。
|
||||
- `BELL_EVIDENCE_RESOLVER_ENABLED`:启用 Bell→Sense 证据解析。
|
||||
- `BELL_SENSE_EVIDENCE_ENDPOINT`、`BELL_SENSE_PRINCIPAL_ID`、`BELL_SENSE_KEY_ID`、`BELL_SENSE_PRIVATE_KEY_PATH`:Sense HTTPS origin 与 Bell 独立证据读取身份。
|
||||
|
||||
推荐启动顺序:完成两端迁移 → 启动 Bell ingress → 启动 Sense ingress/relay → 启用 Brain event_export。#152 的配置/状态 adapter 已有持久状态、重放与恢复接口,部署级 HTTP 托管和根级进程编排在 #154 统一绑定,不能用临时共享文件或数据库替代。
|
||||
|
||||
回退时关闭 Brain `event_export.enabled`、Sense 两个 connector 开关和 Bell ingress/evidence 开关;保留 last-known-good、运行投影、InboundEvent、EvidenceRecord、Outbox、ReplayToken、Receipt、Event 与审计。不得删除事实、关闭 TLS/验签或改用网页登录态。
|
||||
<!-- integration-connectors-v1:end -->
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$script:CoordinationProductNames = @('sense', 'brain', 'bell')
|
||||
$script:CoordinationStartOrder = @('bell', 'sense', 'brain')
|
||||
$script:CoordinationStopOrder = @('brain', 'sense', 'bell')
|
||||
|
||||
function Get-CoordinationRepositoryRoot {
|
||||
return [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
|
||||
}
|
||||
|
||||
function Resolve-CoordinationPath {
|
||||
param([Parameter(Mandatory = $true)][string]$Base, [Parameter(Mandatory = $true)][string]$Value)
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) { throw 'A required path is empty.' }
|
||||
if ([IO.Path]::IsPathRooted($Value)) { return [IO.Path]::GetFullPath($Value) }
|
||||
return [IO.Path]::GetFullPath((Join-Path $Base $Value))
|
||||
}
|
||||
|
||||
function Test-CoordinationPathWithin {
|
||||
param([Parameter(Mandatory = $true)][string]$Child, [Parameter(Mandatory = $true)][string]$Parent)
|
||||
$childPath = [IO.Path]::GetFullPath($Child).TrimEnd('\', '/')
|
||||
$parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd('\', '/')
|
||||
return $childPath.Equals($parentPath, [StringComparison]::OrdinalIgnoreCase) -or
|
||||
$childPath.StartsWith($parentPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
function Get-CoordinationProperty {
|
||||
param([Parameter(Mandatory = $true)]$Object, [Parameter(Mandatory = $true)][string]$Name, [switch]$Optional)
|
||||
$property = $Object.PSObject.Properties[$Name]
|
||||
if (-not $property) {
|
||||
if ($Optional) { return $null }
|
||||
throw "Missing manifest property: $Name"
|
||||
}
|
||||
return $property.Value
|
||||
}
|
||||
|
||||
function Assert-CoordinationProperties {
|
||||
param([Parameter(Mandatory = $true)]$Object, [Parameter(Mandatory = $true)][string[]]$Allowed, [Parameter(Mandatory = $true)][string]$Context)
|
||||
foreach ($property in $Object.PSObject.Properties.Name) {
|
||||
if ($property -notin $Allowed) { throw "$Context contains unsupported property: $property" }
|
||||
}
|
||||
}
|
||||
|
||||
function Read-CoordinationEnvironment {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Environment file not found: $Path" }
|
||||
$values = @{}
|
||||
$lineNumber = 0
|
||||
foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) {
|
||||
$lineNumber++
|
||||
$line = $rawLine.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue }
|
||||
$separator = $line.IndexOf('=')
|
||||
if ($separator -lt 1) { throw "Invalid environment file at line $lineNumber. Expected NAME=value." }
|
||||
$name = $line.Substring(0, $separator).Trim()
|
||||
if ($name -notmatch '^[A-Z][A-Z0-9_]{1,127}$') { throw "Invalid environment variable name at line $lineNumber." }
|
||||
if ($values.ContainsKey($name)) { throw "Duplicate environment variable at line ${lineNumber}: $name" }
|
||||
$value = $line.Substring($separator + 1)
|
||||
if ($value.Length -ge 2) {
|
||||
$first, $last = $value[0], $value[$value.Length - 1]
|
||||
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { $value = $value.Substring(1, $value.Length - 2) }
|
||||
}
|
||||
$values[$name] = $value
|
||||
}
|
||||
return $values
|
||||
}
|
||||
|
||||
function Assert-CoordinationExternalSecretPath {
|
||||
param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$RepositoryRoot, [Parameter(Mandatory = $true)][string[]]$PackageRoots)
|
||||
if (Test-CoordinationPathWithin -Child $Path -Parent $RepositoryRoot) { throw 'Secret or environment files must be outside the repository.' }
|
||||
foreach ($packageRoot in $PackageRoots) {
|
||||
if (Test-CoordinationPathWithin -Child $Path -Parent $packageRoot) { throw 'Secret or environment files must be outside product packages.' }
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-CoordinationDistinctPaths {
|
||||
param([Parameter(Mandatory = $true)][object[]]$Entries)
|
||||
for ($left = 0; $left -lt $Entries.Count; $left++) {
|
||||
for ($right = $left + 1; $right -lt $Entries.Count; $right++) {
|
||||
if ((Test-CoordinationPathWithin -Child $Entries[$left].Path -Parent $Entries[$right].Path) -or
|
||||
(Test-CoordinationPathWithin -Child $Entries[$right].Path -Parent $Entries[$left].Path)) {
|
||||
throw "Deployment paths overlap: $($Entries[$left].Label) and $($Entries[$right].Label)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-CoordinationCommand {
|
||||
param([Parameter(Mandatory = $true)][string]$PackageRoot, [Parameter(Mandatory = $true)]$Command)
|
||||
Assert-CoordinationProperties -Object $Command -Allowed @('executable', 'arguments') -Context 'command'
|
||||
$path = Resolve-CoordinationPath -Base $PackageRoot -Value ([string](Get-CoordinationProperty -Object $Command -Name 'executable'))
|
||||
if (-not (Test-CoordinationPathWithin -Child $path -Parent $PackageRoot)) { throw 'Product commands must be inside their package root.' }
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Product command not found: $path" }
|
||||
$rawArguments = Get-CoordinationProperty -Object $Command -Name 'arguments'
|
||||
if ($rawArguments -is [string]) { throw 'Command arguments must be an array.' }
|
||||
$arguments = @($rawArguments) | ForEach-Object { [string]$_ }
|
||||
return [pscustomobject]@{ Path = $path; Arguments = @($arguments) }
|
||||
}
|
||||
|
||||
function Get-CoordinationLauncher {
|
||||
param([Parameter(Mandatory = $true)]$Command)
|
||||
$extension = [IO.Path]::GetExtension($Command.Path).ToLowerInvariant()
|
||||
if ($extension -eq '.ps1') {
|
||||
$pwsh = (Get-Command pwsh.exe -ErrorAction Stop).Source
|
||||
return [pscustomobject]@{ Executable = $pwsh; Arguments = @('-NoProfile', '-File', $Command.Path) + @($Command.Arguments); CommandToken = $Command.Path }
|
||||
}
|
||||
if ($extension -in @('.bat', '.cmd')) {
|
||||
$cmd = (Get-Command cmd.exe -ErrorAction Stop).Source
|
||||
return [pscustomobject]@{ Executable = $cmd; Arguments = @('/d', '/c', $Command.Path) + @($Command.Arguments); CommandToken = $Command.Path }
|
||||
}
|
||||
return [pscustomobject]@{ Executable = $Command.Path; Arguments = @($Command.Arguments); CommandToken = $Command.Path }
|
||||
}
|
||||
|
||||
function ConvertTo-CoordinationArgument {
|
||||
param([AllowEmptyString()][string]$Value)
|
||||
if ($Value -notmatch '[\s"]') { return $Value }
|
||||
return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"'
|
||||
}
|
||||
|
||||
function Invoke-CoordinationEnvironment {
|
||||
param([Parameter(Mandatory = $true)][hashtable]$Values, [Parameter(Mandatory = $true)][scriptblock]$Action)
|
||||
$saved = @{}
|
||||
try {
|
||||
foreach ($name in $Values.Keys) {
|
||||
$saved[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
[Environment]::SetEnvironmentVariable($name, [string]$Values[$name], 'Process')
|
||||
}
|
||||
return & $Action
|
||||
} finally {
|
||||
foreach ($name in $Values.Keys) { [Environment]::SetEnvironmentVariable($name, $saved[$name], 'Process') }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CoordinationFileDigest {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Get-CoordinationDatabaseIdentity {
|
||||
param([Parameter(Mandatory = $true)][string]$Connection)
|
||||
if ($Connection -match '^postgres(?:ql)?://') {
|
||||
$uri = [Uri]$Connection
|
||||
$role = if ($uri.UserInfo) { [Uri]::UnescapeDataString(($uri.UserInfo -split ':', 2)[0]) } else { '' }
|
||||
return [pscustomobject]@{ Database = [Uri]::UnescapeDataString($uri.AbsolutePath.Trim('/')); Role = $role }
|
||||
}
|
||||
$database = if ($Connection -match '(?i)(?:^|\s)(?:dbname|database)\s*=\s*(?:''([^'']+)''|"([^"]+)"|([^\s]+))') { @($Matches[1], $Matches[2], $Matches[3]) | Where-Object { $_ } | Select-Object -First 1 } else { '' }
|
||||
$role = if ($Connection -match '(?i)(?:^|\s)(?:user|username)\s*=\s*(?:''([^'']+)''|"([^"]+)"|([^\s]+))') { @($Matches[1], $Matches[2], $Matches[3]) | Where-Object { $_ } | Select-Object -First 1 } else { '' }
|
||||
return [pscustomobject]@{ Database = [string]$database; Role = [string]$role }
|
||||
}
|
||||
|
||||
function Read-CoordinationManifest {
|
||||
param([Parameter(Mandatory = $true)][string]$Manifest)
|
||||
$manifestPath = [IO.Path]::GetFullPath($Manifest)
|
||||
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { throw "Coordination manifest not found: $manifestPath" }
|
||||
try { $raw = Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 64 } catch { throw "Coordination manifest is not valid JSON: $($_.Exception.Message)" }
|
||||
Assert-CoordinationProperties -Object $raw -Allowed @('schema_version', 'deployment_id', 'runtime_root', 'products') -Context 'manifest'
|
||||
if ((Get-CoordinationProperty -Object $raw -Name 'schema_version') -ne 'yovision.coordination/v1') { throw 'Unsupported coordination manifest schema version.' }
|
||||
$deploymentID = [string](Get-CoordinationProperty -Object $raw -Name 'deployment_id')
|
||||
if ($deploymentID -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$') { throw 'Invalid deployment_id.' }
|
||||
$manifestRoot = Split-Path -Parent $manifestPath
|
||||
$runtimeRoot = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $raw -Name 'runtime_root'))
|
||||
$rawProducts = Get-CoordinationProperty -Object $raw -Name 'products'
|
||||
Assert-CoordinationProperties -Object $rawProducts -Allowed $script:CoordinationProductNames -Context 'products'
|
||||
$products = @()
|
||||
foreach ($name in $script:CoordinationProductNames) {
|
||||
$item = Get-CoordinationProperty -Object $rawProducts -Name $name
|
||||
Assert-CoordinationProperties -Object $item -Allowed @('enabled', 'version', 'package_root', 'environment_file', 'data_directory', 'log_directory', 'ports', 'browser_origin', 'cookie_name', 'account_namespace', 'database_id', 'database_role', 'start', 'stop', 'health', 'identities') -Context $name
|
||||
$rawEnabled = Get-CoordinationProperty -Object $item -Name 'enabled'
|
||||
if ($rawEnabled -isnot [bool]) { throw "enabled must be a boolean for ${name}." }
|
||||
$version = [string](Get-CoordinationProperty -Object $item -Name 'version')
|
||||
if ([string]::IsNullOrWhiteSpace($version)) { throw "version is required for ${name}." }
|
||||
$packageRoot = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $item -Name 'package_root'))
|
||||
if (-not (Test-Path -LiteralPath $packageRoot -PathType Container)) { throw "Package root not found for ${name}: $packageRoot" }
|
||||
$environmentFile = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $item -Name 'environment_file'))
|
||||
$dataDirectory = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $item -Name 'data_directory'))
|
||||
$logDirectory = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $item -Name 'log_directory'))
|
||||
$start = Resolve-CoordinationCommand -PackageRoot $packageRoot -Command (Get-CoordinationProperty -Object $item -Name 'start')
|
||||
$rawStop = Get-CoordinationProperty -Object $item -Name 'stop' -Optional
|
||||
$stop = if ($null -eq $rawStop) { $null } else { Resolve-CoordinationCommand -PackageRoot $packageRoot -Command $rawStop }
|
||||
$ports = @((Get-CoordinationProperty -Object $item -Name 'ports')) | ForEach-Object { [int]$_ }
|
||||
foreach ($port in $ports) { if ($port -lt 1 -or $port -gt 65535) { throw "Invalid port for ${name}." } }
|
||||
$health = Get-CoordinationProperty -Object $item -Name 'health'
|
||||
Assert-CoordinationProperties -Object $health -Allowed @('kind', 'url', 'timeout_seconds') -Context "$name health"
|
||||
$healthKind = [string](Get-CoordinationProperty -Object $health -Name 'kind')
|
||||
$healthURL = [string](Get-CoordinationProperty -Object $health -Name 'url' -Optional)
|
||||
$healthTimeout = [int](Get-CoordinationProperty -Object $health -Name 'timeout_seconds')
|
||||
if ($healthKind -notin @('process', 'http') -or $healthTimeout -lt 1 -or $healthTimeout -gt 300) { throw "Invalid health policy for ${name}." }
|
||||
if ($healthKind -eq 'http') {
|
||||
$parsedHealth = $null
|
||||
if (-not [Uri]::TryCreate($healthURL, [UriKind]::Absolute, [ref]$parsedHealth) -or $parsedHealth.Scheme -notin @('http', 'https')) { throw "Invalid health URL for ${name}." }
|
||||
if ($ports -notcontains $parsedHealth.Port) { throw "Health URL port is not declared for ${name}." }
|
||||
}
|
||||
$browserOrigin = [string](Get-CoordinationProperty -Object $item -Name 'browser_origin')
|
||||
if (-not [string]::IsNullOrWhiteSpace($browserOrigin)) {
|
||||
$parsedOrigin = $null
|
||||
if (-not [Uri]::TryCreate($browserOrigin, [UriKind]::Absolute, [ref]$parsedOrigin) -or $parsedOrigin.Scheme -notin @('http', 'https') -or $parsedOrigin.AbsolutePath -ne '/' -or $parsedOrigin.Query -or $parsedOrigin.Fragment) { throw "Invalid browser origin for ${name}." }
|
||||
if ($ports -notcontains $parsedOrigin.Port) { throw "Browser origin port is not declared for ${name}." }
|
||||
}
|
||||
$identities = @()
|
||||
foreach ($identity in @((Get-CoordinationProperty -Object $item -Name 'identities'))) {
|
||||
Assert-CoordinationProperties -Object $identity -Allowed @('principal', 'key_id', 'private_key_path') -Context "$name identity"
|
||||
$principal = [string](Get-CoordinationProperty -Object $identity -Name 'principal')
|
||||
$keyID = [string](Get-CoordinationProperty -Object $identity -Name 'key_id')
|
||||
if ($principal -notmatch "^yv:${name}:[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" -or $keyID -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$') { throw "Invalid machine identity metadata for ${name}." }
|
||||
$keyPath = Resolve-CoordinationPath -Base $manifestRoot -Value ([string](Get-CoordinationProperty -Object $identity -Name 'private_key_path'))
|
||||
if (-not (Test-Path -LiteralPath $keyPath -PathType Leaf)) { throw "Machine identity key not found for ${name}." }
|
||||
$identities += [pscustomobject]@{ Principal = $principal; KeyID = $keyID; PrivateKeyPath = $keyPath }
|
||||
}
|
||||
$products += [pscustomobject]@{
|
||||
Name = $name; Enabled = [bool]$rawEnabled; Version = $version
|
||||
PackageRoot = $packageRoot; EnvironmentFile = $environmentFile; Environment = Read-CoordinationEnvironment -Path $environmentFile
|
||||
DataDirectory = $dataDirectory; LogDirectory = $logDirectory; Ports = @($ports)
|
||||
BrowserOrigin = $browserOrigin; CookieName = [string](Get-CoordinationProperty -Object $item -Name 'cookie_name')
|
||||
AccountNamespace = [string](Get-CoordinationProperty -Object $item -Name 'account_namespace'); DatabaseID = [string](Get-CoordinationProperty -Object $item -Name 'database_id'); DatabaseRole = [string](Get-CoordinationProperty -Object $item -Name 'database_role')
|
||||
Start = $start; Stop = $stop; HealthKind = $healthKind; HealthURL = $healthURL; HealthTimeoutSeconds = $healthTimeout; Identities = @($identities)
|
||||
}
|
||||
}
|
||||
$result = [pscustomobject]@{ Path = $manifestPath; Digest = Get-CoordinationFileDigest -Path $manifestPath; DeploymentID = $deploymentID; RuntimeRoot = $runtimeRoot; Products = @($products); RepositoryRoot = Get-CoordinationRepositoryRoot }
|
||||
Assert-CoordinationIsolation -Configuration $result
|
||||
return $result
|
||||
}
|
||||
|
||||
function Assert-CoordinationIsolation {
|
||||
param([Parameter(Mandatory = $true)]$Configuration)
|
||||
$packages = @($Configuration.Products | ForEach-Object { $_.PackageRoot })
|
||||
$paths = @([pscustomobject]@{ Label = 'coordination runtime'; Path = $Configuration.RuntimeRoot })
|
||||
foreach ($product in $Configuration.Products) {
|
||||
$paths += [pscustomobject]@{ Label = "$($product.Name) package"; Path = $product.PackageRoot }
|
||||
$paths += [pscustomobject]@{ Label = "$($product.Name) data"; Path = $product.DataDirectory }
|
||||
$paths += [pscustomobject]@{ Label = "$($product.Name) logs"; Path = $product.LogDirectory }
|
||||
Assert-CoordinationExternalSecretPath -Path $product.EnvironmentFile -RepositoryRoot $Configuration.RepositoryRoot -PackageRoots $packages
|
||||
foreach ($identity in $product.Identities) { Assert-CoordinationExternalSecretPath -Path $identity.PrivateKeyPath -RepositoryRoot $Configuration.RepositoryRoot -PackageRoots $packages }
|
||||
}
|
||||
Assert-CoordinationDistinctPaths -Entries $paths
|
||||
$ports = @{}
|
||||
$environmentFiles = @{}
|
||||
$identityKeys = @{}
|
||||
$privateKeyPaths = @{}
|
||||
foreach ($product in $Configuration.Products) {
|
||||
if ($environmentFiles.ContainsKey($product.EnvironmentFile.ToLowerInvariant())) { throw 'Products must not share an environment file.' }
|
||||
$environmentFiles[$product.EnvironmentFile.ToLowerInvariant()] = $true
|
||||
foreach ($port in $product.Ports) {
|
||||
if ($ports.ContainsKey($port)) { throw "Products must not share port $port." }
|
||||
$ports[$port] = $product.Name
|
||||
}
|
||||
foreach ($identity in $product.Identities) {
|
||||
$identityID = ($identity.Principal + '/' + $identity.KeyID).ToLowerInvariant()
|
||||
if ($identityKeys.ContainsKey($identityID)) { throw 'Machine principal/key pairs must be unique per product instance.' }
|
||||
$identityKeys[$identityID] = $true
|
||||
$privateKeyID = $identity.PrivateKeyPath.ToLowerInvariant()
|
||||
if ($privateKeyPaths.ContainsKey($privateKeyID)) { throw 'Machine identities must not share a private key file.' }
|
||||
$privateKeyPaths[$privateKeyID] = $true
|
||||
}
|
||||
}
|
||||
$sense = $Configuration.Products | Where-Object Name -eq 'sense'
|
||||
$bell = $Configuration.Products | Where-Object Name -eq 'bell'
|
||||
if ($sense.CookieName -ne 'Sense-Admin-Token' -or $bell.CookieName -ne 'Bell-Admin-Token' -or $sense.CookieName -eq $bell.CookieName) { throw 'Sense and Bell browser Cookie names are not isolated.' }
|
||||
if ([string]::IsNullOrWhiteSpace($sense.BrowserOrigin) -or [string]::IsNullOrWhiteSpace($bell.BrowserOrigin) -or $sense.BrowserOrigin -eq $bell.BrowserOrigin) { throw 'Sense and Bell browser origins must be distinct.' }
|
||||
foreach ($field in @('DatabaseID', 'DatabaseRole', 'AccountNamespace')) {
|
||||
if ([string]::IsNullOrWhiteSpace($sense.$field) -or [string]::IsNullOrWhiteSpace($bell.$field) -or $sense.$field -eq $bell.$field) { throw "Sense and Bell $field values must be non-empty and distinct." }
|
||||
}
|
||||
foreach ($required in @(@($sense, 'SENSE_DATABASE_URL', 'SENSE_JWT_SECRET'), @($bell, 'BELL_DATABASE_URL', 'BELL_JWT_SECRET'))) {
|
||||
$product, $databaseKey, $jwtKey = $required
|
||||
if (-not $product.Environment.ContainsKey($databaseKey) -or [string]::IsNullOrWhiteSpace([string]$product.Environment[$databaseKey])) { throw "$databaseKey is required in the external environment file." }
|
||||
if (-not $product.Environment.ContainsKey($jwtKey) -or ([string]$product.Environment[$jwtKey]).Length -lt 32) { throw "$jwtKey must contain at least 32 characters in the external environment file." }
|
||||
}
|
||||
if ([string]$sense.Environment['SENSE_DATABASE_URL'] -eq [string]$bell.Environment['BELL_DATABASE_URL']) { throw 'Sense and Bell must not share a database URL.' }
|
||||
if ([string]$sense.Environment['SENSE_JWT_SECRET'] -ceq [string]$bell.Environment['BELL_JWT_SECRET']) { throw 'Sense and Bell must not share a JWT secret.' }
|
||||
$senseDatabase = Get-CoordinationDatabaseIdentity -Connection ([string]$sense.Environment['SENSE_DATABASE_URL'])
|
||||
$bellDatabase = Get-CoordinationDatabaseIdentity -Connection ([string]$bell.Environment['BELL_DATABASE_URL'])
|
||||
if ($senseDatabase.Database -ne $sense.DatabaseID -or $senseDatabase.Role -ne $sense.DatabaseRole) { throw 'Sense database URL does not match its declared database and role.' }
|
||||
if ($bellDatabase.Database -ne $bell.DatabaseID -or $bellDatabase.Role -ne $bell.DatabaseRole) { throw 'Bell database URL does not match its declared database and role.' }
|
||||
foreach ($portRule in @(@($sense, 'SENSE_PORT', 0), @($bell, 'BELL_PORT', 0), @($bell, 'BELL_WEB_PORT', 1))) {
|
||||
$product, $key, $index = $portRule
|
||||
if (-not $product.Environment.ContainsKey($key) -or [int]$product.Environment[$key] -ne $product.Ports[[int]$index]) { throw "$key must match the declared product port." }
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-CoordinationSelection {
|
||||
param([Parameter(Mandatory = $true)]$Configuration, [string[]]$Product = @('all'), [ValidateSet('start', 'stop', 'status')][string]$Operation = 'status')
|
||||
$requested = @()
|
||||
foreach ($entry in @($Product)) { $requested += @($entry -split ',') | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ } }
|
||||
if ($requested.Count -eq 0 -or $requested -contains 'all') {
|
||||
$requested = if ($Operation -eq 'start') { @($Configuration.Products | Where-Object Enabled | ForEach-Object Name) } else { @($Configuration.Products | ForEach-Object Name) }
|
||||
}
|
||||
foreach ($name in $requested) {
|
||||
if ($name -notin $script:CoordinationProductNames) { throw "Unknown product selection: $name" }
|
||||
$target = $Configuration.Products | Where-Object Name -eq $name
|
||||
if ($Operation -eq 'start' -and -not $target.Enabled) { throw "Product is disabled in the manifest: $name" }
|
||||
}
|
||||
$order = if ($Operation -eq 'stop') { $script:CoordinationStopOrder } else { $script:CoordinationStartOrder }
|
||||
return @($order | Where-Object { $requested -contains $_ } | ForEach-Object { $name = $_; $Configuration.Products | Where-Object Name -eq $name })
|
||||
}
|
||||
|
||||
function Get-CoordinationStatePath {
|
||||
param([Parameter(Mandatory = $true)]$Configuration, [Parameter(Mandatory = $true)]$Product)
|
||||
return Join-Path $Configuration.RuntimeRoot "state\$($Product.Name).json"
|
||||
}
|
||||
|
||||
function Read-CoordinationState {
|
||||
param([Parameter(Mandatory = $true)]$Configuration, [Parameter(Mandatory = $true)]$Product)
|
||||
$path = Get-CoordinationStatePath -Configuration $Configuration -Product $Product
|
||||
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null }
|
||||
try { return Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json } catch { throw "Invalid coordination state for $($Product.Name)." }
|
||||
}
|
||||
|
||||
function Test-CoordinationOwnedProcess {
|
||||
param([Parameter(Mandatory = $true)]$State)
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $([int]$State.pid)" -ErrorAction SilentlyContinue
|
||||
if (-not $process -or [string]::IsNullOrWhiteSpace([string]$process.ExecutablePath)) { return $false }
|
||||
$expected = [IO.Path]::GetFullPath([string]$State.launcher_executable)
|
||||
if (-not [IO.Path]::GetFullPath([string]$process.ExecutablePath).Equals($expected, [StringComparison]::OrdinalIgnoreCase)) { return $false }
|
||||
return ([string]$process.CommandLine).IndexOf([string]$State.command_token, [StringComparison]::OrdinalIgnoreCase) -ge 0
|
||||
}
|
||||
|
||||
function Test-CoordinationHealth {
|
||||
param([Parameter(Mandatory = $true)]$Product, [Parameter(Mandatory = $true)]$State)
|
||||
if (-not (Test-CoordinationOwnedProcess -State $State)) { return $false }
|
||||
if ($Product.HealthKind -eq 'process') { return $true }
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $Product.HealthURL -Method Get -TimeoutSec 3 -UseBasicParsing
|
||||
return $response.StatusCode -ge 200 -and $response.StatusCode -lt 400
|
||||
} catch { return $false }
|
||||
}
|
||||
|
||||
function Wait-CoordinationHealth {
|
||||
param([Parameter(Mandatory = $true)]$Product, [Parameter(Mandatory = $true)]$State)
|
||||
if ($Product.HealthKind -eq 'process') {
|
||||
Start-Sleep -Milliseconds 750
|
||||
if (Test-CoordinationHealth -Product $Product -State $State) { return }
|
||||
throw "$($Product.Name) exited during the process health grace period."
|
||||
}
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds($Product.HealthTimeoutSeconds)
|
||||
do {
|
||||
if (Test-CoordinationHealth -Product $Product -State $State) { return }
|
||||
if (-not (Get-Process -Id ([int]$State.pid) -ErrorAction SilentlyContinue)) { throw "$($Product.Name) exited before becoming healthy." }
|
||||
Start-Sleep -Milliseconds 250
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
throw "$($Product.Name) did not become healthy before the timeout."
|
||||
}
|
||||
|
||||
function Assert-CoordinationPortsAvailable {
|
||||
param([Parameter(Mandatory = $true)]$Product)
|
||||
foreach ($port in $Product.Ports) {
|
||||
if (Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue) { throw "$($Product.Name) port $port is already in use." }
|
||||
}
|
||||
}
|
||||
|
||||
function Start-CoordinationProduct {
|
||||
param([Parameter(Mandatory = $true)]$Configuration, [Parameter(Mandatory = $true)]$Product)
|
||||
$existing = Read-CoordinationState -Configuration $Configuration -Product $Product
|
||||
if ($existing -and (Test-CoordinationOwnedProcess -State $existing)) {
|
||||
if ([string]$existing.manifest_sha256 -ne $Configuration.Digest) { throw "$($Product.Name) is running from a different manifest revision." }
|
||||
if (Test-CoordinationHealth -Product $Product -State $existing) { Write-Host "$($Product.Name) is already running."; return }
|
||||
throw "$($Product.Name) has an owned but unhealthy process. Stop it before restart."
|
||||
}
|
||||
Assert-CoordinationPortsAvailable -Product $Product
|
||||
New-Item -ItemType Directory -Force -Path $Configuration.RuntimeRoot,(Join-Path $Configuration.RuntimeRoot 'state'),$Product.DataDirectory,$Product.LogDirectory | Out-Null
|
||||
$launcher = Get-CoordinationLauncher -Command $Product.Start
|
||||
$argumentLine = (@($launcher.Arguments) | ForEach-Object { ConvertTo-CoordinationArgument -Value ([string]$_) }) -join ' '
|
||||
$stdout = Join-Path $Product.LogDirectory 'coordination.out.log'
|
||||
$stderr = Join-Path $Product.LogDirectory 'coordination.err.log'
|
||||
$process = Invoke-CoordinationEnvironment -Values $Product.Environment -Action {
|
||||
Start-Process -FilePath $launcher.Executable -ArgumentList $argumentLine -WorkingDirectory $Product.PackageRoot -RedirectStandardOutput $stdout -RedirectStandardError $stderr -WindowStyle Hidden -PassThru
|
||||
}
|
||||
$state = [ordered]@{
|
||||
schema_version = 'yovision.coordination-state/v1'; deployment_id = $Configuration.DeploymentID; product = $Product.Name
|
||||
pid = $process.Id; started_at = [DateTime]::UtcNow.ToString('o'); version = $Product.Version; manifest_sha256 = $Configuration.Digest
|
||||
launcher_executable = [IO.Path]::GetFullPath($launcher.Executable); command_token = $launcher.CommandToken; package_root = $Product.PackageRoot
|
||||
}
|
||||
$statePath = Get-CoordinationStatePath -Configuration $Configuration -Product $Product
|
||||
[IO.File]::WriteAllText($statePath, ($state | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false))
|
||||
try {
|
||||
Wait-CoordinationHealth -Product $Product -State ([pscustomobject]$state)
|
||||
Write-Host "$($Product.Name) started (version $($Product.Version))."
|
||||
} catch {
|
||||
if (Test-CoordinationOwnedProcess -State ([pscustomobject]$state)) { & taskkill.exe /PID $process.Id /T /F 2>$null | Out-Null }
|
||||
Remove-Item -LiteralPath $statePath -Force -ErrorAction SilentlyContinue
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-CoordinationProduct {
|
||||
param([Parameter(Mandatory = $true)]$Configuration, [Parameter(Mandatory = $true)]$Product)
|
||||
$statePath = Get-CoordinationStatePath -Configuration $Configuration -Product $Product
|
||||
$state = Read-CoordinationState -Configuration $Configuration -Product $Product
|
||||
if (-not $state) { Write-Host "$($Product.Name) is stopped."; return }
|
||||
if (-not (Test-CoordinationOwnedProcess -State $state)) { throw "$($Product.Name) state is stale or belongs to another process; no process was stopped." }
|
||||
if ($Product.Stop) {
|
||||
$stopLauncher = Get-CoordinationLauncher -Command $Product.Stop
|
||||
$stopArguments = (@($stopLauncher.Arguments) | ForEach-Object { ConvertTo-CoordinationArgument -Value ([string]$_) }) -join ' '
|
||||
$stopEnvironment = @{}
|
||||
foreach ($name in $Product.Environment.Keys) { $stopEnvironment[$name] = $Product.Environment[$name] }
|
||||
$stopEnvironment['YOVISION_COORDINATION_OWNED_PID'] = [string]$state.pid
|
||||
$stopProcess = Invoke-CoordinationEnvironment -Values $stopEnvironment -Action { Start-Process -FilePath $stopLauncher.Executable -ArgumentList $stopArguments -WorkingDirectory $Product.PackageRoot -WindowStyle Hidden -Wait -PassThru }
|
||||
if ($stopProcess.ExitCode -ne 0) { throw "$($Product.Name) stop entrypoint failed with exit code $($stopProcess.ExitCode)." }
|
||||
}
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(10)
|
||||
while ((Test-CoordinationOwnedProcess -State $state) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 200 }
|
||||
if (Test-CoordinationOwnedProcess -State $state) { & taskkill.exe /PID ([int]$state.pid) /T /F | Out-Null }
|
||||
if (Get-Process -Id ([int]$state.pid) -ErrorAction SilentlyContinue) { throw "$($Product.Name) owned process did not stop." }
|
||||
Remove-Item -LiteralPath $statePath -Force
|
||||
Write-Host "$($Product.Name) stopped."
|
||||
}
|
||||
|
||||
function Get-CoordinationProductStatus {
|
||||
param([Parameter(Mandatory = $true)]$Configuration, [Parameter(Mandatory = $true)]$Product)
|
||||
$state = Read-CoordinationState -Configuration $Configuration -Product $Product
|
||||
if (-not $state) { return [pscustomobject]@{ Product = $Product.Name; Status = 'stopped'; PID = ''; Version = $Product.Version; Health = 'not-running' } }
|
||||
if (-not (Test-CoordinationOwnedProcess -State $state)) { return [pscustomobject]@{ Product = $Product.Name; Status = 'stale'; PID = $state.pid; Version = $state.version; Health = 'ownership-mismatch' } }
|
||||
if ([string]$state.manifest_sha256 -ne $Configuration.Digest) { return [pscustomobject]@{ Product = $Product.Name; Status = 'stale'; PID = $state.pid; Version = $state.version; Health = 'manifest-drift' } }
|
||||
$healthy = Test-CoordinationHealth -Product $Product -State $state
|
||||
return [pscustomobject]@{ Product = $Product.Name; Status = $(if ($healthy) { 'running' } else { 'unhealthy' }); PID = $state.pid; Version = $state.version; Health = $(if ($healthy) { 'ok' } else { 'failed' }) }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -File "%~dp0start-yovision.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -0,0 +1,17 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Manifest,
|
||||
[string[]]$Product = @('all'),
|
||||
[switch]$ValidateOnly
|
||||
)
|
||||
. (Join-Path $PSScriptRoot 'coordination-common.ps1')
|
||||
|
||||
try {
|
||||
$configuration = Read-CoordinationManifest -Manifest $Manifest
|
||||
$selection = Resolve-CoordinationSelection -Configuration $configuration -Product $Product -Operation start
|
||||
if ($ValidateOnly) { Write-Host "Coordination manifest is valid for: $(($selection.Name) -join ', ')."; exit 0 }
|
||||
foreach ($item in $selection) { Start-CoordinationProduct -Configuration $configuration -Product $item }
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -File "%~dp0status-yovision.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -0,0 +1,18 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Manifest,
|
||||
[string[]]$Product = @('all'),
|
||||
[switch]$Json
|
||||
)
|
||||
. (Join-Path $PSScriptRoot 'coordination-common.ps1')
|
||||
|
||||
try {
|
||||
$configuration = Read-CoordinationManifest -Manifest $Manifest
|
||||
$selection = Resolve-CoordinationSelection -Configuration $configuration -Product $Product -Operation status
|
||||
$result = @($selection | ForEach-Object { Get-CoordinationProductStatus -Configuration $configuration -Product $_ })
|
||||
if ($Json) { $result | ConvertTo-Json -Depth 4 } else { $result | Format-Table -AutoSize }
|
||||
if (@($result | Where-Object Status -ne 'running').Count -gt 0) { exit 3 }
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
pwsh.exe -NoProfile -File "%~dp0stop-yovision.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
@@ -0,0 +1,15 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Manifest,
|
||||
[string[]]$Product = @('all')
|
||||
)
|
||||
. (Join-Path $PSScriptRoot 'coordination-common.ps1')
|
||||
|
||||
try {
|
||||
$configuration = Read-CoordinationManifest -Manifest $Manifest
|
||||
$selection = Resolve-CoordinationSelection -Configuration $configuration -Product $Product -Operation stop
|
||||
foreach ($item in $selection) { Stop-CoordinationProduct -Configuration $configuration -Product $item }
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
Reference in New Issue
Block a user