367 lines
15 KiB
Python
367 lines
15 KiB
Python
"""Typed configuration: dataclasses, YAML loading and strict validation.
|
|
|
|
Pydantic-free by design (IMPLEMENTATION_PLAN §4.1). Every validation error is
|
|
actionable: it names the offending key and explains what is wrong, so
|
|
``opcda-diagnozer check-config`` output lets an ops engineer fix the file
|
|
without reading the code.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import MISSING, dataclass, field, fields, replace
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from ruamel.yaml import YAML
|
|
from ruamel.yaml.error import YAMLError
|
|
|
|
_yaml = YAML(typ="safe") # pure-Python safe loader; config files are small
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""Invalid configuration; the message is user-facing (check-config output)."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class OpcConfig:
|
|
# server: OPC DA server ProgID, e.g. "Matrikon.OPC.Simulation.1"
|
|
# host: DCOM host; None means a local (same-machine) server
|
|
server: str
|
|
host: str | None = None
|
|
connect_timeout_s: float = 10.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TagEntry:
|
|
# expect_change: True -> frozen-value detection (D13) applies to this tag
|
|
name: str
|
|
expect_change: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeviceEntry:
|
|
"""D16 device-link indicator: a tag saying if the device is reachable.
|
|
|
|
DOWN (``DEVICE_LINK_DOWN``, CRITICAL) when ``tag`` reads non-GOOD, errors,
|
|
or its GOOD value is **not** in ``ok_values`` — the normal (connected)
|
|
reading, configurable per indicator because conventions differ per
|
|
control system: connected-flags ``[true]`` (default), inverted fault-flags
|
|
``[false, 0]``, string states ``["RUN"]``. Compared by string equality on
|
|
the rendered value (SPECIFICATION §6.1).
|
|
"""
|
|
|
|
name: str
|
|
tag: str
|
|
ok_values: tuple[str, ...] = ("true",)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PollConfig:
|
|
interval_s: float = 1.0
|
|
timeout_ms: int = 5000 # D7: beyond this the call is flagged as a hang
|
|
hard_hang_s: float = 120.0 # SPECIFICATION §10.4: abandon + rebuild the COM thread
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LatencyConfig:
|
|
warn_ms: float = 1000.0 # D4 spike threshold
|
|
critical_ms: float = 5000.0 # D5 sustained-slow severity
|
|
slow_poll_streak: int = 5 # D5 consecutive slow polls
|
|
window_polls: int = 60 # D6 rolling window for the p95 trend
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class QualityConfig:
|
|
persistent_bad_s: float = 30.0 # D10
|
|
flapping_transitions_per_window: int = 5 # D11
|
|
flapping_window_s: float = 600.0 # D11 window (10 min)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StaleConfig:
|
|
enabled: bool = True
|
|
max_age_s: float = 120.0 # D12 / D13
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BrowseConfig:
|
|
# Address-space walk guard: deeper SCADA trees are fine, but a walk this
|
|
# deep is almost certainly a server-side cycle (list-items --depth 0).
|
|
max_depth: int = 32
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class JsonlConfig:
|
|
dir: str = "C:/ProgramData/OPCDA-Diagnozer/logs"
|
|
max_mb: int = 50
|
|
backups: int = 10
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EventLogConfig:
|
|
source: str = "OPCDA-Diagnozer"
|
|
min_severity: str = "WARN" # INFO / WARN / CRITICAL
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReportConfig:
|
|
heartbeat_s: float = 300.0 # D15
|
|
jsonl: JsonlConfig = field(default_factory=JsonlConfig)
|
|
eventlog: EventLogConfig = field(default_factory=EventLogConfig)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Config:
|
|
"""Fully validated tool configuration (see config.example.yaml)."""
|
|
|
|
opc: OpcConfig
|
|
poll: PollConfig = field(default_factory=PollConfig)
|
|
tags: tuple[TagEntry, ...] = ()
|
|
devices: tuple[DeviceEntry, ...] = ()
|
|
latency: LatencyConfig = field(default_factory=LatencyConfig)
|
|
quality: QualityConfig = field(default_factory=QualityConfig)
|
|
stale: StaleConfig = field(default_factory=StaleConfig)
|
|
browse: BrowseConfig = field(default_factory=BrowseConfig)
|
|
report: ReportConfig = field(default_factory=ReportConfig)
|
|
|
|
|
|
_ALLOWED_TOP_LEVEL = {"opc", "poll", "tags", "devices", "latency", "quality", "stale", "browse", "report"}
|
|
_TAG_KEYS = {"name", "expect_change"}
|
|
_DEVICE_KEYS = {"name", "tag", "ok_values"}
|
|
|
|
|
|
def _normalize_ok_values(raw: Any, where: str) -> tuple[str, ...]:
|
|
"""ok_values as a tuple of rendered strings ("true", "1", "RUN").
|
|
|
|
The D16 comparison is string equality on the value rendered as text, so
|
|
booleans/ints/strings from YAML all work uniformly: `[true]` -> `("true",)`,
|
|
`[false, 0]` -> `("false", "0")`.
|
|
"""
|
|
if raw is None:
|
|
return ("true",)
|
|
if isinstance(raw, (list, tuple)):
|
|
if not raw:
|
|
raise ConfigError(f"{where}: ok_values must not be empty - set the normal (connected) value(s)")
|
|
values = []
|
|
for item in raw:
|
|
if isinstance(item, bool):
|
|
values.append("true" if item else "false")
|
|
elif isinstance(item, (int, float, str)):
|
|
values.append(str(item).strip())
|
|
else:
|
|
raise ConfigError(
|
|
f"{where}: ok_values entries must be scalars (bool/int/str), got {type(item).__name__}"
|
|
)
|
|
cleaned = tuple(v for v in values if v)
|
|
if not cleaned:
|
|
raise ConfigError(f"{where}: ok_values must not be empty - set the normal (connected) value(s)")
|
|
return cleaned
|
|
# a single scalar is shorthand for a one-element list
|
|
if isinstance(raw, (bool, int, float, str)):
|
|
return _normalize_ok_values([raw], where)
|
|
raise ConfigError(f"{where}: ok_values must be a list of scalars, got {type(raw).__name__}")
|
|
|
|
|
|
def _build_section(cls: type[Any], data: Any, where: str) -> Any:
|
|
"""Instantiate a frozen dataclass section, rejecting unknown/missing keys."""
|
|
data = {} if data is None else data
|
|
if not isinstance(data, dict):
|
|
raise ConfigError(f"{where}: must be a mapping, got {type(data).__name__}")
|
|
known = {f.name for f in fields(cls)}
|
|
unknown = sorted(set(data) - known)
|
|
if unknown:
|
|
raise ConfigError(f"{where}: unknown key(s) {unknown}; accepted keys: {sorted(known)}")
|
|
for f in fields(cls):
|
|
if f.name not in data and f.default is MISSING and f.default_factory is MISSING:
|
|
raise ConfigError(f"{where}: missing required key '{f.name}'")
|
|
try:
|
|
return cls(**data)
|
|
except TypeError as exc:
|
|
raise ConfigError(f"{where}: {exc}") from None
|
|
|
|
|
|
def _build_report(data: Any) -> ReportConfig:
|
|
data = {} if data is None else dict(data)
|
|
jsonl = _build_section(JsonlConfig, data.pop("jsonl", None), "report.jsonl")
|
|
eventlog = _build_section(EventLogConfig, data.pop("eventlog", None), "report.eventlog")
|
|
rest = _build_section(ReportConfig, data, "report")
|
|
return replace(rest, jsonl=jsonl, eventlog=eventlog)
|
|
|
|
|
|
def _build_tags(data: Any) -> tuple[TagEntry, ...]:
|
|
if data is None:
|
|
return ()
|
|
if not isinstance(data, list):
|
|
raise ConfigError(f"tags: must be a list, got {type(data).__name__}")
|
|
entries: list[TagEntry] = []
|
|
for i, item in enumerate(data):
|
|
if isinstance(item, str):
|
|
if not item.strip():
|
|
raise ConfigError(f"tags[{i}]: name must be a non-empty string")
|
|
entries.append(TagEntry(name=item))
|
|
continue
|
|
if not isinstance(item, dict):
|
|
raise ConfigError(
|
|
f"tags[{i}]: must be a tag name string or a mapping with 'name'"
|
|
" (and optional 'expect_change')"
|
|
)
|
|
name = item.get("name")
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise ConfigError(f"tags[{i}]: 'name' is required and must be a non-empty string")
|
|
unknown = sorted(set(item) - _TAG_KEYS)
|
|
if unknown:
|
|
raise ConfigError(f"tags[{i}]: unknown key(s) {unknown}; accepted keys: {sorted(_TAG_KEYS)}")
|
|
expect = item.get("expect_change", False)
|
|
if not isinstance(expect, bool):
|
|
raise ConfigError(
|
|
f"tags[{i}]: 'expect_change' must be a boolean, got {type(expect).__name__}"
|
|
)
|
|
entries.append(TagEntry(name=name, expect_change=expect))
|
|
return tuple(entries)
|
|
|
|
|
|
def _build_devices(data: Any) -> tuple[DeviceEntry, ...]:
|
|
"""Parse the devices: section (D16 link indicators) with actionable errors."""
|
|
if data is None:
|
|
return ()
|
|
if not isinstance(data, list):
|
|
raise ConfigError(f"devices: must be a list, got {type(data).__name__}")
|
|
entries: list[DeviceEntry] = []
|
|
for i, item in enumerate(data):
|
|
if not isinstance(item, dict):
|
|
raise ConfigError(
|
|
f"devices[{i}]: must be a mapping with 'name' and 'tag'"
|
|
)
|
|
unknown = sorted(set(item) - _DEVICE_KEYS)
|
|
if unknown:
|
|
raise ConfigError(
|
|
f"devices[{i}]: unknown key(s) {unknown}; accepted keys: {sorted(_DEVICE_KEYS)}"
|
|
)
|
|
name = item.get("name")
|
|
tag = item.get("tag")
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise ConfigError(f"devices[{i}]: 'name' is required and must be a non-empty string")
|
|
if not isinstance(tag, str) or not tag.strip():
|
|
raise ConfigError(
|
|
f"devices[{i}]: 'tag' is required and must be the connection-indicator "
|
|
"tag ItemID (see list-items)"
|
|
)
|
|
ok_values = _normalize_ok_values(item.get("ok_values"), f"devices[{i}]")
|
|
entries.append(DeviceEntry(name=name, tag=tag, ok_values=ok_values))
|
|
return tuple(entries)
|
|
|
|
|
|
def _validate(config: Config) -> None:
|
|
"""Cross-field validation; each message names the offending key."""
|
|
if not config.opc.server or not config.opc.server.strip():
|
|
raise ConfigError(
|
|
"opc.server: required — the OPC DA server ProgID, "
|
|
"e.g. 'Matrikon.OPC.Simulation.1'"
|
|
)
|
|
if config.opc.connect_timeout_s <= 0:
|
|
raise ConfigError(f"opc.connect_timeout_s: must be > 0, got {config.opc.connect_timeout_s}")
|
|
if not config.tags:
|
|
raise ConfigError(
|
|
"tags: at least one tag is required — without tags there is nothing to monitor"
|
|
)
|
|
names = [t.name for t in config.tags]
|
|
duplicates = sorted({n for n in names if names.count(n) > 1})
|
|
if duplicates:
|
|
raise ConfigError(f"tags: duplicate tag name(s) {duplicates}")
|
|
device_names = [d.name for d in config.devices]
|
|
duplicate_devices = sorted({n for n in device_names if device_names.count(n) > 1})
|
|
if duplicate_devices:
|
|
raise ConfigError(f"devices: duplicate device name(s) {duplicate_devices}")
|
|
indicator_tags = [d.tag for d in config.devices]
|
|
duplicate_indicators = sorted({t for t in indicator_tags if indicator_tags.count(t) > 1})
|
|
if duplicate_indicators:
|
|
raise ConfigError(
|
|
f"devices: duplicate indicator tag(s) {duplicate_indicators} - one device per indicator"
|
|
)
|
|
# An indicator doubles as a monitored tag; listing it in tags: too would double-poll
|
|
# it and double-report its quality transitions (D8 AND D16 fire).
|
|
overlap = sorted(set(indicator_tags) & set(names))
|
|
if overlap:
|
|
raise ConfigError(
|
|
f"tags: {overlap} also appear as device indicators - remove them from "
|
|
"tags: (devices: entries are polled automatically)"
|
|
)
|
|
if config.poll.interval_s <= 0:
|
|
raise ConfigError(f"poll.interval_s: must be > 0, got {config.poll.interval_s}")
|
|
if config.poll.timeout_ms <= 0:
|
|
raise ConfigError(f"poll.timeout_ms: must be > 0, got {config.poll.timeout_ms}")
|
|
if config.poll.hard_hang_s * 1000 <= config.poll.timeout_ms:
|
|
raise ConfigError(
|
|
f"poll.hard_hang_s: must exceed poll.timeout_ms "
|
|
f"({config.poll.timeout_ms} ms); got {config.poll.hard_hang_s} s"
|
|
)
|
|
if not 0 < config.latency.warn_ms <= config.latency.critical_ms:
|
|
raise ConfigError(
|
|
"latency.warn_ms: must satisfy 0 < warn_ms <= critical_ms "
|
|
f"(got warn_ms={config.latency.warn_ms}, critical_ms={config.latency.critical_ms})"
|
|
)
|
|
if config.latency.slow_poll_streak < 1:
|
|
raise ConfigError(f"latency.slow_poll_streak: must be >= 1, got {config.latency.slow_poll_streak}")
|
|
if config.latency.window_polls < 1:
|
|
raise ConfigError(f"latency.window_polls: must be >= 1, got {config.latency.window_polls}")
|
|
if config.quality.persistent_bad_s <= 0:
|
|
raise ConfigError(f"quality.persistent_bad_s: must be > 0, got {config.quality.persistent_bad_s}")
|
|
if config.quality.flapping_transitions_per_window < 1:
|
|
raise ConfigError(
|
|
f"quality.flapping_transitions_per_window: must be >= 1, "
|
|
f"got {config.quality.flapping_transitions_per_window}"
|
|
)
|
|
if config.quality.flapping_window_s <= 0:
|
|
raise ConfigError(f"quality.flapping_window_s: must be > 0, got {config.quality.flapping_window_s}")
|
|
if config.stale.enabled and config.stale.max_age_s <= 0:
|
|
raise ConfigError(f"stale.max_age_s: must be > 0, got {config.stale.max_age_s}")
|
|
if config.browse.max_depth < 1:
|
|
raise ConfigError(f"browse.max_depth: must be >= 1, got {config.browse.max_depth}")
|
|
if config.report.heartbeat_s <= 0:
|
|
raise ConfigError(f"report.heartbeat_s: must be > 0, got {config.report.heartbeat_s}")
|
|
if config.report.eventlog.min_severity not in ("INFO", "WARN", "CRITICAL"):
|
|
raise ConfigError(
|
|
"report.eventlog.min_severity: must be one of INFO/WARN/CRITICAL, "
|
|
f"got {config.report.eventlog.min_severity!r}"
|
|
)
|
|
if config.report.jsonl.max_mb < 1:
|
|
raise ConfigError(f"report.jsonl.max_mb: must be >= 1, got {config.report.jsonl.max_mb}")
|
|
if config.report.jsonl.backups < 0:
|
|
raise ConfigError(f"report.jsonl.backups: must be >= 0, got {config.report.jsonl.backups}")
|
|
|
|
|
|
def load_config(path: str | Path) -> Config:
|
|
"""Load and strictly validate a YAML configuration file."""
|
|
p = Path(path)
|
|
if not p.is_file():
|
|
raise ConfigError(f"config file not found: {p}")
|
|
try:
|
|
data = _yaml.load(p.read_text(encoding="utf-8"))
|
|
except YAMLError as exc:
|
|
raise ConfigError(f"{p}: invalid YAML: {exc}") from None
|
|
if data is None:
|
|
data = {}
|
|
if not isinstance(data, dict):
|
|
raise ConfigError(f"{p}: top level must be a mapping")
|
|
unknown_top = sorted(set(data) - _ALLOWED_TOP_LEVEL)
|
|
if unknown_top:
|
|
raise ConfigError(
|
|
f"{p}: unknown section(s) {unknown_top}; "
|
|
f"accepted sections: {sorted(_ALLOWED_TOP_LEVEL)}"
|
|
)
|
|
config = Config(
|
|
opc=_build_section(OpcConfig, data.get("opc"), "opc"),
|
|
poll=_build_section(PollConfig, data.get("poll"), "poll"),
|
|
tags=_build_tags(data.get("tags")),
|
|
devices=_build_devices(data.get("devices")),
|
|
latency=_build_section(LatencyConfig, data.get("latency"), "latency"),
|
|
quality=_build_section(QualityConfig, data.get("quality"), "quality"),
|
|
stale=_build_section(StaleConfig, data.get("stale"), "stale"),
|
|
browse=_build_section(BrowseConfig, data.get("browse"), "browse"),
|
|
report=_build_report(data.get("report")),
|
|
)
|
|
try:
|
|
_validate(config)
|
|
except TypeError as exc:
|
|
raise ConfigError(f"invalid value type in config: {exc}") from None
|
|
return config |