Compare commits
10 Commits
focas-tier
...
phase-7-st
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae715cca4 | ||
| d2bfcd9f1e | |||
|
|
e4dae01bac | ||
| 6ae638a6de | |||
|
|
2a74daf228 | ||
| 3eb5f1d9da | |||
|
|
f2c1cc84e9 | ||
| 8384e58655 | |||
|
|
96940aeb24 | ||
| 340f580be0 |
@@ -3,6 +3,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
@@ -26,6 +27,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||
|
||||
@@ -34,7 +34,8 @@ shaped (neither is a Modbus-side concept).
|
||||
- `DL205SmokeTests` — FC16 write → FC03 read round-trip on holding register
|
||||
- `DL205CoilMappingTests` — Y-output / C-relay / X-input address mapping
|
||||
(octal → Modbus offset)
|
||||
- `DL205ExceptionCodeTests` — Modbus exception → OPC UA StatusCode mapping
|
||||
- `DL205ExceptionCodeTests` — Modbus exception 0x02 → OPC UA `BadOutOfRange` against the dl205 profile (natural out-of-range path)
|
||||
- `ExceptionInjectionTests` — every other exception code in the mapping table (0x01 / 0x03 / 0x04 / 0x05 / 0x06 / 0x0A / 0x0B) against the `exception_injection` profile on both read + write paths
|
||||
- `DL205FloatCdabQuirkTests` — CDAB word-swap float encoding
|
||||
- `DL205StringQuirkTests` — packed-string V-memory layout
|
||||
- `DL205VMemoryQuirkTests` — V-memory octal addressing
|
||||
@@ -103,8 +104,13 @@ Not a Modbus concept. Driver doesn't implement `IAlarmSource` or
|
||||
|
||||
1. Add `MODBUS_SIM_ENDPOINT` override documentation to
|
||||
`docs/v2/test-data-sources.md` so operators can point the suite at a lab rig.
|
||||
2. Extend `pymodbus` profiles to inject exception responses — a JSON flag per
|
||||
register saying "next read returns exception 0x04."
|
||||
2. ~~Extend `pymodbus` profiles to inject exception responses~~ — **shipped**
|
||||
via the `exception_injection` compose profile + standalone
|
||||
`exception_injector.py` server. Rules in
|
||||
`Docker/profiles/exception_injection.json` map `(fc, address)` to an
|
||||
exception code; `ExceptionInjectionTests` exercises every code in
|
||||
`MapModbusExceptionToStatus` (0x01 / 0x02 / 0x03 / 0x04 / 0x05 / 0x06 /
|
||||
0x0A / 0x0B) end-to-end on both read (FC03) and write (FC06) paths.
|
||||
3. Add an FX5U profile once a lab rig is available; the scaffolding is in place.
|
||||
|
||||
## Key fixture / config files
|
||||
|
||||
136
docs/v2/implementation/adr-002-driver-vs-virtual-dispatch.md
Normal file
136
docs/v2/implementation/adr-002-driver-vs-virtual-dispatch.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# ADR-002 — Driver-vs-virtual dispatch: how `DriverNodeManager` routes reads, writes, and subscriptions across driver tags and virtual (scripted) tags
|
||||
|
||||
**Status:** Accepted 2026-04-20 — Option B (single NodeManager + NodeSource tag on the resolver output); Options A and C explicitly rejected.
|
||||
|
||||
**Related phase:** [Phase 7 — Scripting Runtime + Scripted Alarms](phase-7-scripting-and-alarming.md) Stream G.
|
||||
|
||||
**Related tasks:** #237 Phase 7 Stream G — Address-space integration.
|
||||
|
||||
**Related ADRs:** [ADR-001 — Equipment node walker](adr-001-equipment-node-walker.md) (this ADR extends the walker + resolver it established).
|
||||
|
||||
## Context
|
||||
|
||||
Phase 7 introduces **virtual tags** — OPC UA variables whose values are computed by user-authored C# scripts against other tags (driver or virtual). Per design decision #2 in the Phase 7 plan, virtual tags **live in the Equipment tree alongside driver tags** (not a separate `/Virtual/...` namespace). An operator browsing `Enterprise/Site/Area/Line/Equipment/` sees a flat list of children that includes both driver-sourced variables (e.g. `SpeedSetpoint` coming from a Modbus tag) and virtual variables (e.g. `LineRate` computed from `SpeedSetpoint × 0.95`).
|
||||
|
||||
From the operator's perspective there is no difference. From the server's perspective there is a big one: a read / write / subscribe on a driver node must dispatch to a driver's `IReadable` / `IWritable` / `ISubscribable` implementation; the same operation on a virtual node must dispatch to the `VirtualTagEngine`. The existing `DriverNodeManager` (shipped in Phase 1, extended by ADR-001) only knows about the driver case today.
|
||||
|
||||
The question is how the dispatch should branch. Three options considered.
|
||||
|
||||
## Options
|
||||
|
||||
### Option A — A separate `VirtualTagNodeManager` sibling to `DriverNodeManager`
|
||||
|
||||
Register a second `INodeManager` with the OPC UA stack dedicated to virtual-tag nodes. Each tag landed under an Equipment folder would be owned by whichever NodeManager materialized it; mixed folders would have children belonging to two different managers.
|
||||
|
||||
**Pros:**
|
||||
- Clean separation — virtual-tag code never touches driver code paths.
|
||||
- Independent lifecycle: restart the virtual-tag engine without touching drivers.
|
||||
|
||||
**Cons:**
|
||||
- ADR-001's `EquipmentNodeWalker` was designed as a single walker producing a single tree under one NodeManager. Forking into two walkers (one per source) risks the UNS / Equipment folders existing twice (once per manager) with different child sets, and the OPC UA stack treating them as distinct nodes.
|
||||
- Mixed equipment folders: when a Line has 3 driver tags + 2 virtual tags, a client browsing the Line folder expects to see 5 children. Two NodeManagers each claiming ownership of the same folder adds the browse-merge problem the stack doesn't do cleanly.
|
||||
- ACL binding (Phase 6.2 trie): one scope per Equipment folder, resolved by `NodeScopeResolver`. Two NodeManagers means two resolution paths or shared resolution logic — cross-manager coupling that defeats the separation.
|
||||
- Audit pathways (Phase 6.2 `IAuditLogger`) and resilience wrappers (Phase 6.1 `CapabilityInvoker`) are wired into the existing `DriverNodeManager`. Duplicating them into a second manager doubles the surface that the Roslyn analyzer from Phase 6.1 Stream A follow-up must keep honest.
|
||||
|
||||
**Rejected** because the sharing of folders (Equipment nodes owning both kinds of children) is the common case, not the exception. Two NodeManagers would fight for ownership on every Equipment node.
|
||||
|
||||
### Option B — Single `DriverNodeManager`, `NodeScopeResolver` returns a `NodeSource` tag, dispatch branches on source
|
||||
|
||||
`NodeScopeResolver` (established in ADR-001) already joins nodes against the config DB to produce a `ScopeId` for ACL enforcement. Extend it to **also return a `NodeSource` enum** (`Driver` or `Virtual`). `DriverNodeManager` dispatch methods check the source and route:
|
||||
|
||||
```csharp
|
||||
internal sealed class DriverNodeManager : CustomNodeManager2
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IDriver> _drivers;
|
||||
private readonly IVirtualTagEngine _virtualTagEngine;
|
||||
private readonly NodeScopeResolver _resolver;
|
||||
|
||||
protected override async Task ReadValueAsync(NodeId nodeId, ...)
|
||||
{
|
||||
var scope = _resolver.Resolve(nodeId);
|
||||
// ... ACL check via Phase 6.2 trie (unchanged)
|
||||
return scope.Source switch
|
||||
{
|
||||
NodeSource.Driver => await _drivers[scope.DriverInstanceId].ReadAsync(...),
|
||||
NodeSource.Virtual => await _virtualTagEngine.ReadAsync(scope.VirtualTagId, ...),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Single address-space tree. `EquipmentNodeWalker` emits one folder per Equipment node and hangs both driver and virtual children under it. Browse / subscribe fan-out / ACL resolution all happen in one NodeManager with one mental model.
|
||||
- ACL binding works identically for both kinds. A user with `ReadEquipment` on `Line1/Pump_7` can read every child, driver-sourced or virtual.
|
||||
- Phase 6.1 resilience wrapping + Phase 6.2 audit logging apply uniformly. The `CapabilityInvoker` analyzer stays correct without new exemptions.
|
||||
- Adding future source kinds (e.g. a "derived tag" that's neither a driver read nor a script evaluation) is a single-enum-case addition — no new NodeManager.
|
||||
|
||||
**Cons:**
|
||||
- `NodeScopeResolver` becomes slightly chunkier — it now carries dispatch metadata in addition to ACL scope. We own that complexity; the payoff is one tree, one lifecycle.
|
||||
- A bug in the dispatch branch could leak a driver call into the virtual path or vice versa. Mitigated by an xUnit theory in Stream G.4 that mixes both kinds in one Equipment folder and asserts each routes correctly.
|
||||
|
||||
**Accepted.**
|
||||
|
||||
### Option C — Virtual tag engine registers as a synthetic `IDriver`
|
||||
|
||||
Implement a `VirtualTagDriverAdapter` that wraps `VirtualTagEngine` and registers it alongside real drivers through the existing `DriverTypeRegistry`. Then `DriverNodeManager` dispatches everything through driver plumbing — virtual tags are just "a driver with no wire."
|
||||
|
||||
**Pros:**
|
||||
- Reuses every existing `IDriver` pathway without modification.
|
||||
- Dispatch branch is trivial because there's no branch — everything routes through driver plumbing.
|
||||
|
||||
**Cons:**
|
||||
- `DriverInstance` is the wrong shape for virtual-tag config: no `DriverType`, no `HostAddress`, no connectivity probe, no lifecycle-initialization parameters, no NSSM wrapper. Forcing it to fit means adding null columns / sentinel values everywhere.
|
||||
- `IDriver.InitializeAsync` / `IRediscoverable` semantics don't match a scripting engine — the engine doesn't "discover" tags against a wire, it compiles scripts against a config snapshot.
|
||||
- The resilience Polly wrappers are calibrated for network-bound calls (timeout / retry / circuit breaker). Applying them to a script evaluation is either a pointless passthrough or wrong tuning.
|
||||
- The Admin UI would need special-casing in every driver-config screen to hide fields that don't apply. The shape mismatch leaks everywhere.
|
||||
|
||||
**Rejected** because the fit is worse than Option B's lightweight dispatch branch. The pretense of uniformity would cost more than the branch it avoids.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B is accepted.**
|
||||
|
||||
`NodeScopeResolver.Resolve(nodeId)` returns a `NodeScope` record with:
|
||||
|
||||
```csharp
|
||||
public sealed record NodeScope(
|
||||
string ScopeId, // ACL scope ID — unchanged from ADR-001
|
||||
NodeSource Source, // NEW: Driver or Virtual
|
||||
string? DriverInstanceId, // populated when Source=Driver
|
||||
string? VirtualTagId); // populated when Source=Virtual
|
||||
|
||||
public enum NodeSource
|
||||
{
|
||||
Driver,
|
||||
Virtual,
|
||||
}
|
||||
```
|
||||
|
||||
`DriverNodeManager` holds a single reference to `IVirtualTagEngine` alongside its driver dictionary. Read / Write / Subscribe dispatch pattern-matches on `scope.Source` and routes accordingly. Writes to a virtual node from an OPC UA client return `BadUserAccessDenied` because per Phase 7 decision #6, virtual tags are writable **only** from scripts via `ctx.SetVirtualTag`. That check lives in `DriverNodeManager` before the dispatch branch — a dedicated ACL rule rather than a capability of the engine.
|
||||
|
||||
Dispatch tests (Phase 7 Stream G.4) must cover at minimum:
|
||||
- Mixed Equipment folder (driver + virtual children) browses with all children visible
|
||||
- Read routes to the correct backend for each source kind
|
||||
- Subscribe delivers changes from both kinds on the same subscription
|
||||
- OPC UA client write to a virtual node returns `BadUserAccessDenied` without invoking the engine
|
||||
- Script-driven write to a virtual node (via `ctx.SetVirtualTag`) updates the value + fires subscription notifications
|
||||
|
||||
## Consequences
|
||||
|
||||
- `EquipmentNodeWalker` (ADR-001) gains an extra input channel: the config DB's `VirtualTag` table alongside the existing `Tag` table. Walker emits both kinds of children under each Equipment folder with the `NodeSource` tag set per row.
|
||||
- `NodeScopeResolver` gains a `NodeSource` return value. The change is additive (ADR-001's `ScopeId` field is unchanged), so Phase 6.2's ACL trie keeps working without modification.
|
||||
- `DriverNodeManager` gains a dispatch branch but the shape of every `I*` call into drivers is unchanged. Phase 6.1's resilience wrapping applies identically to the driver branch; the virtual branch wraps separately (virtual tag evaluation errors map to `BadInternalError` per Phase 7 decision #11, not through the Polly pipeline).
|
||||
- Adding a future source kind (e.g. an alias tag, a cross-cluster federation tag) is one enum case + one dispatch arm + the equivalent walker extension. The architecture is extensible without rewrite.
|
||||
|
||||
## Not Decided (revisitable)
|
||||
|
||||
- **Whether `IVirtualTagEngine` should live alongside `IDriver` in `Core.Abstractions` or stay in the Phase 7 project.** Plan currently keeps it in Phase 7's `Core.VirtualTags` project because it's not a driver capability. If Phase 7 Stream G discovers significant shared surface, promote later — not blocking.
|
||||
- **Whether server-side method calls from OPC UA clients (e.g. a future "force-recompute-this-virtual-tag" admin method) should route through the same dispatch.** Out of scope — virtual tags have no method nodes today; scripted alarm method calls (`OneShotShelve` etc.) route through their own `ScriptedAlarmEngine` path per Phase 7 Stream C.6.
|
||||
|
||||
## References
|
||||
|
||||
- [Phase 7 — Scripting Runtime + Scripted Alarms](phase-7-scripting-and-alarming.md) Stream G
|
||||
- [ADR-001 — Equipment node walker](adr-001-equipment-node-walker.md)
|
||||
- [`docs/v2/plan.md`](../plan.md) decision #110 (Tag-to-Equipment binding)
|
||||
- [`docs/v2/plan.md`](../plan.md) decision #120 (UNS hierarchy requirements)
|
||||
- Phase 6.2 `NodeScopeResolver` ACL join
|
||||
190
docs/v2/implementation/phase-7-scripting-and-alarming.md
Normal file
190
docs/v2/implementation/phase-7-scripting-and-alarming.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Phase 7 — Scripting Runtime, Virtual Tags, and Scripted Alarms
|
||||
|
||||
> **Status**: DRAFT — planning output from the 2026-04-20 interactive planning session. Pending review before work begins. Task #230 tracks the draft; #231–#238 are the stream placeholders.
|
||||
>
|
||||
> **Branch**: `v2/phase-7-scripting-and-alarming`
|
||||
> **Estimated duration**: 10–12 weeks (scope-comparable to Phase 6; largest single phase outside Phase 2 Galaxy split)
|
||||
> **Predecessor**: Phase 6.4 (Admin UI completion) — reuses the tab-plugin pattern + draft/publish flow
|
||||
> **Successor**: v2 release-readiness capstone
|
||||
|
||||
## Phase Objective
|
||||
|
||||
Add two **additive** runtime capabilities on top of the existing driver + Equipment address-space foundation:
|
||||
|
||||
1. **Virtual (calculated) tags** — OPC UA variables whose values are computed by user-authored C# scripts against other tags (driver or virtual), evaluated on change and/or timer. They live in the existing Equipment/UNS tree alongside driver tags and behave identically to clients (browse, subscribe, historize).
|
||||
2. **Scripted alarms** — OPC UA Part 9 alarms whose condition is a user-authored C# predicate. Full state machine (EnabledState / ActiveState / AckedState / ConfirmedState / ShelvingState) with persistent operator-supplied state across restarts. Complement the existing Galaxy-native and AB CIP ALMD alarm sources — they do not replace them.
|
||||
|
||||
Tie-in capability — **historian alarm sink**:
|
||||
|
||||
3. **Aveva Historian as alarm system of record** — every qualifying alarm transition (activation, ack, confirm, clear, shelve, disable, comment) from **any `IAlarmSource`** (scripted + Galaxy + ALMD) routes through a new local SQLite store-and-forward queue to Galaxy.Host, which uses its already-loaded `aahClientManaged` DLLs to write to the Historian's alarm schema. Per-alarm `HistorizeToAveva` toggle gates which sources flow (default off for Galaxy-native since Galaxy itself already historizes them). Plant operators query one uniform historical alarm timeline.
|
||||
|
||||
**Why it's additive, not a rewrite**: every `IAlarmSource` implementation shipped in Phase 6.x stays unchanged; scripted alarms register as an additional source in the existing fan-out. The Equipment node walker built in ADR-001 gains a "virtual" source kind alongside "driver" without removing anything. Operator-facing semantics for existing driver tags and alarms are unchanged.
|
||||
|
||||
## Design Decisions (locked in the 2026-04-20 planning session)
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---------|-----------|
|
||||
| 1 | Script language = **C# via Roslyn scripting** | Developer audience, strong typing, AST walkable for dependency inference, existing .NET 10 runtime in main server. |
|
||||
| 2 | Virtual tags live in the **Equipment tree** alongside driver tags (not a separate `/Virtual/...` namespace) | Operator mental model stays unified; calculated `LineRate` shows up under the Line1 folder next to the driver-sourced `SpeedSetpoint` it's derived from. |
|
||||
| 3 | Evaluation trigger = **change-driven + timer-driven**; operator chooses per-tag | Change-driven is cheap at steady state; timer is the escape hatch for polling derivations that don't have a discrete "input changed" signal. |
|
||||
| 4 | Script shape = **Shape A — one script per virtual tag/alarm**; `return` produces the value (or `bool` for alarm condition) | Minimal surface; no predicate/action split. Alarm side-effects (severity, message) configured out-of-band, not in the script. |
|
||||
| 5 | Alarm fidelity = **full OPC UA Part 9** | Uniform with Galaxy + ALMD on the wire; client-side tooling (HMIs, historians, event pipelines) gets one shape. |
|
||||
| 6 | Sandbox = **read-only context**; scripts can only read any tag + write to virtual tags | Strict Roslyn `ScriptOptions` allow-list. No HttpClient / File / Process / reflection. |
|
||||
| 7 | Dependency declaration = **AST inference**; operator doesn't maintain a separate dependency list | `CSharpSyntaxWalker` extracts `ctx.GetTag("path")` string-literal calls at compile time; dynamic paths rejected at publish. |
|
||||
| 8 | Config storage = **config DB with generation-sealed cache** (same as driver instances) | Virtual tags + alarms publish atomically in the same generation as the driver instance config they may depend on. |
|
||||
| 9 | Script return value shape (`ctx.GetTag`) = **`DataValue { Value, StatusCode, Timestamp }`** | Scripts branch on quality naturally without separate `ctx.GetQuality(...)` calls. |
|
||||
| 10 | Historize virtual tags = **per-tag checkbox** | Writes flow through the same history-write path as driver tags. Consumed by existing `IHistoryProvider`. |
|
||||
| 11 | Per-tag error isolation — a throwing script sets that tag's quality to `BadInternalError`; engine keeps running for every other tag | Mirrors Phase 6.1 Stream B's per-surface error handling. |
|
||||
| 12 | Dedicated Serilog sink = `scripts-*.log` rolling file; structured-property `ScriptName` for filtering | Keeps noisy script logs out of the main `opcua-*.log`. `ctx.Logger.Info/Warning/Error/Debug` bound in the script context. |
|
||||
| 13 | Alarm message = **template with substitution** (`"Reactor temp {Reactor/Temp} exceeded {Limit}"`) | Middle ground between static and separate message-script; engine resolves `{path}` tokens at event emission. |
|
||||
| 14 | Alarm state persistence — `ActiveState` recomputed from tag values on startup; `EnabledState / AckedState / ConfirmedState / ShelvingState` + audit trail persist to config DB | Operators don't re-ack after restart; ack history survives for compliance (GxP / 21 CFR Part 11). |
|
||||
| 15 | Historian sink scope = **all `IAlarmSource` implementations**, not just scripted; per-alarm `HistorizeToAveva` toggle | Plant gets one consolidated alarm timeline; Galaxy-native alarms default off to avoid duplication. |
|
||||
| 16 | Historian failure mode = **SQLite store-and-forward queue on the node**; config DB is source of truth, Historian is best-effort projection | Operators never blocked by Historian downtime; failed writes queue + retry when Historian recovers. |
|
||||
| 17 | Historian ingestion path = **IPC to Galaxy.Host**, which calls the already-loaded `aahClientManaged` DLLs | Reuses existing bitness / licensing / Tier-C isolation. No new 32-bit DLL load in the main server. |
|
||||
| 18 | Admin UI code editor = **Monaco** via the Admin project's asset pipeline | Industry default for C# editing in a browser; ~3 MB bundle acceptable given Admin is operator-facing only, not public. Revisitable if bundle size becomes a deployment constraint. |
|
||||
| 19 | Cascade evaluation order = **serial** for v1; parallel promoted to a Phase 7 follow-up | Deterministic, easier to reason about, simplifies cycle + ordering bugs in the rollout. Parallel becomes a tuning knob when real 1000+ virtual-tag deployments measure contention. |
|
||||
| 20 | Shelving UX = **OPC UA method calls only** (`OneShotShelve` / `TimedShelve` / `Unshelve` on the `AlarmConditionType` node); **no Admin UI shelve controls** | Plant HMIs + OPC UA clients already speak these methods by spec; reinventing the UI adds surface without operator value. Admin still renders current shelve state + audit trail read-only on the alarm detail page. |
|
||||
| 21 | Dead-lettered historian events retained for **30 days** in the SQLite queue; Admin `/alarms/historian` exposes a "Retry dead-lettered" button | Long enough for a Historian outage or licensing glitch to be resolved + operator to investigate; short enough that the SQLite file doesn't grow unbounded. Configurable via `AlarmHistorian:DeadLetterRetentionDays` for deployments with stricter compliance windows. |
|
||||
| 22 | Test harness synthetic inputs = **declared inputs only** (from the AST walker's extracted dependency set) | Enforces the dependency declaration — if a path can't be supplied to the harness, the AST walker didn't see it and the script can't reference it at runtime. Catches dependency-inference drift at test time, not publish time. |
|
||||
|
||||
## Scope — What Changes
|
||||
|
||||
| Concern | Change |
|
||||
|---------|--------|
|
||||
| **New project `OtOpcUa.Core.Scripting`** (.NET 10) | Roslyn-based script engine. Compiles user C# scripts with a sandboxed `ScriptOptions` allow-list (numeric / string / datetime / `ScriptContext` API only — no reflection / File / Process / HttpClient). `DependencyExtractor` uses `CSharpSyntaxWalker` to enumerate `ctx.GetTag("...")` literal-string calls; rejects non-literal paths at publish time. Per-script compile cache keyed by source hash. Per-evaluation timeout. Exception in script → tag goes `BadInternalError`; engine unaffected for other tags. `ctx.Logger` is a Serilog `ILogger` bound to the `scripts-*.log` rolling sink with structured property `ScriptName`. |
|
||||
| **New project `OtOpcUa.Core.VirtualTags`** (.NET 10) | `VirtualTagEngine` consumes the `DependencyExtractor` output, builds a topological dependency graph spanning driver tags + other virtual tags (cycle detection at publish time), schedules re-evaluation on change + on timer, propagates results through an `IVirtualTagSource` that implements `IReadable` + `ISubscribable` so `DriverNodeManager` routes reads / subscriptions uniformly. Per-tag `Historize` flag routes to the same history-write path driver tags use. |
|
||||
| **New project `OtOpcUa.Core.ScriptedAlarms`** (.NET 10) | `ScriptedAlarmEngine` materializes each configured alarm as an OPC UA `AlarmConditionType` (or `LimitAlarmType` / `OffNormalAlarmType`). On startup, re-evaluates every predicate against current tag values to rebuild `ActiveState` — no persistence needed for the active flag. Persistent state: `EnabledState`, `AckedState`, `ConfirmedState`, `ShelvingState`, branch stack, ack audit (user/time/comment). Template message substitution resolves `{TagPath}` tokens at event emission. Ack / Confirm / Shelve method nodes bound to the engine; transitions audit-logged via the existing `IAuditLogger` (Phase 6.2). Registers as an additional `IAlarmSource` — no change to the existing fan-out. |
|
||||
| **New project `OtOpcUa.Core.AlarmHistorian`** (.NET 10) | `IAlarmHistorianSink` abstraction + `SqliteStoreAndForwardSink` default implementation. Every qualifying `IAlarmSource` emission (per-alarm `HistorizeToAveva` toggle) persists to a local SQLite queue (`%ProgramData%\OtOpcUa\alarm-historian-queue.db`). Background drain worker reads unsent rows + forwards over IPC to Galaxy.Host. Failed writes keep the row pending with exponential backoff. Queue capacity bounded (default 1M events, oldest-dropped with a structured warning log). |
|
||||
| **`Driver.Galaxy.Shared`** — new IPC contracts | `HistorianAlarmEventRequest` (activation / ack / confirm / clear / shelve / disable / comment payloads matching the Aveva Historian alarm schema) + `HistorianAlarmEventResponse` (ack / retry-please / permanent-fail). `HistorianConnectivityStatusNotification` so the main server can surface "Historian disconnected" on the Admin `/hosts` page. |
|
||||
| **`Driver.Galaxy.Host`** — new frame handler for alarm writes | Reuses the already-loaded `aahClientManaged.dll` + `aahClientCommon.dll`. Maps the IPC request DTOs to the historian SDK's alarm-event API (exact method TBD during Stream D.2 — needs a live-historian smoke to confirm the right SDK entry point). Errors map to structured response codes so the main server's backoff logic can distinguish "transient" from "permanent". |
|
||||
| **Config DB schema** — new tables | `VirtualTag (Id, EquipmentPath, Name, DataType, IntervalMs?, ChangeTriggerEnabled, Historize, ScriptId)`; `Script (Id, SourceCode, CompiledHash, Language='CSharp')`; `ScriptedAlarm (Id, EquipmentPath, Name, AlarmType, Severity, MessageTemplate, HistorizeToAveva, PredicateScriptId)`; `ScriptedAlarmState (AlarmId, EnabledState, AckedState, ConfirmedState, ShelvingState, ShelvingExpiresUtc?, LastAckUser, LastAckComment, LastAckUtc, BranchStack_JSON)`. Every write goes through `sp_PublishGeneration` + `IAuditLogger`. |
|
||||
| **Address-space build** — Phase 6 `EquipmentNodeWalker` extension | Emits virtual-tag nodes alongside driver-sourced nodes under the same Equipment folder. `NodeScopeResolver` gains a `Virtual` source kind alongside `Driver`. `DriverNodeManager` dispatch routes reads / writes / subscriptions to the `VirtualTagEngine` when the source is virtual. |
|
||||
| **Admin UI** — new tabs | `/virtual-tags` and `/scripted-alarms` tabs under the existing draft/publish flow. Monaco-based C# code editor (syntax highlighting, IntelliSense against a hand-written type stub for `ScriptContext`). Dependency preview panel shows the inferred input list from the AST walker. Test-harness lets operator supply synthetic `DataValue` inputs + see script output + logger emissions without publishing. Per-alarm controls: `AlarmType`, `Severity`, `MessageTemplate`, `HistorizeToAveva`. New `/alarms/historian` diagnostics view: queue depth, drain rate, last-successful-write, per-alarm "last routed to historian" timestamp. |
|
||||
| **`DriverTypeRegistry`** — no change | Scripting is not a driver — it doesn't register as a `DriverType`. The engine hangs off the same `SealedBootstrap` as drivers but through a different composition root. |
|
||||
|
||||
## Scope — What Does NOT Change
|
||||
|
||||
| Item | Reason |
|
||||
|------|--------|
|
||||
| Existing `IAlarmSource` implementations (Galaxy, AB CIP ALMD) | Scripted alarms register as an *additional* source; existing sources pass through unchanged. Default `HistorizeToAveva=false` for Galaxy alarms avoids duplicating records the Galaxy historian wiring already captures. |
|
||||
| Driver capability surface (`IReadable` / `IWritable` / `ISubscribable` / etc.) | Virtual tags implement the same interfaces — drivers and virtual tags are interchangeable from the node manager's perspective. No new capability. |
|
||||
| Config DB publication flow (`sp_PublishGeneration` + sealed cache) | Virtual tag + alarm tables plug in as additional rows. Atomic publish semantics unchanged. |
|
||||
| Authorization trie (Phase 6.2) | Virtual-tag nodes inherit the Equipment scope's grants — same treatment as the Phase 6.4 Identification sub-folder. No new scope level. |
|
||||
| Tier-C isolation topology | Scripting engine runs in the main .NET 10 server process. Roslyn scripts are already sandboxed via `ScriptOptions`; no need for process isolation because they have no unmanaged reach. Galaxy.Host's existing Tier-C boundary already owns the historian SDK writes. |
|
||||
| Galaxy alarm ingestion path into the historian | Galaxy writes alarms directly via `aahClientManaged` today; Phase 7 Stream D gives it a *second* path (via the new sink) when a Galaxy alarm has `HistorizeToAveva=true`, but the direct path stays for the default case. |
|
||||
| OPC UA wire protocol / AddressSpace schema | Clients see new nodes under existing folders + new alarm conditions. No new namespaces, no new ObjectTypes beyond what Part 9 already defines. |
|
||||
|
||||
## Entry Gate Checklist
|
||||
|
||||
- [ ] All Phase 6.x exit gates cleared (#133, #142, #151, #158)
|
||||
- [ ] Equipment node walker wired into `DriverNodeManager` (task #212 — done)
|
||||
- [ ] `IAuditLogger` surface live (Phase 6.2 Stream A)
|
||||
- [ ] `sp_PublishGeneration` + sealed-cache flow verified on the existing driver-config tables
|
||||
- [ ] Dev Aveva Historian reachable from the dev box (for Stream D.2 smoke)
|
||||
- [ ] `v2` branch clean + baseline tests green
|
||||
- [ ] Blazor editor component library picked (Monaco confirmed vs alternatives — see decision to log)
|
||||
- [ ] Review this plan — decisions #1–#17 signed off, no open questions
|
||||
|
||||
## Task Breakdown
|
||||
|
||||
### Stream A — `Core.Scripting` (Roslyn engine + sandbox + AST inference + logger) — **2 weeks**
|
||||
|
||||
1. **A.1** Project scaffold + NuGet `Microsoft.CodeAnalysis.CSharp.Scripting`. `ScriptOptions` allow-list (`typeof(object).Assembly`, `typeof(Enumerable).Assembly`, the Core.Scripting assembly itself — nothing else). Hand-written `ScriptContext` base class with `GetTag(string)` / `SetVirtualTag(string, object)` / `Logger` / `Now` / `Deadband(double, double, double)` helpers.
|
||||
2. **A.2** `DependencyExtractor : CSharpSyntaxWalker`. Visits every `InvocationExpressionSyntax` targeting `ctx.GetTag` / `ctx.SetVirtualTag`; accepts only a `LiteralExpressionSyntax` argument. Non-literal arguments (concat, variable, method call) → publish-time rejection with an actionable error pointing the operator at the exact span. Outputs `IReadOnlySet<string> Inputs` + `IReadOnlySet<string> Outputs`.
|
||||
3. **A.3** Compile cache. `(source_hash) → compiled Script<T>`. Recompile only when source changes. Warm on `SealedBootstrap`.
|
||||
4. **A.4** Per-evaluation timeout wrapper (default 250ms; configurable per tag). Timeout = tag quality `BadInternalError` + structured warning log. Keeps a single runaway script from starving the engine.
|
||||
5. **A.5** Serilog sink wiring. New `scripts-*.log` rolling file enricher; `ctx.Logger` returns an `ILogger` with `ForContext("ScriptName", ...)`. Main `opcua-*.log` gets a companion entry at WARN level if a script logs ERROR, so the operator sees it in the primary log.
|
||||
6. **A.6** Tests: AST extraction unit tests (30+ cases covering literal / concat / variable / null / method-returned paths); sandbox escape tests (attempt `typeof`, `Assembly.Load`, `File.OpenRead` — all must fail at compile); exception isolation (throwing script doesn't kill the engine); timeout behavior; logger structured-property binding.
|
||||
|
||||
### Stream B — Virtual tag engine (dependency graph + change/timer schedulers + historize) — **1.5 weeks**
|
||||
|
||||
1. **B.1** `VirtualTagEngine`. Ingests the set of compiled scripts + their inputs/outputs; builds a directed dependency graph (driver tag ID → virtual tag ID → virtual tag ID). Cycle detection at publish-time via Tarjan; publish rejects with a clear error message listing the cycle.
|
||||
2. **B.2** `ChangeTriggerDispatcher`. Subscribes to every referenced driver tag via the existing `ISubscribable` fan-out. On a `DataValueSnapshot` delta (value / status / timestamp — any of the three), enqueues affected virtual tags for re-evaluation in topological order.
|
||||
3. **B.3** `TimerTriggerDispatcher`. Per-tag `IntervalMs` scheduled via a shared timer-wheel. Independent of change triggers — a tag can have both, either, or neither.
|
||||
4. **B.4** `EvaluationPipeline`. Serial evaluation per cascade (parallel promoted to a follow-up — avoids cross-tag ordering bugs on first rollout). Exception handling per A.4; propagates results via `IVirtualTagSource`.
|
||||
5. **B.5** `IVirtualTagSource` implementation. Implements `IReadable` + `ISubscribable`. Reads return the most recent evaluated value; subscriptions receive `OnDataChange` events on each re-evaluation.
|
||||
6. **B.6** History routing. Per-tag `Historize` flag emits the value + timestamp to the existing history-write path used by drivers.
|
||||
7. **B.7** Tests: dependency graph (happy + cycle); change cascade through two levels of virtual tags; timer-only tag ignores input changes; change + timer both configured; error propagation; historize on/off.
|
||||
|
||||
### Stream C — Scripted alarm engine + Part 9 state machine + template messages — **2.5 weeks**
|
||||
|
||||
1. **C.1** Alarm config model + `ScriptedAlarmEngine` skeleton. Alarms materialize as `AlarmConditionType` (or subtype — `LimitAlarm`, `OffNormal`) nodes under their configured Equipment path. Severity loaded from config.
|
||||
2. **C.2** `Part9StateMachine`. Tracks `EnabledState`, `ActiveState`, `AckedState`, `ConfirmedState`, `ShelvingState` per condition ID. Shelving has `OneShotShelving` + `TimedShelving` variants + an `UnshelveTime` timer.
|
||||
3. **C.3** Predicate evaluation. On any input change (same trigger mechanism as Stream B), run the `bool` predicate. On `false → true` transition, activate (increment branch stack if prior Ack-but-not-Confirmed state exists). On `true → false`, clear (but keep condition visible if retain flag set).
|
||||
4. **C.4** Startup recovery. For every configured alarm, run the predicate against current tag values to rebuild `ActiveState` *only*. Load `EnabledState` / `AckedState` / `ConfirmedState` / `ShelvingState` + audit from the `ScriptedAlarmState` table. No re-acknowledgment required for conditions that were acked before restart.
|
||||
5. **C.5** Template substitution. Engine resolves `{TagPath}` tokens in `MessageTemplate` at event emission time using current tag values. Unresolvable tokens (bad path, missing tag) emit a structured error log + substitute `{?}` so the event still fires.
|
||||
6. **C.6** OPC UA method binding. `Acknowledge`, `Confirm`, `AddComment`, `OneShotShelve`, `TimedShelve`, `Unshelve` methods on each condition node route to the engine + persist via audit-logged writes to `ScriptedAlarmState`.
|
||||
7. **C.7** `IAlarmSource` implementation. Emits Part 9-shaped events through the existing fan-out the `AlarmTracker` composes.
|
||||
8. **C.8** Tests: every transition (all 32 state combinations the state machine can produce); startup recovery (seed table with varied ack/confirm/shelve state, restart, verify correct recovery); template substitution (literal path, nested path, bad path); shelving timer expiry; OPC UA method calls via Client.CLI.
|
||||
|
||||
### Stream D — Historian alarm sink (SQLite store-and-forward + Galaxy.Host IPC) — **2 weeks**
|
||||
|
||||
1. **D.1** `Core.AlarmHistorian` project. `IAlarmHistorianSink` interface; `SqliteStoreAndForwardSink` default implementation using Microsoft.Data.Sqlite. Schema: `Queue (RowId, AlarmId, EventType, PayloadJson, EnqueuedUtc, LastAttemptUtc?, AttemptCount, DeadLettered)`. Queue capacity bounded; oldest-dropped on overflow with structured warning.
|
||||
2. **D.2** **Live-historian smoke** against the dev box's Aveva Historian. Identify the exact `aahClientManaged` alarm-write API entry point (likely `IAlarmsDatabase.WriteAlarmEvent` or equivalent — verify with a throwaway Galaxy.Host test hook). Document in a short `docs/v2/historian-alarm-api.md` artifact.
|
||||
3. **D.3** `Driver.Galaxy.Shared` contract additions. `HistorianAlarmEventRequest` / `HistorianAlarmEventResponse` / `HistorianConnectivityStatusNotification`. Round-trip tests in `Driver.Galaxy.Shared.Tests`.
|
||||
4. **D.4** `Driver.Galaxy.Host` handler. Translates incoming `HistorianAlarmEventRequest` to the SDK call identified in D.2. Returns structured response (Ack / RetryPlease / PermanentFail). Connectivity notifications sent proactively when the SDK's session drops.
|
||||
5. **D.5** Drain worker in the main server. Polls the SQLite queue; batches up to 100 events per IPC round-trip; exponential backoff on `RetryPlease` (1s → 2s → 5s → 15s → 60s cap); `PermanentFail` dead-letters the row + structured error log.
|
||||
6. **D.6** Per-alarm toggle wired through: `HistorizeToAveva` column on both `ScriptedAlarm` + a new `AlarmHistorizationPolicy` projection the Galaxy / ALMD alarm sources consult (default `false` for Galaxy, `true` for scripted, operator-adjustable per-alarm).
|
||||
7. **D.7** `/alarms/historian` diagnostics view in Admin. Queue depth, drain rate, last-successful-write, last-error, per-alarm last-routed timestamp.
|
||||
8. **D.8** Tests: SQLite queue round-trip; drain worker with fake IPC (success / retry / perm-fail); overflow eviction; Galaxy.Host handler against a stub historian API; end-to-end with the live historian on the dev box (non-CI — operator-invoked).
|
||||
|
||||
### Stream E — Config DB schema + generation-sealed cache extensions — **1 week**
|
||||
|
||||
1. **E.1** EF migration for new tables. Foreign keys from `VirtualTag.ScriptId` / `ScriptedAlarm.PredicateScriptId` to `Script.Id`.
|
||||
2. **E.2** `sp_PublishGeneration` extension. Sealed-cache snapshot includes virtual tags + scripted alarms + their scripts. Atomic publish guarantees the address-space build sees a consistent view.
|
||||
3. **E.3** CRUD services. `VirtualTagService`, `ScriptedAlarmService`, `ScriptService`. Each audit-logged; Ack / Confirm / Shelve persist through `ScriptedAlarmStateService` with full audit trail (who / when / comment / previous state).
|
||||
4. **E.4** Tests: migration up / down; publish atomicity (concurrent writes to different alarm rows don't leak into an in-flight publish); audit trail on every mutation.
|
||||
|
||||
### Stream F — Admin UI scripting tab — **2 weeks**
|
||||
|
||||
1. **F.1** Monaco editor Razor component. CSS-isolated; loads Monaco via NPM + the Admin project's existing asset pipeline. C# syntax highlighting (Monaco ships it). IntelliSense via a hand-written `ScriptContext.cs` type stub delivered with the editor (not the compiled Core.Scripting DLL — keeps the browser bundle small).
|
||||
2. **F.2** `/virtual-tags` tab. List view (Equipment path / Name / DataType / inputs-summary / Historize / actions). Edit pane splits: Monaco editor left, dependency preview panel right (live-updates from a debounced `/api/scripting/analyze` endpoint that runs the `DependencyExtractor`). Publish button gated by Phase 6.2 `WriteConfigure` permission.
|
||||
3. **F.3** `/scripted-alarms` tab. Same editor shape + extra controls: AlarmType dropdown, Severity slider, MessageTemplate textbox with live-preview showing `{path}` token resolution against latest tag values, `HistorizeToAveva` checkbox. **Alarm detail page displays current `ShelvingState` + `LastAckUser / LastAckUtc / LastAckComment` read-only** — no shelve/unshelve / ack / confirm buttons per decision #20. Operators drive state transitions via OPC UA method calls from plant HMIs or the Client.CLI.
|
||||
4. **F.4** Test harness. Modal that lets the operator supply synthetic `DataValue` inputs for the dependency set + see script output + logger emissions (rendered in a virtual terminal). Enables testing without publishing.
|
||||
5. **F.5** Script log viewer. SignalR stream of the `scripts-*.log` sink filtered by the script under edit (using the structured `ScriptName` property). Tail-last-200 + "load more".
|
||||
6. **F.6** `/alarms/historian` diagnostics view per Stream D.7.
|
||||
7. **F.7** Playwright smoke. Author a calc tag, publish, verify it appears in the equipment tree via a probe OPC UA read. Author an alarm, verify it appears in `AlarmsAndConditions`.
|
||||
|
||||
### Stream G — Address-space integration — **1 week**
|
||||
|
||||
1. **G.1** `EquipmentNodeWalker` extension. Current walker iterates driver tags per equipment; extend to also iterate virtual tags + alarms. `NodeScopeResolver` returns `NodeSource.Virtual` for virtual nodes and `NodeSource.Driver` for existing.
|
||||
2. **G.2** `DriverNodeManager` dispatch. Read / Write / Subscribe operations check the resolved source and route to `VirtualTagEngine` or the driver as appropriate. Writes to virtual tags allowed only from scripts (per decision #6) — OPC UA client writes to a virtual node return `BadUserAccessDenied`.
|
||||
3. **G.3** `AlarmTracker` composition. The `ScriptedAlarmEngine` registers as an additional `IAlarmSource` — no new composition code, the existing fan-out already accepts multiple sources.
|
||||
4. **G.4** Tests: mixed equipment folder (driver tag + virtual tag + driver-native alarm + scripted alarm) browsable via Client.CLI; read / subscribe round-trip for the virtual tag; scripted alarm transitions visible in the alarm event stream.
|
||||
|
||||
### Stream H — Exit gate — **1 week**
|
||||
|
||||
1. **H.1** Compliance script real-checks: schema migrations applied; new tables populated from a draft→publish cycle; sealed-generation snapshot includes virtual tags + alarms; SQLite alarm queue initialized; `scripts-*.log` sink emitting; `AlarmConditionType` nodes materialize in the address space; per-alarm `HistorizeToAveva` toggle enforced end-to-end.
|
||||
2. **H.2** Full-solution `dotnet test` baseline. Target: Phase 6 baseline + ~300 new tests across Streams A–G.
|
||||
3. **H.3** `docs/v2/plan.md` Migration Strategy §6 update — add Phase 7.
|
||||
4. **H.4** Phase-status memory update.
|
||||
5. **H.5** Merge `v2/phase-7-scripting-and-alarming` → `v2`.
|
||||
|
||||
## Compliance Checks (run at exit gate)
|
||||
|
||||
- [ ] **Sandbox escape**: attempts to reference `System.IO.File`, `System.Net.Http.HttpClient`, `System.Diagnostics.Process`, or `typeof(X).Assembly.Load` fail at script compile with an actionable error.
|
||||
- [ ] **Dependency inference**: `ctx.GetTag(myStringVar)` (non-literal path) is rejected at publish with a span-pointed error; `ctx.GetTag("Line1/Speed")` is accepted + appears in the inferred input set.
|
||||
- [ ] **Change cascade**: tag A → virtual tag B → virtual tag C. When A changes, B recomputes, then C recomputes. Single change event triggers the full cascade in topological order within one evaluation pass.
|
||||
- [ ] **Cycle rejection**: publish a config where virtual tag B depends on A and A depends on B. Publish fails pre-commit with a clear cycle message.
|
||||
- [ ] **Startup recovery**: seed `ScriptedAlarmState` with one acked+confirmed alarm + one shelved alarm + one clean alarm, restart, verify operator does NOT see ack prompts for the first two, shelving remains in effect, clean alarm is clear.
|
||||
- [ ] **Ack audit**: acknowledge an alarm; `IAuditLogger` captures user / timestamp / comment / prior state; row persists through restart.
|
||||
- [ ] **Historian queue durability**: take Galaxy.Host offline, fire 10 alarm transitions, bring Galaxy.Host back; queue drains all 10 in order.
|
||||
- [ ] **Per-alarm historian toggle**: Galaxy-native alarm with `HistorizeToAveva=false` does NOT enqueue; scripted alarm with `HistorizeToAveva=true` DOES enqueue.
|
||||
- [ ] **Script timeout**: infinite-loop script times out at 250ms; tag quality `BadInternalError`; other tags unaffected.
|
||||
- [ ] **Log isolation**: `ctx.Logger.Error("test")` lands in `scripts-*.log` with structured property `ScriptName=<name>`; main `opcua-*.log` gets a WARN companion entry.
|
||||
- [ ] **ACL binding**: virtual tag under an Equipment scope inherits the Equipment's grants. User without the Equipment grant reads the virtual tag and gets `BadUserAccessDenied`.
|
||||
|
||||
## Decisions Resolved in Plan Review
|
||||
|
||||
Every open question from the initial draft was resolved in the 2026-04-20 plan review — see decisions #18–#22 in the decisions table above. No pending questions block Stream A.
|
||||
|
||||
## References
|
||||
|
||||
- [`docs/v2/plan.md`](../plan.md) §6 Migration Strategy — add Phase 7 as the final additive phase before v2 release readiness.
|
||||
- [`docs/v2/implementation/overview.md`](overview.md) — phase gate conventions.
|
||||
- [`docs/v2/implementation/phase-6-2-authorization-runtime.md`](phase-6-2-authorization-runtime.md) — `IAuditLogger` surface reused for Ack/Confirm/Shelve + script edits.
|
||||
- [`docs/v2/implementation/phase-6-4-admin-ui-completion.md`](phase-6-4-admin-ui-completion.md) — draft/publish flow, diff viewer, tab-plugin pattern reused.
|
||||
- [`docs/v2/implementation/phase-2-galaxy-out-of-process.md`](phase-2-galaxy-out-of-process.md) — Galaxy.Host IPC shape + shared-contract conventions reused for Stream D.
|
||||
- [`docs/v2/driver-specs.md`](../driver-specs.md) §Alarm semantics — Part 9 fidelity requirements.
|
||||
- [`docs/v2/driver-stability.md`](../driver-stability.md) — per-surface error handling, crash-loop breaker patterns Stream A.4 mirrors.
|
||||
- [`docs/v2/config-db-schema.md`](../config-db-schema.md) — add a Phase 7 §§ for `VirtualTag`, `Script`, `ScriptedAlarm`, `ScriptedAlarmState`.
|
||||
83
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/CompiledScriptCache.cs
Normal file
83
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/CompiledScriptCache.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Source-hash-keyed compile cache for user scripts. Roslyn compilation is the most
|
||||
/// expensive step in the evaluator pipeline (5-20ms per script depending on size);
|
||||
/// re-compiling on every value-change event would starve the virtual-tag engine.
|
||||
/// The cache is generic on the <see cref="ScriptContext"/> subclass + result type so
|
||||
/// different engines (virtual-tag / alarm-predicate / future alarm-action) each get
|
||||
/// their own cache instance — there's no cross-type pollution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Concurrent-safe: <see cref="ConcurrentDictionary{TKey, TValue}"/> of
|
||||
/// <see cref="Lazy{T}"/> means a miss on two threads compiles exactly once.
|
||||
/// <see cref="LazyThreadSafetyMode.ExecutionAndPublication"/> guarantees other
|
||||
/// threads block on the in-flight compile rather than racing to duplicate work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Cache is keyed on SHA-256 of the UTF-8 bytes of the source — collision-free in
|
||||
/// practice. Whitespace changes therefore miss the cache on purpose; operators
|
||||
/// see re-compile time on their first evaluation after a format-only edit which
|
||||
/// is rare and benign.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No capacity bound. Virtual-tag + alarm scripts are operator-authored and
|
||||
/// bounded by config DB (typically low thousands). If that changes in v3, add an
|
||||
/// LRU eviction policy — the API stays the same.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CompiledScriptCache<TContext, TResult>
|
||||
where TContext : ScriptContext
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, Lazy<ScriptEvaluator<TContext, TResult>>> _cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Return the compiled evaluator for <paramref name="scriptSource"/>, compiling
|
||||
/// on first sight + reusing thereafter. If the source fails to compile, the
|
||||
/// original Roslyn / sandbox exception propagates; the cache entry is removed so
|
||||
/// the next call retries (useful during Admin UI authoring when the operator is
|
||||
/// still fixing syntax).
|
||||
/// </summary>
|
||||
public ScriptEvaluator<TContext, TResult> GetOrCompile(string scriptSource)
|
||||
{
|
||||
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||
|
||||
var key = HashSource(scriptSource);
|
||||
var lazy = _cache.GetOrAdd(key, _ => new Lazy<ScriptEvaluator<TContext, TResult>>(
|
||||
() => ScriptEvaluator<TContext, TResult>.Compile(scriptSource),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication));
|
||||
|
||||
try
|
||||
{
|
||||
return lazy.Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Failed compile — evict so a retry with corrected source can succeed.
|
||||
_cache.TryRemove(key, out _);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current entry count. Exposed for Admin UI diagnostics / tests.</summary>
|
||||
public int Count => _cache.Count;
|
||||
|
||||
/// <summary>Drop every cached compile. Used on config generation publish + tests.</summary>
|
||||
public void Clear() => _cache.Clear();
|
||||
|
||||
/// <summary>True when the exact source has been compiled at least once + is still cached.</summary>
|
||||
public bool Contains(string scriptSource)
|
||||
=> _cache.ContainsKey(HashSource(scriptSource));
|
||||
|
||||
private static string HashSource(string source)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(source);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash);
|
||||
}
|
||||
}
|
||||
137
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs
Normal file
137
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/DependencyExtractor.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a script's source text + extracts every <c>ctx.GetTag("literal")</c> and
|
||||
/// <c>ctx.SetVirtualTag("literal", ...)</c> call. Outputs the static dependency set
|
||||
/// the virtual-tag engine uses to build its change-trigger subscription graph (Phase
|
||||
/// 7 plan decision #7 — AST inference, operator doesn't maintain a separate list).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The tag-path argument MUST be a literal string expression. Variables,
|
||||
/// concatenation, interpolation, and method-returned strings are rejected because
|
||||
/// the extractor can't statically know what tag they'll resolve to at evaluation
|
||||
/// time — the dependency graph needs to know every possible input up front.
|
||||
/// Rejections carry the exact source span so the Admin UI can point at the offending
|
||||
/// token.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Identifier matching is by spelling: the extractor looks for
|
||||
/// <c>ctx.GetTag(...)</c> / <c>ctx.SetVirtualTag(...)</c> literally. A deliberately
|
||||
/// misspelled method call (<c>ctx.GetTagz</c>) is not picked up but will also fail
|
||||
/// to compile against <see cref="ScriptContext"/>, so there's no way to smuggle a
|
||||
/// dependency past the extractor while still having a working script.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DependencyExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse <paramref name="scriptSource"/> + return the inferred read + write tag
|
||||
/// paths, or a list of rejection messages if non-literal paths were used.
|
||||
/// </summary>
|
||||
public static DependencyExtractionResult Extract(string scriptSource)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scriptSource))
|
||||
return new DependencyExtractionResult(
|
||||
Reads: new HashSet<string>(StringComparer.Ordinal),
|
||||
Writes: new HashSet<string>(StringComparer.Ordinal),
|
||||
Rejections: []);
|
||||
|
||||
var tree = CSharpSyntaxTree.ParseText(scriptSource, options:
|
||||
new CSharpParseOptions(kind: SourceCodeKind.Script));
|
||||
var root = tree.GetRoot();
|
||||
|
||||
var walker = new Walker();
|
||||
walker.Visit(root);
|
||||
|
||||
return new DependencyExtractionResult(
|
||||
Reads: walker.Reads,
|
||||
Writes: walker.Writes,
|
||||
Rejections: walker.Rejections);
|
||||
}
|
||||
|
||||
private sealed class Walker : CSharpSyntaxWalker
|
||||
{
|
||||
private readonly HashSet<string> _reads = new(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _writes = new(StringComparer.Ordinal);
|
||||
private readonly List<DependencyRejection> _rejections = [];
|
||||
|
||||
public IReadOnlySet<string> Reads => _reads;
|
||||
public IReadOnlySet<string> Writes => _writes;
|
||||
public IReadOnlyList<DependencyRejection> Rejections => _rejections;
|
||||
|
||||
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
|
||||
{
|
||||
// Only interested in member-access form: ctx.GetTag(...) / ctx.SetVirtualTag(...).
|
||||
// Anything else (free functions, chained calls, static calls) is ignored — but
|
||||
// still visit children in case a ctx.GetTag call is nested inside.
|
||||
if (node.Expression is MemberAccessExpressionSyntax member)
|
||||
{
|
||||
var methodName = member.Name.Identifier.ValueText;
|
||||
if (methodName is nameof(ScriptContext.GetTag) or nameof(ScriptContext.SetVirtualTag))
|
||||
{
|
||||
HandleTagCall(node, methodName);
|
||||
}
|
||||
}
|
||||
base.VisitInvocationExpression(node);
|
||||
}
|
||||
|
||||
private void HandleTagCall(InvocationExpressionSyntax node, string methodName)
|
||||
{
|
||||
var args = node.ArgumentList.Arguments;
|
||||
if (args.Count == 0)
|
||||
{
|
||||
_rejections.Add(new DependencyRejection(
|
||||
Span: node.Span,
|
||||
Message: $"Call to ctx.{methodName} has no arguments. " +
|
||||
"The tag path must be the first argument."));
|
||||
return;
|
||||
}
|
||||
|
||||
var pathArg = args[0].Expression;
|
||||
if (pathArg is not LiteralExpressionSyntax literal
|
||||
|| !literal.Token.IsKind(SyntaxKind.StringLiteralToken))
|
||||
{
|
||||
_rejections.Add(new DependencyRejection(
|
||||
Span: pathArg.Span,
|
||||
Message: $"Tag path passed to ctx.{methodName} must be a string literal. " +
|
||||
$"Dynamic paths (variables, concatenation, interpolation, method " +
|
||||
$"calls) are rejected at publish so the dependency graph can be " +
|
||||
$"built statically. Got: {pathArg.Kind()} ({pathArg})"));
|
||||
return;
|
||||
}
|
||||
|
||||
var path = (string?)literal.Token.Value ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
_rejections.Add(new DependencyRejection(
|
||||
Span: literal.Span,
|
||||
Message: $"Tag path passed to ctx.{methodName} is empty or whitespace."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (methodName == nameof(ScriptContext.GetTag))
|
||||
_reads.Add(path);
|
||||
else
|
||||
_writes.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Output of <see cref="DependencyExtractor.Extract"/>.</summary>
|
||||
public sealed record DependencyExtractionResult(
|
||||
IReadOnlySet<string> Reads,
|
||||
IReadOnlySet<string> Writes,
|
||||
IReadOnlyList<DependencyRejection> Rejections)
|
||||
{
|
||||
/// <summary>True when no rejections were recorded — safe to publish.</summary>
|
||||
public bool IsValid => Rejections.Count == 0;
|
||||
}
|
||||
|
||||
/// <summary>A single non-literal-path rejection with the exact source span for UI pointing.</summary>
|
||||
public sealed record DependencyRejection(TextSpan Span, string Message);
|
||||
152
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs
Normal file
152
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ForbiddenTypeAnalyzer.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Post-compile sandbox guard. <c>ScriptOptions</c> alone can't reliably
|
||||
/// constrain the type surface a script can reach because .NET 10's type-forwarding
|
||||
/// system resolves many BCL types through multiple assemblies — restricting the
|
||||
/// reference list doesn't stop <c>System.Net.Http.HttpClient</c> from being found if
|
||||
/// any transitive reference forwards to <c>System.Net.Http</c>. This analyzer walks
|
||||
/// the script's syntax tree after compile, uses the <see cref="SemanticModel"/> to
|
||||
/// resolve every type / member reference, and rejects any whose containing namespace
|
||||
/// matches a deny-list pattern.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Deny-list is the authoritative Phase 7 plan decision #6 set:
|
||||
/// <c>System.IO</c>, <c>System.Net</c>, <c>System.Diagnostics.Process</c>,
|
||||
/// <c>System.Reflection</c>, <c>System.Threading.Thread</c>,
|
||||
/// <c>System.Runtime.InteropServices</c>. <c>System.Environment</c> (for process
|
||||
/// env-var read) is explicitly left allowed — it's read-only process state, doesn't
|
||||
/// persist outside, and the test file pins this compromise so tightening later is
|
||||
/// a deliberate plan decision.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deny-list prefix match. <c>System.Net</c> catches <c>System.Net.Http</c>,
|
||||
/// <c>System.Net.Sockets</c>, <c>System.Net.NetworkInformation</c>, etc. — every
|
||||
/// subnamespace. If a script needs something under a denied prefix, Phase 7's
|
||||
/// operator audience authors it through a helper the plan team adds as part of
|
||||
/// the <see cref="ScriptContext"/> surface, not by unlocking the namespace.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ForbiddenTypeAnalyzer
|
||||
{
|
||||
/// <summary>
|
||||
/// Namespace prefixes scripts are NOT allowed to reference. Each string is
|
||||
/// matched as a prefix against the resolved symbol's namespace name (dot-
|
||||
/// delimited), so <c>System.IO</c> catches <c>System.IO.File</c>,
|
||||
/// <c>System.IO.Pipes</c>, and any future subnamespace without needing explicit
|
||||
/// enumeration.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyList<string> ForbiddenNamespacePrefixes =
|
||||
[
|
||||
"System.IO",
|
||||
"System.Net",
|
||||
"System.Diagnostics", // catches Process, ProcessStartInfo, EventLog, Trace/Debug file sinks
|
||||
"System.Reflection",
|
||||
"System.Threading.Thread", // raw Thread — Tasks stay allowed (different namespace)
|
||||
"System.Runtime.InteropServices",
|
||||
"Microsoft.Win32", // registry
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Scan the <paramref name="compilation"/> for references to forbidden types.
|
||||
/// Returns empty list when the script is clean; non-empty list means the script
|
||||
/// must be rejected at publish with the rejections surfaced to the operator.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ForbiddenTypeRejection> Analyze(Compilation compilation)
|
||||
{
|
||||
if (compilation is null) throw new ArgumentNullException(nameof(compilation));
|
||||
|
||||
var rejections = new List<ForbiddenTypeRejection>();
|
||||
foreach (var tree in compilation.SyntaxTrees)
|
||||
{
|
||||
var semantic = compilation.GetSemanticModel(tree);
|
||||
var root = tree.GetRoot();
|
||||
foreach (var node in root.DescendantNodes())
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case ObjectCreationExpressionSyntax obj:
|
||||
CheckSymbol(semantic.GetSymbolInfo(obj.Type).Symbol, obj.Type.Span, rejections);
|
||||
break;
|
||||
case InvocationExpressionSyntax inv when inv.Expression is MemberAccessExpressionSyntax memberAcc:
|
||||
CheckSymbol(semantic.GetSymbolInfo(memberAcc.Expression).Symbol, memberAcc.Expression.Span, rejections);
|
||||
CheckSymbol(semantic.GetSymbolInfo(inv).Symbol, inv.Span, rejections);
|
||||
break;
|
||||
case MemberAccessExpressionSyntax mem:
|
||||
// Catches static calls like System.IO.File.ReadAllText(...) — the
|
||||
// MemberAccess "System.IO.File" resolves to the File type symbol
|
||||
// whose containing namespace is System.IO, triggering a rejection.
|
||||
CheckSymbol(semantic.GetSymbolInfo(mem.Expression).Symbol, mem.Expression.Span, rejections);
|
||||
break;
|
||||
case IdentifierNameSyntax id when node.Parent is not MemberAccessExpressionSyntax:
|
||||
CheckSymbol(semantic.GetSymbolInfo(id).Symbol, id.Span, rejections);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return rejections;
|
||||
}
|
||||
|
||||
private static void CheckSymbol(ISymbol? symbol, TextSpan span, List<ForbiddenTypeRejection> rejections)
|
||||
{
|
||||
if (symbol is null) return;
|
||||
|
||||
var typeSymbol = symbol switch
|
||||
{
|
||||
ITypeSymbol t => t,
|
||||
IMethodSymbol m => m.ContainingType,
|
||||
IPropertySymbol p => p.ContainingType,
|
||||
IFieldSymbol f => f.ContainingType,
|
||||
_ => null,
|
||||
};
|
||||
if (typeSymbol is null) return;
|
||||
|
||||
var ns = typeSymbol.ContainingNamespace?.ToDisplayString() ?? string.Empty;
|
||||
foreach (var forbidden in ForbiddenNamespacePrefixes)
|
||||
{
|
||||
if (ns == forbidden || ns.StartsWith(forbidden + ".", StringComparison.Ordinal))
|
||||
{
|
||||
rejections.Add(new ForbiddenTypeRejection(
|
||||
Span: span,
|
||||
TypeName: typeSymbol.ToDisplayString(),
|
||||
Namespace: ns,
|
||||
Message: $"Type '{typeSymbol.ToDisplayString()}' is in the forbidden namespace '{ns}'. " +
|
||||
$"Scripts cannot reach {forbidden}* per Phase 7 sandbox rules."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A single forbidden-type reference in a user script.</summary>
|
||||
public sealed record ForbiddenTypeRejection(
|
||||
TextSpan Span,
|
||||
string TypeName,
|
||||
string Namespace,
|
||||
string Message);
|
||||
|
||||
/// <summary>Thrown from <see cref="ScriptEvaluator{TContext, TResult}.Compile"/> when the
|
||||
/// post-compile forbidden-type analyzer finds references to denied namespaces.</summary>
|
||||
public sealed class ScriptSandboxViolationException : Exception
|
||||
{
|
||||
public IReadOnlyList<ForbiddenTypeRejection> Rejections { get; }
|
||||
|
||||
public ScriptSandboxViolationException(IReadOnlyList<ForbiddenTypeRejection> rejections)
|
||||
: base(BuildMessage(rejections))
|
||||
{
|
||||
Rejections = rejections;
|
||||
}
|
||||
|
||||
private static string BuildMessage(IReadOnlyList<ForbiddenTypeRejection> rejections)
|
||||
{
|
||||
var lines = rejections.Select(r => $" - {r.Message}");
|
||||
return "Script references types outside the Phase 7 sandbox allow-list:\n"
|
||||
+ string.Join("\n", lines);
|
||||
}
|
||||
}
|
||||
80
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs
Normal file
80
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptContext.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// The API user scripts see as the global <c>ctx</c>. Abstract — concrete subclasses
|
||||
/// (e.g. <c>VirtualTagScriptContext</c>, <c>AlarmScriptContext</c>) plug in the
|
||||
/// actual tag-backend + logger + virtual-tag writer for each evaluation. Phase 7 plan
|
||||
/// decision #6: scripts can read any tag, write only to virtual tags, and have no
|
||||
/// other .NET reach — no HttpClient, no File, no Process, no reflection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every member on this type MUST be serializable in the narrow sense that
|
||||
/// <see cref="DependencyExtractor"/> can recognize tag-access call sites from the
|
||||
/// script AST. Method names used from scripts are locked — renaming
|
||||
/// <see cref="GetTag"/> or <see cref="SetVirtualTag"/> is a breaking change for every
|
||||
/// authored script and the dependency extractor must update in lockstep.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// New helpers (<see cref="Now"/>, <see cref="Deadband"/>) are additive: adding a
|
||||
/// method doesn't invalidate existing scripts. Do not remove or rename without a
|
||||
/// plan-level decision + migration for authored scripts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public abstract class ScriptContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Read a tag's current value + quality + source timestamp. Path syntax is
|
||||
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c> (forward-slash delimited,
|
||||
/// matching the Equipment-namespace browse tree). Returns a
|
||||
/// <see cref="DataValueSnapshot"/> so scripts branch on quality without a second
|
||||
/// call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="path"/> MUST be a string literal in the script source — dynamic
|
||||
/// paths (variables, concatenation, method-returned strings) are rejected at
|
||||
/// publish by <see cref="DependencyExtractor"/>. This is intentional: the static
|
||||
/// dependency set is required for the change-driven scheduler to subscribe to the
|
||||
/// right upstream tags at load time.
|
||||
/// </remarks>
|
||||
public abstract DataValueSnapshot GetTag(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Write a value to a virtual tag. Operator scripts cannot write to driver-sourced
|
||||
/// tags — the OPC UA dispatch in <c>DriverNodeManager</c> rejects that separately
|
||||
/// per ADR-002 with <c>BadUserAccessDenied</c>. This method is the only write path
|
||||
/// virtual tags have.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Path rules identical to <see cref="GetTag"/> — literal only, dependency
|
||||
/// extractor tracks the write targets so the engine knows what downstream
|
||||
/// subscribers to notify.
|
||||
/// </remarks>
|
||||
public abstract void SetVirtualTag(string path, object? value);
|
||||
|
||||
/// <summary>
|
||||
/// Current UTC timestamp. Prefer this over <see cref="DateTime.UtcNow"/> in
|
||||
/// scripts so the harness can supply a deterministic clock for tests.
|
||||
/// </summary>
|
||||
public abstract DateTime Now { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-script Serilog logger. Output lands in the dedicated <c>scripts-*.log</c>
|
||||
/// sink with structured property <c>ScriptName</c> = the script's configured name.
|
||||
/// Use at error level to surface problems; main <c>opcua-*.log</c> receives a
|
||||
/// companion WARN entry so operators see script errors in the primary log.
|
||||
/// </summary>
|
||||
public abstract ILogger Logger { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Deadband helper — returns <c>true</c> when <paramref name="current"/> differs
|
||||
/// from <paramref name="previous"/> by more than <paramref name="tolerance"/>.
|
||||
/// Useful for alarm predicates that shouldn't flicker on small noise. Pure
|
||||
/// function; no side effects.
|
||||
/// </summary>
|
||||
public static bool Deadband(double current, double previous, double tolerance)
|
||||
=> Math.Abs(current - previous) > tolerance;
|
||||
}
|
||||
75
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs
Normal file
75
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles + runs user scripts against a <see cref="ScriptContext"/> subclass. Core
|
||||
/// evaluator — no caching, no timeout, no logging side-effects yet (those land in
|
||||
/// Stream A.3, A.4, A.5 respectively). Stream B + C wrap this with the dependency
|
||||
/// scheduler + alarm state machine.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Scripts are compiled against <see cref="ScriptGlobals{TContext}"/> so the
|
||||
/// context member is named <c>ctx</c> in the script, matching the
|
||||
/// <see cref="DependencyExtractor"/>'s walker and the Admin UI type stub.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Compile pipeline is a three-step gate: (1) Roslyn compile — catches syntax
|
||||
/// errors + type-resolution failures, throws <see cref="CompilationErrorException"/>;
|
||||
/// (2) <see cref="ForbiddenTypeAnalyzer"/> runs against the semantic model —
|
||||
/// catches sandbox escapes that slipped past reference restrictions due to .NET's
|
||||
/// type forwarding, throws <see cref="ScriptSandboxViolationException"/>; (3)
|
||||
/// delegate creation — throws at this layer only for internal Roslyn bugs, not
|
||||
/// user error.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Runtime exceptions thrown from user code propagate unwrapped. The virtual-tag
|
||||
/// engine (Stream B) catches them per-tag + maps to <c>BadInternalError</c>
|
||||
/// quality per Phase 7 decision #11 — this layer doesn't swallow anything so
|
||||
/// tests can assert on the original exception type.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptEvaluator<TContext, TResult>
|
||||
where TContext : ScriptContext
|
||||
{
|
||||
private readonly ScriptRunner<TResult> _runner;
|
||||
|
||||
private ScriptEvaluator(ScriptRunner<TResult> runner)
|
||||
{
|
||||
_runner = runner;
|
||||
}
|
||||
|
||||
public static ScriptEvaluator<TContext, TResult> Compile(string scriptSource)
|
||||
{
|
||||
if (scriptSource is null) throw new ArgumentNullException(nameof(scriptSource));
|
||||
|
||||
var options = ScriptSandbox.Build(typeof(TContext));
|
||||
var script = CSharpScript.Create<TResult>(
|
||||
code: scriptSource,
|
||||
options: options,
|
||||
globalsType: typeof(ScriptGlobals<TContext>));
|
||||
|
||||
// Step 1 — Roslyn compile. Throws CompilationErrorException on syntax / type errors.
|
||||
var diagnostics = script.Compile();
|
||||
|
||||
// Step 2 — forbidden-type semantic analysis. Defense-in-depth against reference-list
|
||||
// leaks due to type forwarding.
|
||||
var rejections = ForbiddenTypeAnalyzer.Analyze(script.GetCompilation());
|
||||
if (rejections.Count > 0)
|
||||
throw new ScriptSandboxViolationException(rejections);
|
||||
|
||||
// Step 3 — materialize the callable delegate.
|
||||
var runner = script.CreateDelegate();
|
||||
return new ScriptEvaluator<TContext, TResult>(runner);
|
||||
}
|
||||
|
||||
/// <summary>Run against an already-constructed context.</summary>
|
||||
public Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (context is null) throw new ArgumentNullException(nameof(context));
|
||||
var globals = new ScriptGlobals<TContext> { ctx = context };
|
||||
return _runner(globals, ct);
|
||||
}
|
||||
}
|
||||
19
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs
Normal file
19
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptGlobals.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="ScriptContext"/> as a named field so user scripts see
|
||||
/// <c>ctx.GetTag(...)</c> instead of the bare <c>GetTag(...)</c> that Roslyn's
|
||||
/// globalsType convention would produce. Keeps the script ergonomics operators
|
||||
/// author against consistent with the dependency extractor (which looks for the
|
||||
/// <c>ctx.</c> prefix) and with the Admin UI hand-written type stub.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Generic on <typeparamref name="TContext"/> so alarm predicates can use a richer
|
||||
/// context (e.g. with an <c>Alarm</c> property carrying the owning condition's
|
||||
/// metadata) without affecting virtual-tag contexts.
|
||||
/// </remarks>
|
||||
public class ScriptGlobals<TContext>
|
||||
where TContext : ScriptContext
|
||||
{
|
||||
public TContext ctx { get; set; } = default!;
|
||||
}
|
||||
87
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs
Normal file
87
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for the <see cref="ScriptOptions"/> every user script is compiled against.
|
||||
/// Implements Phase 7 plan decision #6 (read-only sandbox) by whitelisting only the
|
||||
/// assemblies + namespaces the script API needs; no <c>System.IO</c>, no
|
||||
/// <c>System.Net</c>, no <c>System.Diagnostics.Process</c>, no
|
||||
/// <c>System.Reflection</c>. Attempts to reference those types in a script fail at
|
||||
/// compile with a compiler error that points at the exact span — the operator sees
|
||||
/// the rejection before publish, not at evaluation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Roslyn's default <see cref="ScriptOptions"/> references <c>mscorlib</c> /
|
||||
/// <c>System.Runtime</c> transitively which pulls in every type in the BCL — this
|
||||
/// class overrides that with an explicit minimal allow-list.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Namespaces pre-imported so scripts don't have to write <c>using</c> clauses:
|
||||
/// <c>System</c>, <c>System.Math</c>-style statics are reachable via
|
||||
/// <see cref="Math"/>, and <c>ZB.MOM.WW.OtOpcUa.Core.Abstractions</c> so scripts
|
||||
/// can name <see cref="DataValueSnapshot"/> directly.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The sandbox cannot prevent a script from allocating unbounded memory or
|
||||
/// spinning in a tight loop — those are budget concerns, handled by the
|
||||
/// per-evaluation timeout (Stream A.4) + the test-harness (Stream F.4) that lets
|
||||
/// operators preview output before publishing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ScriptSandbox
|
||||
{
|
||||
/// <summary>
|
||||
/// Build the <see cref="ScriptOptions"/> used for every virtual-tag / alarm
|
||||
/// script. <paramref name="contextType"/> is the concrete
|
||||
/// <see cref="ScriptContext"/> subclass the globals will be of — the compiler
|
||||
/// uses its type to resolve <c>ctx.GetTag(...)</c> calls.
|
||||
/// </summary>
|
||||
public static ScriptOptions Build(Type contextType)
|
||||
{
|
||||
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
|
||||
if (!typeof(ScriptContext).IsAssignableFrom(contextType))
|
||||
throw new ArgumentException(
|
||||
$"Script context type must derive from {nameof(ScriptContext)}", nameof(contextType));
|
||||
|
||||
// Allow-listed assemblies — each explicitly chosen. Adding here is a
|
||||
// plan-level decision; do not expand casually. HashSet so adding the
|
||||
// contextType's assembly is idempotent when it happens to be Core.Scripting
|
||||
// already.
|
||||
var allowedAssemblies = new HashSet<System.Reflection.Assembly>
|
||||
{
|
||||
// System.Private.CoreLib — primitives (int, double, bool, string, DateTime,
|
||||
// TimeSpan, Math, Convert, nullable<T>). Can't practically script without it.
|
||||
typeof(object).Assembly,
|
||||
// System.Linq — IEnumerable extensions (Where / Select / Sum / Average / etc.).
|
||||
typeof(System.Linq.Enumerable).Assembly,
|
||||
// Core.Abstractions — DataValueSnapshot + DriverDataType so scripts can name
|
||||
// the types they receive from ctx.GetTag.
|
||||
typeof(DataValueSnapshot).Assembly,
|
||||
// Core.Scripting itself — ScriptContext base class + Deadband static.
|
||||
typeof(ScriptContext).Assembly,
|
||||
// Serilog.ILogger — script-side logger type.
|
||||
typeof(Serilog.ILogger).Assembly,
|
||||
// Concrete context type's assembly — production contexts subclass
|
||||
// ScriptContext in Core.VirtualTags / Core.ScriptedAlarms; tests use their
|
||||
// own subclass. The globals wrapper is generic on this type so Roslyn must
|
||||
// be able to resolve it during compilation.
|
||||
contextType.Assembly,
|
||||
};
|
||||
|
||||
var allowedImports = new[]
|
||||
{
|
||||
"System",
|
||||
"System.Linq",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Abstractions",
|
||||
"ZB.MOM.WW.OtOpcUa.Core.Scripting",
|
||||
};
|
||||
|
||||
return ScriptOptions.Default
|
||||
.WithReferences(allowedAssemblies)
|
||||
.WithImports(allowedImports);
|
||||
}
|
||||
}
|
||||
102
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs
Normal file
102
src/ZB.MOM.WW.OtOpcUa.Core.Scripting/TimedScriptEvaluator.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a <see cref="ScriptEvaluator{TContext, TResult}"/> with a per-evaluation
|
||||
/// wall-clock timeout. Default is 250ms per Phase 7 plan Stream A.4; configurable
|
||||
/// per tag so deployments with slower backends can widen it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Implemented with <see cref="Task.WaitAsync(TimeSpan, CancellationToken)"/>
|
||||
/// rather than a cancellation-token-only approach because Roslyn-compiled
|
||||
/// scripts don't internally poll the cancellation token unless the user code
|
||||
/// does async work. A CPU-bound infinite loop in a script won't honor a
|
||||
/// cooperative cancel — <c>WaitAsync</c> returns control when the timeout fires
|
||||
/// regardless of whether the inner task completes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Known limitation:</b> when a script times out, the underlying ScriptRunner
|
||||
/// task continues running on a thread-pool thread until the Roslyn runtime
|
||||
/// returns. In the CPU-bound-infinite-loop case that's effectively "leaked" —
|
||||
/// the thread is tied up until the runtime decides to return, which it may
|
||||
/// never do. Phase 7 plan Stream A.4 accepts this as a known trade-off; tighter
|
||||
/// CPU budgeting would require an out-of-process script runner, which is a v3
|
||||
/// concern. In practice, the timeout + structured warning log surfaces the
|
||||
/// offending script so the operator can fix it; the orphan thread is rare.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Caller-supplied <see cref="CancellationToken"/> is honored — if the caller
|
||||
/// cancels before the timeout fires, the caller's cancel wins and the
|
||||
/// <see cref="OperationCanceledException"/> propagates (not wrapped as
|
||||
/// <see cref="ScriptTimeoutException"/>). That distinction matters: the
|
||||
/// virtual-tag engine's shutdown path cancels scripts on dispose; it shouldn't
|
||||
/// see those as timeouts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TimedScriptEvaluator<TContext, TResult>
|
||||
where TContext : ScriptContext
|
||||
{
|
||||
/// <summary>Default timeout per Phase 7 plan Stream A.4 — 250ms.</summary>
|
||||
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(250);
|
||||
|
||||
private readonly ScriptEvaluator<TContext, TResult> _inner;
|
||||
|
||||
/// <summary>Wall-clock budget per evaluation. Script exceeding this throws <see cref="ScriptTimeoutException"/>.</summary>
|
||||
public TimeSpan Timeout { get; }
|
||||
|
||||
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner)
|
||||
: this(inner, DefaultTimeout)
|
||||
{
|
||||
}
|
||||
|
||||
public TimedScriptEvaluator(ScriptEvaluator<TContext, TResult> inner, TimeSpan timeout)
|
||||
{
|
||||
_inner = inner ?? throw new ArgumentNullException(nameof(inner));
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive.");
|
||||
Timeout = timeout;
|
||||
}
|
||||
|
||||
public async Task<TResult> RunAsync(TContext context, CancellationToken ct = default)
|
||||
{
|
||||
if (context is null) throw new ArgumentNullException(nameof(context));
|
||||
|
||||
// Push evaluation to a thread-pool thread so a CPU-bound script (e.g. a tight
|
||||
// loop with no async work) doesn't hog the caller's thread before WaitAsync
|
||||
// gets to register its timeout. Without this, Roslyn's ScriptRunner executes
|
||||
// synchronously on the calling thread and returns an already-completed Task,
|
||||
// so WaitAsync sees a completed task and never fires the timeout.
|
||||
var runTask = Task.Run(() => _inner.RunAsync(context, ct), ct);
|
||||
try
|
||||
{
|
||||
return await runTask.WaitAsync(Timeout, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
// WaitAsync's synthesized timeout — the inner task may still be running
|
||||
// on its thread-pool thread (known leak documented in the class summary).
|
||||
// Wrap so callers can distinguish from user-written timeout logic.
|
||||
throw new ScriptTimeoutException(Timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a script evaluation exceeds its configured timeout. The virtual-tag
|
||||
/// engine (Stream B) catches this + maps the owning tag's quality to
|
||||
/// <c>BadInternalError</c> per Phase 7 plan decision #11, logging a structured
|
||||
/// warning with the offending script name so operators can locate + fix it.
|
||||
/// </summary>
|
||||
public sealed class ScriptTimeoutException : Exception
|
||||
{
|
||||
public TimeSpan Timeout { get; }
|
||||
|
||||
public ScriptTimeoutException(TimeSpan timeout)
|
||||
: base($"Script evaluation exceeded the configured timeout of {timeout.TotalMilliseconds:F1} ms. " +
|
||||
"The script was either CPU-bound or blocked on a slow operation; check ctx.Logger output " +
|
||||
"around the timeout and consider widening the timeout per tag, simplifying the script, or " +
|
||||
"moving heavy work out of the evaluation path.")
|
||||
{
|
||||
Timeout = timeout;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Roslyn scripting API — compiles user C# snippets with a constrained ScriptOptions
|
||||
allow-list so scripts can't reach Process/File/HttpClient/reflection. Per Phase 7
|
||||
plan decisions #1 + #6. -->
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.12.0"/>
|
||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,151 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the source-hash keyed compile cache. Roslyn compilation is the most
|
||||
/// expensive step in the evaluator pipeline; this cache collapses redundant
|
||||
/// compiles of unchanged scripts to zero-cost lookups + makes sure concurrent
|
||||
/// callers never double-compile.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CompiledScriptCacheTests
|
||||
{
|
||||
private sealed class CompileCountingGate
|
||||
{
|
||||
public int Count;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_call_compiles_and_caches()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.Count.ShouldBe(0);
|
||||
|
||||
var e = cache.GetOrCompile("""return 42;""");
|
||||
e.ShouldNotBeNull();
|
||||
cache.Count.ShouldBe(1);
|
||||
cache.Contains("""return 42;""").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identical_source_returns_the_same_compiled_evaluator()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var first = cache.GetOrCompile("""return 1;""");
|
||||
var second = cache.GetOrCompile("""return 1;""");
|
||||
ReferenceEquals(first, second).ShouldBeTrue();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_source_produces_different_evaluator()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var a = cache.GetOrCompile("""return 1;""");
|
||||
var b = cache.GetOrCompile("""return 2;""");
|
||||
ReferenceEquals(a, b).ShouldBeFalse();
|
||||
cache.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Whitespace_difference_misses_cache()
|
||||
{
|
||||
// Documented behavior: reformatting a script recompiles. Simpler + cheaper
|
||||
// than the alternative (AST-canonicalize then hash) and doesn't happen often.
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.GetOrCompile("""return 1;""");
|
||||
cache.GetOrCompile("return 1; "); // trailing whitespace — different hash
|
||||
cache.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cached_evaluator_still_runs_correctly()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, double>();
|
||||
var e = cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""");
|
||||
var ctx = new FakeScriptContext().Seed("In", 7.0);
|
||||
|
||||
// Run twice through the cache — both must return the same correct value.
|
||||
var first = await e.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
var second = await cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""")
|
||||
.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
first.ShouldBe(21.0);
|
||||
second.ShouldBe(21.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_compile_is_evicted_so_retry_with_corrected_source_works()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
|
||||
// First attempt — undefined identifier, compile throws.
|
||||
Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
|
||||
cache.Count.ShouldBe(0, "failed compile must be evicted so retry can re-attempt");
|
||||
|
||||
// Retry with corrected source succeeds + caches.
|
||||
cache.GetOrCompile("""return 42;""").ShouldNotBeNull();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_drops_every_entry()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
cache.GetOrCompile("""return 1;""");
|
||||
cache.GetOrCompile("""return 2;""");
|
||||
cache.Count.ShouldBe(2);
|
||||
|
||||
cache.Clear();
|
||||
cache.Count.ShouldBe(0);
|
||||
cache.Contains("""return 1;""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Concurrent_compiles_of_the_same_source_deduplicate()
|
||||
{
|
||||
// LazyThreadSafetyMode.ExecutionAndPublication guarantees only one compile
|
||||
// even when multiple threads race GetOrCompile against an empty cache.
|
||||
// We can't directly count Roslyn compilations — but we can assert all
|
||||
// concurrent callers see the same evaluator instance.
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
const string src = """return 99;""";
|
||||
|
||||
var tasks = Enumerable.Range(0, 20)
|
||||
.Select(_ => Task.Run(() => cache.GetOrCompile(src)))
|
||||
.ToArray();
|
||||
Task.WhenAll(tasks).GetAwaiter().GetResult();
|
||||
|
||||
var firstInstance = tasks[0].Result;
|
||||
foreach (var t in tasks)
|
||||
ReferenceEquals(t.Result, firstInstance).ShouldBeTrue();
|
||||
cache.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_TContext_TResult_pairs_use_separate_cache_instances()
|
||||
{
|
||||
// Documented: each engine (virtual-tag / alarm-predicate / alarm-action) owns
|
||||
// its own cache. The type-parametric design makes this the default without
|
||||
// cross-contamination at the dictionary level.
|
||||
var intCache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
var boolCache = new CompiledScriptCache<FakeScriptContext, bool>();
|
||||
|
||||
intCache.GetOrCompile("""return 1;""");
|
||||
boolCache.GetOrCompile("""return true;""");
|
||||
|
||||
intCache.Count.ShouldBe(1);
|
||||
boolCache.Count.ShouldBe(1);
|
||||
intCache.Contains("""return true;""").ShouldBeFalse();
|
||||
boolCache.Contains("""return 1;""").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_source_throws_ArgumentNullException()
|
||||
{
|
||||
var cache = new CompiledScriptCache<FakeScriptContext, int>();
|
||||
Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the AST walker that extracts static tag dependencies from user scripts
|
||||
/// + rejects every form of non-literal path. Locks the parse shape the virtual-tag
|
||||
/// engine's change-trigger scheduler will depend on (Phase 7 plan Stream A.2).
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class DependencyExtractorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Extracts_single_literal_read()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("Line1/Speed").Value;""");
|
||||
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldContain("Line1/Speed");
|
||||
result.Writes.ShouldBeEmpty();
|
||||
result.Rejections.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extracts_multiple_distinct_reads()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var a = ctx.GetTag("Line1/A").Value;
|
||||
var b = ctx.GetTag("Line1/B").Value;
|
||||
return (double)a + (double)b;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(2);
|
||||
result.Reads.ShouldContain("Line1/A");
|
||||
result.Reads.ShouldContain("Line1/B");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deduplicates_identical_reads_across_the_script()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
if (((double)ctx.GetTag("X").Value) > 0)
|
||||
return ctx.GetTag("X").Value;
|
||||
return 0;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(1);
|
||||
result.Reads.ShouldContain("X");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tracks_virtual_tag_writes_separately_from_reads()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var v = (double)ctx.GetTag("InTag").Value;
|
||||
ctx.SetVirtualTag("OutTag", v * 2);
|
||||
return v;
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldContain("InTag");
|
||||
result.Writes.ShouldContain("OutTag");
|
||||
result.Reads.ShouldNotContain("OutTag");
|
||||
result.Writes.ShouldNotContain("InTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_variable_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var path = "Line1/Speed";
|
||||
return ctx.GetTag(path).Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections.Count.ShouldBe(1);
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_concatenated_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("Line1/" + "Speed").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_interpolated_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var n = 1;
|
||||
return ctx.GetTag($"Line{n}/Speed").Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_method_returned_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
string BuildPath() => "Line1/Speed";
|
||||
return ctx.GetTag(BuildPath()).Value;
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("string literal");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_empty_literal_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag("").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Message.ShouldContain("empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_whitespace_only_path()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""return ctx.GetTag(" ").Value;""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ignores_non_ctx_method_named_GetTag()
|
||||
{
|
||||
// Scripts are free to define their own helper called "GetTag" — as long as it's
|
||||
// not on the ctx instance, the extractor doesn't pick it up. The sandbox
|
||||
// compile will still reject any path that isn't on the ScriptContext type.
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
string helper_GetTag(string p) => p;
|
||||
return helper_GetTag("NotATag");
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_source_is_a_no_op()
|
||||
{
|
||||
DependencyExtractor.Extract("").IsValid.ShouldBeTrue();
|
||||
DependencyExtractor.Extract(" ").IsValid.ShouldBeTrue();
|
||||
DependencyExtractor.Extract(null!).IsValid.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejection_carries_source_span_for_UI_pointing()
|
||||
{
|
||||
// Offending path at column 23-29 in the source — Admin UI uses Span to
|
||||
// underline the exact token.
|
||||
const string src = """return ctx.GetTag(path).Value;""";
|
||||
var result = DependencyExtractor.Extract(src);
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections[0].Span.Start.ShouldBeGreaterThan(0);
|
||||
result.Rejections[0].Span.Length.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiple_bad_paths_all_reported_in_one_pass()
|
||||
{
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
var p1 = "A"; var p2 = "B";
|
||||
return ctx.GetTag(p1).Value.ToString() + ctx.GetTag(p2).Value.ToString();
|
||||
""");
|
||||
result.IsValid.ShouldBeFalse();
|
||||
result.Rejections.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nested_literal_GetTag_inside_expression_is_extracted()
|
||||
{
|
||||
// Supports patterns like ctx.GetTag("A") > ctx.GetTag("B") — both literal args
|
||||
// are captured even when the enclosing expression is complex.
|
||||
var result = DependencyExtractor.Extract(
|
||||
"""
|
||||
return ((double)ctx.GetTag("A").Value) > ((double)ctx.GetTag("B").Value);
|
||||
""");
|
||||
result.IsValid.ShouldBeTrue();
|
||||
result.Reads.Count.ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="ScriptContext"/> for tests. Holds a tag dictionary + a write
|
||||
/// log + a deterministic clock. Concrete subclasses in production will wire
|
||||
/// GetTag/SetVirtualTag through the virtual-tag engine + driver dispatch; here they
|
||||
/// hit a plain dictionary.
|
||||
/// </summary>
|
||||
public sealed class FakeScriptContext : ScriptContext
|
||||
{
|
||||
public Dictionary<string, DataValueSnapshot> Tags { get; } = new(StringComparer.Ordinal);
|
||||
public List<(string Path, object? Value)> Writes { get; } = [];
|
||||
|
||||
public override DateTime Now { get; } = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
public override ILogger Logger { get; } = new LoggerConfiguration().CreateLogger();
|
||||
|
||||
public override DataValueSnapshot GetTag(string path)
|
||||
{
|
||||
return Tags.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, Now); // BadNodeIdUnknown
|
||||
}
|
||||
|
||||
public override void SetVirtualTag(string path, object? value)
|
||||
{
|
||||
Writes.Add((path, value));
|
||||
}
|
||||
|
||||
public FakeScriptContext Seed(string path, object? value,
|
||||
uint statusCode = 0u, DateTime? sourceTs = null)
|
||||
{
|
||||
Tags[path] = new DataValueSnapshot(value, statusCode, sourceTs ?? Now, Now);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles scripts against the Phase 7 sandbox + asserts every forbidden API
|
||||
/// (HttpClient / File / Process / reflection) fails at compile, not at evaluation.
|
||||
/// Locks decision #6 — scripts can't escape to the broader .NET surface.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ScriptSandboxTests
|
||||
{
|
||||
[Fact]
|
||||
public void Happy_path_script_compiles_and_returns()
|
||||
{
|
||||
// Baseline — ctx + Math + basic types must work.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||
"""
|
||||
var v = (double)ctx.GetTag("X").Value;
|
||||
return Math.Abs(v) * 2.0;
|
||||
""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Happy_path_script_runs_and_reads_seeded_tag()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||
"""return (double)ctx.GetTag("In").Value * 2.0;""");
|
||||
|
||||
var ctx = new FakeScriptContext().Seed("In", 21.0);
|
||||
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
result.ShouldBe(42.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_records_the_write()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
ctx.SetVirtualTag("Out", 42);
|
||||
return 0;
|
||||
""");
|
||||
var ctx = new FakeScriptContext();
|
||||
await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
ctx.Writes.Count.ShouldBe(1);
|
||||
ctx.Writes[0].Path.ShouldBe("Out");
|
||||
ctx.Writes[0].Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_File_IO_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, string>.Compile(
|
||||
"""return System.IO.File.ReadAllText("c:/secrets.txt");"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_HttpClient_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var c = new System.Net.Http.HttpClient();
|
||||
return 0;
|
||||
"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Process_Start_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
System.Diagnostics.Process.Start("cmd.exe");
|
||||
return 0;
|
||||
"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Reflection_Assembly_Load_at_compile()
|
||||
{
|
||||
Should.Throw<ScriptSandboxViolationException>(() =>
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
System.Reflection.Assembly.Load("System.Core");
|
||||
return 0;
|
||||
"""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_Environment_GetEnvironmentVariable_at_compile()
|
||||
{
|
||||
// Environment lives in System.Private.CoreLib (allow-listed for primitives) —
|
||||
// BUT calling .GetEnvironmentVariable exposes process state we don't want in
|
||||
// scripts. In an allow-list sandbox this passes because mscorlib is allowed;
|
||||
// relying on ScriptSandbox alone isn't enough for the Environment class. We
|
||||
// document here that the CURRENT sandbox allows Environment — acceptable because
|
||||
// Environment doesn't leak outside the process boundary, doesn't side-effect
|
||||
// persistent state, and Phase 7 plan decision #6 targets File/Net/Process/
|
||||
// reflection specifically.
|
||||
//
|
||||
// This test LOCKS that compromise: operators should not be surprised if a
|
||||
// script reads an env var. If we later decide to tighten, this test flips.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, string?>.Compile(
|
||||
"""return System.Environment.GetEnvironmentVariable("PATH");""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_exception_propagates_unwrapped()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""throw new InvalidOperationException("boom");""");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctx_Now_is_available_without_DateTime_UtcNow_reaching_wall_clock()
|
||||
{
|
||||
// Scripts that need a timestamp go through ctx.Now so tests can pin it.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, DateTime>.Compile("""return ctx.Now;""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deadband_helper_is_reachable_from_scripts()
|
||||
{
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
|
||||
"""return ScriptContext.Deadband(10.5, 10.0, 0.3);""");
|
||||
evaluator.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Linq_Enumerable_is_available_from_scripts()
|
||||
{
|
||||
// LINQ is in the allow-list because SCADA math frequently wants Sum / Average
|
||||
// / Where. Confirm it works.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var nums = new[] { 1, 2, 3, 4, 5 };
|
||||
return nums.Where(n => n > 2).Sum();
|
||||
""");
|
||||
var result = await evaluator.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken);
|
||||
result.ShouldBe(12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DataValueSnapshot_is_usable_in_scripts()
|
||||
{
|
||||
// ctx.GetTag returns DataValueSnapshot so scripts branch on quality.
|
||||
var evaluator = ScriptEvaluator<FakeScriptContext, bool>.Compile(
|
||||
"""
|
||||
var v = ctx.GetTag("T");
|
||||
return v.StatusCode == 0;
|
||||
""");
|
||||
var ctx = new FakeScriptContext().Seed("T", 5.0);
|
||||
var result = await evaluator.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_error_gives_location_in_diagnostics()
|
||||
{
|
||||
// Compile errors must carry the source span so the Admin UI can point at them.
|
||||
try
|
||||
{
|
||||
ScriptEvaluator<FakeScriptContext, int>.Compile("""return fooBarBaz + 1;""");
|
||||
Assert.Fail("expected CompilationErrorException");
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
ex.Diagnostics.ShouldNotBeEmpty();
|
||||
ex.Diagnostics[0].Location.ShouldNotBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the per-evaluation timeout wrapper. Fast scripts complete normally;
|
||||
/// CPU-bound or hung scripts throw <see cref="ScriptTimeoutException"/> instead of
|
||||
/// starving the engine. Caller-supplied cancellation tokens take precedence over the
|
||||
/// timeout so driver-shutdown paths see a clean cancel rather than a timeout.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TimedScriptEvaluatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Fast_script_completes_under_timeout_and_returns_value()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, double>.Compile(
|
||||
"""return (double)ctx.GetTag("In").Value + 1.0;""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, double>(
|
||||
inner, TimeSpan.FromSeconds(1));
|
||||
|
||||
var ctx = new FakeScriptContext().Seed("In", 41.0);
|
||||
var result = await timed.RunAsync(ctx, TestContext.Current.CancellationToken);
|
||||
result.ShouldBe(42.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_longer_than_timeout_throws_ScriptTimeoutException()
|
||||
{
|
||||
// Scripts can't easily do Thread.Sleep in the sandbox (System.Threading.Thread
|
||||
// is denied). But a tight CPU loop exceeds any short timeout.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 5000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromMilliseconds(50));
|
||||
|
||||
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Timeout.ShouldBe(TimeSpan.FromMilliseconds(50));
|
||||
ex.Message.ShouldContain("50.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Caller_cancellation_takes_precedence_over_timeout()
|
||||
{
|
||||
// A CPU-bound script that would otherwise timeout; external ct fires first.
|
||||
// Expected: OperationCanceledException (not ScriptTimeoutException) so shutdown
|
||||
// paths aren't misclassified as timeouts.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 10000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromSeconds(5));
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(80));
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_timeout_is_250ms_per_plan()
|
||||
{
|
||||
TimedScriptEvaluator<FakeScriptContext, int>.DefaultTimeout
|
||||
.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_or_negative_timeout_is_rejected_at_construction()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.Zero));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromMilliseconds(-1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_inner_is_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentNullException>(() =>
|
||||
new TimedScriptEvaluator<FakeScriptContext, int>(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_context_is_rejected()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile("""return 1;""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner);
|
||||
Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await timed.RunAsync(null!, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Script_exception_propagates_unwrapped()
|
||||
{
|
||||
// User-thrown exceptions must come through as-is — NOT wrapped in
|
||||
// ScriptTimeoutException. The virtual-tag engine catches them per-tag and
|
||||
// maps to BadInternalError; conflating with timeout would lose that info.
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""throw new InvalidOperationException("script boom");""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(inner, TimeSpan.FromSeconds(1));
|
||||
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldBe("script boom");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScriptTimeoutException_message_points_at_diagnostic_path()
|
||||
{
|
||||
var inner = ScriptEvaluator<FakeScriptContext, int>.Compile(
|
||||
"""
|
||||
var end = Environment.TickCount64 + 5000;
|
||||
while (Environment.TickCount64 < end) { }
|
||||
return 1;
|
||||
""");
|
||||
var timed = new TimedScriptEvaluator<FakeScriptContext, int>(
|
||||
inner, TimeSpan.FromMilliseconds(30));
|
||||
|
||||
var ex = await Should.ThrowAsync<ScriptTimeoutException>(async () =>
|
||||
await timed.RunAsync(new FakeScriptContext(), TestContext.Current.CancellationToken));
|
||||
ex.Message.ShouldContain("ctx.Logger");
|
||||
ex.Message.ShouldContain("widening the timeout");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -15,6 +15,12 @@ RUN pip install --no-cache-dir "pymodbus[simulator]==3.13.0"
|
||||
WORKDIR /fixtures
|
||||
COPY profiles/ /fixtures/
|
||||
|
||||
# Standalone exception-injection server (pure Python stdlib — no pymodbus
|
||||
# dependency). Speaks raw Modbus/TCP and emits arbitrary exception codes
|
||||
# per rules in exception_injection.json. Drives the `exception_injection`
|
||||
# compose profile. See Docker/README.md §exception injection.
|
||||
COPY exception_injector.py /fixtures/
|
||||
|
||||
EXPOSE 5020
|
||||
|
||||
# Default to the standard profile; docker-compose.yml overrides per service.
|
||||
|
||||
@@ -9,9 +9,10 @@ nothing else.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + the four profile JSONs |
|
||||
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500`); all bind `:5020` so only one runs at a time |
|
||||
| [`Dockerfile`](Dockerfile) | `python:3.12-slim-bookworm` + `pymodbus[simulator]==3.13.0` + every profile JSON + `exception_injector.py` |
|
||||
| [`docker-compose.yml`](docker-compose.yml) | One service per profile (`standard` / `dl205` / `mitsubishi` / `s7_1500` / `exception_injection`); all bind `:5020` so only one runs at a time |
|
||||
| [`profiles/*.json`](profiles/) | Same seed-register definitions the native launcher uses — canonical source |
|
||||
| [`exception_injector.py`](exception_injector.py) | Pure-stdlib Modbus/TCP server that emits arbitrary exception codes per rule — used by the `exception_injection` profile |
|
||||
|
||||
## Run
|
||||
|
||||
@@ -29,6 +30,10 @@ docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\
|
||||
|
||||
# Siemens S7-1500 MB_SERVER quirks
|
||||
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile s7_1500 up
|
||||
|
||||
# Exception-injection — end-to-end coverage of every Modbus exception code
|
||||
# (01/02/03/04/05/06/0A/0B), not just the 02 + 03 pymodbus emits naturally
|
||||
docker compose -f tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests\Docker\docker-compose.yml --profile exception_injection up
|
||||
```
|
||||
|
||||
Detached + stop:
|
||||
@@ -61,6 +66,36 @@ dotnet test tests\ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests
|
||||
records a `SkipReason` when unreachable, so tests stay green on a fresh
|
||||
clone without Docker running.
|
||||
|
||||
## Exception injection
|
||||
|
||||
pymodbus's simulator naturally emits only Modbus exception codes `0x02`
|
||||
(Illegal Data Address, on reads outside its configured ranges) and
|
||||
`0x03` (Illegal Data Value, on over-length requests). The driver's
|
||||
`MapModbusExceptionToStatus` table translates eight codes: `0x01`,
|
||||
`0x02`, `0x03`, `0x04`, `0x05`, `0x06`, `0x0A`, `0x0B`. Unit tests
|
||||
lock the translation function; the integration side previously only
|
||||
proved the wire-to-status path for `0x02`.
|
||||
|
||||
The `exception_injection` profile runs
|
||||
[`exception_injector.py`](exception_injector.py) — a tiny standalone
|
||||
Modbus/TCP server written against the Python stdlib (zero
|
||||
dependencies outside what's in the base image). It speaks the wire
|
||||
protocol directly (FC 01/02/03/04/05/06/15/16) and looks up each
|
||||
incoming `(fc, address)` against the rules in
|
||||
[`profiles/exception_injection.json`](profiles/exception_injection.json);
|
||||
a matching rule makes the server reply with
|
||||
`[fc | 0x80, exception_code]` instead of the normal response.
|
||||
|
||||
Current rules (see the JSON file for the canonical list):
|
||||
|
||||
- `FC03 @1000..1007` — one per exception code (`0x01`/`0x02`/`0x03`/`0x04`/`0x05`/`0x06`/`0x0A`/`0x0B`)
|
||||
- `FC06 @2000..2001` — `0x04` Server Failure, `0x06` Server Busy (write-path coverage)
|
||||
- `FC16 @3000` — `0x04` Server Failure (multi-register write path)
|
||||
|
||||
Adding more coverage is append-only: drop a new `{fc, address,
|
||||
exception, description}` entry into the JSON, restart the service,
|
||||
add an `[InlineData]` row in `ExceptionInjectionTests`.
|
||||
|
||||
## References
|
||||
|
||||
- [`docs/drivers/Modbus-Test-Fixture.md`](../../../docs/drivers/Modbus-Test-Fixture.md) — coverage map + gap inventory
|
||||
|
||||
@@ -77,3 +77,24 @@ services:
|
||||
"--modbus_device", "dev",
|
||||
"--json_file", "/fixtures/s7_1500.json"
|
||||
]
|
||||
|
||||
# Exception-injection profile. Runs the standalone pure-stdlib Modbus/TCP
|
||||
# server shipped as exception_injector.py instead of the pymodbus
|
||||
# simulator — pymodbus naturally emits only exception codes 02 + 03, and
|
||||
# this profile extends integration coverage to the other codes the
|
||||
# driver's MapModbusExceptionToStatus table handles (01, 04, 05, 06,
|
||||
# 0A, 0B). Rules are driven by exception_injection.json.
|
||||
exception_injection:
|
||||
profiles: ["exception_injection"]
|
||||
image: otopcua-pymodbus:3.13.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: otopcua-modbus-exception-injector
|
||||
restart: "no"
|
||||
ports:
|
||||
- "5020:5020"
|
||||
command: [
|
||||
"python", "/fixtures/exception_injector.py",
|
||||
"--config", "/fixtures/exception_injection.json"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Minimal Modbus/TCP server that supports per-address + per-function-code
|
||||
exception injection — the missing piece of the pymodbus simulator, which
|
||||
only naturally emits exception code 02 (Illegal Data Address) via its
|
||||
"invalid" list and 03 (Illegal Data Value) via spec-enforced length caps.
|
||||
|
||||
Integration tests against this fixture drive the driver's
|
||||
`MapModbusExceptionToStatus` end-to-end over the wire for codes 01, 04,
|
||||
05, 06, 0A, 0B — the ones the pymodbus simulator can't be configured to
|
||||
return.
|
||||
|
||||
Wire protocol — straight Modbus/TCP (spec chapter 7.1):
|
||||
|
||||
MBAP header (7 bytes): [tx_id:u16 BE][proto=0:u16][length:u16][unit_id:u8]
|
||||
then length-1 bytes of PDU. Length covers unit_id + PDU.
|
||||
|
||||
Supported function codes (enough for the driver's RMW + read paths):
|
||||
01 Read Coils, 02 Read Discrete Inputs,
|
||||
03 Read Holding Registers, 04 Read Input Registers,
|
||||
05 Write Single Coil, 06 Write Single Register,
|
||||
15 Write Multiple Coils, 16 Write Multiple Registers.
|
||||
|
||||
Config JSON schema (see exception_injection.json):
|
||||
|
||||
{
|
||||
"listen": { "host": "0.0.0.0", "port": 5020 },
|
||||
"seeds": { "hr": { "<addr>": <uint16>, ... },
|
||||
"ir": { "<addr>": <uint16>, ... },
|
||||
"co": { "<addr>": <0|1>, ... },
|
||||
"di": { "<addr>": <0|1>, ... } },
|
||||
"rules": [ { "fc": <int>, "address": <int>, "exception": <int>,
|
||||
"description": "..." }, ... ]
|
||||
}
|
||||
|
||||
Rules match on (fc, starting address). A matching rule wins and the server
|
||||
responds with the PDU `[fc | 0x80, exception_code]`.
|
||||
|
||||
Zero runtime dependencies outside the Python stdlib so the Docker image
|
||||
stays tiny.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
log = logging.getLogger("exception_injector")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rule:
|
||||
fc: int
|
||||
address: int
|
||||
exception: int
|
||||
description: str = ""
|
||||
|
||||
|
||||
class Store:
|
||||
"""In-memory data store backing non-injected reads + writes."""
|
||||
|
||||
def __init__(self, seeds: dict[str, dict[str, int]]) -> None:
|
||||
self.hr: dict[int, int] = {int(k): int(v) for k, v in seeds.get("hr", {}).items()}
|
||||
self.ir: dict[int, int] = {int(k): int(v) for k, v in seeds.get("ir", {}).items()}
|
||||
self.co: dict[int, int] = {int(k): int(v) for k, v in seeds.get("co", {}).items()}
|
||||
self.di: dict[int, int] = {int(k): int(v) for k, v in seeds.get("di", {}).items()}
|
||||
|
||||
def read_bits(self, table: dict[int, int], addr: int, count: int) -> bytes:
|
||||
"""Pack `count` bits LSB-first into the Modbus bit response body."""
|
||||
bits = [table.get(addr + i, 0) & 1 for i in range(count)]
|
||||
out = bytearray((count + 7) // 8)
|
||||
for i, b in enumerate(bits):
|
||||
if b:
|
||||
out[i // 8] |= 1 << (i % 8)
|
||||
return bytes(out)
|
||||
|
||||
def read_regs(self, table: dict[int, int], addr: int, count: int) -> bytes:
|
||||
"""Pack `count` uint16 BE into the Modbus register response body."""
|
||||
return b"".join(struct.pack(">H", table.get(addr + i, 0) & 0xFFFF) for i in range(count))
|
||||
|
||||
|
||||
class Server:
|
||||
EXC_ILLEGAL_FUNCTION = 0x01
|
||||
EXC_ILLEGAL_DATA_ADDRESS = 0x02
|
||||
EXC_ILLEGAL_DATA_VALUE = 0x03
|
||||
|
||||
def __init__(self, store: Store, rules: list[Rule]) -> None:
|
||||
self._store = store
|
||||
# Index rules by (fc, address) for O(1) lookup.
|
||||
self._rules: dict[tuple[int, int], Rule] = {(r.fc, r.address): r for r in rules}
|
||||
|
||||
def lookup_rule(self, fc: int, address: int) -> Rule | None:
|
||||
return self._rules.get((fc, address))
|
||||
|
||||
def exception_pdu(self, fc: int, code: int) -> bytes:
|
||||
return bytes([fc | 0x80, code & 0xFF])
|
||||
|
||||
def handle_pdu(self, pdu: bytes) -> bytes:
|
||||
if not pdu:
|
||||
return self.exception_pdu(0, self.EXC_ILLEGAL_FUNCTION)
|
||||
|
||||
fc = pdu[0]
|
||||
|
||||
# Reads: FC 01/02/03/04 — [fc u8][addr u16][quantity u16]
|
||||
if fc in (0x01, 0x02, 0x03, 0x04):
|
||||
if len(pdu) != 5:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
addr, count = struct.unpack(">HH", pdu[1:5])
|
||||
|
||||
rule = self.lookup_rule(fc, addr)
|
||||
if rule is not None:
|
||||
log.info("inject fc=%d addr=%d -> exception 0x%02X (%s)",
|
||||
fc, addr, rule.exception, rule.description)
|
||||
return self.exception_pdu(fc, rule.exception)
|
||||
|
||||
# Spec caps — FC01/02 allow 1..2000 bits; FC03/04 allow 1..125 regs.
|
||||
if fc in (0x01, 0x02):
|
||||
if not 1 <= count <= 2000:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
body = self._store.read_bits(
|
||||
self._store.co if fc == 0x01 else self._store.di, addr, count)
|
||||
return bytes([fc, len(body)]) + body
|
||||
|
||||
if not 1 <= count <= 125:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
body = self._store.read_regs(
|
||||
self._store.hr if fc == 0x03 else self._store.ir, addr, count)
|
||||
return bytes([fc, len(body)]) + body
|
||||
|
||||
# FC05 — [fc u8][addr u16][value u16] where value is 0xFF00=ON or 0x0000=OFF.
|
||||
if fc == 0x05:
|
||||
if len(pdu) != 5:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
addr, value = struct.unpack(">HH", pdu[1:5])
|
||||
rule = self.lookup_rule(fc, addr)
|
||||
if rule is not None:
|
||||
return self.exception_pdu(fc, rule.exception)
|
||||
if value == 0xFF00:
|
||||
self._store.co[addr] = 1
|
||||
elif value == 0x0000:
|
||||
self._store.co[addr] = 0
|
||||
else:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
return pdu # FC05 echoes the request on success.
|
||||
|
||||
# FC06 — [fc u8][addr u16][value u16].
|
||||
if fc == 0x06:
|
||||
if len(pdu) != 5:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
addr, value = struct.unpack(">HH", pdu[1:5])
|
||||
rule = self.lookup_rule(fc, addr)
|
||||
if rule is not None:
|
||||
return self.exception_pdu(fc, rule.exception)
|
||||
self._store.hr[addr] = value
|
||||
return pdu # FC06 echoes on success.
|
||||
|
||||
# FC15 — [fc u8][addr u16][count u16][byte_count u8][values...]
|
||||
if fc == 0x0F:
|
||||
if len(pdu) < 6:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
addr, count = struct.unpack(">HH", pdu[1:5])
|
||||
rule = self.lookup_rule(fc, addr)
|
||||
if rule is not None:
|
||||
return self.exception_pdu(fc, rule.exception)
|
||||
# Happy-path ignore-the-data, ack with standard response.
|
||||
return struct.pack(">BHH", fc, addr, count)
|
||||
|
||||
# FC16 — [fc u8][addr u16][count u16][byte_count u8][u16 values...]
|
||||
if fc == 0x10:
|
||||
if len(pdu) < 6:
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_DATA_VALUE)
|
||||
addr, count = struct.unpack(">HH", pdu[1:5])
|
||||
rule = self.lookup_rule(fc, addr)
|
||||
if rule is not None:
|
||||
return self.exception_pdu(fc, rule.exception)
|
||||
byte_count = pdu[5]
|
||||
data = pdu[6:6 + byte_count]
|
||||
for i in range(count):
|
||||
self._store.hr[addr + i] = struct.unpack(">H", data[i * 2:i * 2 + 2])[0]
|
||||
return struct.pack(">BHH", fc, addr, count)
|
||||
|
||||
return self.exception_pdu(fc, self.EXC_ILLEGAL_FUNCTION)
|
||||
|
||||
async def handle_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
peer = writer.get_extra_info("peername")
|
||||
log.info("client connected from %s", peer)
|
||||
try:
|
||||
while True:
|
||||
hdr = await reader.readexactly(7)
|
||||
tx_id, proto, length, unit_id = struct.unpack(">HHHB", hdr)
|
||||
if length < 1:
|
||||
return
|
||||
pdu = await reader.readexactly(length - 1)
|
||||
|
||||
resp = self.handle_pdu(pdu)
|
||||
out = struct.pack(">HHHB", tx_id, proto, len(resp) + 1, unit_id) + resp
|
||||
writer.write(out)
|
||||
await writer.drain()
|
||||
except asyncio.IncompleteReadError:
|
||||
log.info("client %s disconnected", peer)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception("unexpected error serving %s", peer)
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
|
||||
def load_config(path: str) -> tuple[Store, list[Rule], str, int]:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
raw = json.load(fh)
|
||||
listen = raw.get("listen", {})
|
||||
host = listen.get("host", "0.0.0.0")
|
||||
port = int(listen.get("port", 5020))
|
||||
store = Store(raw.get("seeds", {}))
|
||||
rules = [
|
||||
Rule(
|
||||
fc=int(r["fc"]),
|
||||
address=int(r["address"]),
|
||||
exception=int(r["exception"]),
|
||||
description=str(r.get("description", "")),
|
||||
)
|
||||
for r in raw.get("rules", [])
|
||||
]
|
||||
return store, rules, host, port
|
||||
|
||||
|
||||
async def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--config", required=True, help="Path to exception-injection JSON config.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s - %(message)s")
|
||||
|
||||
store, rules, host, port = load_config(args.config)
|
||||
server = Server(store, rules)
|
||||
listener = await asyncio.start_server(server.handle_connection, host, port)
|
||||
|
||||
log.info("exception-injector listening on %s:%d with %d rule(s)", host, port, len(rules))
|
||||
for r in rules:
|
||||
log.info(" rule: fc=%d addr=%d -> exception 0x%02X (%s)",
|
||||
r.fc, r.address, r.exception, r.description)
|
||||
|
||||
async with listener:
|
||||
await listener.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(asyncio.run(main(sys.argv[1:])))
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"_comment": "Modbus exception-injection profile — feeds exception_injector.py (not pymodbus). Rules match by (fc, address). HR[0-31] are address-as-value for the happy-path reads; HR[1000..1010] + coils[2000..2010] carry per-exception-code rules. Every code in the driver's MapModbusExceptionToStatus table that pymodbus can't naturally emit has a dedicated slot. See Docker/README.md §exception injection.",
|
||||
|
||||
"listen": { "host": "0.0.0.0", "port": 5020 },
|
||||
|
||||
"seeds": {
|
||||
"hr": {
|
||||
"0": 0, "1": 1, "2": 2, "3": 3,
|
||||
"4": 4, "5": 5, "6": 6, "7": 7,
|
||||
"8": 8, "9": 9, "10": 10, "11": 11,
|
||||
"12": 12, "13": 13, "14": 14, "15": 15,
|
||||
"16": 16, "17": 17, "18": 18, "19": 19,
|
||||
"20": 20, "21": 21, "22": 22, "23": 23,
|
||||
"24": 24, "25": 25, "26": 26, "27": 27,
|
||||
"28": 28, "29": 29, "30": 30, "31": 31
|
||||
}
|
||||
},
|
||||
|
||||
"rules": [
|
||||
{ "fc": 3, "address": 1000, "exception": 1, "description": "FC03 @1000 -> Illegal Function (0x01)" },
|
||||
{ "fc": 3, "address": 1001, "exception": 2, "description": "FC03 @1001 -> Illegal Data Address (0x02)" },
|
||||
{ "fc": 3, "address": 1002, "exception": 3, "description": "FC03 @1002 -> Illegal Data Value (0x03)" },
|
||||
{ "fc": 3, "address": 1003, "exception": 4, "description": "FC03 @1003 -> Server Failure (0x04)" },
|
||||
{ "fc": 3, "address": 1004, "exception": 5, "description": "FC03 @1004 -> Acknowledge (0x05)" },
|
||||
{ "fc": 3, "address": 1005, "exception": 6, "description": "FC03 @1005 -> Server Busy (0x06)" },
|
||||
{ "fc": 3, "address": 1006, "exception": 10, "description": "FC03 @1006 -> Gateway Path Unavailable (0x0A)" },
|
||||
{ "fc": 3, "address": 1007, "exception": 11, "description": "FC03 @1007 -> Gateway Target No Response (0x0B)" },
|
||||
|
||||
{ "fc": 6, "address": 2000, "exception": 4, "description": "FC06 @2000 -> Server Failure (0x04, e.g. CPU in PROGRAM mode)" },
|
||||
{ "fc": 6, "address": 2001, "exception": 6, "description": "FC06 @2001 -> Server Busy (0x06)" },
|
||||
|
||||
{ "fc": 16, "address": 3000, "exception": 4, "description": "FC16 @3000 -> Server Failure (0x04)" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end verification that the driver's <c>MapModbusExceptionToStatus</c>
|
||||
/// translation is wire-correct for every exception code in the mapping table —
|
||||
/// not just 0x02, which is the only code the pymodbus simulator naturally emits.
|
||||
/// Drives the standalone <c>exception_injector.py</c> server (<c>exception_injection</c>
|
||||
/// compose profile) at each of the rule addresses in
|
||||
/// <c>Docker/profiles/exception_injection.json</c> and asserts the driver surfaces
|
||||
/// the expected OPC UA StatusCode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Why integration coverage on top of the unit tests: the unit tests prove the
|
||||
/// translation function is correct; these prove the driver wires it through on
|
||||
/// the read + write paths unchanged, after the MBAP header + PDU round-trip
|
||||
/// (where a subtle framing bug could swallow or misclassify the exception).
|
||||
/// </remarks>
|
||||
[Collection(ModbusSimulatorCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "ExceptionInjection")]
|
||||
public sealed class ExceptionInjectionTests(ModbusSimulatorFixture sim)
|
||||
{
|
||||
private const uint StatusGood = 0u;
|
||||
private const uint StatusBadOutOfRange = 0x803C0000u;
|
||||
private const uint StatusBadNotSupported = 0x803D0000u;
|
||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||
private const uint StatusBadCommunicationError = 0x80050000u;
|
||||
|
||||
private void SkipUnlessInjectorLive()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
var profile = Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE");
|
||||
if (!string.Equals(profile, "exception_injection", StringComparison.OrdinalIgnoreCase))
|
||||
Assert.Skip("MODBUS_SIM_PROFILE != exception_injection — skipping. " +
|
||||
"Start the fixture with --profile exception_injection.");
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<DataValueSnapshot>> ReadSingleAsync(int address, string tagName)
|
||||
{
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(tagName,
|
||||
ModbusRegion.HoldingRegisters, Address: (ushort)address,
|
||||
DataType: ModbusDataType.UInt16, Writable: false),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-exc");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
return await driver.ReadAsync([tagName], TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1000, StatusBadNotSupported, "exc 0x01 (Illegal Function) -> BadNotSupported")]
|
||||
[InlineData(1001, StatusBadOutOfRange, "exc 0x02 (Illegal Data Address) -> BadOutOfRange")]
|
||||
[InlineData(1002, StatusBadOutOfRange, "exc 0x03 (Illegal Data Value) -> BadOutOfRange")]
|
||||
[InlineData(1003, StatusBadDeviceFailure, "exc 0x04 (Server Failure) -> BadDeviceFailure")]
|
||||
[InlineData(1004, StatusBadDeviceFailure, "exc 0x05 (Acknowledge / long op) -> BadDeviceFailure")]
|
||||
[InlineData(1005, StatusBadDeviceFailure, "exc 0x06 (Server Busy) -> BadDeviceFailure")]
|
||||
[InlineData(1006, StatusBadCommunicationError, "exc 0x0A (Gateway Path Unavailable) -> BadCommunicationError")]
|
||||
[InlineData(1007, StatusBadCommunicationError, "exc 0x0B (Gateway Target No Response) -> BadCommunicationError")]
|
||||
public async Task FC03_read_at_injection_address_surfaces_expected_status(
|
||||
int address, uint expectedStatus, string scenario)
|
||||
{
|
||||
SkipUnlessInjectorLive();
|
||||
var results = await ReadSingleAsync(address, $"Injected_{address}");
|
||||
results[0].StatusCode.ShouldBe(expectedStatus, scenario);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FC03_read_at_non_injected_address_returns_Good()
|
||||
{
|
||||
// Sanity: HR[0..31] are seeded with address-as-value in the profile. A read at
|
||||
// one of those addresses must come back Good (0) — otherwise the injector is
|
||||
// misbehaving and every other assertion in this class is uninformative.
|
||||
SkipUnlessInjectorLive();
|
||||
var results = await ReadSingleAsync(address: 5, tagName: "Healthy_5");
|
||||
results[0].StatusCode.ShouldBe(StatusGood);
|
||||
results[0].Value.ShouldBe((ushort)5);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2000, StatusBadDeviceFailure, "exc 0x04 on FC06 -> BadDeviceFailure (CPU in PROGRAM mode)")]
|
||||
[InlineData(2001, StatusBadDeviceFailure, "exc 0x06 on FC06 -> BadDeviceFailure (Server Busy)")]
|
||||
public async Task FC06_write_at_injection_address_surfaces_expected_status(
|
||||
int address, uint expectedStatus, string scenario)
|
||||
{
|
||||
SkipUnlessInjectorLive();
|
||||
var tag = $"InjectedWrite_{address}";
|
||||
var opts = new ModbusDriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
UnitId = 1,
|
||||
Timeout = TimeSpan.FromSeconds(2),
|
||||
Tags =
|
||||
[
|
||||
new ModbusTagDefinition(tag,
|
||||
ModbusRegion.HoldingRegisters, Address: (ushort)address,
|
||||
DataType: ModbusDataType.UInt16, Writable: true),
|
||||
],
|
||||
Probe = new ModbusProbeOptions { Enabled = false },
|
||||
};
|
||||
await using var driver = new ModbusDriver(opts, driverInstanceId: "modbus-exc-write");
|
||||
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var writes = await driver.WriteAsync(
|
||||
[new WriteRequest(tag, (ushort)42)],
|
||||
TestContext.Current.CancellationToken);
|
||||
writes[0].StatusCode.ShouldBe(expectedStatus, scenario);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user