docs: rewrite IncrementalSync + AddressSpace for v3, fix driver-specs/driver-stability tier claims, update Secrets runbook for pair replication + KEK rotation (#623)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+65
-77
@@ -1,96 +1,84 @@
|
|||||||
# Address Space
|
# Address Space
|
||||||
|
|
||||||
> ⚠️ **Accuracy warning (audited 2026-07-27) — this page is v2-era and contradicts the shipped v3
|
The server serves **two OPC UA namespaces** under one shared root folder. Identity is owned by
|
||||||
> address space.** Corrections, in order of how badly they mislead:
|
`V3NodeIds` (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs`) and `AddressSpaceRealm`; the tree is
|
||||||
>
|
built from the deploy artifact (see `docs/IncrementalSync.md`), not by driver discovery at runtime.
|
||||||
> - **There is no "single custom namespace".** v3 exposes **two**:
|
|
||||||
> `https://zb.com/otopcua/raw` (`s=<RawPath>`) and `https://zb.com/otopcua/uns`
|
|
||||||
> (`s=<Area>/<Line>/<Equipment>/<EffectiveName>`), registered together at
|
|
||||||
> `OtOpcUaNodeManager.cs:45`. Identity authority is `V3NodeIds` + `AddressSpaceRealm`. The retired
|
|
||||||
> `https://zb.com/otopcua/ns` survives only in a comment marking it retired (`V3NodeIds.cs:17`).
|
|
||||||
> - **`Phase7Applier` / `Phase7Composer` / `GalaxyTagPlan` do not exist**, nor do the file paths cited
|
|
||||||
> for them. Renamed by `40e8a23e` to `AddressSpaceApplier` / `AddressSpaceComposer`
|
|
||||||
> (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`); the composition result type is
|
|
||||||
> `AddressSpaceComposition` (`AddressSpaceComposer.cs:13`).
|
|
||||||
> - **The `SystemPlatform` namespace kind is retired** — Galaxy is a standard Equipment-kind driver.
|
|
||||||
> - **`GenericDriverNodeManager` is test scaffolding**, not a production dispatch path
|
|
||||||
> (`GenericDriverNodeManager.cs:71`).
|
|
||||||
> - **The §Rediscovery section is wrong**: nothing consumes `OnRediscoveryNeeded`
|
|
||||||
> ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518)), and the driver list is stale
|
|
||||||
> (MQTT and MTConnect now raise it too).
|
|
||||||
>
|
|
||||||
> For the current design see the "v3 OPC UA Address Space (Batch 4)" section of `CLAUDE.md`,
|
|
||||||
> `docs/Raw.md`, and `docs/Uns.md`.
|
|
||||||
|
|
||||||
Address-space construction is a two-layer system. The **driver-facing layer** is the streaming builder: a driver implements `ITagDiscovery.DiscoverAsync` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs`) and emits `Folder` / `Variable` / `AddProperty` calls into an `IAddressSpaceBuilder` as it walks its backend — no buffering of the whole tree. `GenericDriverNodeManager` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs`) wraps that builder to capture alarm-condition sinks and routes alarm events from the driver to them. The **SDK materialization layer** turns the resulting node descriptions into live OPC UA nodes: `OpcUaPublishActor` drives the write-only `IOpcUaAddressSpaceSink`, whose production binding `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`) forwards to `OtOpcUaNodeManager` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`), a `CustomNodeManager2` subclass that owns the `FolderState` / `BaseDataVariableState` instances. The same code path serves Galaxy object hierarchies, Modbus PLC registers, AB CIP tags, TwinCAT symbols, FOCAS CNC parameters, and OPC UA Client aggregations — Galaxy is one driver of seven, not the driver.
|
| Realm | Namespace URI | Subtree | NodeId `s=` identifier |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `Raw` | `https://zb.com/otopcua/raw` | `/raw` device tree: Folder → Driver → Device → TagGroup → Tag | the node's **RawPath** (e.g. `Plant/Modbus/dev1/Speed`) |
|
||||||
|
| `Uns` | `https://zb.com/otopcua/uns` | UNS tree: Area → Line → Equipment → signal | slash-joined `Area/Line/Equipment[/EffectiveName]` |
|
||||||
|
|
||||||
|
`OtOpcUaNodeManager` registers both URIs (raw is index `NamespaceIndexes[0]`, UNS `[1]`). The retired
|
||||||
|
single namespace `https://zb.com/otopcua/ns` and the `EquipmentNodeIds` scheme no longer exist. The realm
|
||||||
|
travels beside the identifier at every sink seam (`IOpcUaAddressSpaceSink`); it is never parsed out of the
|
||||||
|
id string. Authoring and semantics: `docs/Raw.md`, `docs/Uns.md`.
|
||||||
|
|
||||||
## Root folder
|
## Root folder
|
||||||
|
|
||||||
`OtOpcUaNodeManager.CreateAddressSpace` creates a single shared root `FolderState` (`NodeId = OtOpcUa`, `BrowseName = OtOpcUa`, `EventNotifier = None`) under the standard OPC UA `Objects` folder, wired with an `Organizes` reference. Every driver's folders and variables hang beneath this one root; the server is published under a single `ApplicationUri = urn:OtOpcUa` (the `OpcUaApplicationHostOptions.ApplicationUri` default) and all nodes live in the server's single custom namespace, not a per-driver `urn:OtOpcUa:{DriverInstanceId}`. The UNS Area → Line → Equipment folder skeleton under the root is materialised by `Phase7Applier.MaterialiseHierarchy` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs`); SystemPlatform (Galaxy) tags are materialised by `Phase7Applier.MaterialiseGalaxyTags`.
|
`OtOpcUaNodeManager.CreateAddressSpace` creates **one** shared root `FolderState` — identifier and browse
|
||||||
|
name `OtOpcUa` (`V3NodeIds.SharedRootFolderId`), minted in the **raw** namespace — under the standard
|
||||||
## IAddressSpaceBuilder surface
|
`Objects` folder with an `Organizes` reference. Top-level Raw folders and top-level UNS Areas both parent
|
||||||
|
under it. The identifier is a display name, not a path segment; the data-plane authorizer maps it onto the
|
||||||
`IAddressSpaceBuilder` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs`) offers three calls:
|
trie's zero-segment realm-root scope (`docs/security.md`). The application URI is
|
||||||
|
`OpcUaApplicationHostOptions.ApplicationUri` (default `urn:OtOpcUa`); there is no per-driver namespace.
|
||||||
- `Folder(browseName, displayName)` — creates a child `FolderState` and returns a child builder scoped to it.
|
|
||||||
- `Variable(browseName, displayName, DriverAttributeInfo attributeInfo)` — creates a `BaseDataVariableState` and returns an `IVariableHandle` the driver keeps for alarm wiring.
|
|
||||||
- `AddProperty(browseName, DriverDataType, value)` — attaches a `PropertyState` for static metadata (e.g. equipment identification fields).
|
|
||||||
|
|
||||||
Drivers drive ordering. Typical pattern: root → folder per equipment → variables per tag. `GenericDriverNodeManager.BuildAddressSpaceAsync` calls `DiscoverAsync` once on startup and once per rediscovery cycle, tearing down the previous alarm subscription and clearing its sink registry before each re-walk so a redeploy doesn't double-fire alarm events.
|
|
||||||
|
|
||||||
## DriverAttributeInfo → OPC UA variable
|
|
||||||
|
|
||||||
Each variable carries a `DriverAttributeInfo` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs`):
|
|
||||||
|
|
||||||
| Field | Role |
|
|
||||||
|---|---|
|
|
||||||
| `FullName` | driver-side full reference used as the lookup key for Read/Write/Subscribe; also seeds the variable's string `NodeId` |
|
|
||||||
| `DriverDataType` | resolved to a built-in `DataTypeIds.*` NodeId at materialization time — `OtOpcUaNodeManager.ResolveBuiltInDataType` maps the data-type name string; each driver first maps its native type into `DriverDataType` (e.g. Galaxy via `Browse/DataTypeMap.Map`) |
|
|
||||||
| `IsArray` / `ArrayDim` | declared 1-D-array length carried as metadata; the Galaxy discoverer sets `ArrayDim` only when the gateway reports a positive dimension |
|
|
||||||
| `SecurityClass` | write-authorization tier (`SecurityClassification`); enforced server-side by the `NodePermissions` ACL evaluator (`TriePermissionEvaluator`) mapping each `OpcUaOperation` to a required permission bit. The Galaxy driver also caches it per full reference (`_securityByFullRef`) to answer `GetSecurityClassification` |
|
|
||||||
| `IsHistorized` | marks the attribute as feeding historian / HistoryRead |
|
|
||||||
| `IsAlarm` | drives the `MarkAsAlarmCondition` pass (see below) |
|
|
||||||
| `WriteIdempotent` | when true the attribute's writes are safe to replay, so the capability invoker may apply Polly retry; defaults false so pulses / acks / counters aren't auto-retried |
|
|
||||||
| `Source` | `NodeSourceKind` discriminator (`Driver` / `Virtual` / `ScriptedAlarm`) that decides which subsystem dispatches the node's Read/Write/Subscribe |
|
|
||||||
|
|
||||||
The variable is created with `StatusCode = BadWaitingForInitialData` and a null value until the first Read or `ISubscribable.OnDataChange` push lands. Note the production SDK sink (`OtOpcUaNodeManager.EnsureVariable`) currently materialises every variable as `ValueRank = Scalar`, read-only `AccessLevel`, and `Historizing = false` — the `IsArray`/`IsHistorized` intent lives in `DriverAttributeInfo` but is not yet projected onto the SDK node.
|
|
||||||
|
|
||||||
## CapturingBuilder + alarm sink registration
|
|
||||||
|
|
||||||
`GenericDriverNodeManager.BuildAddressSpaceAsync` wraps the supplied builder in a `CapturingBuilder` before calling `DiscoverAsync`. The wrapper observes every `Variable()` call: when a returned `IVariableHandle.MarkAsAlarmCondition(AlarmConditionInfo)` fires, the sink is registered in the manager's `_alarmSinks` dictionary keyed by the variable's `FullReference`. Subsequent `IAlarmSource.OnAlarmEvent` pushes are routed to the matching sink by `SourceNodeId`. This keeps the alarm-wiring protocol declarative — drivers just flag `DriverAttributeInfo.IsAlarm = true` and the materialization of the OPC UA `AlarmConditionState` node is handled by the server layer. See `docs/AlarmTracking.md`.
|
|
||||||
|
|
||||||
## NodeId scheme
|
## NodeId scheme
|
||||||
|
|
||||||
All nodes share the server's single custom namespace (`NamespaceIndex`); NodeIds are string identifiers, not numeric. The string values come from the source rows / driver references — there is no per-driver namespace prefix:
|
Segments are validated by `RawPaths.ValidateSegment` (no embedded `/`, no leading/trailing whitespace;
|
||||||
|
ordinal, case-sensitive). `V3NodeIds.Raw(rawPath)` is a pass-through; `V3NodeIds.Uns(...)` /
|
||||||
|
`UnsChild(parent, segment)` build UNS ids.
|
||||||
|
|
||||||
| Node type | NodeId (string identifier) | Example |
|
| Node | Realm | `s=` identifier | Example |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| Shared root | `OtOpcUa` | `OtOpcUa` |
|
| Shared root | Raw | `OtOpcUa` | `OtOpcUa` |
|
||||||
| UNS Area / Line / Equipment folder | the Config-DB `UnsAreaId` / `UnsLineId` / `EquipmentId` | `EQ_Press_07` |
|
| Raw folder / driver / device / tag group / tag | Raw | its own RawPath | `Plant/Modbus/dev1/Speed` |
|
||||||
| Galaxy tag variable | the MXAccess reference (`Phase7Applier` uses `GalaxyTagPlan.MxAccessRef`) | `DelmiaReceiver_001.DownloadPath` |
|
| UNS Area / Line / Equipment folder | Uns | `Area`, `Area/Line`, `Area/Line/Equipment` | `packaging/line-1/press-07` |
|
||||||
| Equipment tag variable | the driver full reference from `DriverAttributeInfo.FullName` | driver-specific |
|
| UNS reference variable (`UnsTagReference`), virtual tag, scripted alarm | Uns | `Area/Line/Equipment/EffectiveName` | `packaging/line-1/press-07/speed` |
|
||||||
|
|
||||||
For Galaxy the variable `FullName` is the `tag_name.AttributeName` MXAccess reference; AB CIP uses `tag.Name` or `tag.Name.member` for UDT members; the shape is the driver's choice. Browse-path resolution (OPC UA `TranslateBrowsePathsToNodeIds`) is the canonical way clients map a browse path to one of these flat NodeIds.
|
Area, Line and Equipment names must match `^[a-z0-9-]{1,32}$` (`docs/Uns.md`). EffectiveNames are unique per
|
||||||
|
equipment across references, virtual tags and scripted alarms.
|
||||||
|
|
||||||
## Per-driver hierarchy examples
|
## One source, fanned to both namespaces
|
||||||
|
|
||||||
- **Galaxy**: `GalaxyDriver.DiscoverAsync` delegates to `GalaxyDiscoverer` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/GalaxyDiscoverer.cs`), which walks the hierarchy from `IGalaxyHierarchySource` — one folder per Galaxy object (browse name = `contained_name`, falling back to `tag_name`), one variable per dynamic attribute (full reference = `tag_name.AttributeName`). It copies the gateway-supplied `IsAlarm` flag through to `DriverAttributeInfo` and, for alarm-bearing attributes, calls `MarkAsAlarmCondition` with the five sub-attribute refs built by `AlarmRefBuilder`.
|
Every device value has exactly one source: the raw tag's node, keyed by RawPath. A `UnsTagReference`
|
||||||
- **Modbus**: streams one folder per device, one variable per register range from `ModbusDriverOptions`. No alarm surface.
|
projects that raw tag into an equipment as a UNS variable that carries an `Organizes` reference to its raw
|
||||||
- **AB CIP**: `AbCipDriver.DiscoverAsync` emits an `AbCip` root, then a folder per configured device. Pre-declared tags become variables under the device folder; UDT (`Structure`) tags fan out into a sub-folder with one variable per member; when controller browse is enabled, `IAbCipTagEnumerator` adds discovered tags under a `Discovered/` sub-folder. (`AbCipTemplateCache` caches UDT layouts for the libplctag enumerator.)
|
node and mirrors it. `DriverHostActor` fans one driver publish for a RawPath to the raw NodeId **and** every
|
||||||
- **OPC UA Client**: re-exposes a remote server's address space — `OpcUaClientDriver.DiscoverAsync` browses the upstream from `BrowseRoot` into a `Remote` folder (pass 1), then batch-reads DataType/AccessLevel/ValueRank/Historizing per variable before registering them (pass 2).
|
referencing UNS NodeId with identical value, quality and timestamp. Writes via either NodeId route to the
|
||||||
|
same driver reference under the same realm-qualified `WriteOperate` gate. Native alarms materialise once at
|
||||||
|
the raw tag (`ConditionId = RawPath`) and fan through notifiers to the raw device folder and every
|
||||||
|
referencing equipment folder. Both NodeIds register the same historian tagname.
|
||||||
|
|
||||||
See `docs/v2/driver-specs.md` for the per-driver discovery contracts.
|
## Materialisation
|
||||||
|
|
||||||
## Rediscovery
|
`AddressSpaceComposer` turns the deployment artifact into an `AddressSpaceComposition`;
|
||||||
|
`AddressSpaceApplier` (both in `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`) walks it (`MaterialiseHierarchy`,
|
||||||
|
`MaterialiseRawSubtree`, `MaterialiseUnsReferences`, `MaterialiseEquipmentVirtualTags`,
|
||||||
|
`MaterialiseScriptedAlarms`, ...) into the write-only `IOpcUaAddressSpaceSink`, whose production binding
|
||||||
|
`SdkAddressSpaceSink` forwards to `OtOpcUaNodeManager`, the `CustomNodeManager2` that owns the
|
||||||
|
`FolderState` / `BaseDataVariableState` instances. Variables are created through `EnsureVariable`:
|
||||||
|
`ValueRank` is `OneDimension` with `ArrayDimensions=[arrayLength]` for array tags and `Scalar` otherwise;
|
||||||
|
`AccessLevel` carries the write bit only for writable nodes (array nodes only when the driver can write the
|
||||||
|
array); `Historizing` plus the `HistoryRead` bit are set when a historian tagname is supplied.
|
||||||
|
|
||||||
Drivers that implement `IRediscoverable` fire `OnRediscoveryNeeded` when their backend signals a change. Galaxy's `DeployWatcher` raises it when the observed `time_of_last_deploy` advances; TwinCAT raises it on the ADS symbol-version-changed signal (`DeviceSymbolVersionInvalid`, error 1809). Core re-runs `DiscoverAsync` and diffs — see `docs/IncrementalSync.md`. Drivers that don't implement `IRediscoverable` (Modbus, S7, OPC UA Client) only change their address space when a new generation is published from the Config DB.
|
The nodes start `BadWaitingForInitialData` until the first read or subscription push. Datatype names resolve
|
||||||
|
through `OtOpcUaNodeManager.ResolveBuiltInDataType`; each driver first maps its native type into
|
||||||
|
`DriverDataType` (Galaxy: `Driver.Galaxy/Browse/DataTypeMap.cs`).
|
||||||
|
|
||||||
|
## Driver discovery is the browse picker, not the address space
|
||||||
|
|
||||||
|
`ITagDiscovery.DiscoverAsync` (streaming into `IAddressSpaceBuilder`: `Folder` / `Variable` /
|
||||||
|
`AddProperty`, each variable described by a `DriverAttributeInfo`) feeds the `/raw` browse-commit flow via
|
||||||
|
`Commons/Browsing/DiscoveryDriverBrowser`: the operator browses a device, picks leaves and commits them as
|
||||||
|
raw tags, which then reach the server as an ordinary deployment. Drivers that signal backend change via
|
||||||
|
`IRediscoverable` only raise an operator "re-browse" prompt — the served tree never changes on its own.
|
||||||
|
See `docs/IncrementalSync.md`. `GenericDriverNodeManager` (`Core/OpcUa/`) is leftover scaffolding with no
|
||||||
|
production caller.
|
||||||
|
|
||||||
## Key source files
|
## Key source files
|
||||||
|
|
||||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — orchestration + `CapturingBuilder`
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/V3NodeIds.cs`, `AddressSpaceRealm.cs` — identity authority
|
||||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`, `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs` — OPC UA materialization (write-only sink fed by the actor system)
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/RawPaths.cs` — RawPath/segment rules
|
||||||
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Applier.cs` — materialises the UNS folder hierarchy + Galaxy tags into the sink
|
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`, `SdkAddressSpaceSink.cs` — SDK materialisation
|
||||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs` — walks Config-DB Equipment-namespace rows into the builder
|
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`, `AddressSpaceApplier.cs` — compose and apply
|
||||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAddressSpaceBuilder.cs` — builder contract
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs`, `IAddressSpaceBuilder.cs`, `DriverAttributeInfo.cs` — discovery-side contracts
|
||||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ITagDiscovery.cs` — driver discovery capability
|
|
||||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverAttributeInfo.cs` — per-attribute descriptor
|
|
||||||
|
|||||||
+58
-77
@@ -1,90 +1,71 @@
|
|||||||
# Incremental Sync
|
# Incremental Sync
|
||||||
|
|
||||||
> ⚠️ **Accuracy warning (audited 2026-07-27) — most of this page describes machinery that is retired
|
How a change reaches the running server's address space in v3. There is exactly one path that
|
||||||
> or was never wired.** Read it as v2-era design history, not as current behaviour.
|
changes the served address space — the **deploy artifact** — and one advisory signal
|
||||||
>
|
(`IRediscoverable`) that never changes it.
|
||||||
> - **"Rebuild flow" (below) is not a production path.** It is built on `GenericDriverNodeManager`,
|
|
||||||
> which is Core test scaffolding with **zero production references**
|
|
||||||
> (`GenericDriverNodeManager.cs:71`). Its `_variablesByFullRef` / `_sourceByFullRef` fields do not
|
|
||||||
> exist in `src/`.
|
|
||||||
> - **Driver-backend rediscovery has no consumer.** Nothing in `src/Server/` or `src/Core/`
|
|
||||||
> subscribes to `IRediscoverable.OnRediscoveryNeeded`; `DriverHostActor.HandleDiscoveredNodes`
|
|
||||||
> (`DriverHostActor.cs:986-1002`) short-circuits unconditionally. The `ScopeHint` is read by
|
|
||||||
> nothing. Drivers that raise it today: Galaxy, TwinCAT, MQTT/Sparkplug, MTConnect
|
|
||||||
> ([#518](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/518),
|
|
||||||
> [#507](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/507)).
|
|
||||||
> - **The generation-publish path is retired.** `sp_PublishGeneration` and
|
|
||||||
> `sp_ComputeGenerationDiff` are stubs that `RAISERROR`
|
|
||||||
> (`20260715230637_V3Initial.StoredProcedures.cs:194`); `DraftRevisionToken` does not exist.
|
|
||||||
>
|
|
||||||
> **The one live rebuild path in v3** is the deploy artifact: `ConfigComposer` →
|
|
||||||
> `Deployment.ArtifactBlob` → `DriverHostActor` apply → `AddressSpaceComposer` /
|
|
||||||
> `AddressSpaceApplier` diff-apply.
|
|
||||||
|
|
||||||
Two distinct change-detection paths feed the running server: driver-backend rediscovery (Galaxy's `time_of_last_deploy`, TwinCAT's symbol-version-changed) and generation-level config publishes from the Admin UI. Both flow into re-runs of `ITagDiscovery.DiscoverAsync`, but they originate differently.
|
## The deploy-artifact path
|
||||||
|
|
||||||
## Driver-backend rediscovery — IRediscoverable
|
1. **Compose.** `ConfigComposer` (`src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs`)
|
||||||
|
snapshots the whole Config DB into one gzip-compressed JSON blob (`Deployment.ArtifactBlob`) whose
|
||||||
|
`RevisionHash` is the SHA-256 of those exact bytes. A deployment is fleet-wide; each node
|
||||||
|
self-scopes the artifact (`DeploymentArtifact.ResolveClusterScope`).
|
||||||
|
2. **Dispatch.** `ConfigPublishCoordinator` (`.../ControlPlane/Coordinators/`) sends each enabled
|
||||||
|
`ClusterNode` a payload-free dispatch and waits for acks until the apply deadline. A node obtains the
|
||||||
|
bytes either straight from central SQL (`ConfigSource:Mode=Direct`) or over gRPC with a SHA-256
|
||||||
|
check (`FetchAndCache`) — see `docs/Configuration.md` §`ConfigSource`.
|
||||||
|
3. **Apply.** `DriverHostActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/`) opens the artifact and
|
||||||
|
reconciles in two halves:
|
||||||
|
- **Drivers** — `DriverSpawnPlanner.Compute` yields `ToStop` / `ToApplyDelta` / `ToSpawn`. A changed
|
||||||
|
driver config reaches the live driver as `IDriver.ReinitializeAsync` on its `DriverInstanceActor`;
|
||||||
|
a removed driver is stopped. An unreadable or empty blob is "no answer": the apply fails and the
|
||||||
|
running children are left alone (never an empty configuration).
|
||||||
|
- **Address space** — `AddressSpaceComposer` builds an `AddressSpaceComposition` from the artifact;
|
||||||
|
`AddressSpacePlan` diffs it against the last applied composition; `AddressSpaceChangeClassifier`
|
||||||
|
picks the delta class; `AddressSpaceApplier`
|
||||||
|
(`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/`) mutates `OtOpcUaNodeManager` through the sink.
|
||||||
|
4. **Classes** (`AddressSpaceChangeKind`): `Empty`, `AttributeOnly`, `PureAdd`, `PureRemove`,
|
||||||
|
`AddRemoveMix` — all applied surgically, so unrelated MonitoredItems survive — and `Rebuild`, the
|
||||||
|
default-closed fallback for anything the classifier does not whitelist. Any surgical `false`/throw
|
||||||
|
also ratchets to a safe rebuild, and a degraded non-rebuild apply does not advance the last-applied
|
||||||
|
composition (the next deploy re-diffs and re-ensures). The whitelist, the UNS-mirror repair rule and
|
||||||
|
the add/remove announcements are described in the "surgical deploys" paragraph of `CLAUDE.md`
|
||||||
|
and `docs/plans/2026-08-15-surgical-deploys.md`.
|
||||||
|
|
||||||
Drivers whose backend has a native change signal implement `IRediscoverable` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs`):
|
Virtual tags and scripted alarms ride the same artifact and the same plan; a script edit is just another
|
||||||
|
changed record in the composition (see `docs/VirtualTags.md`, `docs/ScriptedAlarms.md`).
|
||||||
|
|
||||||
```csharp
|
## Driver-backend rediscovery is advisory
|
||||||
public interface IRediscoverable
|
|
||||||
{
|
|
||||||
event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
|
||||||
}
|
|
||||||
public sealed record RediscoveryEventArgs(string Reason, string? ScopeHint);
|
|
||||||
```
|
|
||||||
|
|
||||||
The driver fires the event with a reason string (for the diagnostic log) and an optional scope hint — a non-null hint lets Core scope the rebuild surgically to that subtree; null means "the whole address space may have changed".
|
Drivers whose backend signals a tag-set change implement `IRediscoverable`
|
||||||
|
(`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs`; `OnRediscoveryNeeded` carries a
|
||||||
|
`Reason` and an optional `ScopeHint`). Raisers today: **Galaxy** (`DeployWatcher`, new
|
||||||
|
`time_of_last_deploy`), **TwinCAT** (ADS symbol-version-changed), **MQTT/Sparkplug** and **MTConnect**.
|
||||||
|
AbCip, AbLegacy, FOCAS, Modbus, S7 and the rest raise nothing.
|
||||||
|
|
||||||
Drivers that implement the capability today:
|
`DriverInstanceActor` is the only production consumer: it subscribes in `PreStart`, detaches in
|
||||||
|
`PostStop`, and folds the raise into the driver-health snapshot
|
||||||
|
(`DriverHealthChanged.RediscoveryNeededUtc` / `.RediscoveryReason`), which the AdminUI `/hosts` page shows
|
||||||
|
as a **re-browse** chip. **It never rebuilds or edits the served address space** — v3 raw tags are authored
|
||||||
|
explicitly in `/raw`, so an operator re-browses the device and commits, and the result reaches the
|
||||||
|
server as an ordinary deployment. `ScopeHint` is not read by the actor. (The older runtime
|
||||||
|
injection path was removed, #507 closed as superseded.)
|
||||||
|
|
||||||
- **Galaxy** — `DeployWatcher` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/Browse/DeployWatcher.cs`) subscribes to the mxaccessgw gRPC stream (`IGalaxyDeployWatchSource.WatchAsync`) and fires on a new `time_of_last_deploy` value. The gateway polls the Galaxy repository DB internally; the driver side is event-driven.
|
## Not part of the v3 path
|
||||||
- **TwinCAT** — observes ADS symbol-version-changed notifications (ADS error `DeviceSymbolVersionInvalid`, decimal 1809 / `0x0711`). Note: legacy Beckhoff documentation sometimes cites `0x0702` (`DeviceInvalidGroup`) — that is a transcription error; the correct code is `0x0711` per `TwinCATStatusMapper.AdsSymbolVersionChanged` (`src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStatusMapper.cs:35`).
|
|
||||||
|
|
||||||
Static drivers (Modbus, S7, AB CIP, AB Legacy, FOCAS) do not implement `IRediscoverable` — their tags only change when a new generation is published from the Config DB. Core sees absence of the interface and skips change-detection wiring for those drivers (decision #54).
|
- `sp_PublishGeneration` / `sp_ComputeGenerationDiff` are stubs that `RAISERROR` in the v3 baseline
|
||||||
|
migration; there is no generation-publish flow and no `DraftRevisionToken`.
|
||||||
## Config-DB generation publishes
|
- `GenericDriverNodeManager` (`Core/OpcUa/`) is retained scaffolding with no production caller; the
|
||||||
|
diff / teardown / subscription-replay "rebuild flow" described by earlier versions of this page does not
|
||||||
Tag-set changes authored in the Admin UI (UNS edits, CSV imports, driver-config edits) accumulate in a draft generation and commit via `sp_PublishGeneration`. The delta between the currently-published generation and the proposed next one is computed by `sp_ComputeGenerationDiff`, which drives:
|
exist in `src/`.
|
||||||
|
|
||||||
- The publish-preview surface in the Admin UI (`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Deployments.razor`, backed by `AdminOperationsClient`) so operators can preview what will change before clicking Publish.
|
|
||||||
- The 409-on-stale-draft flow (decision #161) — a UNS drag-reorder preview carries a `DraftRevisionToken` so Confirm returns `409 Conflict / refresh-required` if the draft advanced between preview and commit.
|
|
||||||
|
|
||||||
After publish, the server's generation applier invokes `IDriver.ReinitializeAsync(driverConfigJson, ct)` on every driver whose `DriverInstance.DriverConfig` row changed in the new generation. Reinitialize is the in-process recovery path for Tier A/B drivers; if it fails the driver is marked `DriverState.Faulted` and its nodes go Bad quality — but the server process stays running. See `docs/v2/driver-stability.md`.
|
|
||||||
|
|
||||||
Drivers whose discovery depends on Config DB state (Modbus register maps, S7 DBs, AB CIP tag lists) re-run their discovery inside `ReinitializeAsync`; Core then diffs the new node set against the current address space.
|
|
||||||
|
|
||||||
## Rebuild flow
|
|
||||||
|
|
||||||
When a rediscovery is triggered (by either source), `GenericDriverNodeManager` re-runs `ITagDiscovery.DiscoverAsync` into the same `CapturingBuilder` it used at first build. The new node set is diffed against the current:
|
|
||||||
|
|
||||||
1. **Diff** — full-name comparison of the new `DriverAttributeInfo` set against the existing `_variablesByFullRef` map. Added / removed / modified references are partitioned.
|
|
||||||
2. **Snapshot subscriptions** — before teardown, Core captures the current monitored-item ref-counts for every affected reference so subscriptions can be replayed after rebuild.
|
|
||||||
3. **Teardown** — removed / modified variable nodes are deleted via `CustomNodeManager2.DeleteNode`. Driver-side subscriptions for those references are unwound via `ISubscribable.UnsubscribeAsync`.
|
|
||||||
4. **Rebuild** — added / modified references get fresh `BaseDataVariableState` nodes via the standard `IAddressSpaceBuilder.Variable(...)` path. Alarm-flagged references re-register their `IAlarmConditionSink` through `CapturingBuilder`.
|
|
||||||
5. **Restore subscriptions** — for every captured reference that still exists after rebuild, Core re-opens the driver subscription and restores the original ref-count.
|
|
||||||
|
|
||||||
Exceptions during teardown are swallowed per decision #12 — a driver throw must not leave the node tree half-deleted.
|
|
||||||
|
|
||||||
## Scope hint
|
|
||||||
|
|
||||||
When `RediscoveryEventArgs.ScopeHint` is non-null (e.g. a folder path), Core restricts the diff to that subtree. This matters for Galaxy Platform-scoped deployments where a `time_of_last_deploy` advance may only affect one platform's subtree. Null scope falls back to a full-tree diff.
|
|
||||||
|
|
||||||
## Virtual tags in the rebuild
|
|
||||||
|
|
||||||
Per [ADR-002](v2/implementation/adr-002-driver-vs-virtual-dispatch.md), virtual (scripted) tags live in the same address space as driver tags and flow through the same rebuild. `EquipmentNodeWalker` (`src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/EquipmentNodeWalker.cs`) emits virtual-tag children alongside driver-tag children with `DriverAttributeInfo.Source = NodeSourceKind.Virtual`, and `DriverNodeManager` registers each variable's source in `_sourceByFullRef` so the dispatch branches correctly after rebuild. Virtual-tag script changes published from the Admin UI land through the same generation-publish path — the `VirtualTagEngine` recompiles its script bundle when its config row changes and `DriverNodeManager` re-registers any added/removed virtual variables through the standard diff path. Subscription restoration after rebuild runs through each source's `ISubscribable` — either the driver's or `VirtualTagSource` — without special-casing.
|
|
||||||
|
|
||||||
## Active subscriptions survive rebuild
|
|
||||||
|
|
||||||
Subscriptions for unchanged references stay live across rebuilds — their ref-count map is not disturbed. Clients monitoring a stable tag never see a data-change gap during a deploy, only clients monitoring a tag that was genuinely removed see the subscription drop.
|
|
||||||
|
|
||||||
## Key source files
|
## Key source files
|
||||||
|
|
||||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IRediscoverable.cs` — backend-change capability
|
- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs` — artifact composition + hash
|
||||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs` — discovery orchestration
|
- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs` — dispatch + ack set
|
||||||
- `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs` — `ReinitializeAsync` contract
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` — apply + driver reconcile
|
||||||
- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs` — publish-flow driver
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` — node-side artifact parsing
|
||||||
- `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs` — cluster singleton invoked by the Admin UI's `AdminOperationsClient`
|
- `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs`, `AddressSpacePlan.cs`,
|
||||||
- `docs/v2/config-db-schema.md` — `sp_PublishGeneration` + `sp_ComputeGenerationDiff`
|
`AddressSpaceChangeClassifier.cs`, `AddressSpaceApplier.cs` — compose, diff, classify, apply
|
||||||
- `docs/v2/admin-ui.md` — DiffViewer + draft-revision-token flow
|
- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs` — `IRediscoverable` consumer
|
||||||
|
- `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs` — in-place update seam
|
||||||
|
|||||||
@@ -96,14 +96,15 @@ to the actual offending register(s). Per-tag escape hatch:
|
|||||||
from the driver's `DriverConfig` JSON. Key fields:
|
from the driver's `DriverConfig` JSON. Key fields:
|
||||||
|
|
||||||
- **Endpoint** — `Host`, `Port` (default 502), `UnitId`, `Timeout`. Per-tag `UnitId` overrides drive multi-slave gateway topology.
|
- **Endpoint** — `Host`, `Port` (default 502), `UnitId`, `Timeout`. Per-tag `UnitId` overrides drive multi-slave gateway topology.
|
||||||
|
- **`Transport`** — `Tcp` (default; MBAP header + transaction id) or `RtuOverTcp` (RTU `[addr][PDU][CRC-16]` framing tunnelled over the socket to a serial-to-Ethernet gateway); string-valued, absent = `Tcp`. The Test Connect probe follows the authored mode.
|
||||||
- **`Tags`** — the pre-declared `ModbusTagDefinition` list; this *is* the address space.
|
- **`Tags`** — the pre-declared `ModbusTagDefinition` list; this *is* the address space.
|
||||||
- **`Probe`** — connectivity-probe interval / timeout / probe register (default register 0).
|
- **`Probe`** — connectivity-probe interval / timeout / probe register (default register 0).
|
||||||
- **Read/write caps** — `MaxRegistersPerRead` (125), `MaxRegistersPerWrite` (123), `MaxCoilsPerRead` (2000), plus `MaxReadGap` and `AutoProhibitReprobeInterval` for coalescing.
|
- **Read/write caps** — `MaxRegistersPerRead` (125), `MaxRegistersPerWrite` (123), `MaxCoilsPerRead` (2000), plus `MaxReadGap` and `AutoProhibitReprobeInterval` for coalescing.
|
||||||
- **Function-code overrides** — `UseFC15ForSingleCoilWrites`, `UseFC16ForSingleRegisterWrites` for PLCs that only accept multi-write codes.
|
- **Function-code overrides** — `UseFC15ForSingleCoilWrites`, `UseFC16ForSingleRegisterWrites` for PLCs that only accept multi-write codes.
|
||||||
- **Resilience** — `AutoReconnect`, `KeepAlive`, `IdleDisconnectTimeout`, `Reconnect` backoff, and `WriteOnChangeOnly` redundant-write suppression.
|
- **Resilience** — `AutoReconnect`, `KeepAlive`, `IdleDisconnectTimeout`, `Reconnect` backoff, and `WriteOnChangeOnly` redundant-write suppression.
|
||||||
|
|
||||||
Full per-field descriptions live in `ModbusDriverOptions.cs`. The JSON skeleton
|
Full per-field descriptions live in `ModbusDriverOptions.cs`. The v2-era JSON skeleton
|
||||||
is reproduced in [docs/v2/driver-specs.md §2](../v2/driver-specs.md).
|
in [docs/v2/driver-specs.md §2](../v2/driver-specs.md) predates the `Transport` key and the v3 raw-tag model.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Three properties worth knowing:
|
|||||||
- **Historian.Gateway** (server-side historian backend, not a tag driver) is documented in the main guide:
|
- **Historian.Gateway** (server-side historian backend, not a tag driver) is documented in the main guide:
|
||||||
- [../Historian.md](../Historian.md) — HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (`SendEvent`), continuous historization (`WriteLiveValues`), `EnsureTags` provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: [Historian.Wonderware.md](Historian.Wonderware.md).)
|
- [../Historian.md](../Historian.md) — HistorianGateway backend: read-path registration, HistoryRead dispatch, alarm store-and-forward (`SendEvent`), continuous historization (`WriteLiveValues`), `EnsureTags` provisioning, config keys, deployment prerequisites. (The retired Wonderware sidecar backend it replaced: [Historian.Wonderware.md](Historian.Wonderware.md).)
|
||||||
|
|
||||||
- The full per-field spec (capability surface, config schema, addressing, data-type maps, connection settings, quirks for every driver) lives in [docs/v2/driver-specs.md](../v2/driver-specs.md). The overview pages above are the short path; that file is the authoritative per-driver reference.
|
- The full per-field spec (capability surface, config schema, addressing, data-type maps, connection settings, quirks for every driver) lives in [docs/v2/driver-specs.md](../v2/driver-specs.md). The overview pages above are the short path and are the current reference; that file is the v2-era per-field spec (see its status banner for which drivers it covers).
|
||||||
|
|
||||||
## Test-fixture coverage maps
|
## Test-fixture coverage maps
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
|
|||||||
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
|
- [Siemens S7](S7-Test-Fixture.md) — Dockerized `python-snap7` server; DB/MB read + write round-trip verified end-to-end on `:1102`
|
||||||
- [AB Legacy](AbLegacy-Test-Fixture.md) — Dockerized `ab_server` PCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end. `/1,0` cip-path required for the Docker fixture; real hardware uses empty. Residual gap: bit-file writes (`B3:0/5`) still surface BadState — real HW / RSEmulate 500 for those
|
- [AB Legacy](AbLegacy-Test-Fixture.md) — Dockerized `ab_server` PCCC mode across SLC500 / MicroLogix / PLC-5 profiles (task #224); N/F/L-file round-trip verified end-to-end. `/1,0` cip-path required for the Docker fixture; real hardware uses empty. Residual gap: bit-file writes (`B3:0/5`) still surface BadState — real HW / RSEmulate 500 for those
|
||||||
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
|
- [TwinCAT](TwinCAT-Test-Fixture.md) — XAR-VM integration scaffolding (task #221); three smoke tests skip when VM unreachable. Unit via `FakeTwinCATClient` with native-notification harness
|
||||||
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; Tier C out-of-process isolation scoped but not shipped
|
- [FOCAS](FOCAS-Test-Fixture.md) — no integration fixture, unit-only via `FakeFocasClient`; FOCAS runs in-process (Tier A) — the Tier C out-of-process isolation once scoped was never built
|
||||||
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — Dockerized `opc-plc` integration suite (task #215): real Secure Channel + Session, read + subscribe verified end-to-end; write not yet exercised in the integration suite; exhaustive capability matrix (reconnect, failover, cert-auth, history, alarms) via unit suite with mocked `Session`
|
- [OPC UA Client](OpcUaClient-Test-Fixture.md) — Dockerized `opc-plc` integration suite (task #215): real Secure Channel + Session, read + subscribe verified end-to-end; write not yet exercised in the integration suite; exhaustive capability matrix (reconnect, failover, cert-auth, history, alarms) via unit suite with mocked `Session`
|
||||||
- [MQTT](Mqtt-Test-Fixture.md) — Dockerized `eclipse-mosquitto` with **TLS + real auth on both listeners (no anonymous fallback)** + a JSON-publisher sidecar; env-gated live suite (**15 tests**) — 7 plain (TLS/auth connect, CA pinning both ways, subscribe→value, retained seeding, wrong-password rejection) + 8 Sparkplug against a **project-owned C# edge-node simulator** on the `sparkplug` compose profile (birth→alias bind, DDATA, NDEATH→STALE, rebirth NCMD round-trip). Births are never retained, so `docker restart otopcua-sparkplug-sim` is how you force a fresh one
|
- [MQTT](Mqtt-Test-Fixture.md) — Dockerized `eclipse-mosquitto` with **TLS + real auth on both listeners (no anonymous fallback)** + a JSON-publisher sidecar; env-gated live suite (**15 tests**) — 7 plain (TLS/auth connect, CA pinning both ways, subscribe→value, retained seeding, wrong-password rejection) + 8 Sparkplug against a **project-owned C# edge-node simulator** on the `sparkplug` compose profile (birth→alias bind, DDATA, NDEATH→STALE, rebirth NCMD round-trip). Births are never retained, so `docker restart otopcua-sparkplug-sim` is how you force a fresh one
|
||||||
- [Galaxy](../v1/drivers/Galaxy-Test-Fixture.md) — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive)
|
- [Galaxy](../v1/drivers/Galaxy-Test-Fixture.md) — richest harness: gateway E2E + ZB SQL live-smoke + MXAccess opt-in (v1 archive)
|
||||||
@@ -125,5 +125,5 @@ Each driver has a dedicated fixture doc that lays out what the integration / uni
|
|||||||
- [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-side `IHistoryProvider` (it forwards HistoryRead to the upstream server); the AVEVA Historian path is served server-side by the HistorianGateway-backed `IHistorianDataSource` instead. Other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
|
- [HistoricalDataAccess.md](../v1/HistoricalDataAccess.md) — `IHistoryProvider` dispatch, aggregate mapping, continuation points. The OPC UA Client driver is the only driver that implements driver-side `IHistoryProvider` (it forwards HistoryRead to the upstream server); the AVEVA Historian path is served server-side by the HistorianGateway-backed `IHistorianDataSource` instead. Other drivers do not implement the interface and return `BadHistoryOperationUnsupported`.
|
||||||
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, TwinCAT, and MQTT have no alarm source.
|
- [AlarmTracking.md](../AlarmTracking.md) — `IAlarmSource` event model and filtering. Implemented by Galaxy (native MxAccess alarms, working end-to-end), OPC UA Client, AB CIP, and FOCAS; AB Legacy, Modbus, S7, TwinCAT, and MQTT have no alarm source.
|
||||||
- [Subscriptions.md](../v1/Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`.
|
- [Subscriptions.md](../v1/Subscriptions.md) — how the Server multiplexes subscriptions onto `ISubscribable.OnDataChange`.
|
||||||
- [docs/v2/driver-stability.md](../v2/driver-stability.md) — tier system (A / B / C), shared `CapabilityPolicy` defaults per tier × capability, `MemoryTracking` hybrid formula, and process-level recycle rules.
|
- [docs/v2/driver-stability.md](../v2/driver-stability.md) — historical design record of the A / B / C tier model. Only `DriverTier` + the `CapabilityPolicy` defaults per tier × capability (`DriverResilienceOptions.GetTierDefaults`) are live; its `MemoryTracking` / recycle / out-of-process isolation machinery was deleted (#522) and every driver runs Tier A.
|
||||||
- [docs/v2/plan.md](../v2/plan.md) — authoritative vision, architecture decisions, migration strategy.
|
- [docs/v2/plan.md](../v2/plan.md) — authoritative vision, architecture decisions, migration strategy.
|
||||||
|
|||||||
@@ -31,35 +31,90 @@ plaintext secret on every node, no matter its role:
|
|||||||
combination — must unwrap the store with the exact same master key. A
|
combination — must unwrap the store with the exact same master key. A
|
||||||
per-node KEK (e.g. a per-box DPAPI-protected key) would make each node
|
per-node KEK (e.g. a per-box DPAPI-protected key) would make each node
|
||||||
decrypt every *other* node's ciphertext rows to garbage.
|
decrypt every *other* node's ciphertext rows to garbage.
|
||||||
2. **Identical store rows on every node.** All nodes must read the same SQLite
|
2. **The same store rows on every node of a pair.** Nodes must read the same rows —
|
||||||
database (same file, or a replicated/shared copy with the same rows) — not
|
not independently-seeded stores that merely share a KEK. Either mount one SQLite file
|
||||||
independently-seeded stores that happen to share a KEK.
|
(see "Alternative: shared volume" below) or, preferred, turn on Akka secret replication
|
||||||
|
so each pair converges its own rows.
|
||||||
|
|
||||||
`ZB.MOM.WW.Secrets` ships a SQLite-only `ISecretStore` with a `NoOpSecretReplicator`
|
The base `ZB.MOM.WW.Secrets` store is a plain local SQLite `ISecretStore` whose default
|
||||||
— there is no built-in cross-node replication today. Meeting both requirements in
|
replicator is `NoOpSecretReplicator`: **with `Secrets:Replication:Enabled=false` (the
|
||||||
production is a deployment concern, covered below.
|
default) nothing replicates and each node's store is private.** That is what this page
|
||||||
|
used to describe as the only option. It is no longer: pair replication exists
|
||||||
|
(`ZB.MOM.WW.Secrets.Replicator.AkkaDotNet`, lib pin `0.6.2` in `Directory.Packages.props`;
|
||||||
|
gated by `SecretsRegistration`, see below).
|
||||||
|
|
||||||
## Recommended interim posture (G-5)
|
## Recommended posture: pair-local Akka replication
|
||||||
|
|
||||||
Until real replication exists (G-7, below), the recommended production posture is:
|
Opt in with `Secrets:Replication:Enabled=true` on **every node** (default `false`, so absent
|
||||||
|
config means a plain local SQLite store). `SecretsRegistration.AddOtOpcUaSecrets`
|
||||||
|
(`src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/SecretsRegistration.cs`) then registers
|
||||||
|
`AddZbSecretsAkkaReplication` **instead of** `AddZbSecrets` (the two are alternatives, never
|
||||||
|
additive) and a `SecretReplicationStarter` hosted service resolves the store at boot so a
|
||||||
|
node that never reads a secret still joins anti-entropy. Keys: `Secrets:Replication:Enabled`,
|
||||||
|
`:AnnounceInterval` (default `00:00:30`; each node publishes a name/revision/timestamp/tombstone
|
||||||
|
manifest per interval, no ciphertext), `:ActorName` (`zb-secret-replication`).
|
||||||
|
|
||||||
- **`Secrets:MasterKey:Source = File`**, with `FilePath` pointing at a **read-only
|
- **Scope is PAIR-LOCAL.** Since mesh Phase 6 each application cluster is its own 2-node Akka
|
||||||
key file that is identical on every node of every role** — a base64-encoded
|
mesh and replication rides that mesh's DistributedPubSub, so a secret converges **within its
|
||||||
32-byte key, generated out-of-band (e.g. `openssl rand -base64 32`), distributed
|
pair and nowhere else**. There is no fleet-wide sync (site nodes have no SQL hub). Seed each
|
||||||
to each node's filesystem/secret-mount by the deployment tooling, and **never
|
pair independently: central via `/admin/secrets`, driver-only site pairs (no AdminUI) via the
|
||||||
committed** to the repo. Treat it with the same discipline as any other
|
secrets CLI against one node's store, or a ciphertext-only bundle import. See
|
||||||
production secret (restrictive file ACLs, no logging, rotated via a future
|
`docs/Redundancy.md` §"Secrets replication is pair-local".
|
||||||
KEK-rotation runbook — not yet built).
|
- **Both nodes of a pair must share the same KEK** — rows replicate verbatim (ciphertext, wrapped
|
||||||
- **`Secrets:SqlitePath`** pointing at a **single shared or replicated volume**
|
DEK and `kek_id` untouched), never re-encrypted.
|
||||||
that every node mounts (admin, driver, and dev/fused alike), so every node's
|
- **Deletes replicate** as soft tombstones (last-writer-wins on `updated_utc`/`revision`).
|
||||||
migrator opens and reads the same rows at boot.
|
- **Re-wraps do not replicate** (a re-wrap preserves `revision`/`updated_utc`, so it is invisible to
|
||||||
|
last-writer-wins). KEK rotation therefore runs against each node's store — see the next section.
|
||||||
|
- The store should sit on a **persistent per-node path** (`Secrets:SqlitePath`, e.g. the node's
|
||||||
|
`/app/data` volume in containers); the relative default lands inside the image layer and is lost on
|
||||||
|
recreate.
|
||||||
|
- Evidence: live-gated PASS 4/4 on the three-mesh docker-dev rig (convergence <= 7 s, no cross-pair
|
||||||
|
leakage in any direction, delete < 1 s, clean restart without resurrection) —
|
||||||
|
`docs/plans/2026-08-07-secrets-pair-local-live-gate.md`. Replication stays default-off in the product.
|
||||||
|
|
||||||
Writes to the store are rare and human-driven — an operator using the
|
Whichever store layout is used, the KEK is delivered per node as before:
|
||||||
`/admin/secrets` UI (admin nodes only) or the `ZB.MOM.WW.Secrets` CLI — while
|
**`Secrets:MasterKey:Source = File`** with `FilePath` pointing at a read-only base64 32-byte key
|
||||||
reads happen on every node's boot and on the `ResolveCacheTtl` refresh cycle,
|
(`openssl rand -base64 32`), distributed identically to every node of the pair by deployment tooling and
|
||||||
regardless of role. The access pattern is read-mostly / effectively
|
**never committed**. Writes are rare and human-driven (`/admin/secrets` on admin nodes, or the
|
||||||
single-writer, which is what makes a shared SQLite volume viable as an interim
|
CLI); reads happen at every node's boot and each `ResolveCacheTtl` refresh.
|
||||||
posture (see caveat below).
|
|
||||||
|
## Rotating the KEK
|
||||||
|
|
||||||
|
Rotation re-wraps each row's per-secret DEK under the new KEK (`wrapped_dek`, `wrap_nonce`,
|
||||||
|
`wrap_tag`, `kek_id` change; the sealed body, `revision` and `updated_utc` do not). The
|
||||||
|
authoritative operator procedure and troubleshooting table live with the library:
|
||||||
|
`scadaproj/ZB.MOM.WW.Secrets/docs/operations/kek-rotation.md`, driven by the `secret rewrap-all`
|
||||||
|
CLI verb (in that repo; not packed). The OtOpcUa-specific points:
|
||||||
|
|
||||||
|
1. **Back up every store first** (copy each node's SQLite file) — the backup is the complete undo.
|
||||||
|
2. **Stage the new KEK on every node of the pair** (same file path / env var as `Secrets:MasterKey`
|
||||||
|
resolves) but do not restart yet. Never pass key values on a command line; use
|
||||||
|
`--old-key-env VAR` / `--old-key-file PATH` and, if the new key is not the node's configured
|
||||||
|
provider, `--new-key-file`.
|
||||||
|
3. **Run `secret rewrap-all --old-key-env ZB_SECRETS_OLD_KEY` once per store, i.e. on EACH node.**
|
||||||
|
Because re-wraps do not replicate, rotating one node leaves its partner's rows on the old
|
||||||
|
`kek_id`. With replication on, do not leave the pair half-rotated: a node whose rows carry a KEK
|
||||||
|
it cannot unwrap fails closed (`Row wrapped by unknown KEK`) on resolve.
|
||||||
|
4. **Quiesce first**: stop (or drain) the node while its store is rewrapped. Rotating a pair
|
||||||
|
node-by-node while the other node keeps serving works for resolves, but do not author secrets
|
||||||
|
anywhere in the pair mid-rotation — a fresh write is wrapped under whichever KEK the writing node
|
||||||
|
currently uses and replicates verbatim to a partner that may not yet hold that KEK.
|
||||||
|
5. **Cut over**: confirm `Secrets:MasterKey` resolves the new key on both nodes, start them, and
|
||||||
|
confirm a `${secret:}` value resolves (the pre-host expander fails the boot loudly if not).
|
||||||
|
6. **Destroy the old key** only after both stores resolve cleanly. Re-running `rewrap-all` is
|
||||||
|
idempotent (`alreadyCurrent`), so an interrupted pass is resumed by running it again.
|
||||||
|
7. Rollback: restore the step-1 backups on both nodes and revert the KEK source.
|
||||||
|
|
||||||
|
Pair rotation across **separate pairs** (central, site-a, site-b) is independent per pair; there is no
|
||||||
|
fleet-wide order.
|
||||||
|
|
||||||
|
## Alternative: shared SQLite volume (no replication)
|
||||||
|
|
||||||
|
The earlier interim posture — every node of a pair mounting one `Secrets:SqlitePath` on a shared
|
||||||
|
or replicated volume — still works for a deployment that keeps `Secrets:Replication:Enabled=false`:
|
||||||
|
reads dominate and writes are effectively single-writer. See the caveat below; it is not safe with
|
||||||
|
concurrent writers, and it is not available to DB-less driver-only site nodes that have no shared
|
||||||
|
volume with their partner.
|
||||||
|
|
||||||
## How it's delivered (do NOT commit these values)
|
## How it's delivered (do NOT commit these values)
|
||||||
|
|
||||||
@@ -107,14 +162,14 @@ or key is missing.
|
|||||||
|
|
||||||
SQLite's file-locking model does not tolerate concurrent multi-writer access well
|
SQLite's file-locking model does not tolerate concurrent multi-writer access well
|
||||||
over network filesystems (SMB/NFS locking is unreliable, and even on a clustered
|
over network filesystems (SMB/NFS locking is unreliable, and even on a clustered
|
||||||
block volume only one writer should be active at a time). The interim posture
|
block volume only one writer should be active at a time). The shared-volume alternative
|
||||||
above is acceptable because:
|
above is acceptable because:
|
||||||
|
|
||||||
- Reads dominate (every node's boot + cache-refresh cycle, across every role).
|
- Reads dominate (every node's boot + cache-refresh cycle, across every role).
|
||||||
- Writes are rare, human-initiated, and effectively single-writer in practice
|
- Writes are rare, human-initiated, and effectively single-writer in practice
|
||||||
(an operator runs the CLI/UI against one admin node at a time).
|
(an operator runs the CLI/UI against one admin node at a time).
|
||||||
|
|
||||||
It is **not** a substitute for real replication, and it is not safe if multiple
|
It is **not** a substitute for real replication (Akka pair replication, above), and it is not safe if multiple
|
||||||
nodes attempt concurrent writes. Do not build automation that writes secrets
|
nodes attempt concurrent writes. Do not build automation that writes secrets
|
||||||
from more than one node simultaneously.
|
from more than one node simultaneously.
|
||||||
|
|
||||||
@@ -128,31 +183,15 @@ ConfigDb. That mechanism is unrelated to `ZB.MOM.WW.Secrets`' envelope encryptio
|
|||||||
(KEK + SQLite store) and must **not** be reconfigured as part of secrets-adoption
|
(KEK + SQLite store) and must **not** be reconfigured as part of secrets-adoption
|
||||||
work — doing so risks invalidating active sessions for an unrelated reason.
|
work — doing so risks invalidating active sessions for an unrelated reason.
|
||||||
|
|
||||||
It is, however, the model for where the *next* iteration of secret storage
|
## The G-7 hand-off (status)
|
||||||
should go — see the G-7 hand-off below.
|
|
||||||
|
|
||||||
## The G-7 hand-off
|
G-7 (`scadaproj/components/secrets/GAPS.md`) asked for real cross-node replication. It was
|
||||||
|
built in two flavours in the library (`ZB.MOM.WW.Secrets.Replicator.SqlServer` and
|
||||||
The posture above is an interim, ops-only workaround. The long-term shape,
|
`.AkkaDotNet`, plus a gRPC replicator package). **OtOpcUa production uses the Akka pair-local
|
||||||
tracked as **G-7** in `scadaproj/components/secrets/GAPS.md`, is a
|
option** (the decision recorded in scadaproj#3), wired by `SecretsRegistration` as described above;
|
||||||
ConfigDb-backed `ISecretStore` that mirrors the pattern OtOpcUa already uses for
|
the ConfigDb-backed store this page used to anticipate is not what shipped here. Driver-only nodes
|
||||||
the Data Protection key ring:
|
have no ConfigDb since mesh Phase 4, which is a further reason a ConfigDb secret store would not
|
||||||
|
reach them.
|
||||||
```csharp
|
|
||||||
services.AddDataProtection()
|
|
||||||
.PersistKeysToDbContext<OtOpcUaConfigDbContext>()
|
|
||||||
.SetApplicationName("OtOpcUa");
|
|
||||||
```
|
|
||||||
|
|
||||||
(`src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:73-75`).
|
|
||||||
`OtOpcUaConfigDbContext` already gives every node — admin, driver, and dev/fused
|
|
||||||
alike — a single MS SQL-backed source of truth for the Data Protection key ring;
|
|
||||||
the secret store is the natural next tenant of that same shared database instead
|
|
||||||
of a shared SQLite file. Building this requires new `ZB.MOM.WW.Secrets` library
|
|
||||||
code (a ConfigDb-backed `ISecretStore` implementation) that does not exist yet,
|
|
||||||
overlaps the G-7 tracking item, and is explicitly **deferred there** — it is not
|
|
||||||
built as part of this cut. This runbook's shared-SQLite-volume posture is the
|
|
||||||
bridge until G-7 lands.
|
|
||||||
|
|
||||||
## Dev/test/default posture (unchanged)
|
## Dev/test/default posture (unchanged)
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -1,6 +1,22 @@
|
|||||||
# OtOpcUa v2 — Driver Implementation Specifications
|
# OtOpcUa v2 — Driver Implementation Specifications
|
||||||
|
|
||||||
> **Status**: DRAFT — reference document for plan.md
|
> **Status (2026-10)**: v2-era DRAFT, **not authoritative**. It covers 8 of the 12 shipped drivers and
|
||||||
|
> several of its architecture claims are stale. The current per-driver references are the pages in
|
||||||
|
> [`docs/drivers/`](../drivers/README.md) (the README there lists all 12 and is the accurate overview).
|
||||||
|
>
|
||||||
|
> - **Not covered here at all:** MQTT/Sparkplug ([Mqtt.md](../drivers/Mqtt.md)), MTConnect
|
||||||
|
> ([MTConnect.md](../drivers/MTConnect.md)), Sql ([Sql.md](../drivers/Sql.md)), Calculation
|
||||||
|
> ([Calculation.md](../drivers/Calculation.md)).
|
||||||
|
> - **Hosting/tier claims are stale:** every driver runs in-process as Tier A (Galaxy reaches MXAccess over
|
||||||
|
> gRPC to `mxaccessgw`; FOCAS is a pure-managed in-process `FocasWireClient`, not a `Focas.Host` pipe
|
||||||
|
> service) — see `docs/v2/driver-stability.md`'s banner. The "SystemPlatform namespace kind" is retired:
|
||||||
|
> Galaxy is a standard Equipment-kind driver, and the address space is the dual Raw/UNS namespace
|
||||||
|
> ([../AddressSpace.md](../AddressSpace.md)).
|
||||||
|
> - **Modbus:** the driver has its own socket layer (no NModbus dependency) and a driver-level
|
||||||
|
> `transport` key — `Tcp` (default, MBAP framing) or `RtuOverTcp` (RTU `[addr][PDU][CRC-16]` framing tunnelled
|
||||||
|
> to a serial-to-Ethernet gateway); see [Modbus.md](../drivers/Modbus.md).
|
||||||
|
> - **AB CIP / AB Legacy:** a second, managed `Transport` (`Native` | `Libplctag`) exists — `CLAUDE.md`
|
||||||
|
> §"AB drivers: native transport" and the driver pages.
|
||||||
>
|
>
|
||||||
> **Created**: 2026-04-16
|
> **Created**: 2026-04-16
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ Driver stability needs to be a first-class architectural concern, not a per-driv
|
|||||||
|
|
||||||
Every driver is assigned to one of three tiers based on the trust level of its dependency stack:
|
Every driver is assigned to one of three tiers based on the trust level of its dependency stack:
|
||||||
|
|
||||||
|
> **Design intent, not the shipped assignment.** The per-driver tier tables below record the proposed
|
||||||
|
> assignment (Galaxy/FOCAS in Tier C, the Ab/S7/TwinCAT drivers in Tier B). As shipped every driver is Tier A —
|
||||||
|
> see the banner above and `docs/drivers/README.md`, which is the accurate reference.
|
||||||
|
|
||||||
### Tier A — Pure Managed
|
### Tier A — Pure Managed
|
||||||
|
|
||||||
Drivers whose entire dependency chain is verifiable .NET. Standard exception handling and Polly are sufficient. Run in-process in the main server.
|
Drivers whose entire dependency chain is verifiable .NET. Standard exception handling and Polly are sufficient. Run in-process in the main server.
|
||||||
|
|||||||
Reference in New Issue
Block a user