merge WP5 (dual-namespace harness/integration tests + address-space docs) into v3/batch4-address-space
This commit is contained in:
@@ -68,9 +68,45 @@ shared-lib consumption, or per-project commands — update the **OtOpcUa entry i
|
||||
gRPC session. The gateway owns the COM apartment + STA pump
|
||||
server-side; the driver speaks `MxCommand` / `MxEvent` protos
|
||||
exclusively.
|
||||
3. **OPC UA Server** — Exposes authored equipment tags as variable nodes.
|
||||
Galaxy tags are bound by `TagConfig.FullName` (`tag_name.AttributeName`);
|
||||
reads/writes/subscriptions are translated to that reference for MXAccess.
|
||||
3. **OPC UA Server** — Exposes the deployed tree under **two OPC UA namespaces**
|
||||
(see below). Galaxy tags are bound by `TagConfig.FullName`
|
||||
(`tag_name.AttributeName`); reads/writes/subscriptions are translated to that
|
||||
reference for MXAccess.
|
||||
|
||||
### v3 OPC UA Address Space (Batch 4): dual namespace
|
||||
|
||||
The server exposes **two OPC UA namespaces** (replacing the single
|
||||
`https://zb.com/otopcua/ns`), built by `AddressSpaceComposer` /
|
||||
`AddressSpaceApplier` and served by `OtOpcUaNodeManager` (which registers both
|
||||
URIs; `V3NodeIds` + `AddressSpaceRealm` are the identity authority):
|
||||
|
||||
| Namespace URI | Realm | Subtree | NodeId `s=` scheme |
|
||||
|---|---|---|---|
|
||||
| `https://zb.com/otopcua/raw` | `AddressSpaceRealm.Raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
|
||||
| `https://zb.com/otopcua/uns` | `AddressSpaceRealm.Uns` | the UNS tree (Area → Line → Equipment → signal) | slash-joined **`Area/Line/Equipment/EffectiveName`** |
|
||||
|
||||
- **Single source, fanned to both.** Every device value has exactly one source —
|
||||
the raw tag's node. A `UnsTagReference` projects that raw tag into an equipment
|
||||
as a UNS-namespace variable that carries an **`Organizes` reference to its raw
|
||||
node** and mirrors it. One driver publish for a RawPath fans (in `DriverHostActor`)
|
||||
to the raw NodeId AND every referencing UNS NodeId with identical
|
||||
value/quality/timestamp — the mux key stays single (RawPath).
|
||||
- **Writes via either NodeId** route to the same driver ref under the same
|
||||
`WriteOperate` gating (the node-manager write gate is realm-qualified and fails
|
||||
closed); a failed device write reverts both NodeIds via the shared fan-out
|
||||
(write-outcome self-correction).
|
||||
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
|
||||
fan via SDK notifiers to the raw device folder AND every referencing equipment
|
||||
folder — one `ReportEvent`, one Server-object copy; ack/confirm/shelve route on
|
||||
`ConditionId = RawPath`.
|
||||
- **Historian dual-registration.** Both NodeIds register the **same** historian
|
||||
tagname; the mux `HistorizedTagRef` stays single (RawPath). HistoryRead works
|
||||
through either NodeId.
|
||||
|
||||
The retired `EquipmentNodeIds` (`{equipmentId}/{folderPath}/{name}`) scheme and
|
||||
the single-namespace `EquipmentTags` materialization path are gone. See
|
||||
`docs/plans/2026-07-15-raw-uns-two-subtree-v3-design.md` +
|
||||
`docs/plans/2026-07-15-v3-batch4-address-space-plan.md`, and `docs/Uns.md`.
|
||||
|
||||
### Key Concept: Tag Name and FullName
|
||||
|
||||
@@ -219,6 +255,8 @@ The AdminUI's global **UNS** page (`/uns`) is the single surface for managing th
|
||||
|
||||
**v3 (Batch 3): UNS Equipment is reference-only.** The equipment **Tags** tab no longer authors or binds tags — it holds `UnsTagReference` rows pointing at raw tags authored in `/raw`. "+ Add reference" opens the `RawTree` in picker mode, **cluster-scoped** (structurally + server-enforced `tag.cluster == equipment.cluster`); rows show effective name / RawPath / inherited DataType+AccessLevel / display-name override. **Effective-name uniqueness** (references + VirtualTags + ScriptedAlarms share the `{EquipmentId}/{EffectiveName}` NodeId space) is enforced at authoring (`IEffectiveNameGuard`) and at the deploy gate (`DraftValidator.UnsEffectiveNameCollision` — catches rename-induced collisions). Scripts use **`ctx.GetTag("{{equip}}/<RefName>")`** — resolved per-equipment through `UnsTagReference` effective names to the backing RawPath at both compose seams (`AddressSpaceComposer` + `DeploymentArtifact`, via the shared `EquipmentReferenceMap`, byte-parity); an unresolved `<RefName>` is a deploy error (`EquipReferenceUnresolved`) AND a live Monaco diagnostic (`OTSCRIPT_EQUIPREF`) — editor accepts ⇔ publish accepts. `EquipmentScriptPaths.DeriveEquipmentBase` is deleted (the dot-joint `{{equip}}.X` became the slash-joint `{{equip}}/<RefName>`). Raw rename warns when a beneath-it tag is historized-without-override / UNS-referenced / named by a script literal (`RawTreeService` substring scan). `ImportEquipmentModal` dropped the `DriverInstanceId` column. See `docs/Uns.md` + `docs/ScriptEditor.md`.
|
||||
|
||||
**v3 (Batch 4 = v3.0): dual-namespace address space.** The OPC UA server exposes **two namespaces** — `https://zb.com/otopcua/raw` (Raw device tree, `s=<RawPath>`) + `https://zb.com/otopcua/uns` (UNS tree, `s=<Area>/<Line>/<Equipment>/<EffectiveName>`) — replacing the single `.../ns` and the retired `EquipmentNodeIds` scheme. Every value has ONE source (the raw node's publish); a driver publish for a RawPath fans in `DriverHostActor` to the raw NodeId AND every referencing UNS NodeId with identical value/quality/timestamp, and each UNS variable `Organizes`-references its raw node. Writes route via **either** NodeId to the same driver ref under the same realm-qualified `WriteOperate` gate (failed-write revert works through both); native alarms materialize ONCE at the raw tag (`ConditionId = RawPath`) and fan via SDK `AddNotifier` to the raw device folder + every referencing equipment folder (one `ReportEvent`, one Server-object copy); both NodeIds register the SAME historian tagname (mux `HistorizedTagRef` stays single, keyed by RawPath). Identity authority: `V3NodeIds` + `AddressSpaceRealm` (carried explicitly at every sink seam — never parsed out of the id string). See the "v3 OPC UA Address Space (Batch 4)" section above, `docs/Uns.md`, and `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
|
||||
|
||||
The `/uns` **TagModal** uses **driver-typed tag-config editors**: it dispatches by the bound driver's `DriverType` to a per-driver editor (Modbus/S7/AbCip/AbLegacy/TwinCAT/Focas/OpcUaClient) via `TagConfigEditorMap`, with client-side validation via `TagConfigValidator`; unmapped drivers (only Galaxy) fall back to the generic raw-`TagConfig`-JSON textarea. Each editor is a thin razor shell over a pure `<Driver>TagConfigModel` (`FromJson`/`ToJson`/`Validate`, preserves unknown keys). To add a driver's editor, copy the Modbus template under `Components/Shared/Uns/TagEditors/` + `Uns/TagEditors/`, reusing the driver's enums + camelCase JSON property names, and register it in `TagConfigEditorMap` + `TagConfigValidator`. See `docs/plans/2026-06-09-driver-typed-tag-editors-design.md`.
|
||||
|
||||
## Scripting / Script Editor
|
||||
|
||||
@@ -24,6 +24,33 @@ condition — the dedup logic prefers the richer driver-native record
|
||||
because it carries the full operator + raise-time + category metadata
|
||||
that the value-driven path collapses.
|
||||
|
||||
## v3 Batch 4 — multi-notifier delivery (raw + equipment folders)
|
||||
|
||||
Under the v3 dual-namespace address space, a native driver alarm is authored on a
|
||||
**raw tag** (`/raw`) and its Part 9 `AlarmConditionState` materializes **once** at the
|
||||
raw tag — `ConditionId` = the tag's **RawPath**, parent notifier = the raw device/group
|
||||
folder (`ns=Raw`). Because equipment references raw tags (v3 reference-only UNS), the
|
||||
same condition is wired as an **event notifier of every referencing equipment folder**
|
||||
(`ns=UNS`) via the SDK `AddNotifier` pattern (`OtOpcUaNodeManager.WireAlarmNotifiers`):
|
||||
`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
|
||||
`EnsureFolderIsEventNotifier(equipFolder)`.
|
||||
|
||||
- A **single** `ReportEvent` fans through the SDK notifier graph to the raw device folder,
|
||||
every referencing equipment folder, and up to the Server object. A subscriber at any one
|
||||
root receives **exactly one** copy of the transition — the Part 9 shared-`InstanceStateSnapshot`
|
||||
dedup (`MonitoredItem.QueueEvent` → `IsEventContainedInQueue`), **never** one copy per root.
|
||||
Duplicating the `ReportEvent` per root is rejected by design (distinct EventIds would break
|
||||
Server-object dedup + Part 9 ack correlation).
|
||||
- **Teardown is symmetric:** the node manager tracks the wired notifier pairs and calls
|
||||
`RemoveNotifier(..., bidirectional:true)` on rebuild / subtree-removal / reference-removal, so
|
||||
inverse-notifier entries never leak across redeploys.
|
||||
- **Ack/confirm/shelve route on `ConditionId = RawPath`** (never `SourceNodeId`) regardless of
|
||||
which notifier root the operator subscribed at — an ack issued from an equipment-folder
|
||||
subscription resolves to the same raw condition.
|
||||
- The `alerts`-topic `AlarmTransitionEvent` carries the (possibly empty) **list of referencing
|
||||
equipment paths**, and the AdminUI `/alerts` page shows **one row per condition** (primary
|
||||
identity RawPath + condition NodeId) with the equipment list as display metadata.
|
||||
|
||||
## Galaxy driver path (driver-native)
|
||||
|
||||
Restored in PR B.2 of the epic. `GalaxyDriver` implements
|
||||
|
||||
+23
-6
@@ -199,6 +199,15 @@ The server supports all four OPC UA HistoryRead variants:
|
||||
`Historizing=true` and `AccessLevels.HistoryRead` are set at materialization so any compliant
|
||||
OPC UA client can discover historized capability from the node's attributes.
|
||||
|
||||
> **v3 Batch 4 — dual-namespace registration.** A historized raw tag surfaces as **two**
|
||||
> variable nodes: the Raw-namespace node (`ns=Raw, s=<RawPath>`) and, for each referencing
|
||||
> equipment, a UNS-namespace node (`ns=UNS, s=<Area>/<Line>/<Equipment>/<EffectiveName>`).
|
||||
> **Both NodeIds register the SAME historian tagname** (the raw tag's `FullName` /
|
||||
> `historianTagname`), so HistoryRead against either NodeId resolves to the same tagname and
|
||||
> returns the same series. The historization intent is single-sourced — the mux's
|
||||
> `HistorizedTagRef` set stays keyed by RawPath (no doubles), and continuous historization /
|
||||
> `EnsureTags` provisioning run once per raw tag regardless of how many equipment reference it.
|
||||
|
||||
**Equipment-folder event-notifier nodes** serve Event history. Every equipment folder that
|
||||
owns at least one alarm condition is already an event notifier; the server registers a
|
||||
`sourceName` (the equipment id) for each such folder and maps event history reads to the
|
||||
@@ -355,31 +364,39 @@ for the full alarm-historian routing.
|
||||
The `historyread` command reads historical data from any node. Supply start and end times in
|
||||
ISO 8601 UTC form. See [docs/Client.CLI.md](Client.CLI.md) for the full flag reference.
|
||||
|
||||
> **v3 Batch 4 NodeIds.** A historized tag is readable through **either** of its two NodeIds —
|
||||
> the Raw-namespace node (`s=<RawPath>`, e.g. `Plant/Modbus/dev1/Speed`) or a referencing
|
||||
> equipment's UNS-namespace node (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`). Both register
|
||||
> the same historian tagname, so the returned series is identical. The `ns=N` index is assigned
|
||||
> at connect time per the namespace URI (`https://zb.com/otopcua/raw` /
|
||||
> `https://zb.com/otopcua/uns`) — resolve it from the server's `NamespaceArray` rather than
|
||||
> hard-coding it. The examples below show a Raw-namespace read (`ns=2` illustrative).
|
||||
|
||||
```bash
|
||||
# Raw history for a historized Galaxy tag (last 24 hours by default)
|
||||
# Raw history for a historized tag via its Raw-namespace NodeId (last 24 hours by default)
|
||||
otopcua-cli historyread \
|
||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
||||
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z"
|
||||
|
||||
# Limit to 100 values
|
||||
# Same series via a referencing equipment's UNS-namespace NodeId, limited to 100 values
|
||||
otopcua-cli historyread \
|
||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
||||
-n "ns=3;s=filling/line1/station1/Speed" \
|
||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||
--max 100
|
||||
|
||||
# 1-hour average aggregate
|
||||
otopcua-cli historyread \
|
||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
||||
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||
--aggregate Average --interval 3600000
|
||||
|
||||
# Authenticated read (ReadOnly role or higher required)
|
||||
otopcua-cli historyread \
|
||||
-u opc.tcp://localhost:4840/OtOpcUa \
|
||||
-n "ns=2;s=EQ-55297329838d/GalaxyTestTag" \
|
||||
-n "ns=2;s=Plant/Modbus/dev1/Speed" \
|
||||
--start "2026-06-13T00:00:00Z" --end "2026-06-14T00:00:00Z" \
|
||||
-U reader -P password
|
||||
```
|
||||
|
||||
@@ -212,6 +212,13 @@ The catalog is scoped per Blazor circuit; each call creates and disposes its own
|
||||
`DbContext` via the pooled factory (same pattern as `UnsTreeService`). Results
|
||||
are bounded to 200 entries to keep the completion list responsive on large fleets.
|
||||
|
||||
> **v3 Batch 4 (dual namespace) — unchanged for scripts.** A `ctx.GetTag(...)` literal
|
||||
> still resolves by the tag's `FullName` / **RawPath** — which is precisely the identity
|
||||
> of the Raw-namespace node (`ns=Raw, s=<RawPath>`). A `{{equip}}/<RefName>` token resolves
|
||||
> through the equipment's `UnsTagReference` to that same backing RawPath (see below), so
|
||||
> scripts key off the single value source regardless of the UNS projection. The dual
|
||||
> namespace changes only the OPC UA address-space surface, not script tag-path semantics.
|
||||
|
||||
### Literal gate
|
||||
|
||||
`TryGetTagPathLiteral` identifies the tag-path context by climbing from the
|
||||
|
||||
+21
-4
@@ -127,9 +127,25 @@ object alongside the usual `"FullName"`:
|
||||
(unchanged behaviour). `"alarm"` **present** → the tag materialises as a Part 9
|
||||
`AlarmConditionState` under its equipment folder **instead of** a value variable.
|
||||
No EF/schema change is required; the intent rides in the schemaless `TagConfig`
|
||||
blob and is parsed byte-parity in both the compose (`Phase7Composer`) and deploy
|
||||
blob and is parsed byte-parity in both the compose (`AddressSpaceComposer`) and deploy
|
||||
(`DeploymentArtifact`) paths.
|
||||
|
||||
> **v3 Batch 4 — multi-notifier fan-out.** The `"alarm"` object now rides on a **raw
|
||||
> tag** authored in `/raw` (referenced into equipment), and the Part 9 condition
|
||||
> materializes **once at the raw tag** — its `ConditionId` is the tag's **RawPath**, its
|
||||
> parent notifier is the raw device/group folder. For **each referencing equipment**, the
|
||||
> node manager (`WireAlarmNotifiers`) wires the SDK `AddNotifier` pattern
|
||||
> (`alarm.AddNotifier(equipFolder, isInverse:true)` + `equipFolder.AddNotifier(alarm)` +
|
||||
> `EnsureFolderIsEventNotifier(equipFolder)`) so the single condition fans to the raw device
|
||||
> folder AND every equipment folder. A **single** `ReportEvent` fans to all notifier roots —
|
||||
> a Server-object subscriber sees **exactly one** copy (the Part 9 shared-snapshot dedup),
|
||||
> never one-per-root. Teardown is symmetric (`RemoveNotifier(..., bidirectional:true)` on
|
||||
> rebuild / reference removal) so inverse-notifier entries never leak across redeploys.
|
||||
> Ack/confirm/shelve route on **`ConditionId = RawPath`** (never `SourceNodeId`), and the
|
||||
> `/alerts` row lists the referencing equipment paths. See
|
||||
> [AlarmTracking.md](AlarmTracking.md) and the WP4 section of
|
||||
> `docs/plans/2026-07-15-v3-batch4-address-space-plan.md`.
|
||||
|
||||
### TagConfig alarm fields
|
||||
|
||||
| Field | Values | Default |
|
||||
@@ -208,9 +224,10 @@ native alarm transitions identically. Publication to the `alerts` topic is
|
||||
the OPC UA condition-node write is ungated on all nodes so a Secondary stays warm
|
||||
for failover.
|
||||
|
||||
The alarm is authored on the `Tags` tab of the equipment page (`/uns/equipment/{id}`)
|
||||
by editing the tag's raw `TagConfig` JSON to include the `"alarm"` object. No
|
||||
other configuration is required.
|
||||
The alarm is authored on the **raw tag** in `/raw` by including the `"alarm"` object in the
|
||||
tag's `TagConfig` JSON (v3 Batch 4 — equipment no longer authors tags; it references raw
|
||||
tags). No other configuration is required: any equipment that references the raw tag
|
||||
automatically receives the alarm at its equipment folder via the multi-notifier fan-out above.
|
||||
|
||||
### Native-alarm OPC UA operator operations
|
||||
|
||||
|
||||
+35
@@ -110,6 +110,41 @@ UNS-referenced, or named by a script literal — see [`Raw.md`](Raw.md).
|
||||
`GalaxyMxGateway` driver in `/raw`, referenced into equipment like any other raw
|
||||
tag. There is no separate alias concept or `SystemPlatform`-kind namespace.
|
||||
|
||||
### OPC UA address-space projection (v3 Batch 4 — dual namespace)
|
||||
|
||||
> **v3 (Batch 4):** the server now exposes the address space under **two OPC UA
|
||||
> namespaces** instead of the old single `https://zb.com/otopcua/ns`:
|
||||
>
|
||||
> | Namespace URI | Subtree | NodeId `s=` scheme |
|
||||
> |---|---|---|
|
||||
> | `https://zb.com/otopcua/raw` | the `/raw` device tree (Folder → Driver → Device → TagGroup → Tag) | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
|
||||
> | `https://zb.com/otopcua/uns` | the UNS tree (Area → Line → Equipment → signal) | the slash-joined **`Area/Line/Equipment/EffectiveName`** |
|
||||
|
||||
Every device value has **exactly one source** — the raw tag's node in the Raw
|
||||
namespace. A `UnsTagReference` projects that raw tag into an equipment as a
|
||||
**UNS-namespace variable** whose NodeId leaf is the reference's **effective name**
|
||||
(the display-name override else the raw tag's `Name`). The UNS variable does not
|
||||
bind a driver of its own: it carries an **`Organizes` reference to its backing raw
|
||||
node** and mirrors it. A single driver publish for a RawPath **fans out** to the
|
||||
raw NodeId AND every referencing UNS NodeId with identical value / quality /
|
||||
source-timestamp — the two NodeIds never drift.
|
||||
|
||||
- **Reads / subscriptions** work through either NodeId and return the same data.
|
||||
- **Writes** route through either NodeId to the same backing driver ref under the
|
||||
**same `WriteOperate` gating** (a UNS write is neither more nor less privileged
|
||||
than the raw write it fans from); a failed device write reverts both NodeIds via
|
||||
the shared fan-out.
|
||||
- **HistoryRead** works through either NodeId and returns the same series — both
|
||||
NodeIds register the **same historian tagname** (see [`Historian.md`](Historian.md)).
|
||||
- **Native alarms** materialize once at the raw tag (`ConditionId = RawPath`) and
|
||||
fan via SDK notifiers to the raw device folder AND every referencing equipment
|
||||
folder — see [`ScriptedAlarms.md`](ScriptedAlarms.md) / [`AlarmTracking.md`](AlarmTracking.md).
|
||||
|
||||
Note the two distinct identity strings: the wire **NodeId** is the path
|
||||
`Area/Line/Equipment/EffectiveName`, while the **effective-name uniqueness key**
|
||||
above is `{EquipmentId}/{EffectiveName}` (the logical per-equipment collision
|
||||
space the guards enforce). They are related but not the same string.
|
||||
|
||||
### Virtual tags
|
||||
|
||||
A virtual tag is bound to an equipment and driven by a **script** (no driver).
|
||||
|
||||
+75
-3
@@ -29,9 +29,11 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
/// real <c>AddressSpaceComposer</c> over the SAME seeded config (loaded back from the deploy DB) and pins
|
||||
/// the Batch-4 dual-tree: the seeded raw tag surfaces as a Raw-realm Variable keyed by its RawPath, and the
|
||||
/// UnsTagReference surfaces as a UNS-realm Variable carrying that RawPath (the Organizes target + fan-out).
|
||||
/// <para><i>Note:</i> the composer is what WP1 lights up; the artifact-decode seam
|
||||
/// (<c>DeploymentArtifact.ParseComposition</c>) is migrated by WP3, so the dual-tree assertion runs through
|
||||
/// the composer directly rather than the full deploy → artifact round-trip.</para>
|
||||
/// <para><i>Note:</i> the composer is what WP1 lights up. WP3 migrated the artifact-decode seam
|
||||
/// (<c>DeploymentArtifact.ParseComposition</c>) to carry both realms too, so
|
||||
/// <see cref="Deployed_artifact_round_trips_both_namespaces_and_seals_on_the_cluster"/> (added in WP5) pins
|
||||
/// the dual tree through the FULL deploy → persisted-artifact → decode round-trip a driver node applies, and
|
||||
/// additionally asserts the dual-namespace deploy seals across the redundant 2-node cluster.</para>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The OPC UA address-space browse is exercised separately against a real SDK node manager in
|
||||
@@ -142,6 +144,76 @@ public sealed class EquipmentNamespaceMaterializationTests
|
||||
composition.EquipmentTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>BATCH-4 harness round-trip + redundancy: deploying the seeded dual-namespace config through the
|
||||
/// REAL deploy pipeline (AdminOperations → ConfigPublishCoordinator → BOTH DriverHostActors) seals on the
|
||||
/// 2-node cluster, AND the persisted artifact round-trips BOTH realms via
|
||||
/// <see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/> — the raw tag as a Raw-realm
|
||||
/// Variable keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the
|
||||
/// Organizes/fan-out backing path). This is the harness-level dual-namespace materialization proof: the
|
||||
/// second namespace neither breaks redundant sealing nor is lost across artifact serialization. (WP3 migrated
|
||||
/// the artifact-decode seam to carry both realms, so — unlike the composer-direct test above — this exercises
|
||||
/// the full deploy → persisted-artifact → decode path a driver node actually applies.)</summary>
|
||||
[Fact]
|
||||
public async Task Deployed_artifact_round_trips_both_namespaces_and_seals_on_the_cluster()
|
||||
{
|
||||
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||
await harness.SeedDefaultClusterAsync("c1");
|
||||
await SeedUnsHierarchyAsync(harness);
|
||||
|
||||
await using var scope = harness.NodeA.Services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
|
||||
|
||||
var result = await client.StartDeploymentAsync(createdBy: "alice@test", Ct);
|
||||
result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}");
|
||||
var deploymentId = result.DeploymentId!.Value.Value;
|
||||
|
||||
// Redundancy non-interference: the dual-namespace deploy seals after BOTH DriverHostActors apply — the
|
||||
// second namespace does not break the redundant seal path (the ServiceLevel / primary-gate machinery
|
||||
// is orthogonal to the address-space namespace count; see FailoverDuringDeploy / PrimaryGateFailover).
|
||||
var artifact = Array.Empty<byte>();
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
await using var db = await CreateDbAsync(harness);
|
||||
var d = await db.Deployments.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct);
|
||||
if (d is { Status: DeploymentStatus.Sealed, ArtifactBlob.Length: > 0 })
|
||||
{
|
||||
artifact = d.ArtifactBlob;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, TimeSpan.FromSeconds(20));
|
||||
|
||||
await using (var db = await CreateDbAsync(harness))
|
||||
{
|
||||
var nodeStates = await db.NodeDeploymentStates.AsNoTracking()
|
||||
.Where(s => s.DeploymentId == deploymentId)
|
||||
.ToListAsync(Ct);
|
||||
nodeStates.Count.ShouldBe(2, "both cluster nodes record a per-node deployment state");
|
||||
nodeStates.ShouldAllBe(s => s.Status == NodeDeploymentStatus.Applied);
|
||||
}
|
||||
|
||||
// Round-trip the PERSISTED artifact through the artifact-decode seam and pin the dual tree.
|
||||
var composition = DeploymentArtifact.ParseComposition(artifact);
|
||||
|
||||
// Raw subtree: the seeded raw tag survives as a Raw-realm Variable keyed by its RawPath.
|
||||
var rawTag = composition.RawTags.ShouldHaveSingleItem();
|
||||
rawTag.TagId.ShouldBe("tag-speed");
|
||||
rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed");
|
||||
|
||||
// UNS subtree: the UnsTagReference survives as a UNS-realm Variable carrying the backing RawPath (the
|
||||
// Organizes UNS→Raw target + the fan-out source).
|
||||
var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
|
||||
unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
unsVar.EquipmentId.ShouldBe(EquipmentId);
|
||||
unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
|
||||
unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed");
|
||||
|
||||
// The retired equipment-namespace tag path stays empty (values flow through raw + UNS now).
|
||||
composition.EquipmentTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static async Task SeedUnsHierarchyAsync(TwoNodeClusterHarness harness)
|
||||
{
|
||||
await using var db = await CreateDbAsync(harness);
|
||||
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 (B4-WP5) — the over-the-wire proof of the dual-namespace address space. Boots the real
|
||||
/// <see cref="OtOpcUaSdkServer"/> (exactly as <c>SubscriptionSurvivalTests</c> / the multi-notifier
|
||||
/// alarm test do), materialises a raw device folder + a raw tag Variable (<c>ns=Raw, s=<RawPath></c>)
|
||||
/// and an equipment folder + a UNS reference Variable (<c>ns=UNS, s=<Area/Line/Equip/Eff></c>) through
|
||||
/// the PRODUCTION <see cref="SdkAddressSpaceSink"/>, wires the <c>Organizes</c> UNS→Raw edge, then drives a
|
||||
/// real OPC UA client to assert:
|
||||
/// <list type="number">
|
||||
/// <item>BOTH namespace URIs (<see cref="V3NodeIds.RawNamespaceUri"/> +
|
||||
/// <see cref="V3NodeIds.UnsNamespaceUri"/>) are registered + distinct (they replaced the single
|
||||
/// <c>.../ns</c>).</item>
|
||||
/// <item>Both subtrees browse + read: the raw Variable at its RawPath NodeId and the UNS Variable at its
|
||||
/// equipment-path NodeId.</item>
|
||||
/// <item>The UNS Variable <c>Organizes</c>-references its backing raw node.</item>
|
||||
/// <item>Single-source fan-out parity: one driver publish (modelled as a
|
||||
/// <see cref="SdkAddressSpaceSink.WriteValue"/> to each NodeId with identical value/quality/timestamp)
|
||||
/// is read back IDENTICALLY on both NodeIds.</item>
|
||||
/// <item>HistoryRead via EITHER NodeId returns <c>GoodNoData</c> under the shared historian tagname (the
|
||||
/// <c>NullHistorianDataSource</c> default — the offline parity path).</item>
|
||||
/// <item>The <c>WriteOperate</c> gate is symmetric across both NodeIds: an anonymous client write is
|
||||
/// rejected identically on the raw and the UNS NodeId (fail-closed at the role gate — the POSITIVE
|
||||
/// realm-qualified routing path is unit-covered in <c>DriverHostActorWriteRoutingTests</c> /
|
||||
/// <c>NodeManagerWriteRevertTests</c>, which drive a role-carrying identity the node manager
|
||||
/// directly).</item>
|
||||
/// </list>
|
||||
/// Heavy in-process server+client integration — runs in the serial integration pass, and is fully
|
||||
/// offline-safe (no Docker / SQL / gateway; the historian read defaults to the Null source).
|
||||
/// </summary>
|
||||
public sealed class DualNamespaceAddressSpaceTests
|
||||
{
|
||||
private const string ServerUri = "urn:OtOpcUa.DualNamespaceAddressSpace";
|
||||
|
||||
// Raw device tree (ns=Raw): Folder → tag Variable, both keyed by RawPath.
|
||||
private const string RawDeviceFolder = "Plant/Modbus/dev1";
|
||||
private const string RawTagPath = "Plant/Modbus/dev1/Speed";
|
||||
// UNS equipment tree (ns=UNS): equipment Folder → reference Variable keyed by the equipment path.
|
||||
private const string EquipFolder = "filling/line1/station1";
|
||||
private const string UnsVarPath = "filling/line1/station1/Speed";
|
||||
// Both NodeIds register the SAME historian tagname (the raw tag's RawPath).
|
||||
private const string HistorianTagname = RawTagPath;
|
||||
|
||||
[Fact]
|
||||
public async Task Both_namespaces_registered_and_both_subtrees_browse_read_and_organize()
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-browse-{Guid.NewGuid():N}");
|
||||
var port = AllocateFreePort();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
try
|
||||
{
|
||||
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri, ct);
|
||||
await using var _ = host;
|
||||
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
MaterialiseDualTree(sink);
|
||||
// One driver publish per NodeId (the fan-out the DriverHostActor performs) — identical payload.
|
||||
var ts = DateTime.UtcNow;
|
||||
sink.WriteValue(RawTagPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw);
|
||||
sink.WriteValue(UnsVarPath, 42.5f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns);
|
||||
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
|
||||
// (1) both namespaces registered + distinct.
|
||||
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||
rawNs.ShouldBeGreaterThan((ushort)0, "the Raw namespace must be registered");
|
||||
unsNs.ShouldBeGreaterThan((ushort)0, "the UNS namespace must be registered");
|
||||
rawNs.ShouldNotBe(unsNs, "the two namespaces must have distinct indices");
|
||||
|
||||
var rawNodeId = new NodeId(RawTagPath, rawNs);
|
||||
var unsNodeId = new NodeId(UnsVarPath, unsNs);
|
||||
|
||||
// (2) both subtrees browse + read.
|
||||
var rawValue = await session.ReadValueAsync(rawNodeId, ct);
|
||||
var unsValue = await session.ReadValueAsync(unsNodeId, ct);
|
||||
StatusCode.IsGood(rawValue.StatusCode).ShouldBeTrue("raw node reads Good");
|
||||
StatusCode.IsGood(unsValue.StatusCode).ShouldBeTrue("uns node reads Good");
|
||||
Convert.ToSingle(rawValue.Value).ShouldBe(42.5f);
|
||||
Convert.ToSingle(unsValue.Value).ShouldBe(42.5f);
|
||||
|
||||
// (3) the UNS Variable Organizes-references its backing raw node.
|
||||
var (_, refs) = await BrowseForwardAsync(session, unsNodeId, ReferenceTypeIds.Organizes);
|
||||
var organizesRaw = refs.Any(r =>
|
||||
r.ReferenceTypeId == ReferenceTypeIds.Organizes &&
|
||||
r.NodeId.NamespaceIndex == rawNs &&
|
||||
r.NodeId.ToString().Contains(RawTagPath, StringComparison.Ordinal));
|
||||
organizesRaw.ShouldBeTrue(
|
||||
$"the UNS variable ({UnsVarPath}) must Organizes-reference its raw node ({RawTagPath}); " +
|
||||
$"forward Organizes refs = [{string.Join(", ", refs.Select(r => r.NodeId))}]");
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDelete(pkiRoot + "-srv");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Single_source_fans_to_both_nodeids_with_identical_value_quality_and_timestamp()
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-fanout-{Guid.NewGuid():N}");
|
||||
var port = AllocateFreePort();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
try
|
||||
{
|
||||
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Fanout", ct);
|
||||
await using var _ = host;
|
||||
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
MaterialiseDualTree(sink);
|
||||
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||
|
||||
// The single source (one RawPath) fans to the raw NodeId AND every referencing UNS NodeId with
|
||||
// identical value/quality/timestamp — the fan-out drift invariant.
|
||||
var ts = new DateTime(2026, 7, 16, 8, 30, 0, DateTimeKind.Utc);
|
||||
sink.WriteValue(RawTagPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Raw);
|
||||
sink.WriteValue(UnsVarPath, 123.75f, OpcUaQuality.Good, ts, AddressSpaceRealm.Uns);
|
||||
|
||||
var rawValue = await session.ReadValueAsync(new NodeId(RawTagPath, rawNs), ct);
|
||||
var unsValue = await session.ReadValueAsync(new NodeId(UnsVarPath, unsNs), ct);
|
||||
|
||||
Convert.ToSingle(unsValue.Value).ShouldBe(Convert.ToSingle(rawValue.Value));
|
||||
unsValue.StatusCode.ShouldBe(rawValue.StatusCode);
|
||||
unsValue.SourceTimestamp.ShouldBe(rawValue.SourceTimestamp);
|
||||
unsValue.SourceTimestamp.ShouldBe(ts);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDelete(pkiRoot + "-srv");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HistoryRead_via_either_nodeid_returns_GoodNoData_under_the_shared_tagname()
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-hist-{Guid.NewGuid():N}");
|
||||
var port = AllocateFreePort();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
try
|
||||
{
|
||||
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".History", ct);
|
||||
await using var _ = host;
|
||||
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
MaterialiseDualTree(sink); // both variables historized under the SAME tagname (HistorianTagname)
|
||||
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||
|
||||
var rawStatus = await HistoryReadRawStatusAsync(session, new NodeId(RawTagPath, rawNs), ct);
|
||||
var unsStatus = await HistoryReadRawStatusAsync(session, new NodeId(UnsVarPath, unsNs), ct);
|
||||
|
||||
// NullHistorianDataSource default: a historized node's HistoryRead surfaces GoodNoData (never an
|
||||
// error) — identical for the raw and the UNS NodeId under the one registered historian tagname.
|
||||
rawStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "raw NodeId HistoryRead");
|
||||
unsStatus.ShouldBe((StatusCode)StatusCodes.GoodNoData, "uns NodeId HistoryRead");
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDelete(pkiRoot + "-srv");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteOperate_gate_is_symmetric_across_both_nodeids()
|
||||
{
|
||||
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dualns-write-{Guid.NewGuid():N}");
|
||||
var port = AllocateFreePort();
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
try
|
||||
{
|
||||
var (server, host) = await BootServerAsync(port, pkiRoot + "-srv", ServerUri + ".Write", ct);
|
||||
await using var _ = host;
|
||||
|
||||
var sink = new SdkAddressSpaceSink(server.NodeManager!);
|
||||
MaterialiseDualTree(sink); // both variables writable
|
||||
|
||||
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
||||
var rawNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
||||
var unsNs = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri);
|
||||
|
||||
var rawWrite = await WriteValueStatusAsync(session, new NodeId(RawTagPath, rawNs), 7.0f, ct);
|
||||
var unsWrite = await WriteValueStatusAsync(session, new NodeId(UnsVarPath, unsNs), 7.0f, ct);
|
||||
|
||||
// The anonymous session carries no WriteOperate role, so the node manager's fail-closed write gate
|
||||
// rejects BOTH writes identically — the gate applies uniformly to the raw and the UNS NodeId (a
|
||||
// UNS write is neither more nor less privileged than the raw write it fans from).
|
||||
rawWrite.ShouldBe((StatusCode)StatusCodes.BadUserAccessDenied, "raw NodeId write (no WriteOperate role)");
|
||||
unsWrite.ShouldBe(rawWrite, "uns NodeId write rejected identically to the raw NodeId");
|
||||
}
|
||||
finally
|
||||
{
|
||||
SafeDelete(pkiRoot + "-srv");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Materialise the raw device folder + raw tag Variable, the equipment folder + UNS reference
|
||||
/// Variable (both historized under the SAME tagname, both writable), and the Organizes UNS→Raw edge.</summary>
|
||||
private static void MaterialiseDualTree(SdkAddressSpaceSink sink)
|
||||
{
|
||||
sink.EnsureFolder(RawDeviceFolder, parentNodeId: null, displayName: "dev1", realm: AddressSpaceRealm.Raw);
|
||||
sink.EnsureVariable(RawTagPath, RawDeviceFolder, "Speed", "Float", writable: true,
|
||||
realm: AddressSpaceRealm.Raw, historianTagname: HistorianTagname);
|
||||
|
||||
sink.EnsureFolder(EquipFolder, parentNodeId: null, displayName: "station1", realm: AddressSpaceRealm.Uns);
|
||||
sink.EnsureVariable(UnsVarPath, EquipFolder, "Speed", "Float", writable: true,
|
||||
realm: AddressSpaceRealm.Uns, historianTagname: HistorianTagname);
|
||||
|
||||
// Organizes UNS→Raw: the UNS variable references its backing raw node (the fan-out source).
|
||||
sink.AddReference(UnsVarPath, AddressSpaceRealm.Uns, RawTagPath, AddressSpaceRealm.Raw, "Organizes");
|
||||
}
|
||||
|
||||
private static async Task<(byte[]? ContinuationPoint, ReferenceDescriptionCollection References)>
|
||||
BrowseForwardAsync(ISession session, NodeId node, NodeId referenceType)
|
||||
{
|
||||
// Match the SDK overload the codebase uses (DefaultSessionAdapter.BrowseAsync) — no trailing ct.
|
||||
var (_, cp, refs) = await session.BrowseAsync(
|
||||
null, null, node, 0u, BrowseDirection.Forward, referenceType,
|
||||
includeSubtypes: true, nodeClassMask: 0u);
|
||||
return (cp, refs ?? new ReferenceDescriptionCollection());
|
||||
}
|
||||
|
||||
private static async Task<StatusCode> HistoryReadRawStatusAsync(ISession session, NodeId node, CancellationToken ct)
|
||||
{
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
IsReadModified = false,
|
||||
ReturnBounds = false,
|
||||
};
|
||||
var nodesToRead = new HistoryReadValueIdCollection { new HistoryReadValueId { NodeId = node } };
|
||||
var response = await session.HistoryReadAsync(
|
||||
null, new ExtensionObject(details), TimestampsToReturn.Source, false, nodesToRead, ct);
|
||||
response.Results.ShouldNotBeNull();
|
||||
response.Results.Count.ShouldBe(1);
|
||||
return response.Results[0].StatusCode;
|
||||
}
|
||||
|
||||
private static async Task<StatusCode> WriteValueStatusAsync(ISession session, NodeId node, object value, CancellationToken ct)
|
||||
{
|
||||
var writeCollection = new WriteValueCollection
|
||||
{
|
||||
new WriteValue { NodeId = node, AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) },
|
||||
};
|
||||
var response = await session.WriteAsync(null, writeCollection, ct);
|
||||
response.Results.ShouldNotBeNull();
|
||||
response.Results.Count.ShouldBe(1);
|
||||
return response.Results[0];
|
||||
}
|
||||
|
||||
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host)> BootServerAsync(
|
||||
int port, string pkiRoot, string appUri, CancellationToken ct)
|
||||
{
|
||||
var options = new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = appUri,
|
||||
ApplicationUri = appUri,
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = pkiRoot,
|
||||
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
};
|
||||
var server = new OtOpcUaSdkServer();
|
||||
var host = new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
await host.StartAsync(server, ct);
|
||||
return (server, host);
|
||||
}
|
||||
|
||||
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
||||
{
|
||||
var appConfig = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = "OtOpcUa.DualNamespaceClient",
|
||||
ApplicationUri = $"urn:OtOpcUa.DualNamespaceClient.{Guid.NewGuid():N}",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
||||
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
||||
|
||||
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
||||
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
||||
var endpointConfiguration = EndpointConfiguration.Create(appConfig);
|
||||
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
||||
|
||||
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
||||
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
|
||||
sessionName: "DualNamespaceAddressSpaceTests", sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
private static void SafeDelete(string dir)
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
{
|
||||
try { Directory.Delete(dir, recursive: true); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user