|
|
@@ -0,0 +1,766 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+from collections import defaultdict, deque
|
|
|
+from dataclasses import dataclass
|
|
|
+from datetime import datetime
|
|
|
+from typing import Any
|
|
|
+from uuid import UUID
|
|
|
+
|
|
|
+from app.core.common.identifiers import new_governance_uid
|
|
|
+from app.core.common.timezone_utils import now_china
|
|
|
+from app.core.data_research.errors import (
|
|
|
+ DeviceObservabilityConflict,
|
|
|
+ DeviceObservabilityInvalid,
|
|
|
+ DeviceObservabilityNotFound,
|
|
|
+)
|
|
|
+
|
|
|
+EVENT_TYPES = frozenset({"alarm", "fault", "maintenance", "downtime"})
|
|
|
+EVENT_SEVERITIES = frozenset({"info", "warning", "error", "critical"})
|
|
|
+EVENT_STATUSES = frozenset({"observed", "resolved"})
|
|
|
+NODE_KINDS = frozenset({"asset", "event", "quality_issue"})
|
|
|
+RELATION_TYPES = frozenset(
|
|
|
+ {
|
|
|
+ "part_of",
|
|
|
+ "occurred_on",
|
|
|
+ "indicates",
|
|
|
+ "triggered",
|
|
|
+ "addresses",
|
|
|
+ "impacts",
|
|
|
+ "evidences",
|
|
|
+ "related_to",
|
|
|
+ }
|
|
|
+)
|
|
|
+CAUSAL_RELATION_TYPES = frozenset({"indicates", "triggered", "evidences"})
|
|
|
+SECRET_KEYS = frozenset(
|
|
|
+ {
|
|
|
+ "password",
|
|
|
+ "passwd",
|
|
|
+ "credential",
|
|
|
+ "credentials",
|
|
|
+ "token",
|
|
|
+ "api_key",
|
|
|
+ "authorization",
|
|
|
+ "connection_string",
|
|
|
+ "connection_url",
|
|
|
+ "encrypted_payload",
|
|
|
+ "phone",
|
|
|
+ "mobile",
|
|
|
+ "email",
|
|
|
+ "id_number",
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class OperationalEventRecord:
|
|
|
+ uid: str
|
|
|
+ source_uid: str
|
|
|
+ source_entity: str
|
|
|
+ source_code: str
|
|
|
+ event_type: str
|
|
|
+ asset_uid: str
|
|
|
+ component_uid: str | None
|
|
|
+ title: str
|
|
|
+ severity: str
|
|
|
+ status: str
|
|
|
+ occurred_at: datetime
|
|
|
+ ended_at: datetime | None
|
|
|
+ evidence_refs: tuple[dict[str, Any], ...]
|
|
|
+ content_hash: str
|
|
|
+ created_by: str
|
|
|
+ created_at: datetime | None = None
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class EvidenceRelationRecord:
|
|
|
+ uid: str
|
|
|
+ from_kind: str
|
|
|
+ from_uid: str
|
|
|
+ relation_type: str
|
|
|
+ to_kind: str
|
|
|
+ to_uid: str
|
|
|
+ evidence_refs: tuple[dict[str, Any], ...]
|
|
|
+ source: str
|
|
|
+ created_by: str
|
|
|
+ created_at: datetime | None = None
|
|
|
+
|
|
|
+ @property
|
|
|
+ def identity(self) -> str:
|
|
|
+ raw = "|".join(
|
|
|
+ (
|
|
|
+ self.from_kind,
|
|
|
+ self.from_uid,
|
|
|
+ self.relation_type,
|
|
|
+ self.to_kind,
|
|
|
+ self.to_uid,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class GraphNode:
|
|
|
+ kind: str
|
|
|
+ uid: str
|
|
|
+ node_type: str
|
|
|
+ label: str
|
|
|
+ occurred_at: datetime | None
|
|
|
+ evidence_refs: tuple[dict[str, Any], ...]
|
|
|
+
|
|
|
+ @property
|
|
|
+ def key(self) -> str:
|
|
|
+ return f"{self.kind}:{self.uid}"
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class GraphProjection:
|
|
|
+ anchor_kind: str
|
|
|
+ anchor_uid: str
|
|
|
+ nodes: tuple[GraphNode, ...]
|
|
|
+ relations: tuple[EvidenceRelationRecord, ...]
|
|
|
+ truncated: bool
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class RootCauseCandidate:
|
|
|
+ event_uid: str
|
|
|
+ event_type: str
|
|
|
+ title: str
|
|
|
+ occurred_at: datetime | None
|
|
|
+ support_level: str
|
|
|
+ path_node_uids: tuple[str, ...]
|
|
|
+ path_relation_uids: tuple[str, ...]
|
|
|
+ path_relation_types: tuple[str, ...]
|
|
|
+ evidence_refs: tuple[dict[str, Any], ...]
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class RootCauseAnalysis:
|
|
|
+ analysis_status: str
|
|
|
+ conclusion: str
|
|
|
+ anchor: GraphNode
|
|
|
+ candidates: tuple[RootCauseCandidate, ...]
|
|
|
+ graph: GraphProjection
|
|
|
+ limitations: tuple[str, ...]
|
|
|
+ generated_at: datetime
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class ObservabilityImportResult:
|
|
|
+ events: tuple[OperationalEventRecord, ...]
|
|
|
+ relations: tuple[EvidenceRelationRecord, ...]
|
|
|
+ created_events: int
|
|
|
+ existing_events: int
|
|
|
+ created_relations: int
|
|
|
+ existing_relations: int
|
|
|
+
|
|
|
+
|
|
|
+def _uuid(value: Any, field: str) -> str:
|
|
|
+ try:
|
|
|
+ return str(UUID(str(value)))
|
|
|
+ except (AttributeError, TypeError, ValueError) as exc:
|
|
|
+ raise DeviceObservabilityInvalid(f"{field} must be a UUID") from exc
|
|
|
+
|
|
|
+
|
|
|
+def _text(value: Any, field: str, maximum: int) -> str:
|
|
|
+ normalized = str(value or "").strip()
|
|
|
+ if not normalized:
|
|
|
+ raise DeviceObservabilityInvalid(f"{field} is required")
|
|
|
+ if len(normalized) > maximum:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ f"{field} exceeds {maximum} characters"
|
|
|
+ )
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
+def _optional_text(value: Any, field: str, maximum: int) -> str | None:
|
|
|
+ normalized = str(value or "").strip()
|
|
|
+ if not normalized:
|
|
|
+ return None
|
|
|
+ if len(normalized) > maximum:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ f"{field} exceeds {maximum} characters"
|
|
|
+ )
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
+def _timestamp(value: Any, field: str, *, required: bool) -> datetime | None:
|
|
|
+ if value in (None, ""):
|
|
|
+ if required:
|
|
|
+ raise DeviceObservabilityInvalid(f"{field} is required")
|
|
|
+ return None
|
|
|
+ if isinstance(value, datetime):
|
|
|
+ parsed = value
|
|
|
+ else:
|
|
|
+ try:
|
|
|
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ f"{field} must be ISO-8601"
|
|
|
+ ) from exc
|
|
|
+ if parsed.tzinfo is None:
|
|
|
+ raise DeviceObservabilityInvalid(f"{field} must include a timezone")
|
|
|
+ return parsed
|
|
|
+
|
|
|
+
|
|
|
+def _reject_secrets(value: Any) -> None:
|
|
|
+ if isinstance(value, dict):
|
|
|
+ for key, item in value.items():
|
|
|
+ if str(key).strip().casefold() in SECRET_KEYS:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "evidence contains a secret or direct identifier"
|
|
|
+ )
|
|
|
+ _reject_secrets(item)
|
|
|
+ elif isinstance(value, (list, tuple)):
|
|
|
+ if len(value) > 100:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "evidence arrays cannot exceed 100 items"
|
|
|
+ )
|
|
|
+ for item in value:
|
|
|
+ _reject_secrets(item)
|
|
|
+
|
|
|
+
|
|
|
+def _evidence(value: Any) -> tuple[dict[str, Any], ...]:
|
|
|
+ if value in (None, {}):
|
|
|
+ return ()
|
|
|
+ if not isinstance(value, dict):
|
|
|
+ raise DeviceObservabilityInvalid("evidence must be an object")
|
|
|
+ _reject_secrets(value)
|
|
|
+ serialized = json.dumps(
|
|
|
+ value,
|
|
|
+ ensure_ascii=False,
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+ default=str,
|
|
|
+ )
|
|
|
+ if len(serialized.encode("utf-8")) > 20_000:
|
|
|
+ raise DeviceObservabilityInvalid("evidence exceeds 20000 bytes")
|
|
|
+ return ({"type": "source_evidence", "data": value},)
|
|
|
+
|
|
|
+
|
|
|
+def _event_hash(payload: dict[str, Any]) -> str:
|
|
|
+ serialized = json.dumps(
|
|
|
+ payload,
|
|
|
+ ensure_ascii=False,
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+ default=str,
|
|
|
+ )
|
|
|
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+class DeviceObservabilityService:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ repository,
|
|
|
+ *,
|
|
|
+ uid_factory=new_governance_uid,
|
|
|
+ commit=lambda: None,
|
|
|
+ rollback=lambda: None,
|
|
|
+ clock=now_china,
|
|
|
+ ):
|
|
|
+ self.repository = repository
|
|
|
+ self.uid_factory = uid_factory
|
|
|
+ self.commit = commit
|
|
|
+ self.rollback = rollback
|
|
|
+ self.clock = clock
|
|
|
+
|
|
|
+ def import_evidence(
|
|
|
+ self,
|
|
|
+ payload: Any,
|
|
|
+ actor_uid: Any,
|
|
|
+ ) -> ObservabilityImportResult:
|
|
|
+ if not isinstance(payload, dict):
|
|
|
+ raise DeviceObservabilityInvalid("payload must be an object")
|
|
|
+ source_uid = _uuid(payload.get("source_uid"), "source_uid")
|
|
|
+ actor = _uuid(actor_uid, "actor_uid")
|
|
|
+ if not self.repository.source_exists(source_uid):
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "source_uid must reference an active source"
|
|
|
+ )
|
|
|
+ events_payload = payload.get("events", [])
|
|
|
+ relations_payload = payload.get("relations", [])
|
|
|
+ if not isinstance(events_payload, list) or not isinstance(
|
|
|
+ relations_payload, list
|
|
|
+ ):
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "events and relations must be arrays"
|
|
|
+ )
|
|
|
+ if not events_payload and not relations_payload:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "at least one event or relation is required"
|
|
|
+ )
|
|
|
+ if len(events_payload) > 200:
|
|
|
+ raise DeviceObservabilityInvalid("events cannot exceed 200 items")
|
|
|
+ if len(relations_payload) > 500:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "relations cannot exceed 500 items"
|
|
|
+ )
|
|
|
+
|
|
|
+ events = []
|
|
|
+ relations = []
|
|
|
+ created_events = existing_events = 0
|
|
|
+ created_relations = existing_relations = 0
|
|
|
+ try:
|
|
|
+ for item in events_payload:
|
|
|
+ normalized = self._normalize_event(
|
|
|
+ item,
|
|
|
+ source_uid=source_uid,
|
|
|
+ actor_uid=actor,
|
|
|
+ )
|
|
|
+ current = self.repository.get_event_by_identity(
|
|
|
+ source_uid,
|
|
|
+ normalized.source_entity,
|
|
|
+ normalized.source_code,
|
|
|
+ )
|
|
|
+ if current is not None:
|
|
|
+ if current.content_hash != normalized.content_hash:
|
|
|
+ raise DeviceObservabilityConflict(
|
|
|
+ "event source identity already exists "
|
|
|
+ "with different content"
|
|
|
+ )
|
|
|
+ events.append(current)
|
|
|
+ existing_events += 1
|
|
|
+ continue
|
|
|
+ self.repository.add_event(normalized)
|
|
|
+ events.append(normalized)
|
|
|
+ created_events += 1
|
|
|
+
|
|
|
+ for item in relations_payload:
|
|
|
+ normalized = self._normalize_relation(
|
|
|
+ item,
|
|
|
+ source_uid=source_uid,
|
|
|
+ actor_uid=actor,
|
|
|
+ )
|
|
|
+ current = self.repository.get_relation(normalized)
|
|
|
+ if current is not None:
|
|
|
+ if (
|
|
|
+ current.evidence_refs != normalized.evidence_refs
|
|
|
+ or current.source != normalized.source
|
|
|
+ ):
|
|
|
+ raise DeviceObservabilityConflict(
|
|
|
+ "relation identity already exists "
|
|
|
+ "with different evidence"
|
|
|
+ )
|
|
|
+ relations.append(current)
|
|
|
+ existing_relations += 1
|
|
|
+ continue
|
|
|
+ self.repository.add_relation(normalized)
|
|
|
+ relations.append(normalized)
|
|
|
+ created_relations += 1
|
|
|
+ self.commit()
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|
|
|
+ return ObservabilityImportResult(
|
|
|
+ events=tuple(events),
|
|
|
+ relations=tuple(relations),
|
|
|
+ created_events=created_events,
|
|
|
+ existing_events=existing_events,
|
|
|
+ created_relations=created_relations,
|
|
|
+ existing_relations=existing_relations,
|
|
|
+ )
|
|
|
+
|
|
|
+ def _normalize_event(
|
|
|
+ self,
|
|
|
+ payload: Any,
|
|
|
+ *,
|
|
|
+ source_uid: str,
|
|
|
+ actor_uid: str,
|
|
|
+ ) -> OperationalEventRecord:
|
|
|
+ if not isinstance(payload, dict):
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "each event must be an object"
|
|
|
+ )
|
|
|
+ event_type = _text(payload.get("event_type"), "event_type", 30)
|
|
|
+ if event_type not in EVENT_TYPES:
|
|
|
+ raise DeviceObservabilityInvalid("event_type is not supported")
|
|
|
+ severity = _text(
|
|
|
+ payload.get("severity", "warning"),
|
|
|
+ "severity",
|
|
|
+ 20,
|
|
|
+ )
|
|
|
+ if severity not in EVENT_SEVERITIES:
|
|
|
+ raise DeviceObservabilityInvalid("severity is not supported")
|
|
|
+ status = _text(payload.get("status", "observed"), "status", 20)
|
|
|
+ if status not in EVENT_STATUSES:
|
|
|
+ raise DeviceObservabilityInvalid("status is not supported")
|
|
|
+ occurred_at = _timestamp(
|
|
|
+ payload.get("occurred_at"),
|
|
|
+ "occurred_at",
|
|
|
+ required=True,
|
|
|
+ )
|
|
|
+ ended_at = _timestamp(
|
|
|
+ payload.get("ended_at"),
|
|
|
+ "ended_at",
|
|
|
+ required=False,
|
|
|
+ )
|
|
|
+ if ended_at is not None and ended_at < occurred_at:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "ended_at cannot be before occurred_at"
|
|
|
+ )
|
|
|
+ asset_uid = _uuid(payload.get("asset_uid"), "asset_uid")
|
|
|
+ if not self.repository.node_exists("asset", asset_uid):
|
|
|
+ raise DeviceObservabilityInvalid("asset_uid does not exist")
|
|
|
+ component_uid = None
|
|
|
+ if payload.get("component_uid"):
|
|
|
+ component_uid = _uuid(
|
|
|
+ payload.get("component_uid"),
|
|
|
+ "component_uid",
|
|
|
+ )
|
|
|
+ if not self.repository.node_exists("asset", component_uid):
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "component_uid does not exist"
|
|
|
+ )
|
|
|
+ evidence_refs = _evidence(payload.get("evidence"))
|
|
|
+ source_entity = _text(
|
|
|
+ payload.get("source_entity"),
|
|
|
+ "source_entity",
|
|
|
+ 300,
|
|
|
+ )
|
|
|
+ source_code = _text(
|
|
|
+ payload.get("source_code"),
|
|
|
+ "source_code",
|
|
|
+ 300,
|
|
|
+ )
|
|
|
+ title = _text(payload.get("title"), "title", 500)
|
|
|
+ hash_payload = {
|
|
|
+ "source_uid": source_uid,
|
|
|
+ "source_entity": source_entity,
|
|
|
+ "source_code": source_code,
|
|
|
+ "event_type": event_type,
|
|
|
+ "asset_uid": asset_uid,
|
|
|
+ "component_uid": component_uid,
|
|
|
+ "title": title,
|
|
|
+ "severity": severity,
|
|
|
+ "status": status,
|
|
|
+ "occurred_at": occurred_at.isoformat(),
|
|
|
+ "ended_at": ended_at.isoformat() if ended_at else None,
|
|
|
+ "evidence_refs": evidence_refs,
|
|
|
+ }
|
|
|
+ return OperationalEventRecord(
|
|
|
+ uid=str(self.uid_factory()),
|
|
|
+ source_uid=source_uid,
|
|
|
+ source_entity=source_entity,
|
|
|
+ source_code=source_code,
|
|
|
+ event_type=event_type,
|
|
|
+ asset_uid=asset_uid,
|
|
|
+ component_uid=component_uid,
|
|
|
+ title=title,
|
|
|
+ severity=severity,
|
|
|
+ status=status,
|
|
|
+ occurred_at=occurred_at,
|
|
|
+ ended_at=ended_at,
|
|
|
+ evidence_refs=evidence_refs,
|
|
|
+ created_by=actor_uid,
|
|
|
+ created_at=self.clock(),
|
|
|
+ content_hash=_event_hash(hash_payload),
|
|
|
+ )
|
|
|
+
|
|
|
+ def _endpoint(
|
|
|
+ self,
|
|
|
+ payload: Any,
|
|
|
+ *,
|
|
|
+ source_uid: str,
|
|
|
+ field: str,
|
|
|
+ ) -> tuple[str, str]:
|
|
|
+ if not isinstance(payload, dict):
|
|
|
+ raise DeviceObservabilityInvalid(f"{field} must be an object")
|
|
|
+ kind = _text(payload.get("kind"), f"{field}.kind", 30)
|
|
|
+ if kind not in NODE_KINDS:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ f"{field}.kind is not supported"
|
|
|
+ )
|
|
|
+ if kind == "event" and not payload.get("uid"):
|
|
|
+ source_entity = _text(
|
|
|
+ payload.get("source_entity"),
|
|
|
+ f"{field}.source_entity",
|
|
|
+ 300,
|
|
|
+ )
|
|
|
+ source_code = _text(
|
|
|
+ payload.get("source_code"),
|
|
|
+ f"{field}.source_code",
|
|
|
+ 300,
|
|
|
+ )
|
|
|
+ uid = self.repository.resolve_event_identity(
|
|
|
+ source_uid,
|
|
|
+ source_entity,
|
|
|
+ source_code,
|
|
|
+ )
|
|
|
+ if not uid:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ f"{field} event identity does not exist"
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ uid = _uuid(payload.get("uid"), f"{field}.uid")
|
|
|
+ if not self.repository.node_exists(kind, uid):
|
|
|
+ raise DeviceObservabilityInvalid(f"{field} node does not exist")
|
|
|
+ return kind, uid
|
|
|
+
|
|
|
+ def _normalize_relation(
|
|
|
+ self,
|
|
|
+ payload: Any,
|
|
|
+ *,
|
|
|
+ source_uid: str,
|
|
|
+ actor_uid: str,
|
|
|
+ ) -> EvidenceRelationRecord:
|
|
|
+ if not isinstance(payload, dict):
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "each relation must be an object"
|
|
|
+ )
|
|
|
+ from_kind, from_uid = self._endpoint(
|
|
|
+ payload.get("from"),
|
|
|
+ source_uid=source_uid,
|
|
|
+ field="from",
|
|
|
+ )
|
|
|
+ to_kind, to_uid = self._endpoint(
|
|
|
+ payload.get("to"),
|
|
|
+ source_uid=source_uid,
|
|
|
+ field="to",
|
|
|
+ )
|
|
|
+ if from_kind == to_kind and from_uid == to_uid:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "relation endpoints must be different"
|
|
|
+ )
|
|
|
+ relation_type = _text(
|
|
|
+ payload.get("relation_type"),
|
|
|
+ "relation_type",
|
|
|
+ 40,
|
|
|
+ )
|
|
|
+ if relation_type not in RELATION_TYPES:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "relation_type is not supported"
|
|
|
+ )
|
|
|
+ source = _optional_text(payload.get("source"), "source", 30)
|
|
|
+ if source is None:
|
|
|
+ source = "imported"
|
|
|
+ if source not in {"imported", "manual", "derived"}:
|
|
|
+ raise DeviceObservabilityInvalid("source is not supported")
|
|
|
+ return EvidenceRelationRecord(
|
|
|
+ uid=str(self.uid_factory()),
|
|
|
+ from_kind=from_kind,
|
|
|
+ from_uid=from_uid,
|
|
|
+ relation_type=relation_type,
|
|
|
+ to_kind=to_kind,
|
|
|
+ to_uid=to_uid,
|
|
|
+ evidence_refs=_evidence(payload.get("evidence")),
|
|
|
+ source=source,
|
|
|
+ created_by=actor_uid,
|
|
|
+ created_at=self.clock(),
|
|
|
+ )
|
|
|
+
|
|
|
+ def graph(
|
|
|
+ self,
|
|
|
+ anchor_kind: Any,
|
|
|
+ anchor_uid: Any,
|
|
|
+ *,
|
|
|
+ max_hops: Any = 2,
|
|
|
+ ) -> GraphProjection:
|
|
|
+ kind = _text(anchor_kind, "anchor_kind", 30)
|
|
|
+ if kind not in NODE_KINDS:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "anchor_kind is not supported"
|
|
|
+ )
|
|
|
+ uid = _uuid(anchor_uid, "anchor_uid")
|
|
|
+ try:
|
|
|
+ hops = int(max_hops)
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "max_hops must be an integer"
|
|
|
+ ) from exc
|
|
|
+ if hops < 1 or hops > 3:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "max_hops must be between 1 and 3"
|
|
|
+ )
|
|
|
+ if not self.repository.node_exists(kind, uid):
|
|
|
+ raise DeviceObservabilityNotFound("anchor node does not exist")
|
|
|
+ return self.repository.graph(
|
|
|
+ kind,
|
|
|
+ uid,
|
|
|
+ max_hops=hops,
|
|
|
+ max_nodes=100,
|
|
|
+ max_edges=200,
|
|
|
+ )
|
|
|
+
|
|
|
+ def events(
|
|
|
+ self,
|
|
|
+ filters: Any,
|
|
|
+ *,
|
|
|
+ page: Any = 1,
|
|
|
+ page_size: Any = 20,
|
|
|
+ ):
|
|
|
+ if not isinstance(filters, dict):
|
|
|
+ raise DeviceObservabilityInvalid("filters must be an object")
|
|
|
+ normalized = {}
|
|
|
+ event_type = _optional_text(
|
|
|
+ filters.get("event_type"),
|
|
|
+ "event_type",
|
|
|
+ 30,
|
|
|
+ )
|
|
|
+ if event_type:
|
|
|
+ if event_type not in EVENT_TYPES:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "event_type is not supported"
|
|
|
+ )
|
|
|
+ normalized["event_type"] = event_type
|
|
|
+ severity = _optional_text(
|
|
|
+ filters.get("severity"),
|
|
|
+ "severity",
|
|
|
+ 20,
|
|
|
+ )
|
|
|
+ if severity:
|
|
|
+ if severity not in EVENT_SEVERITIES:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "severity is not supported"
|
|
|
+ )
|
|
|
+ normalized["severity"] = severity
|
|
|
+ status = _optional_text(filters.get("status"), "status", 20)
|
|
|
+ if status:
|
|
|
+ if status not in EVENT_STATUSES:
|
|
|
+ raise DeviceObservabilityInvalid("status is not supported")
|
|
|
+ normalized["status"] = status
|
|
|
+ for field in ("asset_uid", "source_uid"):
|
|
|
+ if filters.get(field):
|
|
|
+ normalized[field] = _uuid(filters[field], field)
|
|
|
+ keyword = _optional_text(filters.get("keyword"), "keyword", 300)
|
|
|
+ if keyword:
|
|
|
+ normalized["keyword"] = keyword
|
|
|
+ try:
|
|
|
+ current_page = int(page)
|
|
|
+ current_page_size = int(page_size)
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "page and page_size must be integers"
|
|
|
+ ) from exc
|
|
|
+ if current_page < 1:
|
|
|
+ raise DeviceObservabilityInvalid("page must be positive")
|
|
|
+ if current_page_size < 1 or current_page_size > 100:
|
|
|
+ raise DeviceObservabilityInvalid(
|
|
|
+ "page_size must be between 1 and 100"
|
|
|
+ )
|
|
|
+ return self.repository.search_events(
|
|
|
+ normalized,
|
|
|
+ page=current_page,
|
|
|
+ page_size=current_page_size,
|
|
|
+ )
|
|
|
+
|
|
|
+ def root_cause(
|
|
|
+ self,
|
|
|
+ anchor_kind: Any,
|
|
|
+ anchor_uid: Any,
|
|
|
+ *,
|
|
|
+ max_hops: Any = 3,
|
|
|
+ ) -> RootCauseAnalysis:
|
|
|
+ graph = self.graph(
|
|
|
+ anchor_kind,
|
|
|
+ anchor_uid,
|
|
|
+ max_hops=max_hops,
|
|
|
+ )
|
|
|
+ nodes = {item.key: item for item in graph.nodes}
|
|
|
+ anchor_key = f"{graph.anchor_kind}:{graph.anchor_uid}"
|
|
|
+ anchor = nodes[anchor_key]
|
|
|
+ incoming = defaultdict(list)
|
|
|
+ for relation in graph.relations:
|
|
|
+ if relation.relation_type in CAUSAL_RELATION_TYPES:
|
|
|
+ incoming[f"{relation.to_kind}:{relation.to_uid}"].append(
|
|
|
+ relation
|
|
|
+ )
|
|
|
+
|
|
|
+ candidates = []
|
|
|
+ queue = deque([(anchor_key, (), (anchor.uid,))])
|
|
|
+ visited_depth = {anchor_key: 0}
|
|
|
+ while queue:
|
|
|
+ current_key, path, node_path = queue.popleft()
|
|
|
+ current_node = nodes.get(current_key)
|
|
|
+ for relation in incoming.get(current_key, ()):
|
|
|
+ source_key = f"{relation.from_kind}:{relation.from_uid}"
|
|
|
+ source_node = nodes.get(source_key)
|
|
|
+ if source_node is None:
|
|
|
+ continue
|
|
|
+ if (
|
|
|
+ current_node is not None
|
|
|
+ and current_node.occurred_at is not None
|
|
|
+ and source_node.occurred_at is not None
|
|
|
+ and source_node.occurred_at > current_node.occurred_at
|
|
|
+ ):
|
|
|
+ continue
|
|
|
+ next_path = (relation, *path)
|
|
|
+ next_nodes = (source_node.uid, *node_path)
|
|
|
+ depth = len(next_path)
|
|
|
+ if (
|
|
|
+ source_node.kind == "event"
|
|
|
+ and source_node.uid != anchor.uid
|
|
|
+ ):
|
|
|
+ evidence_refs = []
|
|
|
+ for item in next_path:
|
|
|
+ evidence_refs.extend(item.evidence_refs)
|
|
|
+ evidence_refs.extend(source_node.evidence_refs)
|
|
|
+ candidates.append(
|
|
|
+ RootCauseCandidate(
|
|
|
+ event_uid=source_node.uid,
|
|
|
+ event_type=source_node.node_type,
|
|
|
+ title=source_node.label,
|
|
|
+ occurred_at=source_node.occurred_at,
|
|
|
+ support_level=(
|
|
|
+ "direct" if depth == 1 else "indirect"
|
|
|
+ ),
|
|
|
+ path_node_uids=next_nodes,
|
|
|
+ path_relation_uids=tuple(
|
|
|
+ item.uid for item in next_path
|
|
|
+ ),
|
|
|
+ path_relation_types=tuple(
|
|
|
+ item.relation_type for item in next_path
|
|
|
+ ),
|
|
|
+ evidence_refs=tuple(evidence_refs[:20]),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ if depth < int(max_hops) and (
|
|
|
+ source_key not in visited_depth
|
|
|
+ or depth < visited_depth[source_key]
|
|
|
+ ):
|
|
|
+ visited_depth[source_key] = depth
|
|
|
+ queue.append((source_key, next_path, next_nodes))
|
|
|
+
|
|
|
+ unique = []
|
|
|
+ seen = set()
|
|
|
+ for candidate in candidates:
|
|
|
+ if candidate.event_uid in seen:
|
|
|
+ continue
|
|
|
+ seen.add(candidate.event_uid)
|
|
|
+ unique.append(candidate)
|
|
|
+ if unique:
|
|
|
+ status = "supported_candidates"
|
|
|
+ conclusion = "发现有证据支持的根因候选,仍需设备专家确认"
|
|
|
+ else:
|
|
|
+ status = "insufficient_evidence"
|
|
|
+ conclusion = "证据不足,无法确认根因"
|
|
|
+ return RootCauseAnalysis(
|
|
|
+ analysis_status=status,
|
|
|
+ conclusion=conclusion,
|
|
|
+ anchor=anchor,
|
|
|
+ candidates=tuple(unique),
|
|
|
+ graph=graph,
|
|
|
+ limitations=(
|
|
|
+ "候选来自持久化关系证据,不等同于已确认根因。",
|
|
|
+ "未接入或未关联的运行事件不会出现在分析结果中。",
|
|
|
+ "结果不触发自动修复、维修计划或来源数据改写。",
|
|
|
+ ),
|
|
|
+ generated_at=self.clock(),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+__all__ = [
|
|
|
+ "DeviceObservabilityConflict",
|
|
|
+ "DeviceObservabilityInvalid",
|
|
|
+ "DeviceObservabilityNotFound",
|
|
|
+ "DeviceObservabilityService",
|
|
|
+ "EvidenceRelationRecord",
|
|
|
+ "GraphNode",
|
|
|
+ "GraphProjection",
|
|
|
+ "ObservabilityImportResult",
|
|
|
+ "OperationalEventRecord",
|
|
|
+ "RootCauseAnalysis",
|
|
|
+ "RootCauseCandidate",
|
|
|
+]
|