Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2844180865 | |||
| d3ab2bfbaf | |||
| 88e773af36 | |||
| f35ebd7aaf | |||
| 0cbb82e466 | |||
| 7b6884031d | |||
| 7ff7a60ae0 | |||
| 8faa2bf23d | |||
| 2099713ed8 | |||
| c05ffc7b39 | |||
| 60017177cb | |||
| 26bae36f8b | |||
| 368390ea9d | |||
| 8f950722c6 | |||
| 1d729fb0f8 | |||
| 0b99aceacb | |||
| d57b42bcd6 | |||
| 5e87f7e16f | |||
| 695fa6408b |
@@ -42,3 +42,9 @@ config_cache*.db
|
||||
|
||||
# Client CLI/UI runtime scratch (last-connected endpoint cache)
|
||||
session.dat
|
||||
|
||||
# Secrets / local credentials — never commit
|
||||
sql_login.txt
|
||||
|
||||
# OPC UA certificate store (runtime PKI: own/trusted/issued/rejected certs + keys)
|
||||
src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/
|
||||
|
||||
@@ -79,11 +79,11 @@
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageVersion Include="Polly.Core" Version="8.6.6" />
|
||||
<PackageVersion Include="S7netplus" Version="0.20.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.1" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
@@ -96,7 +96,13 @@
|
||||
<PackageVersion Include="xunit" Version="2.9.2" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.0.2" />
|
||||
<PackageVersion Include="xunit.v3" Version="1.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.Akka" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Health.EntityFrameworkCore" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Telemetry.Serilog" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.1.0" />
|
||||
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,7 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
<add key="local-mxgw" value="./nuget-packages" />
|
||||
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
<packageSource key="local-mxgw">
|
||||
<package pattern="ZB.MOM.WW.MxGateway.*" />
|
||||
</packageSource>
|
||||
<packageSource key="dohertj2-gitea">
|
||||
<package pattern="ZB.MOM.WW.Health" />
|
||||
<package pattern="ZB.MOM.WW.Health.*" />
|
||||
<package pattern="ZB.MOM.WW.Telemetry" />
|
||||
<package pattern="ZB.MOM.WW.Telemetry.*" />
|
||||
<package pattern="ZB.MOM.WW.Configuration" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# Alarms D.1 — smoke artifact
|
||||
|
||||
> **Status (2026-05-29): alarm-source leg VERIFIED. Historian-write leg still
|
||||
> pending the Windows sidecar + live AVEVA Historian.**
|
||||
>
|
||||
> **Re-confirmed 2026-05-31** against the same gateway (`http://10.100.0.48:5120`):
|
||||
> the Skip-gated live test passed again, pulling a native `Raise` transition
|
||||
> (`Galaxy!TestArea.TestMachine_001.TestAlarm001`, raw sev 500 → OPC UA 750/High,
|
||||
> category `TestArea`, operator comment `Test alarm #1`) through the production
|
||||
> consumer. Independent re-run, not the original capture.
|
||||
>
|
||||
> This is the D.1 deliverable called for by `docs/plans/alarms-worker-wiring-plan.md`
|
||||
> — captured evidence that a live Galaxy alarm reaches lmxopcua through the native
|
||||
> gateway path (not the sub-attribute fallback). It supersedes the "A.2 blocked"
|
||||
> banners in `alarms-over-gateway.md` / `alarms-worker-wiring-plan.md`, which were
|
||||
> written 2026-04-30 before the gateway's alarm feed was working.
|
||||
|
||||
## What was verified
|
||||
|
||||
The mxaccessgw gateway **does** serve native MxAccess alarms today, and the lmxopcua
|
||||
consumer ingests them with full fidelity — **including operator-comment**, the field
|
||||
the 2026-04-30 plan flagged as "the only v1 regression."
|
||||
|
||||
Verified from the macOS dev box against the live gateway at `http://10.100.0.48:5120`
|
||||
(reachable; `nc -z` succeeds). No acknowledge / no writes were issued — read-only
|
||||
`StreamAlarms`.
|
||||
|
||||
### 1. Gateway boundary — raw `StreamAlarms` (`ZB.MOM.WW.MxGateway.Client`)
|
||||
|
||||
A standalone client streamed the active-alarm snapshot: **20 active alarms**, each
|
||||
carrying native metadata. Sample (one of 20):
|
||||
|
||||
```json
|
||||
{ "alarmFullReference": "Galaxy!TestArea.TestMachine_001.TestAlarm001",
|
||||
"sourceObjectReference": "TestMachine_001.TestAlarm001",
|
||||
"alarmTypeName": "DSC", "severity": 500,
|
||||
"currentState": "ALARM_CONDITION_STATE_ACTIVE", "category": "TestArea",
|
||||
"lastTransitionTimestamp": "2026-05-24T16:04:10.856Z",
|
||||
"operatorComment": "Test alarm #1" }
|
||||
```
|
||||
|
||||
Followed by the `SnapshotComplete` marker. `operatorComment`, `category`, `severity`,
|
||||
`currentState`, and `lastTransitionTimestamp` are all populated.
|
||||
|
||||
### 2. lmxopcua consumer — `GatewayGalaxyAlarmFeed` → `GalaxyAlarmTransition`
|
||||
|
||||
The Skip-gated live test
|
||||
`Runtime/GatewayGalaxyAlarmFeedLiveTests.Live_gateway_delivers_native_alarm_transitions_through_the_consumer`
|
||||
wires the real `MxGatewayClient.StreamAlarmsAsync` into the production consumer seam
|
||||
and **passes**. Captured output (`D1_SMOKE_OUT`):
|
||||
|
||||
```
|
||||
# consumer transitions observed: 2+
|
||||
Raise Galaxy!TestArea.TestMachine_001.TestAlarm001 | sev=750(High) raw=500 | cat=TestArea | comment='Test alarm #1' | xitionUtc=2026-05-24T16:04:10.856Z
|
||||
Raise Galaxy!TestArea.TestMachine_003.TestAlarm001 | sev=750(High) raw=500 | cat=TestArea | comment='Test alarm #1' | xitionUtc=2026-05-07T18:14:00.594Z
|
||||
```
|
||||
|
||||
The consumer preserves `operatorComment` + `category` + transition timestamp and
|
||||
applies the OPC UA severity-bucket mapping (`MxAccessSeverityMapper`: raw 500 →
|
||||
OPC UA 750, bucket `High`).
|
||||
|
||||
### 3. Full chain to the OPC UA Part 9 surface (code-path verified)
|
||||
|
||||
`GalaxyDriver.OnAlarmFeedTransition` maps `GalaxyAlarmTransition` →
|
||||
`AlarmEventArgs`, carrying `OperatorComment`, `OriginalRaiseTimestampUtc`,
|
||||
`AlarmCategory`, and the severity bucket onto `IAlarmSource.OnAlarmEvent`.
|
||||
`AlarmEventArgs` already declares those fields — so the **E.7 contract extension is
|
||||
done**, not pending. The server's Part-9 condition layer consumes `IAlarmSource`
|
||||
via `AlarmSurfaceInvoker` → `GenericDriverNodeManager`. Unit coverage:
|
||||
`GalaxyDriverAlarmSourceTests`, `GatewayGalaxyAlarmFeedTests`.
|
||||
|
||||
## How to re-run
|
||||
|
||||
```bash
|
||||
export MXGW_ENDPOINT="http://10.100.0.48:5120"
|
||||
export GALAXY_MXGW_API_KEY="<dev key from docker-dev/docker-compose.yml>"
|
||||
export D1_SMOKE_OUT="/tmp/d1-consumer-transitions.txt" # optional capture
|
||||
dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests \
|
||||
--filter "FullyQualifiedName~GatewayGalaxyAlarmFeedLiveTests"
|
||||
```
|
||||
|
||||
Without the env vars the test `Skip`s, so normal `dotnet test` runs are unaffected.
|
||||
|
||||
## Not covered here (still open)
|
||||
|
||||
1. **Scripted-alarm historian write-back → AVEVA Historian** (C.1's live leg). The
|
||||
`SdkAlarmHistorianWriteBackend` (real `HistorianAccess.AddStreamedValue` path) is
|
||||
implemented and unit-tested, but its `Live_*` write smoke needs the Windows
|
||||
historian sidecar + a live AVEVA Historian — neither reachable from the macOS dev
|
||||
box. Capture this leg on the Windows parity rig.
|
||||
2. **Running-server → OPC UA A&C client round-trip.** This artifact proves the driver
|
||||
consumer end; it does not exercise a full OtOpcUa server surfacing the condition to
|
||||
an OPC UA client, because the docker-dev stack stubs the Galaxy driver on Linux
|
||||
(`DriverInstanceActor.ShouldStub`). Capture on the Windows parity rig (or a Linux
|
||||
host with `ShouldStub` overridden to point the real driver at the gateway).
|
||||
|
||||
## Mechanism — true MxAccess alarm-event support
|
||||
|
||||
The gateway delivers these alarms via **true MxAccess alarm-event support** in the
|
||||
mxaccessgw .NET client — a real alarm-event subscription, **not** the value-driven
|
||||
sub-attribute fallback. (Confirmed by the gateway maintainer; the client-side stream
|
||||
check above can only observe the resulting feed, which is why this artifact records the
|
||||
mechanism here rather than inferring it.) So A.2 is implemented as originally specified:
|
||||
`MX_EVENT_FAMILY_ON_ALARM_TRANSITION` carries genuine native alarm-event metadata, and
|
||||
the operator-comment / original-raise-time / category fields are first-class — not
|
||||
reconstructed from attribute reads.
|
||||
@@ -9,24 +9,41 @@
|
||||
> the new RPCs; the sub-attribute fallback path keeps Galaxy alarms
|
||||
> functional today.
|
||||
>
|
||||
> ⚠️ **Worker-side native alarm subscription blocked on a dev-rig
|
||||
> finding (2026-04-30):** the MXAccess COM Toolkit at
|
||||
> ✅ **UPDATE 2026-05-29 — native alarm feed VERIFIED working; the
|
||||
> 2026-04-30 "blocked" finding below is superseded.** A live
|
||||
> `StreamAlarms` check against the gateway at `10.100.0.48:5120`
|
||||
> returned the active-alarm snapshot (20 alarms) with full native
|
||||
> metadata — `severity`, `category`, `currentState`,
|
||||
> `lastTransitionTimestamp`, **and `operatorComment`** (the field the
|
||||
> note below called "the only v1 regression"). The lmxopcua consumer
|
||||
> (`GatewayGalaxyAlarmFeed` → `GalaxyAlarmTransition` →
|
||||
> `AlarmEventArgs` → `IAlarmSource`) ingests it with full fidelity and
|
||||
> the OPC UA severity-bucket mapping applied — proven by the passing
|
||||
> Skip-gated live test `GatewayGalaxyAlarmFeedLiveTests`. `AlarmEventArgs`
|
||||
> already carries operator-comment / original-raise-time / category, so
|
||||
> **E.7 is done too**. See `docs/plans/alarms-d1-smoke-artifact.md` for
|
||||
> the captured evidence. The gateway delivers this via **true MxAccess
|
||||
> alarm-event support** in the mxaccessgw .NET client (a real
|
||||
> alarm-event subscription — **not** the sub-attribute fallback), so A.2
|
||||
> is implemented as originally specified. Still open: the scripted-alarm
|
||||
> → AVEVA Historian write-back live smoke (C.1's `Live_*` leg) and a full
|
||||
> running-server → OPC UA A&C round-trip — both need the Windows parity rig.
|
||||
>
|
||||
> ⚠️ **[SUPERSEDED — kept for history] Worker-side native alarm
|
||||
> subscription blocked on a dev-rig finding (2026-04-30):** the MXAccess
|
||||
> COM Toolkit at
|
||||
> `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`
|
||||
> exposes no alarm-event family — only `OnDataChange`,
|
||||
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange`.
|
||||
> exposed no alarm-event family — only `OnDataChange`,
|
||||
> `OnWriteComplete`, `OperationComplete`, `OnBufferedDataChange` — and
|
||||
> AVEVA's `aaAlarmManagedClient` / `ArchestrAAlarmsAndEvents.SDK`
|
||||
> assemblies are x64-only and incompatible with the worker's x86
|
||||
> bitness. **Operator decision needed before
|
||||
> `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` carries any events:** either
|
||||
> accept the value-driven sub-attribute path as the production
|
||||
> architecture (operator-comment fidelity is the only v1 regression)
|
||||
> or add an x64 alarm-helper sub-process alongside the worker. See
|
||||
> `src/MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs` in the
|
||||
> mxaccessgw repo for the architectural notes. Live
|
||||
> `aahClientManaged` alarm-event write call site
|
||||
> (`SdkAlarmHistorianWriteBackend` placeholder from PR C.1) and the
|
||||
> D.1 smoke artifact ship once those decisions resolve. The
|
||||
> remainder of this document is preserved as the design record.
|
||||
> assemblies are x64-only vs. the worker's x86 bitness. The operator
|
||||
> decision (accept the value-driven sub-attribute path, or add an x64
|
||||
> alarm-helper sub-process) has since been resolved on the gateway side
|
||||
> — `MX_EVENT_FAMILY_ON_ALARM_TRANSITION` now carries events (verified
|
||||
> above). The C.1 `SdkAlarmHistorianWriteBackend` is **no longer a
|
||||
> placeholder** — it writes through the real
|
||||
> `HistorianAccess.AddStreamedValue` path (only its live-rig write
|
||||
> smoke remains).
|
||||
|
||||
Coordinated epic across two repos:
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Alarms Worker Wiring Plan
|
||||
|
||||
> ✅ **UPDATE 2026-05-29 — the blocker below is RESOLVED on the gateway side; this
|
||||
> plan is largely complete.** A live `StreamAlarms` check against `10.100.0.48:5120`
|
||||
> returns the active-alarm snapshot with full native metadata **including
|
||||
> `operatorComment`**, and the lmxopcua consumer ingests it end-to-end (passing live
|
||||
> test `GatewayGalaxyAlarmFeedLiveTests`). So **A.2 / A.3 / A.4** are functionally done
|
||||
> at the gateway boundary (the worker now emits native alarm transitions and the client
|
||||
> exposes `AcknowledgeAlarm` / `QueryActiveAlarms` RPCs). **C.1** ships real code
|
||||
> (`SdkAlarmHistorianWriteBackend` → `HistorianAccess.AddStreamedValue`). **D.1**'s
|
||||
> alarm-source leg is captured in `docs/plans/alarms-d1-smoke-artifact.md`. Only two
|
||||
> things remain, both needing the Windows parity rig: C.1's live historian-write smoke
|
||||
> and a full running-server → OPC UA A&C round-trip. The per-item detail below is kept
|
||||
> as the historical record of the original blocked state.
|
||||
>
|
||||
> **Context**: The alarms-over-gateway epic shipped 19 PRs across the
|
||||
> `lmxopcua` and `mxaccessgw` repos (merged 2026-04-30). Contracts are live;
|
||||
> the sub-attribute fallback path keeps Galaxy alarms functional today. Four
|
||||
@@ -16,7 +29,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Dev-rig finding that blocks everything (2026-04-30)
|
||||
## Dev-rig finding that blocks everything (2026-04-30) — [SUPERSEDED 2026-05-29]
|
||||
|
||||
During PR A.2 work the following was discovered on the dev box:
|
||||
|
||||
@@ -318,16 +331,20 @@ fallback as production).
|
||||
|
||||
## Summary of blocks
|
||||
|
||||
| Item | Blocked by | Estimated effort once unblocked |
|
||||
|------|-----------|--------------------------------|
|
||||
| A.2 | Architectural decision (x64 alarm-helper vs. sub-attribute fallback as production) | 2–3 days implementation; 1 day tests |
|
||||
| A.3 | A.2 delivering WorkerEvent bodies | 1–2 days |
|
||||
| A.4 | A.2 (active-alarm query needs AlarmClient session) | 1 day |
|
||||
| C.1 | aahClientManaged SDK access (available on dev box); NOT blocked by A.2 | 1–2 days |
|
||||
| D.1 | A.2 + A.3 + C.1 all passing on parity rig | 0.5 day (smoke + artifact capture) |
|
||||
> **Resolved as of 2026-05-29** — see the update banner at the top and
|
||||
> `docs/plans/alarms-d1-smoke-artifact.md`. Original status table kept for history.
|
||||
|
||||
C.1 can proceed in parallel with A.2 / A.3 since the sidecar's `aahClientManaged`
|
||||
is x64 and does not share the worker bitness constraint.
|
||||
| Item | Status (2026-05-29) | Original block |
|
||||
|------|--------------------|----------------|
|
||||
| A.2 | ✅ **True MxAccess alarm-event support** in the gateway client (real alarm-event subscription, not the sub-attribute fallback); verified via live `StreamAlarms` with operator-comment fidelity | Architectural decision (x64 alarm-helper vs. sub-attribute fallback) |
|
||||
| A.3 | ✅ Dispatch + `AcknowledgeAlarm` RPC present on the client surface | A.2 delivering WorkerEvent bodies |
|
||||
| A.4 | ✅ `QueryActiveAlarms` RPC present on the client surface | A.2 (active-alarm query needs AlarmClient session) |
|
||||
| C.1 | ✅ Code shipped (`AddStreamedValue` path); ⏳ live historian-write smoke needs the Windows rig | aahClientManaged SDK access |
|
||||
| D.1 | ◑ Alarm-source leg captured (`alarms-d1-smoke-artifact.md`); ⏳ historian-write leg + full server→A&C round-trip need the Windows rig | A.2 + A.3 + C.1 all passing on parity rig |
|
||||
|
||||
The gateway delivers operator-comment fidelity through **true MxAccess alarm-event
|
||||
support** in the mxaccessgw .NET client — a real alarm-event subscription, not the
|
||||
value-driven sub-attribute path. The sub-attribute fallback is now legacy.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Fail-fast startup validator for <see cref="LdapOptions"/>, built on the shared
|
||||
/// <c>ZB.MOM.WW.Configuration</c> <see cref="OptionsValidatorBase{TOptions}"/>. When LDAP login
|
||||
/// is enabled, <c>Server</c> and <c>SearchBase</c> must be set and <c>Port</c> must be a valid
|
||||
/// TCP port; when disabled — or when <c>DevStubMode</c> bypasses the real bind — all checks are
|
||||
/// skipped. <c>ServiceAccountDn</c>/<c>Password</c> are
|
||||
/// intentionally not required — an empty pair selects the direct-bind path (see
|
||||
/// <see cref="LdapOptions.ServiceAccountDn"/>). Failure messages use <c>"Ldap:"</c> as a
|
||||
/// human-readable field prefix — not the literal bound section path, which is
|
||||
/// <c>Security:Ldap</c> (see <see cref="LdapOptions.SectionName"/>).
|
||||
/// </summary>
|
||||
public sealed class LdapOptionsValidator : OptionsValidatorBase<LdapOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, LdapOptions options)
|
||||
{
|
||||
// Skip the real-LDAP field checks when LDAP login is disabled, or when the dev stub is
|
||||
// active — DevStubMode bypasses the real bind entirely, so Server/SearchBase/Port are
|
||||
// irrelevant and would otherwise force dev configs to carry meaningless placeholders.
|
||||
if (!options.Enabled || options.DevStubMode) return;
|
||||
|
||||
builder.RequireThat(!string.IsNullOrWhiteSpace(options.Server),
|
||||
"Ldap:Server is required when LDAP login is enabled.");
|
||||
builder.RequireThat(!string.IsNullOrWhiteSpace(options.SearchBase),
|
||||
"Ldap:SearchBase is required when LDAP login is enabled.");
|
||||
builder.Port(options.Port, "Ldap:Port");
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Fail-fast startup validator for <see cref="OpcUaApplicationHostOptions"/>, built on the
|
||||
/// shared <c>ZB.MOM.WW.Configuration</c> <see cref="OptionsValidatorBase{TOptions}"/>. The C#
|
||||
/// defaults are all valid, so a host with no explicit <c>"OpcUa"</c> section passes untouched;
|
||||
/// the validator exists to reject explicit prod/env overrides before the OPC UA SDK boots.
|
||||
/// Identity/transport essentials (<c>ApplicationName</c>, <c>ApplicationUri</c>,
|
||||
/// <c>PublicHostname</c>, <c>PkiStoreRoot</c>, <c>OpcUaPort</c>) must be present/valid and at
|
||||
/// least one security profile must be enabled. Optional fields — <c>ApplicationConfigPath</c>,
|
||||
/// <c>PeerApplicationUris</c>, <c>AutoAcceptUntrustedClientCertificates</c>, and
|
||||
/// <c>ProductUri</c> — are intentionally not validated. Failure messages carry the real
|
||||
/// <c>"OpcUa:"</c> section prefix matching the bound configuration section.
|
||||
/// </summary>
|
||||
public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<OpcUaApplicationHostOptions>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Validate(ValidationBuilder builder, OpcUaApplicationHostOptions o)
|
||||
{
|
||||
builder.Required(o.ApplicationName, "OpcUa:ApplicationName");
|
||||
builder.Required(o.ApplicationUri, "OpcUa:ApplicationUri");
|
||||
builder.Required(o.PublicHostname, "OpcUa:PublicHostname");
|
||||
builder.Required(o.PkiStoreRoot, "OpcUa:PkiStoreRoot");
|
||||
builder.Port(o.OpcUaPort, "OpcUa:OpcUaPort");
|
||||
// EnabledSecurityProfiles is declared as IList<T> — that interface does not derive from
|
||||
// IReadOnlyCollection<T>, so it can't bind to MinCount's IReadOnlyCollection<T> parameter
|
||||
// directly. ToList() bridges to the shared primitive while preserving the count (and message).
|
||||
builder.MinCount(o.EnabledSecurityProfiles?.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Reports Healthy on the admin-role leader, Degraded on a non-leader admin member. Used by
|
||||
/// the <c>/health/active</c> endpoint so external load balancers can route admin-singleton
|
||||
/// traffic to the current leader (cookie sessions still work on either node — DataProtection
|
||||
/// keys are shared).
|
||||
/// </summary>
|
||||
public sealed class AdminRoleLeaderHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IClusterRoleInfo _roleInfo;
|
||||
|
||||
/// <summary>Initializes a new instance of the AdminRoleLeaderHealthCheck class.</summary>
|
||||
/// <param name="roleInfo">The cluster role information provider.</param>
|
||||
public AdminRoleLeaderHealthCheck(IClusterRoleInfo roleInfo)
|
||||
{
|
||||
_roleInfo = roleInfo;
|
||||
}
|
||||
|
||||
/// <summary>Checks the health status of the admin role leader.</summary>
|
||||
/// <param name="context">The health check context.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task representing the health check operation.</returns>
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_roleInfo.HasRole("admin"))
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Node does not carry admin role"));
|
||||
|
||||
var leader = _roleInfo.RoleLeader("admin");
|
||||
var isLeader = leader is not null && leader.Value.Equals(_roleInfo.LocalNode);
|
||||
|
||||
return Task.FromResult(isLeader
|
||||
? HealthCheckResult.Healthy($"Admin leader ({_roleInfo.LocalNode})")
|
||||
: HealthCheckResult.Degraded($"Admin member but not leader (leader={leader?.Value ?? "<unknown>"})"));
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using Akka.Actor;
|
||||
using Akka.Cluster;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
public sealed class AkkaClusterHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly ActorSystem _system;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the AkkaClusterHealthCheck class.
|
||||
/// </summary>
|
||||
/// <param name="system">The Akka actor system to check cluster health for.</param>
|
||||
public AkkaClusterHealthCheck(ActorSystem system)
|
||||
{
|
||||
_system = system;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the health of the Akka cluster asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="context">The health check context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cluster = Akka.Cluster.Cluster.Get(_system);
|
||||
var selfUp = cluster.State.Members.Any(m =>
|
||||
m.Address == cluster.SelfAddress && m.Status == MemberStatus.Up);
|
||||
|
||||
return Task.FromResult(selfUp
|
||||
? HealthCheckResult.Healthy($"Self Up; {cluster.State.Members.Count} member(s)")
|
||||
: HealthCheckResult.Degraded("Self not yet Up in cluster"));
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
public sealed class DatabaseHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly IDbContextFactory<OtOpcUaConfigDbContext> _dbFactory;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DatabaseHealthCheck"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbFactory">The database context factory for the config database.</param>
|
||||
public DatabaseHealthCheck(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the health of the configuration database.
|
||||
/// </summary>
|
||||
/// <param name="context">The health check context.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
await db.Deployments.AsNoTracking().Take(1).ToListAsync(cancellationToken);
|
||||
return HealthCheckResult.Healthy("ConfigDb reachable");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("ConfigDb unreachable", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,40 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using ZB.MOM.WW.Health;
|
||||
using ZB.MOM.WW.Health.Akka;
|
||||
using ZB.MOM.WW.Health.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
|
||||
public static class HealthEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the standard ASP.NET Core health-check infrastructure plus the OtOpcUa-specific
|
||||
/// probes. Mirrors ScadaLink's three-tier pattern: <c>ready</c> = boot ok; <c>active</c> =
|
||||
/// fully serving traffic; <c>healthz</c> = bare process liveness.
|
||||
/// Registers the shared ZB.MOM.WW health probes. Tier semantics preserved: configdb + akka on
|
||||
/// ready+active; admin-leader on active only.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to register health checks with.</param>
|
||||
public static IServiceCollection AddOtOpcUaHealth(this IServiceCollection services)
|
||||
{
|
||||
services.AddHealthChecks()
|
||||
.AddCheck<DatabaseHealthCheck>("configdb", tags: new[] { "ready", "active" })
|
||||
.AddCheck<AkkaClusterHealthCheck>("akka", tags: new[] { "ready", "active" })
|
||||
.AddCheck<AdminRoleLeaderHealthCheck>("admin-leader", tags: new[] { "active" });
|
||||
.AddTypeActivatedCheck<DatabaseHealthCheck<OtOpcUaConfigDbContext>>(
|
||||
"configdb",
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
|
||||
args: new DatabaseHealthCheckOptions<OtOpcUaConfigDbContext>
|
||||
{
|
||||
ProbeQuery = static (db, ct) => db.Deployments.AsNoTracking().Take(1).ToListAsync(ct),
|
||||
})
|
||||
.AddTypeActivatedCheck<AkkaClusterHealthCheck>(
|
||||
"akka",
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Ready, ZbHealthTags.Active },
|
||||
args: AkkaClusterStatusPolicy.OtOpcUaCompat)
|
||||
.AddTypeActivatedCheck<ActiveNodeHealthCheck>(
|
||||
"admin-leader",
|
||||
failureStatus: null,
|
||||
tags: new[] { ZbHealthTags.Active },
|
||||
args: "admin");
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -27,21 +42,7 @@ public static class HealthEndpoints
|
||||
/// <param name="app">The endpoint route builder.</param>
|
||||
public static IEndpointRouteBuilder MapOtOpcUaHealth(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// AllowAnonymous on all three — Traefik / k8s liveness probes / load-balancers
|
||||
// hit these without credentials. Without it the AddOtOpcUaAuth fallback policy
|
||||
// 401s every probe and Traefik marks every backend unhealthy.
|
||||
app.MapHealthChecks("/health/ready", new HealthCheckOptions
|
||||
{
|
||||
Predicate = c => c.Tags.Contains("ready"),
|
||||
}).AllowAnonymous();
|
||||
app.MapHealthChecks("/health/active", new HealthCheckOptions
|
||||
{
|
||||
Predicate = c => c.Tags.Contains("active"),
|
||||
}).AllowAnonymous();
|
||||
app.MapHealthChecks("/healthz", new HealthCheckOptions
|
||||
{
|
||||
Predicate = _ => false, // process-liveness only — no probes run.
|
||||
}).AllowAnonymous();
|
||||
app.MapZbHealth();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Observability;
|
||||
using ZB.MOM.WW.Telemetry;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
|
||||
@@ -15,16 +15,25 @@ public static class ObservabilityExtensions
|
||||
{
|
||||
/// <summary>Adds OtOpcUa observability (metrics and tracing) to the service collection.</summary>
|
||||
/// <param name="services">The service collection to add observability services to.</param>
|
||||
public static IServiceCollection AddOtOpcUaObservability(this IServiceCollection services)
|
||||
/// <param name="configuration">
|
||||
/// Configuration read for the opt-in OTLP exporter. <c>OtOpcUa:Telemetry:Exporter</c>
|
||||
/// (parsed case-insensitively to <see cref="ZbExporter"/>) switches to OTLP when set to
|
||||
/// <c>Otlp</c>; <c>OtOpcUa:Telemetry:OtlpEndpoint</c> sets the OTLP endpoint. With no
|
||||
/// config the exporter stays Prometheus (the default).
|
||||
/// </param>
|
||||
public static IServiceCollection AddOtOpcUaObservability(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddOpenTelemetry()
|
||||
.WithMetrics(b => b
|
||||
.AddMeter(OtOpcUaTelemetry.MeterName)
|
||||
.AddPrometheusExporter())
|
||||
.WithTracing(b => b
|
||||
.AddSource(OtOpcUaTelemetry.ActivitySourceName));
|
||||
|
||||
return services;
|
||||
return services.AddZbTelemetry(o =>
|
||||
{
|
||||
o.ServiceName = "otopcua";
|
||||
o.Meters = [OtOpcUaTelemetry.MeterName];
|
||||
o.ActivitySources = [OtOpcUaTelemetry.ActivitySourceName];
|
||||
if (Enum.TryParse<ZbExporter>(configuration["OtOpcUa:Telemetry:Exporter"], ignoreCase: true, out var exporter))
|
||||
o.Exporter = exporter;
|
||||
var otlp = configuration["OtOpcUa:Telemetry:OtlpEndpoint"];
|
||||
if (!string.IsNullOrWhiteSpace(otlp))
|
||||
o.OtlpEndpoint = otlp;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -35,7 +44,7 @@ public static class ObservabilityExtensions
|
||||
/// <param name="app">The endpoint route builder.</param>
|
||||
public static IEndpointRouteBuilder MapOtOpcUaMetrics(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPrometheusScrapingEndpoint("/metrics");
|
||||
app.MapZbMetrics();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
@@ -20,7 +20,7 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposable
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly OpcUaApplicationHostOptions _options;
|
||||
private readonly DeferredAddressSpaceSink _deferredSink;
|
||||
private readonly DeferredServiceLevelPublisher _deferredServiceLevel;
|
||||
private readonly IOpcUaUserAuthenticator _userAuthenticator;
|
||||
@@ -33,19 +33,19 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the OtOpcUaServerHostedService class.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The application configuration.</param>
|
||||
/// <param name="options">The validated OPC UA host options (bound from the <c>OpcUa</c> section and validated at startup via <c>ValidateOnStart</c>).</param>
|
||||
/// <param name="deferredSink">The deferred address space sink that receives the real sink once the server is ready.</param>
|
||||
/// <param name="deferredServiceLevel">The deferred service level publisher that receives the real publisher once the server is ready.</param>
|
||||
/// <param name="userAuthenticator">The OPC UA user authenticator.</param>
|
||||
/// <param name="loggerFactory">The logger factory for creating loggers.</param>
|
||||
public OtOpcUaServerHostedService(
|
||||
IConfiguration configuration,
|
||||
IOptions<OpcUaApplicationHostOptions> options,
|
||||
DeferredAddressSpaceSink deferredSink,
|
||||
DeferredServiceLevelPublisher deferredServiceLevel,
|
||||
IOpcUaUserAuthenticator userAuthenticator,
|
||||
ILoggerFactory loggerFactory)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_options = options.Value;
|
||||
_deferredSink = deferredSink;
|
||||
_deferredServiceLevel = deferredServiceLevel;
|
||||
_userAuthenticator = userAuthenticator;
|
||||
@@ -59,12 +59,9 @@ public sealed class OtOpcUaServerHostedService : IHostedService, IAsyncDisposabl
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = new OpcUaApplicationHostOptions();
|
||||
_configuration.GetSection("OpcUa").Bind(options);
|
||||
|
||||
_server = new OtOpcUaSdkServer();
|
||||
_appHost = new OpcUaApplicationHost(
|
||||
options,
|
||||
_options,
|
||||
_loggerFactory.CreateLogger<OpcUaApplicationHost>(),
|
||||
_userAuthenticator);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Akka.Hosting;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
|
||||
@@ -10,16 +11,20 @@ using ZB.MOM.WW.OtOpcUa.ControlPlane;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Host;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Drivers;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Health;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Observability;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
using ZB.MOM.WW.OtOpcUa.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.Telemetry.Serilog;
|
||||
|
||||
// Roles drive the entire conditional wiring below — see ZB.MOM.WW.OtOpcUa.Cluster.RoleParser.
|
||||
var roles = RoleParser.Parse(Environment.GetEnvironmentVariable("OTOPCUA_ROLES"));
|
||||
@@ -45,11 +50,10 @@ var roleSuffix = roles.Length == 0 ? null : string.Join('-', roles.OrderBy(r =>
|
||||
if (roleSuffix is not null)
|
||||
builder.Configuration.AddJsonFile($"appsettings.{roleSuffix}.json", optional: true, reloadOnChange: true);
|
||||
|
||||
// Serilog — rolling daily file sink per CLAUDE.md. Console for local dev.
|
||||
builder.Host.UseSerilog((ctx, lc) => lc
|
||||
.ReadFrom.Configuration(ctx.Configuration)
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/otopcua-.log", rollingInterval: RollingInterval.Day));
|
||||
// Serilog — shared ZB.MOM.WW.Telemetry bootstrap. Sinks (Console + rolling daily file)
|
||||
// now live in appsettings.json (ReadFrom.Configuration); AddZbSerilog layers in the
|
||||
// shared NodeHostname / TraceContext / Redaction enrichers and trace correlation.
|
||||
builder.AddZbSerilog(o => o.ServiceName = "otopcua");
|
||||
|
||||
// Windows-service registration is handled at install time by scripts/install/Install-Services.ps1
|
||||
// (Task 62) rather than in-process, so the binary stays cross-platform-compilable.
|
||||
@@ -96,10 +100,18 @@ if (hasDriver)
|
||||
new RoslynScriptedAlarmEvaluator(sp.GetRequiredService<ILoggerFactory>().CreateLogger<RoslynScriptedAlarmEvaluator>()));
|
||||
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
||||
|
||||
builder.Services.AddOptions<LdapOptions>().Bind(builder.Configuration.GetSection("Ldap"));
|
||||
builder.Services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
|
||||
// TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers this) ends up
|
||||
// with exactly one descriptor; on a driver-only node this is the sole registration.
|
||||
builder.Services.TryAddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||
|
||||
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
|
||||
// and let OtOpcUaServerHostedService consume the validated IOptions instance rather than
|
||||
// re-binding the section imperatively. Defaults pass; this guards explicit prod/env overrides.
|
||||
builder.Services.AddValidatedOptions<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(
|
||||
builder.Configuration, "OpcUa");
|
||||
|
||||
builder.Services.AddHostedService<OtOpcUaServerHostedService>();
|
||||
}
|
||||
|
||||
@@ -135,7 +147,7 @@ if (hasAdmin)
|
||||
}
|
||||
|
||||
builder.Services.AddOtOpcUaHealth();
|
||||
builder.Services.AddOtOpcUaObservability();
|
||||
builder.Services.AddOtOpcUaObservability(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting"/>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Health" />
|
||||
<PackageReference Include="ZB.MOM.WW.Health.Akka" />
|
||||
<PackageReference Include="ZB.MOM.WW.Health.EntityFrameworkCore" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry" />
|
||||
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
|
||||
<PackageReference Include="ZB.MOM.WW.Configuration" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
{}
|
||||
{
|
||||
"Serilog": {
|
||||
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" },
|
||||
{ "Name": "File", "Args": { "path": "logs/otopcua-.log", "rollingInterval": "Day" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapA
|
||||
/// <param name="ct">A cancellation token to observe while waiting for the operation to complete.</param>
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
// Enabled is the master switch and wins over DevStubMode — when LDAP auth is turned off,
|
||||
// refuse to authenticate at all (no bind, no dev-stub bypass).
|
||||
if (!_options.Enabled)
|
||||
return new(false, null, null, [], [], "LDAP authentication is disabled.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new(false, null, null, [], [], "Username is required");
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
|
||||
@@ -2,12 +2,12 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP + role-mapping configuration for the Admin UI. Bound from <c>appsettings.json</c>
|
||||
/// <c>Authentication:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
||||
/// <c>Security:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
||||
/// <c>C:\publish\glauth\auth.md</c>).
|
||||
/// </summary>
|
||||
public sealed class LdapOptions
|
||||
{
|
||||
public const string SectionName = "Authentication:Ldap";
|
||||
public const string SectionName = "Security:Ldap";
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether LDAP authentication is enabled.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
@@ -36,7 +37,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<JwtTokenService>();
|
||||
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||
services.AddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
// TryAdd so a fused admin+driver node (which also registers it in Program.cs for the
|
||||
// driver path) ends up with exactly one descriptor regardless of registration order.
|
||||
services.TryAddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
using ZB.MOM.WW.MxGateway.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
|
||||
|
||||
/// <summary>
|
||||
/// D.1 smoke (alarm-source leg): drives the REAL gateway <c>StreamAlarms</c> feed through the
|
||||
/// production lmxopcua consumer (<see cref="GatewayGalaxyAlarmFeed"/>) and asserts native alarm
|
||||
/// transitions — with operator comment, category, original raise time, and the mapped OPC UA
|
||||
/// severity bucket preserved — reach the driver-side boundary that feeds
|
||||
/// <c>IAlarmSource.OnAlarmEvent</c>.
|
||||
/// <para>
|
||||
/// Skip-gated: runs only when <c>MXGW_ENDPOINT</c> + <c>GALAXY_MXGW_API_KEY</c> are set to a
|
||||
/// reachable gateway. Captured 2026-05-29 against <c>10.100.0.48:5120</c> — see
|
||||
/// <c>docs/plans/alarms-d1-smoke-artifact.md</c>. Set <c>D1_SMOKE_OUT</c> to dump the observed
|
||||
/// transitions to a file for artifact capture.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class GatewayGalaxyAlarmFeedLiveTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Live_gateway_delivers_native_alarm_transitions_through_the_consumer()
|
||||
{
|
||||
var endpoint = Environment.GetEnvironmentVariable("MXGW_ENDPOINT");
|
||||
var apiKey = Environment.GetEnvironmentVariable("GALAXY_MXGW_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey))
|
||||
Assert.Skip("Set MXGW_ENDPOINT + GALAXY_MXGW_API_KEY to run the live gateway alarm-feed smoke.");
|
||||
|
||||
var client = MxGatewayClient.Create(new MxGatewayClientOptions
|
||||
{
|
||||
Endpoint = new Uri(endpoint!, UriKind.Absolute),
|
||||
ApiKey = apiKey!,
|
||||
UseTls = false,
|
||||
ConnectTimeout = TimeSpan.FromSeconds(10),
|
||||
DefaultCallTimeout = TimeSpan.FromSeconds(30),
|
||||
StreamTimeout = TimeSpan.FromSeconds(30),
|
||||
});
|
||||
|
||||
var observed = new List<GalaxyAlarmTransition>();
|
||||
var gotOne = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// Wire the live client's StreamAlarms method group into the production consumer seam.
|
||||
await using var feed = new GatewayGalaxyAlarmFeed(client.StreamAlarmsAsync, clientName: "D1Smoke");
|
||||
feed.OnAlarmTransition += (_, t) =>
|
||||
{
|
||||
lock (observed) { observed.Add(t); }
|
||||
gotOne.TrySetResult(true);
|
||||
};
|
||||
feed.Start();
|
||||
|
||||
// The stream opens with the active-alarm snapshot, so we expect ≥1 transition promptly.
|
||||
await Task.WhenAny(gotOne.Task, Task.Delay(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken));
|
||||
|
||||
List<GalaxyAlarmTransition> snapshot;
|
||||
lock (observed) snapshot = observed.ToList();
|
||||
|
||||
snapshot.ShouldNotBeEmpty(
|
||||
"Live gateway should deliver at least the active-alarm snapshot through the lmxopcua consumer.");
|
||||
var first = snapshot[0];
|
||||
first.AlarmFullReference.ShouldNotBeNullOrWhiteSpace();
|
||||
first.OpcUaSeverity.ShouldBeGreaterThan(0); // severity bucket mapping applied by the consumer
|
||||
|
||||
foreach (var t in snapshot.Take(8))
|
||||
TestContext.Current.SendDiagnosticMessage(
|
||||
$"{t.TransitionKind,-11} {t.AlarmFullReference} sev={t.OpcUaSeverity}({t.SeverityBucket}) cat={t.Category} comment='{t.OperatorComment}'");
|
||||
TestContext.Current.SendDiagnosticMessage($"TOTAL consumer transitions observed: {snapshot.Count}");
|
||||
|
||||
// Deterministic artifact capture (only when D1_SMOKE_OUT is set).
|
||||
var outPath = Environment.GetEnvironmentVariable("D1_SMOKE_OUT");
|
||||
if (!string.IsNullOrWhiteSpace(outPath))
|
||||
{
|
||||
var lines = snapshot.Take(50).Select(t =>
|
||||
$"{t.TransitionKind,-11} {t.AlarmFullReference} | sev={t.OpcUaSeverity}({t.SeverityBucket}) raw={t.RawMxAccessSeverity} | cat={t.Category} | comment='{t.OperatorComment}' | xitionUtc={t.TransitionTimestampUtc:o}");
|
||||
await File.WriteAllLinesAsync(outPath!,
|
||||
new[] { $"# consumer transitions observed: {snapshot.Count}" }.Concat(lines),
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
@@ -25,6 +25,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZB.MOM.WW.MxGateway.Contracts" />
|
||||
<!-- Client package: only the Skip-gated live alarm-feed smoke (GatewayGalaxyAlarmFeedLiveTests)
|
||||
constructs a real MxGatewayClient. Unit tests use the fake stream-factory seam. -->
|
||||
<PackageReference Include="ZB.MOM.WW.MxGateway.Client" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for the LDAP config-section fix. The real config (admin/driver/Development
|
||||
/// overlays) lives under <c>Security:Ldap</c>, and <see cref="LdapOptions.SectionName"/> must point
|
||||
/// there so the configured <c>DevStubMode</c> actually binds. Previously the binders used the
|
||||
/// nonexistent <c>"Ldap"</c>/<c>"Authentication:Ldap"</c> sections, so the dev stub never activated.
|
||||
/// </summary>
|
||||
public sealed class LdapOptionsBindingTests
|
||||
{
|
||||
/// <summary><see cref="LdapOptions.SectionName"/> resolves to the real overlay section.</summary>
|
||||
[Fact]
|
||||
public void SectionName_is_Security_Ldap()
|
||||
{
|
||||
LdapOptions.SectionName.ShouldBe("Security:Ldap");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Binding from <see cref="LdapOptions.SectionName"/> reads the configured <c>DevStubMode</c>
|
||||
/// from the real <c>Security:Ldap</c> section — proving the dev stub now takes effect.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Binding_from_SectionName_reads_Security_Ldap_DevStubMode()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Security:Ldap:DevStubMode"] = "true",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var options = configuration.GetSection(LdapOptions.SectionName).Get<LdapOptions>();
|
||||
|
||||
options.ShouldNotBeNull();
|
||||
options.DevStubMode.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Negative control: binding from the old (nonexistent) <c>"Ldap"</c> section against the same
|
||||
/// <c>Security:Ldap</c> config does NOT pick up <c>DevStubMode</c> — it falls back to the C#
|
||||
/// default (false). This is the pre-fix behaviour the change corrects.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Binding_from_old_Ldap_section_does_not_read_DevStubMode()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Security:Ldap:DevStubMode"] = "true",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var options = configuration.GetSection("Ldap").Get<LdapOptions>() ?? new LdapOptions();
|
||||
|
||||
options.DevStubMode.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 3 — verifies the net-new <see cref="LdapOptionsValidator"/> (built on the shared
|
||||
/// <c>ZB.MOM.WW.Configuration</c> <c>OptionsValidatorBase</c>/<c>ValidationBuilder</c>) gates on
|
||||
/// <see cref="LdapOptions.Enabled"/>, and that when enabled it requires <c>Server</c>,
|
||||
/// <c>SearchBase</c>, and a valid <c>Port</c>. Failure messages carry the real <c>"Ldap:"</c>
|
||||
/// section prefix so they read correctly when surfaced at host startup.
|
||||
/// </summary>
|
||||
public sealed class LdapOptionsValidatorTests
|
||||
{
|
||||
private static readonly LdapOptionsValidator Sut = new();
|
||||
|
||||
/// <summary>Valid enabled options pass validation.</summary>
|
||||
[Fact]
|
||||
public void Valid_enabled_options_succeed()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>When LDAP is disabled all checks are skipped, so a blank config still passes.</summary>
|
||||
[Fact]
|
||||
public void Disabled_options_succeed_even_when_blank()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = false,
|
||||
Server = string.Empty,
|
||||
SearchBase = string.Empty,
|
||||
Port = 0,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the dev stub is active the real LDAP fields are irrelevant (the bind is bypassed), so
|
||||
/// the gate skips the Server/SearchBase/Port checks even though LDAP is nominally enabled.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DevStubMode_options_succeed_even_when_server_blank()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
DevStubMode = true,
|
||||
Server = string.Empty,
|
||||
SearchBase = string.Empty,
|
||||
Port = 0,
|
||||
};
|
||||
|
||||
Sut.Validate(null, options).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a blank server reports the required-server failure.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_blank_server_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = string.Empty,
|
||||
SearchBase = "dc=x",
|
||||
Port = 389,
|
||||
};
|
||||
|
||||
var result = Sut.Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("Ldap:Server is required when LDAP login is enabled.");
|
||||
}
|
||||
|
||||
/// <summary>Enabled with a blank search base reports the required-search-base failure.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_blank_search_base_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = string.Empty,
|
||||
Port = 389,
|
||||
};
|
||||
|
||||
var result = Sut.Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("Ldap:SearchBase is required when LDAP login is enabled.");
|
||||
}
|
||||
|
||||
/// <summary>Enabled with port 0 reports the port-range failure using the shared primitive wording.</summary>
|
||||
[Fact]
|
||||
public void Enabled_with_zero_port_fails()
|
||||
{
|
||||
var options = new LdapOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Server = "ldap",
|
||||
SearchBase = "dc=x",
|
||||
Port = 0,
|
||||
};
|
||||
|
||||
var result = Sut.Validate(null, options);
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("Ldap:Port must be between 1 and 65535 (was 0)");
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 4 — verifies the net-new <see cref="OpcUaApplicationHostOptionsValidator"/> (built on the
|
||||
/// shared <c>ZB.MOM.WW.Configuration</c> <c>OptionsValidatorBase</c>/<c>ValidationBuilder</c>) that
|
||||
/// gates the OPC UA host options at startup. The C# defaults are all valid so a host with no
|
||||
/// explicit <c>"OpcUa"</c> section still passes; the validator exists to reject explicit
|
||||
/// prod/env overrides. Failure messages carry the real <c>"OpcUa:"</c> section prefix and the
|
||||
/// exact shared-primitive wording so they read correctly when surfaced via <c>ValidateOnStart</c>.
|
||||
/// </summary>
|
||||
public sealed class OpcUaApplicationHostOptionsValidatorTests
|
||||
{
|
||||
private static readonly OpcUaApplicationHostOptionsValidator Sut = new();
|
||||
|
||||
/// <summary>The C# defaults (the as-bound shape when the section is absent) pass validation.</summary>
|
||||
[Fact]
|
||||
public void Default_options_succeed()
|
||||
{
|
||||
Sut.Validate(null, new OpcUaApplicationHostOptions()).Succeeded.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>A port of 0 reports the shared port-range failure with the OpcUa prefix.</summary>
|
||||
[Fact]
|
||||
public void Zero_port_fails()
|
||||
{
|
||||
var result = Sut.Validate(null, new OpcUaApplicationHostOptions { OpcUaPort = 0 });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("OpcUa:OpcUaPort must be between 1 and 65535 (was 0)");
|
||||
}
|
||||
|
||||
/// <summary>A blank public hostname reports the shared required failure with the OpcUa prefix.</summary>
|
||||
[Fact]
|
||||
public void Blank_public_hostname_fails()
|
||||
{
|
||||
var result = Sut.Validate(null, new OpcUaApplicationHostOptions { PublicHostname = "" });
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("OpcUa:PublicHostname is required");
|
||||
}
|
||||
|
||||
/// <summary>An empty security-profile list reports the shared min-count failure with the OpcUa prefix.</summary>
|
||||
[Fact]
|
||||
public void Empty_security_profiles_fails()
|
||||
{
|
||||
var result = Sut.Validate(null, new OpcUaApplicationHostOptions
|
||||
{
|
||||
EnabledSecurityProfiles = new List<OpcUaSecurityProfile>(),
|
||||
});
|
||||
|
||||
result.Failed.ShouldBeTrue();
|
||||
result.Failures.ShouldContain("OpcUa:EnabledSecurityProfiles must contain at least 1 item(s) (had 0)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user