Phase 1 Streams B–E scaffold + Phase 2 Streams A–C scaffold — 8 new projects with ~70 new tests, all green alongside the 494 v1 IntegrationTests baseline (parity preserved: no v1 tests broken; legacy OtOpcUa.Host untouched). Phase 1 finish: Configuration project (16 entities + 10 enums + DbContext + DesignTimeDbContextFactory + InitialSchema/StoredProcedures/AuthorizationGrants migrations — 8 procs including sp_PublishGeneration with MERGE on ExternalIdReservation per decision #124, sp_RollbackToGeneration cloning rows into a new published generation, sp_ValidateDraft with cross-cluster-namespace + EquipmentUuid-immutability + ZTag/SAPID reservation pre-flight, sp_ComputeGenerationDiff with CHECKSUM-based row signature — plus OtOpcUaNode/OtOpcUaAdmin SQL roles with EXECUTE grants scoped to per-principal-class proc sets and DENY UPDATE/DELETE/INSERT/SELECT on dbo schema); managed DraftValidator covering UNS segment regex, path length, EquipmentUuid immutability across generations, same-cluster namespace binding (decision #122), reservation pre-flight, EquipmentId derivation (decision #125), driver↔namespace compatibility — returning every failing rule in one pass; LiteDB local cache with round-trip + ring pruning + corruption-fast-fail; GenerationApplier with per-entity Added/Removed/Modified diff and dependency-ordered callbacks (namespace → driver → device → equipment → poll-group → tag, Removed before Added); Core project with GenericDriverNodeManager (scaffold for the Phase 2 Galaxy port) and DriverHost lifecycle registry; Server project using Microsoft.Extensions.Hosting BackgroundService replacing TopShelf, with NodeBootstrap that falls back to LiteDB cache when the central DB is unreachable (decision #79); Admin project scaffolded as Blazor Server with Bootstrap 5 sidebar layout, cookie auth, three admin roles (ConfigViewer/ConfigEditor/FleetAdmin), Cluster + Generation services fronting the stored procs. Phase 2 scaffold: Driver.Galaxy.Shared (netstandard2.0) with full MessagePack IPC contract surface — Hello version negotiation, Open/CloseSession, Heartbeat, DiscoverHierarchy + GalaxyObjectInfo/GalaxyAttributeInfo, Read/WriteValues, Subscribe/Unsubscribe/OnDataChange, AlarmSubscribe/Event/Ack, HistoryRead, HostConnectivityStatus, Recycle — plus length-prefixed framing (decision #28) with a 16 MiB cap and thread-safe FrameWriter/FrameReader; Driver.Galaxy.Host (net48) implementing the Tier C cross-cutting protections from driver-stability.md — strict PipeAcl (allow configured server SID only, explicit deny on LocalSystem + Administrators), PipeServer with caller-SID verification via pipe.RunAsClient + WindowsIdentity.GetCurrent and per-process shared-secret Hello, Galaxy-specific MemoryWatchdog (warn at max(1.5×baseline, +200 MB), soft-recycle at max(2×baseline, +200 MB), hard ceiling 1.5 GB, slope ≥5 MB/min over 30-min rolling window), RecyclePolicy (1 soft recycle per hour cap + 03:00 local daily scheduled), PostMortemMmf (1000-entry ring buffer in %ProgramData%\OtOpcUa\driver-postmortem\galaxy.mmf, survives hard crash, readable cross-process), MxAccessHandle : SafeHandle (ReleaseHandle loops Marshal.ReleaseComObject until refcount=0 then calls optional unregister callback), StaPump with responsiveness probe (BlockingCollection dispatcher for Phase 1 — real Win32 GetMessage/DispatchMessage pump slots in with the same semantics when the Galaxy code lift happens), IsExternalInit shim for init setters on .NET 4.8; Driver.Galaxy.Proxy (net10) implementing IDriver + ITagDiscovery forwarding over the IPC channel with MX data-type and security-classification mapping, plus Supervisor pieces — Backoff (5s → 15s → 60s capped, reset-on-stable-run), CircuitBreaker (3 crashes per 5 min opens; 1h → 4h → manual cooldown escalation; sticky alert doesn't auto-clear), HeartbeatMonitor (2s cadence, 3 consecutive misses = host dead per driver-stability.md). Infrastructure: docker SQL Server remapped to host port 14330 to coexist with the native MSSQL14 Galaxy ZB DB instance on 1433; NuGetAuditSuppress applied per-project for two System.Security.Cryptography.Xml advisories that only reach via EF Core Design with PrivateAssets=all (fix ships in 11.0.0-preview); .slnx gains 14 project registrations. Deferred with explicit TODOs in docs/v2/implementation/phase-2-partial-exit-evidence.md: Phase 1 Stream E Admin UI pages (Generations listing + draft-diff-publish, Equipment CRUD with OPC 40010 fields, UNS Areas/Lines tabs, ACLs + permission simulator, Generic JSON config editor, SignalR real-time, Release-Reservation + Merge-Equipment workflows, LDAP login page, AppServer smoke test per decision #142), Phase 2 Stream D (Galaxy MXAccess code lift out of legacy OtOpcUa.Host, dual-service installer, appsettings → DriverConfig migration script, legacy Host deletion — blocked by parity), Phase 2 Stream E (v1 IntegrationTests against v2 topology, Client.CLI walkthrough diff, four 2026-04-13 stability findings regression tests, adversarial review — requires live MXAccess runtime).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Host/ZB.MOM.WW.OtOpcUa.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/ZB.MOM.WW.OtOpcUa.Historian.Aveva.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
@@ -9,6 +16,13 @@
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Tests/ZB.MOM.WW.OtOpcUa.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/ZB.MOM.WW.OtOpcUa.IntegrationTests.csproj"/>
|
||||
|
||||
@@ -58,7 +58,7 @@ Running record of every v2 dev service stood up on this developer machine. Updat
|
||||
|
||||
| Service | Container / Process | Version | Host:Port | Credentials (dev-only) | Data location | Status |
|
||||
|---------|---------------------|---------|-----------|------------------------|---------------|--------|
|
||||
| **Central config DB** | Docker container `otopcua-mssql` (image `mcr.microsoft.com/mssql/server:2022-latest`) | 16.0.4250.1 (RTM-CU24-GDR, KB5083252) | `localhost:1433` | User `sa` / Password `OtOpcUaDev_2026!` | Docker named volume `otopcua-mssql-data` (mounted at `/var/opt/mssql` inside container) | ✅ Running |
|
||||
| **Central config DB** | Docker container `otopcua-mssql` (image `mcr.microsoft.com/mssql/server:2022-latest`) | 16.0.4250.1 (RTM-CU24-GDR, KB5083252) | `localhost:14330` (host) → `1433` (container) — remapped from 1433 to avoid collision with the native MSSQL14 instance that hosts the Galaxy `ZB` DB (both bind 0.0.0.0:1433; whichever wins the race gets connections) | User `sa` / Password `OtOpcUaDev_2026!` | Docker named volume `otopcua-mssql-data` (mounted at `/var/opt/mssql` inside container) | ✅ Running — `InitialSchema` migration applied, 16 entity tables live |
|
||||
| Dev Galaxy (AVEVA System Platform) | Local install on this dev box | v1 baseline | Local COM via MXAccess | Windows Auth | Galaxy repository DB `ZB` on local SQL Server (separate instance from `otopcua-mssql` — legacy v1 Galaxy DB, not related to v2 config DB) | ✅ Available (per CLAUDE.md) |
|
||||
| GLAuth (LDAP) | Local install at `C:\publish\glauth\` | v1 baseline | `localhost:3893` (LDAP) / `3894` (LDAPS) | Bind DN `cn=admin,dc=otopcua,dc=local` / password in `glauth-otopcua.cfg` | `C:\publish\glauth\` | Pending — v2 test users + groups config not yet seeded (Phase 1 Stream E task) |
|
||||
| OPC Foundation reference server | Not yet built | — | `localhost:62541` (target) | `user1` / `password1` (reference-server defaults) | — | Pending (needed for Phase 5 OPC UA Client driver testing) |
|
||||
@@ -75,7 +75,7 @@ Copy-paste-ready. **Never commit these to the repo** — they go in `appsettings
|
||||
```jsonc
|
||||
{
|
||||
"ConfigDatabase": {
|
||||
"ConnectionString": "Server=localhost,1433;Database=OtOpcUaConfig_Dev;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=true;Encrypt=false;"
|
||||
"ConnectionString": "Server=localhost,14330;Database=OtOpcUaConfig_Dev;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=true;Encrypt=false;"
|
||||
},
|
||||
"Authentication": {
|
||||
"Ldap": {
|
||||
@@ -135,7 +135,7 @@ Dev credentials in this inventory are convenience defaults, not secrets. Change
|
||||
|
||||
| Resource | Purpose | Type | Default port | Default credentials | Owner |
|
||||
|----------|---------|------|--------------|---------------------|-------|
|
||||
| **SQL Server 2022 dev edition** | Central config DB; integration tests against `Configuration` project | Local install OR Docker container `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | `sa` / `OtOpcUaDev_2026!` (dev only — production uses Integrated Security or gMSA per decision #46) | Developer (per machine) |
|
||||
| **SQL Server 2022 dev edition** | Central config DB; integration tests against `Configuration` project | Local install OR Docker container `mcr.microsoft.com/mssql/server:2022-latest` | 1433 default, or 14330 when a native MSSQL instance (e.g. the Galaxy `ZB` host) already occupies 1433 | `sa` / `OtOpcUaDev_2026!` (dev only — production uses Integrated Security or gMSA per decision #46) | Developer (per machine) |
|
||||
| **GLAuth (LDAP server)** | Admin UI authentication tests; data-path ACL evaluation tests | Local binary at `C:\publish\glauth\` per existing CLAUDE.md | 3893 (LDAP) / 3894 (LDAPS) | Service principal: `cn=admin,dc=otopcua,dc=local` / `OtOpcUaDev_2026!`; test users defined in GLAuth config | Developer (per machine) |
|
||||
| **Local dev Galaxy** (Aveva System Platform) | Galaxy driver tests; v1 IntegrationTests parity | Existing on dev box per CLAUDE.md | n/a (local COM) | Windows Auth | Developer (already present per project setup) |
|
||||
|
||||
@@ -270,11 +270,13 @@ Order matters because some installs have prerequisites and several need admin el
|
||||
docker run --name otopcua-mssql `
|
||||
-e "ACCEPT_EULA=Y" `
|
||||
-e "MSSQL_SA_PASSWORD=OtOpcUaDev_2026!" `
|
||||
-p 1433:1433 `
|
||||
-p 14330:1433 `
|
||||
-v otopcua-mssql-data:/var/opt/mssql `
|
||||
-d mcr.microsoft.com/mssql/server:2022-latest
|
||||
```
|
||||
|
||||
The host port is **14330**, not 1433, to coexist with the native MSSQL14 instance that hosts the Galaxy `ZB` DB on port 1433. Both the native instance and Docker's port-proxy will happily bind `0.0.0.0:1433`, but only one of them catches any given connection — which is effectively non-deterministic and produces confusing "Login failed for user 'sa'" errors when the native instance wins. Using 14330 eliminates the race entirely.
|
||||
|
||||
The `-v otopcua-mssql-data:/var/opt/mssql` named volume preserves database files across container restarts and `docker rm` — drop it only if you want a strictly throwaway instance.
|
||||
|
||||
Verify:
|
||||
|
||||
163
docs/v2/implementation/phase-2-partial-exit-evidence.md
Normal file
163
docs/v2/implementation/phase-2-partial-exit-evidence.md
Normal file
@@ -0,0 +1,163 @@
|
||||
# Phase 2 — Partial Exit Evidence (2026-04-17)
|
||||
|
||||
> This records what Phase 2 of v2 completed in the current session and what was explicitly
|
||||
> deferred. See `phase-2-galaxy-out-of-process.md` for the full task plan; this is the as-built
|
||||
> delta.
|
||||
|
||||
## Status: **Streams A + B + C scaffolded and test-green. Streams D + E deferred.**
|
||||
|
||||
The goal per the plan is "parity, not regression" — the phase exit gate requires v1
|
||||
IntegrationTests to pass against the v2 Galaxy.Proxy + Galaxy.Host topology byte-for-byte.
|
||||
Achieving that requires live MXAccess runtime plus the Galaxy code lift out of the legacy
|
||||
`OtOpcUa.Host`. Both are operations that need a dev Galaxy up and a parity test cycle to verify.
|
||||
Without that cycle, deleting the legacy Host would break the 494 passing v1 tests that are the
|
||||
parity baseline.
|
||||
|
||||
What *is* done: all scaffolding, IPC contracts, supervisor logic, and stability protections
|
||||
needed to hang the real MXAccess code onto. Every piece has unit-level or IPC-level test
|
||||
coverage.
|
||||
|
||||
## Delivered
|
||||
|
||||
### Stream A — `Driver.Galaxy.Shared` (1 week estimate, **complete**)
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/` (.NET Standard 2.0, MessagePack-only
|
||||
dependency)
|
||||
- **Contracts**: `Hello`/`HelloAck` (version negotiation per Task A.3), `OpenSessionRequest`/
|
||||
`OpenSessionResponse`/`CloseSessionRequest`, `Heartbeat`/`HeartbeatAck`, `ErrorResponse`,
|
||||
`DiscoverHierarchyRequest`/`Response` + `GalaxyObjectInfo` + `GalaxyAttributeInfo`,
|
||||
`ReadValuesRequest`/`Response`, `WriteValuesRequest`/`Response`, `SubscribeRequest`/
|
||||
`Response`/`UnsubscribeRequest`/`OnDataChangeNotification`, `AlarmSubscribeRequest`/
|
||||
`GalaxyAlarmEvent`/`AlarmAckRequest`, `HistoryReadRequest`/`Response`+`HistoryTagValues`,
|
||||
`HostConnectivityStatus`+`RuntimeStatusChangeNotification`, `RecycleHostRequest`/
|
||||
`RecycleStatusResponse`
|
||||
- **Framing**: length-prefixed (decision #28) + 1-byte kind tag + MessagePack body. 16 MiB
|
||||
body cap. `FrameWriter`/`FrameReader` with thread-safe write gate.
|
||||
- **Tests (6)**: reflection-scan round-trip for every `[MessagePackObject]`, referenced-
|
||||
assemblies guard (only MessagePack allowed outside BCL), Hello version defaults,
|
||||
`FrameWriter`↔`FrameReader` interop, oversize-frame rejection.
|
||||
|
||||
### Stream B — `Driver.Galaxy.Host` (3–4 week estimate, **scaffold complete; MXAccess lift deferred**)
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/` (.NET Framework 4.8 AnyCPU — flips to x86 when
|
||||
the Galaxy code lift happens per Task B.1 scope)
|
||||
- **`Ipc/PipeAcl`**: builds the strict `PipeSecurity` — allow configured server-principal SID,
|
||||
explicit deny on LocalSystem + Administrators, owner = allowed SID (decision #76).
|
||||
- **`Ipc/PipeServer`**: named-pipe server that (1) enforces the ACL, (2) verifies caller SID
|
||||
via `pipe.RunAsClient` + `WindowsIdentity.GetCurrent`, (3) requires the per-process shared
|
||||
secret in the Hello frame before any other RPC, (4) rejects major-version mismatches.
|
||||
- **`Stability/MemoryWatchdog`**: Galaxy thresholds — warn at `max(1.5×baseline, +200 MB)`,
|
||||
soft-recycle at `max(2×baseline, +200 MB)`, hard ceiling 1.5 GB, slope ≥5 MB/min over 30 min.
|
||||
Pluggable RSS source for unit testability.
|
||||
- **`Stability/RecyclePolicy`**: 1-recycle/hr cap; 03:00 local daily scheduled recycle.
|
||||
- **`Stability/PostMortemMmf`**: ring buffer of 1000 × 256-byte entries in `%ProgramData%\
|
||||
OtOpcUa\driver-postmortem\galaxy.mmf`. Single-writer / multi-reader. Survives hard crash;
|
||||
supervisor reads the MMF via a second process.
|
||||
- **`Sta/MxAccessHandle`**: `SafeHandle` subclass — `ReleaseHandle` calls `Marshal.ReleaseComObject`
|
||||
in a loop until refcount = 0 then invokes the optional `unregister` callback. Finalizer-safe.
|
||||
Wraps any RCW via `object` so we can unit-test against a mock; the real wiring to
|
||||
`ArchestrA.MxAccess.LMXProxyServer` lands with the deferred code move.
|
||||
- **`Sta/StaPump`**: dedicated STA thread with `BlockingCollection` work queue + `InvokeAsync`
|
||||
dispatch. Responsiveness probe (`IsResponsiveAsync`) returns false on wedge. The real
|
||||
Win32 `GetMessage/DispatchMessage` pump from v1 `LmxProxy.Host` slots in here with the same
|
||||
dispatch semantics.
|
||||
- **`IsExternalInit` shim**: required for `init` setters on .NET 4.8.
|
||||
- **`Program.cs`**: reads `OTOPCUA_GALAXY_PIPE`, `OTOPCUA_ALLOWED_SID`, `OTOPCUA_GALAXY_SECRET`
|
||||
from env (supervisor sets at spawn), runs the pipe server, logs via Serilog to
|
||||
`%ProgramData%\OtOpcUa\galaxy-host-YYYY-MM-DD.log`.
|
||||
- **`Ipc/StubFrameHandler`**: placeholder that heartbeat-acks and returns `not-implemented`
|
||||
errors. Swapped for the real Galaxy-backed handler when the MXAccess code move completes.
|
||||
- **Tests (15)**: `MemoryWatchdog` thresholds + slope detection; `RecyclePolicy` cap + daily
|
||||
schedule; `PostMortemMmf` round-trip + ring-wrap + truncation-safety; `StaPump`
|
||||
apartment-state + responsiveness-probe wedge detection.
|
||||
|
||||
### Stream C — `Driver.Galaxy.Proxy` (1.5 week estimate, **complete as IPC-forwarder**)
|
||||
|
||||
- `src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/` (.NET 10)
|
||||
- **`Ipc/GalaxyIpcClient`**: Hello handshake + shared-secret authentication + single-call
|
||||
request/response over the data-plane pipe. Serializes concurrent callers via
|
||||
`SemaphoreSlim`. Lifts `ErrorResponse` to `GalaxyIpcException` with the error code.
|
||||
- **`GalaxyProxyDriver`**: implements `IDriver` + `ITagDiscovery`. Forwards lifecycle and
|
||||
discovery over IPC; maps Galaxy MX data types → `DriverDataType` and security classifications
|
||||
→ `SecurityClassification`. Stream C-plan capability interfaces for `IReadable`, `IWritable`,
|
||||
`ISubscribable`, `IAlarmSource`, `IHistoryProvider`, `IHostConnectivityProbe`,
|
||||
`IRediscoverable` are structured identically — wire them in when the Host's MXAccess backend
|
||||
exists so the round-trips can actually serve data.
|
||||
- **`Supervisor/Backoff`**: 5s → 15s → 60s capped; `RecordStableRun` resets after 2-min
|
||||
successful run.
|
||||
- **`Supervisor/CircuitBreaker`**: 3 crashes per 5 min opens; cooldown escalates
|
||||
1h → 4h → manual (`TimeSpan.MaxValue`). Sticky alert doesn't auto-clear when cooldown
|
||||
elapses; `ManualReset` only.
|
||||
- **`Supervisor/HeartbeatMonitor`**: 2s cadence, 3 consecutive misses = host dead.
|
||||
- **Tests (11)**: `Backoff` sequence + reset; `CircuitBreaker` full 1h/4h/manual escalation
|
||||
path; `HeartbeatMonitor` miss-count + ack-reset; full IPC handshake round-trip
|
||||
(Host + Proxy over a real named pipe, heartbeat ack verified; shared-secret mismatch
|
||||
rejected with `UnauthorizedAccessException`).
|
||||
|
||||
## Deferred (explicitly noted as TODO)
|
||||
|
||||
### Stream D — Retire legacy `OtOpcUa.Host`
|
||||
|
||||
**Not executable until Stream E parity passes.** Deleting the legacy project now would break
|
||||
the 494 v1 IntegrationTests that are the parity baseline. Recovery requires:
|
||||
|
||||
1. Host MXAccess code lift (Task B.1 "move Galaxy code") from `OtOpcUa.Host/` into
|
||||
`OtOpcUa.Driver.Galaxy.Host/` — STA pump wiring, `MxAccessHandle` backing the real
|
||||
`LMXProxyServer`, `GalaxyRepository` and its SQL queries, `GalaxyRuntimeProbeManager`,
|
||||
Historian loader, the Ipc stub handler replaced with a real `IFrameHandler` that invokes
|
||||
the handle.
|
||||
2. Address-space build via `IAddressSpaceBuilder` produces byte-equivalent OPC UA browse
|
||||
output to v1 (Task C.4).
|
||||
3. Windows service installer registers two services (`OtOpcUa` + `OtOpcUaGalaxyHost`) with
|
||||
the correct service-account SIDs and per-process secret provisioning. Galaxy.Host starts
|
||||
before OtOpcUa.
|
||||
4. `appsettings.json` Galaxy config (MxAccess / Galaxy / Historian sections) migrated into
|
||||
`DriverInstance.DriverConfig` JSON in the Configuration DB via an idempotent migration
|
||||
script. Post-migration, the local `appsettings.json` keeps only `Cluster.NodeId`,
|
||||
`ClusterId`, and the DB conn string per decision #18.
|
||||
|
||||
### Stream E — Parity validation
|
||||
|
||||
Requires live MXAccess + Galaxy runtime and the above lift complete. Work items:
|
||||
|
||||
- Run v1 IntegrationTests against the v2 Galaxy.Proxy + Galaxy.Host topology. Pass count =
|
||||
v1 baseline; failures = 0. Per-test duration regression report flags any test >2× baseline.
|
||||
- Scripted Client.CLI walkthrough recorded at Phase 2 entry gate against v1, replayed
|
||||
against v2; diff must show only timestamp/latency differences.
|
||||
- Regression tests for the four 2026-04-13 stability findings (phantom probe, cross-host
|
||||
quality clear, sync-over-async guard, fire-and-forget alarm drain).
|
||||
- `/codex:adversarial-review --base v2` on the merged Phase 2 diff — findings closed or
|
||||
deferred with rationale.
|
||||
|
||||
## Also deferred from Stream B
|
||||
|
||||
- **Task B.10 FaultShim** (test-only `ArchestrA.MxAccess` substitute for fault injection).
|
||||
Needs the production `ArchestrA.MxAccess` reference in place first; flagged as part of the
|
||||
plan's "mid-gate review" fallback (Risk row 7).
|
||||
- **Task B.8 WM_QUIT hard-exit escalation** — wired in when the real Win32 pump replaces the
|
||||
`BlockingCollection` dispatcher. The `StaPump.IsResponsiveAsync` probe already exists; the
|
||||
supervisor escalation-to-`Environment.Exit(2)` belongs to the Program main loop after the
|
||||
pump integration.
|
||||
|
||||
## Cross-session impact on the build
|
||||
|
||||
- **Full solution**: 926 tests pass, 1 fails (pre-existing Phase 0 baseline
|
||||
`Client.CLI.Tests.SubscribeCommandTests.Execute_PrintsSubscriptionMessage` — not a Phase 2
|
||||
regression; was red before Phase 1 and stays red through Phase 2).
|
||||
- **New projects added to `.slnx`**: `Driver.Galaxy.Shared`, `Driver.Galaxy.Host`,
|
||||
`Driver.Galaxy.Proxy`, plus the three matching test projects.
|
||||
- **No existing tests broke.** The 494 v1 `OtOpcUa.Tests` (net48) and 6 `IntegrationTests`
|
||||
(net48) still pass because the legacy `OtOpcUa.Host` is untouched.
|
||||
|
||||
## Next-session checklist for Stream D + E
|
||||
|
||||
1. Stand up dev Galaxy; capture Client.CLI walkthrough baseline against v1.
|
||||
2. Move Galaxy-specific files from `OtOpcUa.Host` into `Driver.Galaxy.Host`, renaming
|
||||
namespaces. Replace `StubFrameHandler` with the real one.
|
||||
3. Wire up the real Win32 pump inside `StaPump` (lift from scadalink-design's
|
||||
`LmxProxy.Host` reference per CLAUDE.md).
|
||||
4. Run v1 IntegrationTests against the v2 topology — iterate on parity defects until green.
|
||||
5. Run Client.CLI walkthrough and diff.
|
||||
6. Regression tests for the four stability findings.
|
||||
7. Delete legacy `OtOpcUa.Host`; update `.slnx`; update installer scripts.
|
||||
8. Adversarial review; `exit-gate-phase-2.md` recorded; PR merged.
|
||||
18
src/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor
Normal file
18
src/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor
Normal file
@@ -0,0 +1,18 @@
|
||||
@* Root Blazor component. *@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>OtOpcUa Admin</title>
|
||||
<base href="/"/>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
|
||||
<link rel="stylesheet" href="app.css"/>
|
||||
<HeadOutlet/>
|
||||
</head>
|
||||
<body>
|
||||
<Routes/>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
@* ScadaLink-parity sidebar layout per decision #102 (Bootstrap 5, dark sidebar, main content area). *@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="d-flex" style="min-height: 100vh;">
|
||||
<nav class="bg-dark text-light p-3" style="width: 220px;">
|
||||
<h5 class="mb-4">OtOpcUa Admin</h5>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/clusters">Clusters</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/generations">Generations</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/equipment">Equipment</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-light" href="/acls">ACLs</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main class="flex-grow-1 p-4">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
42
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters.razor
Normal file
42
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters.razor
Normal file
@@ -0,0 +1,42 @@
|
||||
@page "/clusters"
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Services
|
||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Entities
|
||||
@inject ClusterService ClusterSvc
|
||||
|
||||
<h1 class="mb-4">Clusters</h1>
|
||||
|
||||
@if (_clusters is null)
|
||||
{
|
||||
<p>Loading…</p>
|
||||
}
|
||||
else if (_clusters.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No clusters yet. Use the stored-proc <code>sp_PublishGeneration</code> workflow to bootstrap.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover">
|
||||
<thead><tr><th>ClusterId</th><th>Name</th><th>Enterprise/Site</th><th>RedundancyMode</th><th>Enabled</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var c in _clusters)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@c.ClusterId</code></td>
|
||||
<td>@c.Name</td>
|
||||
<td>@c.Enterprise / @c.Site</td>
|
||||
<td>@c.RedundancyMode</td>
|
||||
<td>@(c.Enabled ? "Yes" : "No")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<ServerCluster>? _clusters;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_clusters = await ClusterSvc.ListAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
16
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor
Normal file
16
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,16 @@
|
||||
@page "/"
|
||||
|
||||
<h1 class="mb-4">OtOpcUa fleet overview</h1>
|
||||
<p class="text-muted">Phase 1 scaffold — full dashboard lands in Phase 1 Stream E completion.</p>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card"><div class="card-body"><h5 class="card-title">Clusters</h5><a href="/clusters" class="btn btn-primary btn-sm">Manage</a></div></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card"><div class="card-body"><h5 class="card-title">Generations</h5><a href="/generations" class="btn btn-primary btn-sm">Manage</a></div></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card"><div class="card-body"><h5 class="card-title">Equipment</h5><a href="/equipment" class="btn btn-primary btn-sm">Manage</a></div></div>
|
||||
</div>
|
||||
</div>
|
||||
11
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor
Normal file
11
src/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor
Normal file
@@ -0,0 +1,11 @@
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||
|
||||
<Router AppAssembly="@typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(MainLayout)"><p>Not found.</p></LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
10
src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor
Normal file
@@ -0,0 +1,10 @@
|
||||
@using System.Net.Http
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components
|
||||
@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout
|
||||
49
src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
Normal file
49
src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Components;
|
||||
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/otopcua-admin-.log", rollingInterval: RollingInterval.Day));
|
||||
|
||||
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
||||
|
||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(o =>
|
||||
{
|
||||
o.Cookie.Name = "OtOpcUa.Admin";
|
||||
o.LoginPath = "/login";
|
||||
o.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorizationBuilder()
|
||||
.AddPolicy("CanEdit", p => p.RequireRole(AdminRoles.ConfigEditor, AdminRoles.FleetAdmin))
|
||||
.AddPolicy("CanPublish", p => p.RequireRole(AdminRoles.FleetAdmin));
|
||||
|
||||
builder.Services.AddDbContext<OtOpcUaConfigDbContext>(opt =>
|
||||
opt.UseSqlServer(builder.Configuration.GetConnectionString("ConfigDb")
|
||||
?? throw new InvalidOperationException("ConnectionStrings:ConfigDb not configured")));
|
||||
|
||||
builder.Services.AddScoped<ClusterService>();
|
||||
builder.Services.AddScoped<GenerationService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseStaticFiles();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
|
||||
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
// Public for WebApplicationFactory testability.
|
||||
public partial class Program;
|
||||
16
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs
Normal file
16
src/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The three admin roles per <c>admin-ui.md</c> §"Admin Roles" — mapped from LDAP groups at
|
||||
/// sign-in. Each role has a fixed set of capabilities (cluster CRUD, draft → publish, fleet
|
||||
/// admin). The ACL-driven runtime permissions (<c>NodePermissions</c>) govern OPC UA clients;
|
||||
/// these roles govern the Admin UI itself.
|
||||
/// </summary>
|
||||
public static class AdminRoles
|
||||
{
|
||||
public const string ConfigViewer = "ConfigViewer";
|
||||
public const string ConfigEditor = "ConfigEditor";
|
||||
public const string FleetAdmin = "FleetAdmin";
|
||||
|
||||
public static IReadOnlyList<string> All => [ConfigViewer, ConfigEditor, FleetAdmin];
|
||||
}
|
||||
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterService.cs
Normal file
28
src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterService.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster CRUD surface used by the Blazor pages. Writes go through stored procs in later
|
||||
/// phases; Phase 1 reads via EF Core directly (DENY SELECT on <c>dbo</c> schema means this
|
||||
/// service connects as a DB owner during dev — production swaps in a read-only view grant).
|
||||
/// </summary>
|
||||
public sealed class ClusterService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public Task<List<ServerCluster>> ListAsync(CancellationToken ct) =>
|
||||
db.ServerClusters.AsNoTracking().OrderBy(c => c.ClusterId).ToListAsync(ct);
|
||||
|
||||
public Task<ServerCluster?> FindAsync(string clusterId, CancellationToken ct) =>
|
||||
db.ServerClusters.AsNoTracking().FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
|
||||
|
||||
public async Task<ServerCluster> CreateAsync(ServerCluster cluster, string createdBy, CancellationToken ct)
|
||||
{
|
||||
cluster.CreatedAt = DateTime.UtcNow;
|
||||
cluster.CreatedBy = createdBy;
|
||||
db.ServerClusters.Add(cluster);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return cluster;
|
||||
}
|
||||
}
|
||||
71
src/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs
Normal file
71
src/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the draft → diff → publish workflow (decision #89). Publish + rollback call into the
|
||||
/// stored procedures; diff queries <c>sp_ComputeGenerationDiff</c>.
|
||||
/// </summary>
|
||||
public sealed class GenerationService(OtOpcUaConfigDbContext db)
|
||||
{
|
||||
public async Task<ConfigGeneration> CreateDraftAsync(string clusterId, string createdBy, CancellationToken ct)
|
||||
{
|
||||
var gen = new ConfigGeneration
|
||||
{
|
||||
ClusterId = clusterId,
|
||||
Status = GenerationStatus.Draft,
|
||||
CreatedBy = createdBy,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.ConfigGenerations.Add(gen);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return gen;
|
||||
}
|
||||
|
||||
public Task<List<ConfigGeneration>> ListRecentAsync(string clusterId, int limit, CancellationToken ct) =>
|
||||
db.ConfigGenerations.AsNoTracking()
|
||||
.Where(g => g.ClusterId == clusterId)
|
||||
.OrderByDescending(g => g.GenerationId)
|
||||
.Take(limit)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task PublishAsync(string clusterId, long draftGenerationId, string? notes, CancellationToken ct)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_PublishGeneration @ClusterId = {0}, @DraftGenerationId = {1}, @Notes = {2}",
|
||||
[clusterId, draftGenerationId, (object?)notes ?? DBNull.Value],
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task RollbackAsync(string clusterId, long targetGenerationId, string? notes, CancellationToken ct)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"EXEC dbo.sp_RollbackToGeneration @ClusterId = {0}, @TargetGenerationId = {1}, @Notes = {2}",
|
||||
[clusterId, targetGenerationId, (object?)notes ?? DBNull.Value],
|
||||
ct);
|
||||
}
|
||||
|
||||
public async Task<List<DiffRow>> ComputeDiffAsync(long from, long to, CancellationToken ct)
|
||||
{
|
||||
var results = new List<DiffRow>();
|
||||
await using var conn = (SqlConnection)db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open) await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_ComputeGenerationDiff @FromGenerationId = @f, @ToGenerationId = @t";
|
||||
cmd.Parameters.AddWithValue("@f", from);
|
||||
cmd.Parameters.AddWithValue("@t", to);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(new DiffRow(reader.GetString(0), reader.GetString(1), reader.GetString(2)));
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record DiffRow(string TableName, string LogicalId, string ChangeKind);
|
||||
28
src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj
Normal file
28
src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Admin</RootNamespace>
|
||||
<AssemblyName>OtOpcUa.Admin</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
8
src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
Normal file
8
src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"ConfigDb": "Server=localhost,14330;Database=OtOpcUaConfig;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information"
|
||||
}
|
||||
}
|
||||
3
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css
Normal file
3
src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css
Normal file
@@ -0,0 +1,3 @@
|
||||
/* OtOpcUa Admin — ScadaLink-parity palette. Keep it minimal here; lean on Bootstrap 5. */
|
||||
body { background-color: #f5f6fa; }
|
||||
.nav-link.active { background-color: rgba(255,255,255,0.1); border-radius: 4px; }
|
||||
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs
Normal file
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
/// <summary>
|
||||
/// Host-supplied callbacks invoked as the applier walks the diff. Callbacks are idempotent on
|
||||
/// retry (the applier may re-invoke with the same inputs if a later stage fails — nodes
|
||||
/// register-applied to the central DB only after success). Order: namespace → driver → device →
|
||||
/// equipment → poll group → tag, with Removed before Added/Modified.
|
||||
/// </summary>
|
||||
public sealed class ApplyCallbacks
|
||||
{
|
||||
public Func<EntityChange<Namespace>, CancellationToken, Task>? OnNamespace { get; init; }
|
||||
public Func<EntityChange<DriverInstance>, CancellationToken, Task>? OnDriver { get; init; }
|
||||
public Func<EntityChange<Device>, CancellationToken, Task>? OnDevice { get; init; }
|
||||
public Func<EntityChange<Equipment>, CancellationToken, Task>? OnEquipment { get; init; }
|
||||
public Func<EntityChange<PollGroup>, CancellationToken, Task>? OnPollGroup { get; init; }
|
||||
public Func<EntityChange<Tag>, CancellationToken, Task>? OnTag { get; init; }
|
||||
}
|
||||
8
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs
Normal file
8
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
public enum ChangeKind
|
||||
{
|
||||
Added,
|
||||
Removed,
|
||||
Modified,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
public sealed class GenerationApplier(ApplyCallbacks callbacks) : IGenerationApplier
|
||||
{
|
||||
public async Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct)
|
||||
{
|
||||
var diff = GenerationDiffer.Compute(from, to);
|
||||
var errors = new List<string>();
|
||||
|
||||
// Removed first, then Added/Modified — prevents FK dangling while cascades settle.
|
||||
await ApplyPass(diff.Tags, ChangeKind.Removed, callbacks.OnTag, errors, ct);
|
||||
await ApplyPass(diff.PollGroups, ChangeKind.Removed, callbacks.OnPollGroup, errors, ct);
|
||||
await ApplyPass(diff.Equipment, ChangeKind.Removed, callbacks.OnEquipment, errors, ct);
|
||||
await ApplyPass(diff.Devices, ChangeKind.Removed, callbacks.OnDevice, errors, ct);
|
||||
await ApplyPass(diff.Drivers, ChangeKind.Removed, callbacks.OnDriver, errors, ct);
|
||||
await ApplyPass(diff.Namespaces, ChangeKind.Removed, callbacks.OnNamespace, errors, ct);
|
||||
|
||||
foreach (var kind in new[] { ChangeKind.Added, ChangeKind.Modified })
|
||||
{
|
||||
await ApplyPass(diff.Namespaces, kind, callbacks.OnNamespace, errors, ct);
|
||||
await ApplyPass(diff.Drivers, kind, callbacks.OnDriver, errors, ct);
|
||||
await ApplyPass(diff.Devices, kind, callbacks.OnDevice, errors, ct);
|
||||
await ApplyPass(diff.Equipment, kind, callbacks.OnEquipment, errors, ct);
|
||||
await ApplyPass(diff.PollGroups, kind, callbacks.OnPollGroup, errors, ct);
|
||||
await ApplyPass(diff.Tags, kind, callbacks.OnTag, errors, ct);
|
||||
}
|
||||
|
||||
return errors.Count == 0 ? ApplyResult.Ok(diff) : ApplyResult.Fail(diff, errors);
|
||||
}
|
||||
|
||||
private static async Task ApplyPass<T>(
|
||||
IReadOnlyList<EntityChange<T>> changes,
|
||||
ChangeKind kind,
|
||||
Func<EntityChange<T>, CancellationToken, Task>? callback,
|
||||
List<string> errors,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (callback is null) return;
|
||||
|
||||
foreach (var change in changes.Where(c => c.Kind == kind))
|
||||
{
|
||||
try { await callback(change, ct); }
|
||||
catch (Exception ex) { errors.Add($"{typeof(T).Name} {change.Kind} '{change.LogicalId}': {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs
Normal file
70
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
/// <summary>
|
||||
/// Per-entity diff computed locally on the node. The enumerable order matches the dependency
|
||||
/// order expected by <see cref="IGenerationApplier"/>: namespace → driver → device → equipment →
|
||||
/// poll group → tag → ACL, with Removed processed before Added inside each bucket so cascades
|
||||
/// settle before new rows appear.
|
||||
/// </summary>
|
||||
public sealed record GenerationDiff(
|
||||
IReadOnlyList<EntityChange<Namespace>> Namespaces,
|
||||
IReadOnlyList<EntityChange<DriverInstance>> Drivers,
|
||||
IReadOnlyList<EntityChange<Device>> Devices,
|
||||
IReadOnlyList<EntityChange<Equipment>> Equipment,
|
||||
IReadOnlyList<EntityChange<PollGroup>> PollGroups,
|
||||
IReadOnlyList<EntityChange<Tag>> Tags);
|
||||
|
||||
public sealed record EntityChange<T>(ChangeKind Kind, string LogicalId, T? From, T? To);
|
||||
|
||||
public static class GenerationDiffer
|
||||
{
|
||||
public static GenerationDiff Compute(DraftSnapshot? from, DraftSnapshot to)
|
||||
{
|
||||
from ??= new DraftSnapshot { GenerationId = 0, ClusterId = to.ClusterId };
|
||||
|
||||
return new GenerationDiff(
|
||||
Namespaces: DiffById(from.Namespaces, to.Namespaces, x => x.NamespaceId,
|
||||
(a, b) => (a.ClusterId, a.NamespaceUri, a.Kind, a.Enabled, a.Notes)
|
||||
== (b.ClusterId, b.NamespaceUri, b.Kind, b.Enabled, b.Notes)),
|
||||
Drivers: DiffById(from.DriverInstances, to.DriverInstances, x => x.DriverInstanceId,
|
||||
(a, b) => (a.ClusterId, a.NamespaceId, a.Name, a.DriverType, a.Enabled, a.DriverConfig)
|
||||
== (b.ClusterId, b.NamespaceId, b.Name, b.DriverType, b.Enabled, b.DriverConfig)),
|
||||
Devices: DiffById(from.Devices, to.Devices, x => x.DeviceId,
|
||||
(a, b) => (a.DriverInstanceId, a.Name, a.Enabled, a.DeviceConfig)
|
||||
== (b.DriverInstanceId, b.Name, b.Enabled, b.DeviceConfig)),
|
||||
Equipment: DiffById(from.Equipment, to.Equipment, x => x.EquipmentId,
|
||||
(a, b) => (a.EquipmentUuid, a.DriverInstanceId, a.UnsLineId, a.Name, a.MachineCode, a.ZTag, a.SAPID, a.Enabled)
|
||||
== (b.EquipmentUuid, b.DriverInstanceId, b.UnsLineId, b.Name, b.MachineCode, b.ZTag, b.SAPID, b.Enabled)),
|
||||
PollGroups: DiffById(from.PollGroups, to.PollGroups, x => x.PollGroupId,
|
||||
(a, b) => (a.DriverInstanceId, a.Name, a.IntervalMs)
|
||||
== (b.DriverInstanceId, b.Name, b.IntervalMs)),
|
||||
Tags: DiffById(from.Tags, to.Tags, x => x.TagId,
|
||||
(a, b) => (a.DriverInstanceId, a.DeviceId, a.EquipmentId, a.PollGroupId, a.FolderPath, a.Name, a.DataType, a.AccessLevel, a.WriteIdempotent, a.TagConfig)
|
||||
== (b.DriverInstanceId, b.DeviceId, b.EquipmentId, b.PollGroupId, b.FolderPath, b.Name, b.DataType, b.AccessLevel, b.WriteIdempotent, b.TagConfig)));
|
||||
}
|
||||
|
||||
private static List<EntityChange<T>> DiffById<T>(
|
||||
IReadOnlyList<T> from, IReadOnlyList<T> to,
|
||||
Func<T, string> id, Func<T, T, bool> equal)
|
||||
{
|
||||
var fromById = from.ToDictionary(id);
|
||||
var toById = to.ToDictionary(id);
|
||||
var result = new List<EntityChange<T>>();
|
||||
|
||||
foreach (var (logicalId, src) in fromById.Where(kv => !toById.ContainsKey(kv.Key)))
|
||||
result.Add(new(ChangeKind.Removed, logicalId, src, default));
|
||||
|
||||
foreach (var (logicalId, dst) in toById)
|
||||
{
|
||||
if (!fromById.TryGetValue(logicalId, out var src))
|
||||
result.Add(new(ChangeKind.Added, logicalId, default, dst));
|
||||
else if (!equal(src, dst))
|
||||
result.Add(new(ChangeKind.Modified, logicalId, src, dst));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a <see cref="GenerationDiff"/> to whatever backing runtime the node owns: the OPC UA
|
||||
/// address space, driver subscriptions, the local cache, etc. The Core project wires concrete
|
||||
/// callbacks into this via <see cref="ApplyCallbacks"/> so the Configuration project stays free
|
||||
/// of a Core/Server dependency (interface independence per decision #59).
|
||||
/// </summary>
|
||||
public interface IGenerationApplier
|
||||
{
|
||||
Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record ApplyResult(
|
||||
bool Succeeded,
|
||||
GenerationDiff Diff,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static ApplyResult Ok(GenerationDiff diff) => new(true, diff, []);
|
||||
public static ApplyResult Fail(GenerationDiff diff, IReadOnlyList<string> errors) => new(false, diff, errors);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Used by <c>dotnet ef</c> at design time (migrations, scaffolding). Reads the connection string
|
||||
/// from the <c>OTOPCUA_CONFIG_CONNECTION</c> environment variable, falling back to the local dev
|
||||
/// container on <c>localhost:1433</c>.
|
||||
/// </summary>
|
||||
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
// Host-port 14330 avoids collision with the native MSSQL14 instance on 1433 (Galaxy "ZB" DB).
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
public OtOpcUaConfigDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connection = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_CONNECTION")
|
||||
?? DefaultConnectionString;
|
||||
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(connection, sql => sql.MigrationsAssembly(typeof(OtOpcUaConfigDbContext).Assembly.FullName))
|
||||
.Options;
|
||||
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
}
|
||||
51
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs
Normal file
51
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>Physical OPC UA server node within a <see cref="ServerCluster"/>.</summary>
|
||||
public sealed class ClusterNode
|
||||
{
|
||||
/// <summary>Stable per-machine logical ID, e.g. "LINE3-OPCUA-A".</summary>
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required RedundancyRole RedundancyRole { get; set; }
|
||||
|
||||
/// <summary>Machine hostname / IP.</summary>
|
||||
public required string Host { get; set; }
|
||||
|
||||
public int OpcUaPort { get; set; } = 4840;
|
||||
|
||||
public int DashboardPort { get; set; } = 8081;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>ApplicationUri</c> — MUST be unique per node per OPC UA spec. Clients pin trust here.
|
||||
/// Fleet-wide unique index enforces no two nodes share a value (decision #86).
|
||||
/// Stored explicitly, NOT derived from <see cref="Host"/> at runtime — silent rewrite on
|
||||
/// hostname change would break all client trust.
|
||||
/// </summary>
|
||||
public required string ApplicationUri { get; set; }
|
||||
|
||||
/// <summary>Primary = 200, Secondary = 150 by default.</summary>
|
||||
public byte ServiceLevelBase { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node override JSON keyed by DriverInstanceId, merged onto cluster-level DriverConfig
|
||||
/// at apply time. Minimal by intent (decision #81). Nullable when no overrides exist.
|
||||
/// </summary>
|
||||
public string? DriverConfigOverridesJson { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
public ICollection<ClusterNodeCredential> Credentials { get; set; } = [];
|
||||
public ClusterNodeGenerationState? GenerationState { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a <see cref="ClusterNode"/> to the central config DB.
|
||||
/// Per decision #83 — credentials bind to NodeId, not ClusterId.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeCredential
|
||||
{
|
||||
public Guid CredentialId { get; set; }
|
||||
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
public required CredentialKind Kind { get; set; }
|
||||
|
||||
/// <summary>Login name / cert thumbprint / SID / gMSA name.</summary>
|
||||
public required string Value { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public DateTime? RotatedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
public ClusterNode? Node { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which generation each node has applied. Per-node (not per-cluster) — both nodes of a
|
||||
/// 2-node cluster track independently per decision #84.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeGenerationState
|
||||
{
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
public long? CurrentGenerationId { get; set; }
|
||||
|
||||
public DateTime? LastAppliedAt { get; set; }
|
||||
|
||||
public NodeApplyStatus? LastAppliedStatus { get; set; }
|
||||
|
||||
public string? LastAppliedError { get; set; }
|
||||
|
||||
/// <summary>Updated on every poll for liveness detection.</summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
public ClusterNode? Node { get; set; }
|
||||
public ConfigGeneration? CurrentGeneration { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only audit log for every config write + authorization-check event. Grants revoked for
|
||||
/// UPDATE / DELETE on all principals (enforced by the authorization migration in B.3).
|
||||
/// </summary>
|
||||
public sealed class ConfigAuditLog
|
||||
{
|
||||
public long AuditId { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string Principal { get; set; }
|
||||
|
||||
/// <summary>DraftCreated | DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled | ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt | OpcUaAccessDenied | …</summary>
|
||||
public required string EventType { get; set; }
|
||||
|
||||
public string? ClusterId { get; set; }
|
||||
|
||||
public string? NodeId { get; set; }
|
||||
|
||||
public long? GenerationId { get; set; }
|
||||
|
||||
public string? DetailsJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Atomic, immutable snapshot of one cluster's configuration.
|
||||
/// Per decision #82 — cluster-scoped, not fleet-scoped.
|
||||
/// </summary>
|
||||
public sealed class ConfigGeneration
|
||||
{
|
||||
/// <summary>Monotonically increasing ID, generated by <c>IDENTITY(1, 1)</c>.</summary>
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required GenerationStatus Status { get; set; }
|
||||
|
||||
public long? ParentGenerationId { get; set; }
|
||||
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
|
||||
public string? PublishedBy { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
public ConfigGeneration? Parent { get; set; }
|
||||
}
|
||||
23
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>Per-device row for multi-device drivers (Modbus, AB CIP). Optional for single-device drivers.</summary>
|
||||
public sealed class Device
|
||||
{
|
||||
public Guid DeviceRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string DeviceId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="DriverInstance.DriverInstanceId"/>.</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Schemaless per-driver-type device config (host, port, unit ID, slot, etc.).</summary>
|
||||
public required string DeviceConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>One driver instance in a cluster's generation. JSON config is schemaless per-driver-type.</summary>
|
||||
public sealed class DriverInstance
|
||||
{
|
||||
public Guid DriverInstanceRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Logical FK to <see cref="Namespace.NamespaceId"/>. Same-cluster binding enforced by
|
||||
/// <c>sp_ValidateDraft</c> per decision #122: Namespace.ClusterId must equal DriverInstance.ClusterId.
|
||||
/// </summary>
|
||||
public required string NamespaceId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient</summary>
|
||||
public required string DriverType { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
|
||||
public required string DriverConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
64
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// UNS level-5 entity. Only for drivers in Equipment-kind namespaces.
|
||||
/// Per decisions #109 (first-class), #116 (5-identifier model), #125 (system-generated EquipmentId),
|
||||
/// #138–139 (OPC 40010 Identification fields as first-class columns).
|
||||
/// </summary>
|
||||
public sealed class Equipment
|
||||
{
|
||||
public Guid EquipmentRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// System-generated stable internal logical ID. Format: <c>'EQ-' + first 12 hex chars of EquipmentUuid</c>.
|
||||
/// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI (decision #125).
|
||||
/// </summary>
|
||||
public required string EquipmentId { get; set; }
|
||||
|
||||
/// <summary>UUIDv4, IMMUTABLE across all generations of the same EquipmentId. Downstream-consumer join key.</summary>
|
||||
public Guid EquipmentUuid { get; set; }
|
||||
|
||||
/// <summary>Logical FK to the driver providing data for this equipment.</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
/// <summary>Optional logical FK to a multi-device driver's device.</summary>
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="UnsLine.UnsLineId"/>.</summary>
|
||||
public required string UnsLineId { get; set; }
|
||||
|
||||
/// <summary>UNS level 5 segment, matches <c>^[a-z0-9-]{1,32}$</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
// Operator-facing / external-system identifiers (decision #116)
|
||||
|
||||
/// <summary>Operator colloquial id (e.g. "machine_001"). Unique within cluster. Required.</summary>
|
||||
public required string MachineCode { get; set; }
|
||||
|
||||
/// <summary>ERP equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>. Primary browse identifier in Admin UI.</summary>
|
||||
public string? ZTag { get; set; }
|
||||
|
||||
/// <summary>SAP PM equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>.</summary>
|
||||
public string? SAPID { get; set; }
|
||||
|
||||
// OPC UA Companion Spec OPC 40010 Machinery Identification fields (decision #139).
|
||||
// All nullable so equipment can be added before identity is fully captured.
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? HardwareRevision { get; set; }
|
||||
public string? SoftwareRevision { get; set; }
|
||||
public short? YearOfConstruction { get; set; }
|
||||
public string? AssetLocation { get; set; }
|
||||
public string? ManufacturerUri { get; set; }
|
||||
public string? DeviceManualUri { get; set; }
|
||||
|
||||
/// <summary>Nullable hook for future schemas-repo template ID (decision #112).</summary>
|
||||
public string? EquipmentClassRef { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Fleet-wide rollback-safe reservation of ZTag and SAPID. Per decision #124 — NOT generation-versioned.
|
||||
/// Exists outside generation flow specifically because old generations and disabled equipment can
|
||||
/// still hold the same external IDs; per-generation uniqueness indexes fail under rollback/re-enable.
|
||||
/// </summary>
|
||||
public sealed class ExternalIdReservation
|
||||
{
|
||||
public Guid ReservationId { get; set; }
|
||||
|
||||
public required ReservationKind Kind { get; set; }
|
||||
|
||||
public required string Value { get; set; }
|
||||
|
||||
/// <summary>The equipment that owns this reservation. Stays bound even when equipment is disabled.</summary>
|
||||
public Guid EquipmentUuid { get; set; }
|
||||
|
||||
/// <summary>First cluster to publish this reservation.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public DateTime FirstPublishedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string FirstPublishedBy { get; set; }
|
||||
|
||||
public DateTime LastPublishedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Non-null when explicitly released by FleetAdmin (audit-logged, requires reason).</summary>
|
||||
public DateTime? ReleasedAt { get; set; }
|
||||
|
||||
public string? ReleasedBy { get; set; }
|
||||
|
||||
public string? ReleaseReason { get; set; }
|
||||
}
|
||||
31
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA namespace served by a cluster. Generation-versioned per decision #123 —
|
||||
/// namespaces are content (affect what consumers see at the endpoint), not topology.
|
||||
/// </summary>
|
||||
public sealed class Namespace
|
||||
{
|
||||
public Guid NamespaceRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>Stable logical ID across generations, e.g. "LINE3-OPCUA-equipment".</summary>
|
||||
public required string NamespaceId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required NamespaceKind Kind { get; set; }
|
||||
|
||||
/// <summary>E.g. "urn:zb:warsaw-west:equipment". Unique fleet-wide per generation.</summary>
|
||||
public required string NamespaceUri { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
32
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs
Normal file
32
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One ACL grant: an LDAP group gets a set of <see cref="NodePermissions"/> at a specific scope.
|
||||
/// Generation-versioned per decision #130. See <c>acl-design.md</c> for evaluation algorithm.
|
||||
/// </summary>
|
||||
public sealed class NodeAcl
|
||||
{
|
||||
public Guid NodeAclRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string NodeAclId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required string LdapGroup { get; set; }
|
||||
|
||||
public required NodeAclScopeKind ScopeKind { get; set; }
|
||||
|
||||
/// <summary>NULL when <see cref="ScopeKind"/> = <see cref="NodeAclScopeKind.Cluster"/>; otherwise the scoped entity's logical ID.</summary>
|
||||
public string? ScopeId { get; set; }
|
||||
|
||||
/// <summary>Bitmask of <see cref="NodePermissions"/>. Stored as int in SQL.</summary>
|
||||
public required NodePermissions PermissionFlags { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs
Normal file
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>Driver-scoped polling group. Tags reference it via <see cref="Tag.PollGroupId"/>.</summary>
|
||||
public sealed class PollGroup
|
||||
{
|
||||
public Guid PollGroupRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string PollGroupId { get; set; }
|
||||
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
public int IntervalMs { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level deployment unit. 1 or 2 <see cref="ClusterNode"/> members.
|
||||
/// Per <c>config-db-schema.md</c> ServerCluster table.
|
||||
/// </summary>
|
||||
public sealed class ServerCluster
|
||||
{
|
||||
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA".</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>UNS level 1. Canonical org value: "zb" per decision #140.</summary>
|
||||
public required string Enterprise { get; set; }
|
||||
|
||||
/// <summary>UNS level 2, e.g. "warsaw-west".</summary>
|
||||
public required string Site { get; set; }
|
||||
|
||||
public byte NodeCount { get; set; }
|
||||
|
||||
public required RedundancyMode RedundancyMode { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
public DateTime? ModifiedAt { get; set; }
|
||||
|
||||
public string? ModifiedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
public ICollection<ClusterNode> Nodes { get; set; } = [];
|
||||
public ICollection<Namespace> Namespaces { get; set; } = [];
|
||||
public ICollection<ConfigGeneration> Generations { get; set; } = [];
|
||||
}
|
||||
47
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
Normal file
47
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One canonical tag (signal) in a cluster's generation. Per decision #110:
|
||||
/// <see cref="EquipmentId"/> is REQUIRED when the driver is in an Equipment-kind namespace
|
||||
/// and NULL when in SystemPlatform-kind namespace (Galaxy hierarchy preserved).
|
||||
/// </summary>
|
||||
public sealed class Tag
|
||||
{
|
||||
public Guid TagRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string TagId { get; set; }
|
||||
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Required when driver is in Equipment-kind namespace; NULL when in SystemPlatform-kind.
|
||||
/// Cross-table invariant enforced by sp_ValidateDraft (decision #110).
|
||||
/// </summary>
|
||||
public string? EquipmentId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Only used when <see cref="EquipmentId"/> is NULL (SystemPlatform namespace).</summary>
|
||||
public string? FolderPath { get; set; }
|
||||
|
||||
/// <summary>OPC UA built-in type name (Boolean / Int32 / Float / etc.).</summary>
|
||||
public required string DataType { get; set; }
|
||||
|
||||
public required TagAccessLevel AccessLevel { get; set; }
|
||||
|
||||
/// <summary>Per decisions #44–45 — opt-in for write retry eligibility.</summary>
|
||||
public bool WriteIdempotent { get; set; }
|
||||
|
||||
public string? PollGroupId { get; set; }
|
||||
|
||||
/// <summary>Register address / scaling / poll group / byte-order / etc. — schemaless per driver type.</summary>
|
||||
public required string TagConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>UNS level-3 segment. Generation-versioned per decision #115.</summary>
|
||||
public sealed class UnsArea
|
||||
{
|
||||
public Guid UnsAreaRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string UnsAreaId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>UNS level 3 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>UNS level-4 segment. Generation-versioned per decision #115.</summary>
|
||||
public sealed class UnsLine
|
||||
{
|
||||
public Guid UnsLineRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string UnsLineId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="UnsArea.UnsAreaId"/>; resolved within the same generation.</summary>
|
||||
public required string UnsAreaId { get; set; }
|
||||
|
||||
/// <summary>UNS level 4 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Credential kind for <see cref="Entities.ClusterNodeCredential"/>. Per decision #83.</summary>
|
||||
public enum CredentialKind
|
||||
{
|
||||
SqlLogin,
|
||||
ClientCertThumbprint,
|
||||
ADPrincipal,
|
||||
gMSA,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Generation lifecycle state. Draft → Published → Superseded | RolledBack.</summary>
|
||||
public enum GenerationStatus
|
||||
{
|
||||
Draft,
|
||||
Published,
|
||||
Superseded,
|
||||
RolledBack,
|
||||
}
|
||||
25
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>OPC UA namespace kind per decision #107. One of each kind per cluster per generation.</summary>
|
||||
public enum NamespaceKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Equipment namespace — raw signals from native-protocol drivers (Modbus, AB CIP, AB Legacy,
|
||||
/// S7, TwinCAT, FOCAS, and OpcUaClient when gatewaying raw equipment). UNS 5-level hierarchy
|
||||
/// applies.
|
||||
/// </summary>
|
||||
Equipment,
|
||||
|
||||
/// <summary>
|
||||
/// System Platform namespace — Galaxy / MXAccess processed data (v1 LmxOpcUa folded in).
|
||||
/// UNS rules do NOT apply; Galaxy hierarchy preserved as v1 expressed it.
|
||||
/// </summary>
|
||||
SystemPlatform,
|
||||
|
||||
/// <summary>
|
||||
/// Reserved for future replay driver per handoff §"Digital Twin Touchpoints" — not populated
|
||||
/// in v2.0 but enum value reserved so the schema does not need to change when the replay
|
||||
/// driver lands.
|
||||
/// </summary>
|
||||
Simulated,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>ACL scope level. Per <c>acl-design.md</c> §"Scope Hierarchy".</summary>
|
||||
public enum NodeAclScopeKind
|
||||
{
|
||||
Cluster,
|
||||
Namespace,
|
||||
UnsArea,
|
||||
UnsLine,
|
||||
Equipment,
|
||||
Tag,
|
||||
}
|
||||
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Status tracked per node in <see cref="Entities.ClusterNodeGenerationState"/>.</summary>
|
||||
public enum NodeApplyStatus
|
||||
{
|
||||
Applied,
|
||||
RolledBack,
|
||||
Failed,
|
||||
InProgress,
|
||||
}
|
||||
37
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs
Normal file
37
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA client data-path permissions per <c>acl-design.md</c>.
|
||||
/// Stored as <c>int</c> bitmask in <see cref="Entities.NodeAcl.PermissionFlags"/>.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum NodePermissions : uint
|
||||
{
|
||||
None = 0,
|
||||
|
||||
// Read-side
|
||||
Browse = 1 << 0,
|
||||
Read = 1 << 1,
|
||||
Subscribe = 1 << 2,
|
||||
HistoryRead = 1 << 3,
|
||||
|
||||
// Write-side (mirrors v1 SecurityClassification model)
|
||||
WriteOperate = 1 << 4,
|
||||
WriteTune = 1 << 5,
|
||||
WriteConfigure = 1 << 6,
|
||||
|
||||
// Alarm-side
|
||||
AlarmRead = 1 << 7,
|
||||
AlarmAcknowledge = 1 << 8,
|
||||
AlarmConfirm = 1 << 9,
|
||||
AlarmShelve = 1 << 10,
|
||||
|
||||
// OPC UA Part 4 §5.11
|
||||
MethodCall = 1 << 11,
|
||||
|
||||
// Bundles (one-click grants in Admin UI)
|
||||
ReadOnly = Browse | Read | Subscribe | HistoryRead | AlarmRead,
|
||||
Operator = ReadOnly | WriteOperate | AlarmAcknowledge | AlarmConfirm,
|
||||
Engineer = Operator | WriteTune | AlarmShelve,
|
||||
Admin = Engineer | WriteConfigure | MethodCall,
|
||||
}
|
||||
17
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyMode.cs
Normal file
17
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyMode.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster redundancy mode per OPC UA Part 5 §6.5. Persisted as string in
|
||||
/// <c>ServerCluster.RedundancyMode</c> with a CHECK constraint coupling to <c>NodeCount</c>.
|
||||
/// </summary>
|
||||
public enum RedundancyMode
|
||||
{
|
||||
/// <summary>Single-node cluster. Required when <c>NodeCount = 1</c>.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Warm redundancy (non-transparent). Two-node cluster.</summary>
|
||||
Warm,
|
||||
|
||||
/// <summary>Hot redundancy (non-transparent). Two-node cluster.</summary>
|
||||
Hot,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Per-node redundancy role within a cluster. Per decision #84.</summary>
|
||||
public enum RedundancyRole
|
||||
{
|
||||
Primary,
|
||||
Secondary,
|
||||
Standalone,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>External-ID reservation kind. Per decision #124.</summary>
|
||||
public enum ReservationKind
|
||||
{
|
||||
ZTag,
|
||||
SAPID,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Tag-level OPC UA access level baseline. Further narrowed per-user by NodeAcl grants.</summary>
|
||||
public enum TagAccessLevel
|
||||
{
|
||||
Read,
|
||||
ReadWrite,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// A self-contained snapshot of one generation — enough to rebuild the address space on a node
|
||||
/// that has lost DB connectivity. The payload is the JSON-serialized <c>sp_GetGenerationContent</c>
|
||||
/// result; the local cache doesn't inspect the shape, it just round-trips bytes.
|
||||
/// </summary>
|
||||
public sealed class GenerationSnapshot
|
||||
{
|
||||
public int Id { get; set; } // LiteDB auto-ID
|
||||
public required string ClusterId { get; set; }
|
||||
public required long GenerationId { get; set; }
|
||||
public required DateTime CachedAt { get; set; }
|
||||
public required string PayloadJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node local cache of the most-recently-applied generation(s). Used to bootstrap the
|
||||
/// address space when the central DB is unreachable (decision #79 — degraded-but-running).
|
||||
/// </summary>
|
||||
public interface ILocalConfigCache
|
||||
{
|
||||
Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default);
|
||||
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
|
||||
Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// LiteDB-backed <see cref="ILocalConfigCache"/>. One file per node (default
|
||||
/// <c>config_cache.db</c>), one collection per snapshot. Corruption surfaces as
|
||||
/// <see cref="LocalConfigCacheCorruptException"/> on construction or read — callers should
|
||||
/// delete and re-fetch from the central DB (decision #80).
|
||||
/// </summary>
|
||||
public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
{
|
||||
private const string CollectionName = "generations";
|
||||
private readonly LiteDatabase _db;
|
||||
private readonly ILiteCollection<GenerationSnapshot> _col;
|
||||
|
||||
public LiteDbConfigCache(string dbPath)
|
||||
{
|
||||
// LiteDB can be tolerant of header-only corruption at construction time (it may overwrite
|
||||
// the header and "recover"), so we force a write + read probe to fail fast on real corruption.
|
||||
try
|
||||
{
|
||||
_db = new LiteDatabase(new ConnectionString { Filename = dbPath, Upgrade = true });
|
||||
_col = _db.GetCollection<GenerationSnapshot>(CollectionName);
|
||||
_col.EnsureIndex(s => s.ClusterId);
|
||||
_col.EnsureIndex(s => s.GenerationId);
|
||||
_ = _col.Count();
|
||||
}
|
||||
catch (Exception ex) when (ex is LiteException or InvalidDataException or IOException
|
||||
or NotSupportedException or UnauthorizedAccessException
|
||||
or ArgumentOutOfRangeException or FormatException)
|
||||
{
|
||||
_db?.Dispose();
|
||||
throw new LocalConfigCacheCorruptException(
|
||||
$"LiteDB cache at '{dbPath}' is corrupt or unreadable — delete the file and refetch from the central DB.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var snapshot = _col
|
||||
.Find(s => s.ClusterId == clusterId)
|
||||
.OrderByDescending(s => s.GenerationId)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult<GenerationSnapshot?>(snapshot);
|
||||
}
|
||||
|
||||
public Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
// upsert by (ClusterId, GenerationId) — replace in place if already cached
|
||||
var existing = _col
|
||||
.Find(s => s.ClusterId == snapshot.ClusterId && s.GenerationId == snapshot.GenerationId)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existing is null)
|
||||
_col.Insert(snapshot);
|
||||
else
|
||||
{
|
||||
snapshot.Id = existing.Id;
|
||||
_col.Update(snapshot);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var doomed = _col
|
||||
.Find(s => s.ClusterId == clusterId)
|
||||
.OrderByDescending(s => s.GenerationId)
|
||||
.Skip(keepLatest)
|
||||
.Select(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (var id in doomed)
|
||||
_col.Delete(id);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
public sealed class LocalConfigCacheCorruptException(string message, Exception inner)
|
||||
: Exception(message, inner);
|
||||
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.Designer.cs
generated
Normal file
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConfigAuditLog",
|
||||
columns: table => new
|
||||
{
|
||||
AuditId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Timestamp = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
Principal = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
EventType = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: true),
|
||||
DetailsJson = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConfigAuditLog", x => x.AuditId);
|
||||
table.CheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalIdReservation",
|
||||
columns: table => new
|
||||
{
|
||||
ReservationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
Kind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
FirstPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
FirstPublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
LastPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
ReleasedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
ReleasedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
ReleaseReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalIdReservation", x => x.ReservationId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ServerCluster",
|
||||
columns: table => new
|
||||
{
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Enterprise = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Site = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
NodeCount = table.Column<byte>(type: "tinyint", nullable: false),
|
||||
RedundancyMode = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
ModifiedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
ModifiedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ServerCluster", x => x.ClusterId);
|
||||
table.CheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ClusterNode",
|
||||
columns: table => new
|
||||
{
|
||||
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
RedundancyRole = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
Host = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
|
||||
OpcUaPort = table.Column<int>(type: "int", nullable: false),
|
||||
DashboardPort = table.Column<int>(type: "int", nullable: false),
|
||||
ApplicationUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
ServiceLevelBase = table.Column<byte>(type: "tinyint", nullable: false),
|
||||
DriverConfigOverridesJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ClusterNode", x => x.NodeId);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClusterNode_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConfigGeneration",
|
||||
columns: table => new
|
||||
{
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
ParentGenerationId = table.Column<long>(type: "bigint", nullable: true),
|
||||
PublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
PublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConfigGeneration", x => x.GenerationId);
|
||||
table.ForeignKey(
|
||||
name: "FK_ConfigGeneration_ConfigGeneration_ParentGenerationId",
|
||||
column: x => x.ParentGenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ConfigGeneration_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ClusterNodeCredential",
|
||||
columns: table => new
|
||||
{
|
||||
CredentialId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
RotatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ClusterNodeCredential", x => x.CredentialId);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClusterNodeCredential_ClusterNode_NodeId",
|
||||
column: x => x.NodeId,
|
||||
principalTable: "ClusterNode",
|
||||
principalColumn: "NodeId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ClusterNodeGenerationState",
|
||||
columns: table => new
|
||||
{
|
||||
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CurrentGenerationId = table.Column<long>(type: "bigint", nullable: true),
|
||||
LastAppliedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
LastAppliedStatus = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
|
||||
LastAppliedError = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
|
||||
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ClusterNodeGenerationState", x => x.NodeId);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClusterNodeGenerationState_ClusterNode_NodeId",
|
||||
column: x => x.NodeId,
|
||||
principalTable: "ClusterNode",
|
||||
principalColumn: "NodeId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClusterNodeGenerationState_ConfigGeneration_CurrentGenerationId",
|
||||
column: x => x.CurrentGenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Device",
|
||||
columns: table => new
|
||||
{
|
||||
DeviceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeviceConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Device", x => x.DeviceRowId);
|
||||
table.CheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_Device_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DriverInstance",
|
||||
columns: table => new
|
||||
{
|
||||
DriverInstanceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
DriverType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
DriverConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DriverInstance", x => x.DriverInstanceRowId);
|
||||
table.CheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_DriverInstance_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_DriverInstance_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Equipment",
|
||||
columns: table => new
|
||||
{
|
||||
EquipmentRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
MachineCode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ZTag = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
SAPID = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Manufacturer = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Model = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
SerialNumber = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
HardwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
|
||||
SoftwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
|
||||
YearOfConstruction = table.Column<short>(type: "smallint", nullable: true),
|
||||
AssetLocation = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
EquipmentClassRef = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Equipment", x => x.EquipmentRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Equipment_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Namespace",
|
||||
columns: table => new
|
||||
{
|
||||
NamespaceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
NamespaceUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Namespace", x => x.NamespaceRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Namespace_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Namespace_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NodeAcl",
|
||||
columns: table => new
|
||||
{
|
||||
NodeAclRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
NodeAclId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
LdapGroup = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
ScopeKind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
ScopeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
PermissionFlags = table.Column<int>(type: "int", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NodeAcl", x => x.NodeAclRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_NodeAcl_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PollGroup",
|
||||
columns: table => new
|
||||
{
|
||||
PollGroupRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
IntervalMs = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PollGroup", x => x.PollGroupRowId);
|
||||
table.CheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
|
||||
table.ForeignKey(
|
||||
name: "FK_PollGroup_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Tag",
|
||||
columns: table => new
|
||||
{
|
||||
TagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
TagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
FolderPath = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
AccessLevel = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
WriteIdempotent = table.Column<bool>(type: "bit", nullable: false),
|
||||
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
TagConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Tag", x => x.TagRowId);
|
||||
table.CheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_Tag_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UnsArea",
|
||||
columns: table => new
|
||||
{
|
||||
UnsAreaRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UnsArea", x => x.UnsAreaRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_UnsArea_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_UnsArea_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UnsLine",
|
||||
columns: table => new
|
||||
{
|
||||
UnsLineRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UnsLine", x => x.UnsLineRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_UnsLine_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ClusterNode_ApplicationUri",
|
||||
table: "ClusterNode",
|
||||
column: "ApplicationUri",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ClusterNode_Primary_Per_Cluster",
|
||||
table: "ClusterNode",
|
||||
column: "ClusterId",
|
||||
unique: true,
|
||||
filter: "[RedundancyRole] = 'Primary'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClusterNodeCredential_NodeId",
|
||||
table: "ClusterNodeCredential",
|
||||
columns: new[] { "NodeId", "Enabled" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ClusterNodeCredential_Value",
|
||||
table: "ClusterNodeCredential",
|
||||
columns: new[] { "Kind", "Value" },
|
||||
unique: true,
|
||||
filter: "[Enabled] = 1");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClusterNodeGenerationState_Generation",
|
||||
table: "ClusterNodeGenerationState",
|
||||
column: "CurrentGenerationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConfigAuditLog_Cluster_Time",
|
||||
table: "ConfigAuditLog",
|
||||
columns: new[] { "ClusterId", "Timestamp" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConfigAuditLog_Generation",
|
||||
table: "ConfigAuditLog",
|
||||
column: "GenerationId",
|
||||
filter: "[GenerationId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConfigGeneration_Cluster_Published",
|
||||
table: "ConfigGeneration",
|
||||
columns: new[] { "ClusterId", "Status", "GenerationId" },
|
||||
descending: new[] { false, false, true })
|
||||
.Annotation("SqlServer:Include", new[] { "PublishedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConfigGeneration_ParentGenerationId",
|
||||
table: "ConfigGeneration",
|
||||
column: "ParentGenerationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ConfigGeneration_Draft_Per_Cluster",
|
||||
table: "ConfigGeneration",
|
||||
column: "ClusterId",
|
||||
unique: true,
|
||||
filter: "[Status] = 'Draft'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Device_Generation_Driver",
|
||||
table: "Device",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Device_Generation_LogicalId",
|
||||
table: "Device",
|
||||
columns: new[] { "GenerationId", "DeviceId" },
|
||||
unique: true,
|
||||
filter: "[DeviceId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverInstance_ClusterId",
|
||||
table: "DriverInstance",
|
||||
column: "ClusterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverInstance_Generation_Cluster",
|
||||
table: "DriverInstance",
|
||||
columns: new[] { "GenerationId", "ClusterId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverInstance_Generation_Namespace",
|
||||
table: "DriverInstance",
|
||||
columns: new[] { "GenerationId", "NamespaceId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_DriverInstance_Generation_LogicalId",
|
||||
table: "DriverInstance",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId" },
|
||||
unique: true,
|
||||
filter: "[DriverInstanceId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Equipment_Generation_Driver",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Equipment_Generation_Line",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "UnsLineId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Equipment_Generation_MachineCode",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "MachineCode" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Equipment_Generation_SAPID",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "SAPID" },
|
||||
filter: "[SAPID] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Equipment_Generation_ZTag",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "ZTag" },
|
||||
filter: "[ZTag] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Equipment_Generation_LinePath",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "UnsLineId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Equipment_Generation_LogicalId",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "EquipmentId" },
|
||||
unique: true,
|
||||
filter: "[EquipmentId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Equipment_Generation_Uuid",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "EquipmentUuid" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalIdReservation_Equipment",
|
||||
table: "ExternalIdReservation",
|
||||
column: "EquipmentUuid");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ExternalIdReservation_KindValue_Active",
|
||||
table: "ExternalIdReservation",
|
||||
columns: new[] { "Kind", "Value" },
|
||||
unique: true,
|
||||
filter: "[ReleasedAt] IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Namespace_ClusterId",
|
||||
table: "Namespace",
|
||||
column: "ClusterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Namespace_Generation_Cluster",
|
||||
table: "Namespace",
|
||||
columns: new[] { "GenerationId", "ClusterId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Namespace_Generation_Cluster_Kind",
|
||||
table: "Namespace",
|
||||
columns: new[] { "GenerationId", "ClusterId", "Kind" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Namespace_Generation_LogicalId",
|
||||
table: "Namespace",
|
||||
columns: new[] { "GenerationId", "NamespaceId" },
|
||||
unique: true,
|
||||
filter: "[NamespaceId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Namespace_Generation_LogicalId_Cluster",
|
||||
table: "Namespace",
|
||||
columns: new[] { "GenerationId", "NamespaceId", "ClusterId" },
|
||||
unique: true,
|
||||
filter: "[NamespaceId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Namespace_Generation_NamespaceUri",
|
||||
table: "Namespace",
|
||||
columns: new[] { "GenerationId", "NamespaceUri" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NodeAcl_Generation_Cluster",
|
||||
table: "NodeAcl",
|
||||
columns: new[] { "GenerationId", "ClusterId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NodeAcl_Generation_Group",
|
||||
table: "NodeAcl",
|
||||
columns: new[] { "GenerationId", "LdapGroup" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NodeAcl_Generation_Scope",
|
||||
table: "NodeAcl",
|
||||
columns: new[] { "GenerationId", "ScopeKind", "ScopeId" },
|
||||
filter: "[ScopeId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_NodeAcl_Generation_GroupScope",
|
||||
table: "NodeAcl",
|
||||
columns: new[] { "GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId" },
|
||||
unique: true,
|
||||
filter: "[ScopeId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_NodeAcl_Generation_LogicalId",
|
||||
table: "NodeAcl",
|
||||
columns: new[] { "GenerationId", "NodeAclId" },
|
||||
unique: true,
|
||||
filter: "[NodeAclId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PollGroup_Generation_Driver",
|
||||
table: "PollGroup",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_PollGroup_Generation_LogicalId",
|
||||
table: "PollGroup",
|
||||
columns: new[] { "GenerationId", "PollGroupId" },
|
||||
unique: true,
|
||||
filter: "[PollGroupId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ServerCluster_Site",
|
||||
table: "ServerCluster",
|
||||
column: "Site");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ServerCluster_Name",
|
||||
table: "ServerCluster",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tag_Generation_Driver_Device",
|
||||
table: "Tag",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId", "DeviceId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tag_Generation_Equipment",
|
||||
table: "Tag",
|
||||
columns: new[] { "GenerationId", "EquipmentId" },
|
||||
filter: "[EquipmentId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Tag_Generation_EquipmentPath",
|
||||
table: "Tag",
|
||||
columns: new[] { "GenerationId", "EquipmentId", "Name" },
|
||||
unique: true,
|
||||
filter: "[EquipmentId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Tag_Generation_FolderPath",
|
||||
table: "Tag",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId", "FolderPath", "Name" },
|
||||
unique: true,
|
||||
filter: "[EquipmentId] IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Tag_Generation_LogicalId",
|
||||
table: "Tag",
|
||||
columns: new[] { "GenerationId", "TagId" },
|
||||
unique: true,
|
||||
filter: "[TagId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UnsArea_ClusterId",
|
||||
table: "UnsArea",
|
||||
column: "ClusterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UnsArea_Generation_Cluster",
|
||||
table: "UnsArea",
|
||||
columns: new[] { "GenerationId", "ClusterId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_UnsArea_Generation_ClusterName",
|
||||
table: "UnsArea",
|
||||
columns: new[] { "GenerationId", "ClusterId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_UnsArea_Generation_LogicalId",
|
||||
table: "UnsArea",
|
||||
columns: new[] { "GenerationId", "UnsAreaId" },
|
||||
unique: true,
|
||||
filter: "[UnsAreaId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UnsLine_Generation_Area",
|
||||
table: "UnsLine",
|
||||
columns: new[] { "GenerationId", "UnsAreaId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_UnsLine_Generation_AreaName",
|
||||
table: "UnsLine",
|
||||
columns: new[] { "GenerationId", "UnsAreaId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_UnsLine_Generation_LogicalId",
|
||||
table: "UnsLine",
|
||||
columns: new[] { "GenerationId", "UnsLineId" },
|
||||
unique: true,
|
||||
filter: "[UnsLineId] IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ClusterNodeCredential");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ClusterNodeGenerationState");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ConfigAuditLog");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Device");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DriverInstance");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Equipment");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalIdReservation");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Namespace");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NodeAcl");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PollGroup");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Tag");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UnsArea");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UnsLine");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ClusterNode");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ConfigGeneration");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ServerCluster");
|
||||
}
|
||||
}
|
||||
}
|
||||
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.Designer.cs
generated
Normal file
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Stored procedures per <c>config-db-schema.md §"Stored Procedures"</c>. All node + admin DB
|
||||
/// access funnels through these — direct table writes are revoked in the AuthorizationGrants
|
||||
/// migration that follows. CREATE OR ALTER style so procs version with the schema.
|
||||
/// </summary>
|
||||
public partial class StoredProcedures : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.GetCurrentGenerationForCluster);
|
||||
migrationBuilder.Sql(Procs.GetGenerationContent);
|
||||
migrationBuilder.Sql(Procs.RegisterNodeGenerationApplied);
|
||||
migrationBuilder.Sql(Procs.ValidateDraft);
|
||||
migrationBuilder.Sql(Procs.PublishGeneration);
|
||||
migrationBuilder.Sql(Procs.RollbackToGeneration);
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiff);
|
||||
migrationBuilder.Sql(Procs.ReleaseExternalIdReservation);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"sp_ReleaseExternalIdReservation", "sp_ComputeGenerationDiff", "sp_RollbackToGeneration",
|
||||
"sp_PublishGeneration", "sp_ValidateDraft", "sp_RegisterNodeGenerationApplied",
|
||||
"sp_GetGenerationContent", "sp_GetCurrentGenerationForCluster",
|
||||
})
|
||||
{
|
||||
migrationBuilder.Sql($"IF OBJECT_ID(N'dbo.{name}', N'P') IS NOT NULL DROP PROCEDURE dbo.{name};");
|
||||
}
|
||||
}
|
||||
|
||||
private static class Procs
|
||||
{
|
||||
public const string GetCurrentGenerationForCluster = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster
|
||||
@NodeId nvarchar(64),
|
||||
@ClusterId nvarchar(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM dbo.ClusterNodeCredential
|
||||
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
|
||||
BEGIN
|
||||
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM dbo.ClusterNode
|
||||
WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1)
|
||||
BEGIN
|
||||
RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SELECT TOP 1 GenerationId, ClusterId, Status, PublishedAt, PublishedBy, Notes
|
||||
FROM dbo.ConfigGeneration
|
||||
WHERE ClusterId = @ClusterId AND Status = 'Published'
|
||||
ORDER BY GenerationId DESC;
|
||||
END
|
||||
";
|
||||
|
||||
public const string GetGenerationContent = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent
|
||||
@NodeId nvarchar(64),
|
||||
@GenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
|
||||
DECLARE @ClusterId nvarchar(64);
|
||||
|
||||
SELECT @ClusterId = ClusterId FROM dbo.ConfigGeneration WHERE GenerationId = @GenerationId;
|
||||
|
||||
IF @ClusterId IS NULL
|
||||
BEGIN
|
||||
RAISERROR('GenerationId %I64d not found', 16, 1, @GenerationId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.ClusterNodeCredential c
|
||||
JOIN dbo.ClusterNode n ON n.NodeId = c.NodeId
|
||||
WHERE c.NodeId = @NodeId AND c.Value = @Caller AND c.Enabled = 1
|
||||
AND n.ClusterId = @ClusterId AND n.Enabled = 1)
|
||||
BEGIN
|
||||
RAISERROR('Forbidden: caller %s not bound to a node in ClusterId %s', 16, 1, @Caller, @ClusterId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SELECT * FROM dbo.Namespace WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.UnsArea WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.UnsLine WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.DriverInstance WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.Device WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.Equipment WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.PollGroup WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.Tag WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.NodeAcl WHERE GenerationId = @GenerationId;
|
||||
END
|
||||
";
|
||||
|
||||
public const string RegisterNodeGenerationApplied = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_RegisterNodeGenerationApplied
|
||||
@NodeId nvarchar(64),
|
||||
@GenerationId bigint,
|
||||
@Status nvarchar(16),
|
||||
@Error nvarchar(max) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM dbo.ClusterNodeCredential
|
||||
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
|
||||
BEGIN
|
||||
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
MERGE dbo.ClusterNodeGenerationState AS tgt
|
||||
USING (SELECT @NodeId AS NodeId) AS src ON tgt.NodeId = src.NodeId
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
CurrentGenerationId = @GenerationId,
|
||||
LastAppliedAt = SYSUTCDATETIME(),
|
||||
LastAppliedStatus = @Status,
|
||||
LastAppliedError = @Error,
|
||||
LastSeenAt = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED THEN INSERT
|
||||
(NodeId, CurrentGenerationId, LastAppliedAt, LastAppliedStatus, LastAppliedError, LastSeenAt)
|
||||
VALUES (@NodeId, @GenerationId, SYSUTCDATETIME(), @Status, @Error, SYSUTCDATETIME());
|
||||
|
||||
INSERT dbo.ConfigAuditLog (Principal, EventType, NodeId, GenerationId, DetailsJson)
|
||||
VALUES (@Caller, 'NodeApplied', @NodeId, @GenerationId,
|
||||
CONCAT('{""status"":""', @Status, '""}'));
|
||||
END
|
||||
";
|
||||
|
||||
public const string ValidateDraft = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ValidateDraft
|
||||
@DraftGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @ClusterId nvarchar(64);
|
||||
DECLARE @Status nvarchar(16);
|
||||
|
||||
SELECT @ClusterId = ClusterId, @Status = Status
|
||||
FROM dbo.ConfigGeneration WHERE GenerationId = @DraftGenerationId;
|
||||
|
||||
IF @ClusterId IS NULL
|
||||
BEGIN
|
||||
RAISERROR('GenerationId %I64d not found', 16, 1, @DraftGenerationId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF @Status <> 'Draft'
|
||||
BEGIN
|
||||
RAISERROR('GenerationId %I64d is not in Draft status (current=%s)', 16, 1, @DraftGenerationId, @Status);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM dbo.Tag t
|
||||
LEFT JOIN dbo.DriverInstance d ON d.GenerationId = t.GenerationId AND d.DriverInstanceId = t.DriverInstanceId
|
||||
WHERE t.GenerationId = @DraftGenerationId AND d.DriverInstanceId IS NULL)
|
||||
BEGIN
|
||||
RAISERROR('Draft has tags with unresolved DriverInstanceId', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM dbo.Tag t
|
||||
LEFT JOIN dbo.Device dv ON dv.GenerationId = t.GenerationId AND dv.DeviceId = t.DeviceId
|
||||
WHERE t.GenerationId = @DraftGenerationId AND t.DeviceId IS NOT NULL AND dv.DeviceId IS NULL)
|
||||
BEGIN
|
||||
RAISERROR('Draft has tags with unresolved DeviceId', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM dbo.Tag t
|
||||
LEFT JOIN dbo.PollGroup pg ON pg.GenerationId = t.GenerationId AND pg.PollGroupId = t.PollGroupId
|
||||
WHERE t.GenerationId = @DraftGenerationId AND t.PollGroupId IS NOT NULL AND pg.PollGroupId IS NULL)
|
||||
BEGIN
|
||||
RAISERROR('Draft has tags with unresolved PollGroupId', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.DriverInstance di
|
||||
JOIN dbo.Namespace ns ON ns.GenerationId = di.GenerationId AND ns.NamespaceId = di.NamespaceId
|
||||
WHERE di.GenerationId = @DraftGenerationId
|
||||
AND ns.ClusterId <> di.ClusterId)
|
||||
BEGIN
|
||||
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
|
||||
VALUES (SUSER_SNAME(), 'CrossClusterNamespaceAttempt', @ClusterId, @DraftGenerationId);
|
||||
RAISERROR('BadCrossClusterNamespaceBinding: namespace and driver must belong to the same cluster', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.Equipment draft
|
||||
JOIN dbo.Equipment prior
|
||||
ON prior.EquipmentId = draft.EquipmentId
|
||||
AND prior.EquipmentUuid <> draft.EquipmentUuid
|
||||
AND prior.GenerationId <> draft.GenerationId
|
||||
JOIN dbo.ConfigGeneration pg ON pg.GenerationId = prior.GenerationId
|
||||
WHERE draft.GenerationId = @DraftGenerationId
|
||||
AND pg.ClusterId = @ClusterId)
|
||||
BEGIN
|
||||
RAISERROR('EquipmentUuid immutability violated for an EquipmentId that existed in a prior generation', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.Equipment draft
|
||||
JOIN dbo.ExternalIdReservation r
|
||||
ON r.Kind = 'ZTag' AND r.Value = draft.ZTag AND r.ReleasedAt IS NULL
|
||||
AND r.EquipmentUuid <> draft.EquipmentUuid
|
||||
WHERE draft.GenerationId = @DraftGenerationId AND draft.ZTag IS NOT NULL)
|
||||
BEGIN
|
||||
RAISERROR('BadDuplicateExternalIdentifier: a ZTag in the draft is reserved by a different EquipmentUuid', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.Equipment draft
|
||||
JOIN dbo.ExternalIdReservation r
|
||||
ON r.Kind = 'SAPID' AND r.Value = draft.SAPID AND r.ReleasedAt IS NULL
|
||||
AND r.EquipmentUuid <> draft.EquipmentUuid
|
||||
WHERE draft.GenerationId = @DraftGenerationId AND draft.SAPID IS NOT NULL)
|
||||
BEGIN
|
||||
RAISERROR('BadDuplicateExternalIdentifier: a SAPID in the draft is reserved by a different EquipmentUuid', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
END
|
||||
";
|
||||
|
||||
public const string PublishGeneration = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_PublishGeneration
|
||||
@ClusterId nvarchar(64),
|
||||
@DraftGenerationId bigint,
|
||||
@Notes nvarchar(1024) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DECLARE @Lock nvarchar(255) = N'OtOpcUa_Publish_' + @ClusterId;
|
||||
DECLARE @LockResult int;
|
||||
EXEC @LockResult = sp_getapplock @Resource = @Lock, @LockMode = 'Exclusive', @LockTimeout = 0;
|
||||
IF @LockResult < 0
|
||||
BEGIN
|
||||
RAISERROR('PublishConflict: another publish is in progress for cluster %s', 16, 1, @ClusterId);
|
||||
ROLLBACK;
|
||||
RETURN;
|
||||
END
|
||||
|
||||
EXEC dbo.sp_ValidateDraft @DraftGenerationId = @DraftGenerationId;
|
||||
|
||||
MERGE dbo.ExternalIdReservation AS tgt
|
||||
USING (
|
||||
SELECT 'ZTag' AS Kind, ZTag AS Value, EquipmentUuid
|
||||
FROM dbo.Equipment
|
||||
WHERE GenerationId = @DraftGenerationId AND ZTag IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'SAPID', SAPID, EquipmentUuid
|
||||
FROM dbo.Equipment
|
||||
WHERE GenerationId = @DraftGenerationId AND SAPID IS NOT NULL
|
||||
) AS src
|
||||
ON tgt.Kind = src.Kind AND tgt.Value = src.Value AND tgt.EquipmentUuid = src.EquipmentUuid
|
||||
WHEN MATCHED THEN UPDATE SET LastPublishedAt = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt)
|
||||
VALUES (src.Kind, src.Value, src.EquipmentUuid, @ClusterId, SUSER_SNAME(), SYSUTCDATETIME());
|
||||
|
||||
UPDATE dbo.ConfigGeneration
|
||||
SET Status = 'Superseded'
|
||||
WHERE ClusterId = @ClusterId AND Status = 'Published';
|
||||
|
||||
UPDATE dbo.ConfigGeneration
|
||||
SET Status = 'Published',
|
||||
PublishedAt = SYSUTCDATETIME(),
|
||||
PublishedBy = SUSER_SNAME(),
|
||||
Notes = ISNULL(@Notes, Notes)
|
||||
WHERE GenerationId = @DraftGenerationId AND ClusterId = @ClusterId AND Status = 'Draft';
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
RAISERROR('Draft %I64d for cluster %s not found (was it already published?)', 16, 1, @DraftGenerationId, @ClusterId);
|
||||
ROLLBACK;
|
||||
RETURN;
|
||||
END
|
||||
|
||||
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
|
||||
VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);
|
||||
|
||||
COMMIT;
|
||||
END
|
||||
";
|
||||
|
||||
public const string RollbackToGeneration = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_RollbackToGeneration
|
||||
@ClusterId nvarchar(64),
|
||||
@TargetGenerationId bigint,
|
||||
@Notes nvarchar(1024) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM dbo.ConfigGeneration
|
||||
WHERE GenerationId = @TargetGenerationId AND ClusterId = @ClusterId
|
||||
AND Status IN ('Published', 'Superseded'))
|
||||
BEGIN
|
||||
RAISERROR('Target generation %I64d not found or not rollback-eligible', 16, 1, @TargetGenerationId);
|
||||
ROLLBACK; RETURN;
|
||||
END
|
||||
|
||||
DECLARE @NewGenId bigint;
|
||||
INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy, PublishedAt, PublishedBy, Notes)
|
||||
VALUES (@ClusterId, 'Draft', SYSUTCDATETIME(), SUSER_SNAME(), NULL, NULL,
|
||||
ISNULL(@Notes, CONCAT('Rollback clone of generation ', @TargetGenerationId)));
|
||||
SET @NewGenId = SCOPE_IDENTITY();
|
||||
|
||||
INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes)
|
||||
SELECT @NewGenId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes FROM dbo.Namespace WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.UnsArea (GenerationId, UnsAreaId, ClusterId, Name, Notes)
|
||||
SELECT @NewGenId, UnsAreaId, ClusterId, Name, Notes FROM dbo.UnsArea WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.UnsLine (GenerationId, UnsLineId, UnsAreaId, Name, Notes)
|
||||
SELECT @NewGenId, UnsLineId, UnsAreaId, Name, Notes FROM dbo.UnsLine WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
|
||||
SELECT @NewGenId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig FROM dbo.DriverInstance WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.Device (GenerationId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
|
||||
SELECT @NewGenId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig FROM dbo.Device WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.Equipment (GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled)
|
||||
SELECT @NewGenId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled FROM dbo.Equipment WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.PollGroup (GenerationId, PollGroupId, DriverInstanceId, Name, IntervalMs)
|
||||
SELECT @NewGenId, PollGroupId, DriverInstanceId, Name, IntervalMs FROM dbo.PollGroup WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
|
||||
SELECT @NewGenId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig FROM dbo.Tag WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.NodeAcl (GenerationId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes)
|
||||
SELECT @NewGenId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes FROM dbo.NodeAcl WHERE GenerationId = @TargetGenerationId;
|
||||
|
||||
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @NewGenId, @Notes = @Notes;
|
||||
|
||||
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId, DetailsJson)
|
||||
VALUES (SUSER_SNAME(), 'RolledBack', @ClusterId, @NewGenId,
|
||||
CONCAT('{""rolledBackTo"":', @TargetGenerationId, '}'));
|
||||
|
||||
COMMIT;
|
||||
END
|
||||
";
|
||||
|
||||
public const string ComputeGenerationDiff = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
|
||||
public const string ReleaseExternalIdReservation = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
|
||||
@Kind nvarchar(16),
|
||||
@Value nvarchar(64),
|
||||
@ReleaseReason nvarchar(512)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0
|
||||
BEGIN
|
||||
RAISERROR('ReleaseReason is required', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.ExternalIdReservation
|
||||
SET ReleasedAt = SYSUTCDATETIME(),
|
||||
ReleasedBy = SUSER_SNAME(),
|
||||
ReleaseReason = @ReleaseReason
|
||||
WHERE Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL;
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
|
||||
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
|
||||
CONCAT('{""kind"":""', @Kind, '"",""value"":""', @Value, '""}'));
|
||||
END
|
||||
";
|
||||
}
|
||||
}
|
||||
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.Designer.cs
generated
Normal file
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the two DB roles per <c>config-db-schema.md §"Authorization Model"</c> and grants
|
||||
/// EXECUTE on the appropriate stored procedures. Deliberately grants no direct table DML — all
|
||||
/// writes funnel through the procs, which authenticate via <c>SUSER_SNAME()</c>.
|
||||
/// Principals (SQL logins, gMSA users, cert-mapped users) are provisioned by the DBA outside
|
||||
/// this migration and then added to one of the two roles.
|
||||
/// </summary>
|
||||
public partial class AuthorizationGrants : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NULL
|
||||
CREATE ROLE OtOpcUaNode;
|
||||
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NULL
|
||||
CREATE ROLE OtOpcUaAdmin;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaNode;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaNode;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_RegisterNodeGenerationApplied TO OtOpcUaNode;
|
||||
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_ValidateDraft TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_PublishGeneration TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_RollbackToGeneration TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_ComputeGenerationDiff TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_ReleaseExternalIdReservation TO OtOpcUaAdmin;
|
||||
|
||||
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaNode;
|
||||
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaAdmin;
|
||||
DENY SELECT ON SCHEMA::dbo TO OtOpcUaNode;
|
||||
-- Admins may SELECT for reporting views in the future — grant views explicitly, not the schema.
|
||||
DENY SELECT ON SCHEMA::dbo TO OtOpcUaAdmin;
|
||||
");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NOT NULL
|
||||
DROP ROLE OtOpcUaNode;
|
||||
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NOT NULL
|
||||
DROP ROLE OtOpcUaAdmin;
|
||||
");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
487
src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
Normal file
487
src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
Normal file
@@ -0,0 +1,487 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Central config DB context. Schema matches <c>docs/v2/config-db-schema.md</c> exactly —
|
||||
/// any divergence is a defect caught by the SchemaComplianceTests introspection check.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public DbSet<ServerCluster> ServerClusters => Set<ServerCluster>();
|
||||
public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
|
||||
public DbSet<ClusterNodeCredential> ClusterNodeCredentials => Set<ClusterNodeCredential>();
|
||||
public DbSet<ConfigGeneration> ConfigGenerations => Set<ConfigGeneration>();
|
||||
public DbSet<Namespace> Namespaces => Set<Namespace>();
|
||||
public DbSet<UnsArea> UnsAreas => Set<UnsArea>();
|
||||
public DbSet<UnsLine> UnsLines => Set<UnsLine>();
|
||||
public DbSet<DriverInstance> DriverInstances => Set<DriverInstance>();
|
||||
public DbSet<Device> Devices => Set<Device>();
|
||||
public DbSet<Equipment> Equipment => Set<Equipment>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<PollGroup> PollGroups => Set<PollGroup>();
|
||||
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
|
||||
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
|
||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ConfigureServerCluster(modelBuilder);
|
||||
ConfigureClusterNode(modelBuilder);
|
||||
ConfigureClusterNodeCredential(modelBuilder);
|
||||
ConfigureConfigGeneration(modelBuilder);
|
||||
ConfigureNamespace(modelBuilder);
|
||||
ConfigureUnsArea(modelBuilder);
|
||||
ConfigureUnsLine(modelBuilder);
|
||||
ConfigureDriverInstance(modelBuilder);
|
||||
ConfigureDevice(modelBuilder);
|
||||
ConfigureEquipment(modelBuilder);
|
||||
ConfigureTag(modelBuilder);
|
||||
ConfigurePollGroup(modelBuilder);
|
||||
ConfigureNodeAcl(modelBuilder);
|
||||
ConfigureClusterNodeGenerationState(modelBuilder);
|
||||
ConfigureConfigAuditLog(modelBuilder);
|
||||
ConfigureExternalIdReservation(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ServerCluster>(e =>
|
||||
{
|
||||
e.ToTable("ServerCluster", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount",
|
||||
"((NodeCount = 1 AND RedundancyMode = 'None') " +
|
||||
"OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
|
||||
});
|
||||
e.HasKey(x => x.ClusterId);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.Enterprise).HasMaxLength(32);
|
||||
e.Property(x => x.Site).HasMaxLength(32);
|
||||
e.Property(x => x.RedundancyMode).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.Notes).HasMaxLength(1024);
|
||||
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
e.Property(x => x.ModifiedAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.ModifiedBy).HasMaxLength(128);
|
||||
e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_ServerCluster_Name");
|
||||
e.HasIndex(x => x.Site).HasDatabaseName("IX_ServerCluster_Site");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureClusterNode(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ClusterNode>(e =>
|
||||
{
|
||||
e.ToTable("ClusterNode");
|
||||
e.HasKey(x => x.NodeId);
|
||||
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.RedundancyRole).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.Host).HasMaxLength(255);
|
||||
e.Property(x => x.ApplicationUri).HasMaxLength(256);
|
||||
e.Property(x => x.DriverConfigOverridesJson).HasColumnType("nvarchar(max)");
|
||||
e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
|
||||
e.HasOne(x => x.Cluster).WithMany(c => c.Nodes)
|
||||
.HasForeignKey(x => x.ClusterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Fleet-wide unique per decision #86
|
||||
e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri");
|
||||
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId");
|
||||
// At most one Primary per cluster
|
||||
e.HasIndex(x => x.ClusterId).IsUnique()
|
||||
.HasFilter("[RedundancyRole] = 'Primary'")
|
||||
.HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureClusterNodeCredential(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ClusterNodeCredential>(e =>
|
||||
{
|
||||
e.ToTable("ClusterNodeCredential");
|
||||
e.HasKey(x => x.CredentialId);
|
||||
e.Property(x => x.CredentialId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
|
||||
e.Property(x => x.Value).HasMaxLength(512);
|
||||
e.Property(x => x.RotatedAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
|
||||
e.HasOne(x => x.Node).WithMany(n => n.Credentials)
|
||||
.HasForeignKey(x => x.NodeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.NodeId, x.Enabled }).HasDatabaseName("IX_ClusterNodeCredential_NodeId");
|
||||
e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
|
||||
.HasFilter("[Enabled] = 1")
|
||||
.HasDatabaseName("UX_ClusterNodeCredential_Value");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureConfigGeneration(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ConfigGeneration>(e =>
|
||||
{
|
||||
e.ToTable("ConfigGeneration");
|
||||
e.HasKey(x => x.GenerationId);
|
||||
e.Property(x => x.GenerationId).UseIdentityColumn(seed: 1, increment: 1);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.PublishedAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.PublishedBy).HasMaxLength(128);
|
||||
e.Property(x => x.Notes).HasMaxLength(1024);
|
||||
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
|
||||
e.HasOne(x => x.Cluster).WithMany(c => c.Generations)
|
||||
.HasForeignKey(x => x.ClusterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Parent).WithMany()
|
||||
.HasForeignKey(x => x.ParentGenerationId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.ClusterId, x.Status, x.GenerationId })
|
||||
.IsDescending(false, false, true)
|
||||
.IncludeProperties(x => x.PublishedAt)
|
||||
.HasDatabaseName("IX_ConfigGeneration_Cluster_Published");
|
||||
// One Draft per cluster at a time
|
||||
e.HasIndex(x => x.ClusterId).IsUnique()
|
||||
.HasFilter("[Status] = 'Draft'")
|
||||
.HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureNamespace(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Namespace>(e =>
|
||||
{
|
||||
e.ToTable("Namespace");
|
||||
e.HasKey(x => x.NamespaceRowId);
|
||||
e.Property(x => x.NamespaceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.NamespaceId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
|
||||
e.Property(x => x.NamespaceUri).HasMaxLength(256);
|
||||
e.Property(x => x.Notes).HasMaxLength(1024);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany()
|
||||
.HasForeignKey(x => x.GenerationId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Cluster).WithMany(c => c.Namespaces)
|
||||
.HasForeignKey(x => x.ClusterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.Kind }).IsUnique()
|
||||
.HasDatabaseName("UX_Namespace_Generation_Cluster_Kind");
|
||||
e.HasIndex(x => new { x.GenerationId, x.NamespaceUri }).IsUnique()
|
||||
.HasDatabaseName("UX_Namespace_Generation_NamespaceUri");
|
||||
e.HasIndex(x => new { x.GenerationId, x.NamespaceId }).IsUnique()
|
||||
.HasDatabaseName("UX_Namespace_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.NamespaceId, x.ClusterId }).IsUnique()
|
||||
.HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId })
|
||||
.HasDatabaseName("IX_Namespace_Generation_Cluster");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureUnsArea(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<UnsArea>(e =>
|
||||
{
|
||||
e.ToTable("UnsArea");
|
||||
e.HasKey(x => x.UnsAreaRowId);
|
||||
e.Property(x => x.UnsAreaRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.UnsAreaId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(32);
|
||||
e.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_UnsArea_Generation_Cluster");
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId }).IsUnique().HasDatabaseName("UX_UnsArea_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.Name }).IsUnique().HasDatabaseName("UX_UnsArea_Generation_ClusterName");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureUnsLine(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<UnsLine>(e =>
|
||||
{
|
||||
e.ToTable("UnsLine");
|
||||
e.HasKey(x => x.UnsLineRowId);
|
||||
e.Property(x => x.UnsLineRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.UnsLineId).HasMaxLength(64);
|
||||
e.Property(x => x.UnsAreaId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(32);
|
||||
e.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId }).HasDatabaseName("IX_UnsLine_Generation_Area");
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsLineId }).IsUnique().HasDatabaseName("UX_UnsLine_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId, x.Name }).IsUnique().HasDatabaseName("UX_UnsLine_Generation_AreaName");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureDriverInstance(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<DriverInstance>(e =>
|
||||
{
|
||||
e.ToTable("DriverInstance", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson",
|
||||
"ISJSON(DriverConfig) = 1");
|
||||
});
|
||||
e.HasKey(x => x.DriverInstanceRowId);
|
||||
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.NamespaceId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.DriverType).HasMaxLength(32);
|
||||
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_DriverInstance_Generation_Cluster");
|
||||
e.HasIndex(x => new { x.GenerationId, x.NamespaceId }).HasDatabaseName("IX_DriverInstance_Generation_Namespace");
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).IsUnique().HasDatabaseName("UX_DriverInstance_Generation_LogicalId");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureDevice(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Device>(e =>
|
||||
{
|
||||
e.ToTable("Device", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
|
||||
});
|
||||
e.HasKey(x => x.DeviceRowId);
|
||||
e.Property(x => x.DeviceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.DeviceConfig).HasColumnType("nvarchar(max)");
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_Device_Generation_Driver");
|
||||
e.HasIndex(x => new { x.GenerationId, x.DeviceId }).IsUnique().HasDatabaseName("UX_Device_Generation_LogicalId");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureEquipment(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Equipment>(e =>
|
||||
{
|
||||
e.ToTable("Equipment");
|
||||
e.HasKey(x => x.EquipmentRowId);
|
||||
e.Property(x => x.EquipmentRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||
e.Property(x => x.UnsLineId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(32);
|
||||
e.Property(x => x.MachineCode).HasMaxLength(64);
|
||||
e.Property(x => x.ZTag).HasMaxLength(64);
|
||||
e.Property(x => x.SAPID).HasMaxLength(64);
|
||||
e.Property(x => x.Manufacturer).HasMaxLength(64);
|
||||
e.Property(x => x.Model).HasMaxLength(64);
|
||||
e.Property(x => x.SerialNumber).HasMaxLength(64);
|
||||
e.Property(x => x.HardwareRevision).HasMaxLength(32);
|
||||
e.Property(x => x.SoftwareRevision).HasMaxLength(32);
|
||||
e.Property(x => x.AssetLocation).HasMaxLength(256);
|
||||
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
|
||||
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
|
||||
e.Property(x => x.EquipmentClassRef).HasMaxLength(128);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_Equipment_Generation_Driver");
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsLineId }).HasDatabaseName("IX_Equipment_Generation_Line");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentId }).IsUnique().HasDatabaseName("UX_Equipment_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_Generation_LinePath");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentUuid }).IsUnique().HasDatabaseName("UX_Equipment_Generation_Uuid");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ZTag }).HasFilter("[ZTag] IS NOT NULL").HasDatabaseName("IX_Equipment_Generation_ZTag");
|
||||
e.HasIndex(x => new { x.GenerationId, x.SAPID }).HasFilter("[SAPID] IS NOT NULL").HasDatabaseName("IX_Equipment_Generation_SAPID");
|
||||
e.HasIndex(x => new { x.GenerationId, x.MachineCode }).HasDatabaseName("IX_Equipment_Generation_MachineCode");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureTag(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Tag>(e =>
|
||||
{
|
||||
e.ToTable("Tag", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
|
||||
});
|
||||
e.HasKey(x => x.TagRowId);
|
||||
e.Property(x => x.TagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.TagId).HasMaxLength(64);
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.FolderPath).HasMaxLength(512);
|
||||
e.Property(x => x.DataType).HasMaxLength(32);
|
||||
e.Property(x => x.AccessLevel).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.PollGroupId).HasMaxLength(64);
|
||||
e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)");
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId, x.DeviceId }).HasDatabaseName("IX_Tag_Generation_Driver_Device");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentId })
|
||||
.HasFilter("[EquipmentId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_Tag_Generation_Equipment");
|
||||
e.HasIndex(x => new { x.GenerationId, x.TagId }).IsUnique().HasDatabaseName("UX_Tag_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique()
|
||||
.HasFilter("[EquipmentId] IS NOT NULL")
|
||||
.HasDatabaseName("UX_Tag_Generation_EquipmentPath");
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId, x.FolderPath, x.Name }).IsUnique()
|
||||
.HasFilter("[EquipmentId] IS NULL")
|
||||
.HasDatabaseName("UX_Tag_Generation_FolderPath");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigurePollGroup(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<PollGroup>(e =>
|
||||
{
|
||||
e.ToTable("PollGroup", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
|
||||
});
|
||||
e.HasKey(x => x.PollGroupRowId);
|
||||
e.Property(x => x.PollGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.PollGroupId).HasMaxLength(64);
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_PollGroup_Generation_Driver");
|
||||
e.HasIndex(x => new { x.GenerationId, x.PollGroupId }).IsUnique().HasDatabaseName("UX_PollGroup_Generation_LogicalId");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureNodeAcl(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<NodeAcl>(e =>
|
||||
{
|
||||
e.ToTable("NodeAcl");
|
||||
e.HasKey(x => x.NodeAclRowId);
|
||||
e.Property(x => x.NodeAclRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.NodeAclId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.LdapGroup).HasMaxLength(256);
|
||||
e.Property(x => x.ScopeKind).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.ScopeId).HasMaxLength(64);
|
||||
e.Property(x => x.PermissionFlags).HasConversion<int>();
|
||||
e.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_NodeAcl_Generation_Cluster");
|
||||
e.HasIndex(x => new { x.GenerationId, x.LdapGroup }).HasDatabaseName("IX_NodeAcl_Generation_Group");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ScopeKind, x.ScopeId })
|
||||
.HasFilter("[ScopeId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_NodeAcl_Generation_Scope");
|
||||
e.HasIndex(x => new { x.GenerationId, x.NodeAclId }).IsUnique().HasDatabaseName("UX_NodeAcl_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique()
|
||||
.HasDatabaseName("UX_NodeAcl_Generation_GroupScope");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureClusterNodeGenerationState(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ClusterNodeGenerationState>(e =>
|
||||
{
|
||||
e.ToTable("ClusterNodeGenerationState");
|
||||
e.HasKey(x => x.NodeId);
|
||||
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||
e.Property(x => x.LastAppliedAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastAppliedStatus).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.LastAppliedError).HasMaxLength(2048);
|
||||
e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)");
|
||||
|
||||
e.HasOne(x => x.Node).WithOne(n => n.GenerationState).HasForeignKey<ClusterNodeGenerationState>(x => x.NodeId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.CurrentGeneration).WithMany().HasForeignKey(x => x.CurrentGenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => x.CurrentGenerationId).HasDatabaseName("IX_ClusterNodeGenerationState_Generation");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureConfigAuditLog(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ConfigAuditLog>(e =>
|
||||
{
|
||||
e.ToTable("ConfigAuditLog", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson",
|
||||
"DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
|
||||
});
|
||||
e.HasKey(x => x.AuditId);
|
||||
e.Property(x => x.AuditId).UseIdentityColumn(seed: 1, increment: 1);
|
||||
e.Property(x => x.Timestamp).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.Principal).HasMaxLength(128);
|
||||
e.Property(x => x.EventType).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||
e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
|
||||
|
||||
e.HasIndex(x => new { x.ClusterId, x.Timestamp })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_ConfigAuditLog_Cluster_Time");
|
||||
e.HasIndex(x => x.GenerationId)
|
||||
.HasFilter("[GenerationId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_ConfigAuditLog_Generation");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureExternalIdReservation(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ExternalIdReservation>(e =>
|
||||
{
|
||||
e.ToTable("ExternalIdReservation");
|
||||
e.HasKey(x => x.ReservationId);
|
||||
e.Property(x => x.ReservationId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.Value).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.FirstPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.FirstPublishedBy).HasMaxLength(128);
|
||||
e.Property(x => x.LastPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.ReleasedAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.ReleasedBy).HasMaxLength(128);
|
||||
e.Property(x => x.ReleaseReason).HasMaxLength(512);
|
||||
|
||||
// Active reservations unique per (Kind, Value) — filtered index lets released rows coexist with a new reservation of the same value.
|
||||
// The UX_ filtered index covers active-reservation lookups; history queries over released rows
|
||||
// fall back to the table scan (released rows are rare + small). No separate non-unique (Kind, Value)
|
||||
// index is declared because EF Core merges duplicate column sets into a single index, which would
|
||||
// clobber the filtered-unique name.
|
||||
e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
|
||||
.HasFilter("[ReleasedAt] IS NULL")
|
||||
.HasDatabaseName("UX_ExternalIdReservation_KindValue_Active");
|
||||
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Inputs for draft validation. Contains the draft's rows plus the minimum prior-generation
|
||||
/// rows needed for cross-generation invariants (EquipmentUuid stability, UnsArea identity).
|
||||
/// </summary>
|
||||
public sealed class DraftSnapshot
|
||||
{
|
||||
public required long GenerationId { get; init; }
|
||||
public required string ClusterId { get; init; }
|
||||
|
||||
public IReadOnlyList<Namespace> Namespaces { get; init; } = [];
|
||||
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
|
||||
public IReadOnlyList<Device> Devices { get; init; } = [];
|
||||
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
|
||||
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
|
||||
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
|
||||
public IReadOnlyList<Tag> Tags { get; init; } = [];
|
||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||
|
||||
/// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary>
|
||||
public IReadOnlyList<Equipment> PriorEquipment { get; init; } = [];
|
||||
|
||||
/// <summary>Active reservations (<c>ReleasedAt IS NULL</c>) for pre-flight.</summary>
|
||||
public IReadOnlyList<ExternalIdReservation> ActiveReservations { get; init; } = [];
|
||||
}
|
||||
176
src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
Normal file
176
src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Managed-code pre-publish validator per decision #91. Complements the structural checks in
|
||||
/// <c>sp_ValidateDraft</c> — this layer owns schema validation for JSON columns, UNS segment
|
||||
/// regex, EquipmentId derivation, cross-cluster checks, and anything else that's uncomfortable
|
||||
/// to express in T-SQL. Returns every failing rule in one pass (decision: surface all errors,
|
||||
/// not just the first, so operators fix in bulk).
|
||||
/// </summary>
|
||||
public static class DraftValidator
|
||||
{
|
||||
private static readonly Regex UnsSegment = new(@"^[a-z0-9-]{1,32}$", RegexOptions.Compiled);
|
||||
private const string UnsDefaultSegment = "_default";
|
||||
private const int MaxPathLength = 200;
|
||||
|
||||
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
|
||||
ValidateUnsSegments(draft, errors);
|
||||
ValidatePathLength(draft, errors);
|
||||
ValidateEquipmentUuidImmutability(draft, errors);
|
||||
ValidateSameClusterNamespaceBinding(draft, errors);
|
||||
ValidateReservationPreflight(draft, errors);
|
||||
ValidateEquipmentIdDerivation(draft, errors);
|
||||
ValidateDriverNamespaceCompatibility(draft, errors);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static bool IsValidSegment(string? s) =>
|
||||
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
|
||||
|
||||
private static void ValidateUnsSegments(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
foreach (var a in draft.UnsAreas)
|
||||
if (!IsValidSegment(a.Name))
|
||||
errors.Add(new("UnsSegmentInvalid",
|
||||
$"UnsArea.Name '{a.Name}' does not match [a-z0-9-]{{1,32}} or '_default'",
|
||||
a.UnsAreaId));
|
||||
|
||||
foreach (var l in draft.UnsLines)
|
||||
if (!IsValidSegment(l.Name))
|
||||
errors.Add(new("UnsSegmentInvalid",
|
||||
$"UnsLine.Name '{l.Name}' does not match [a-z0-9-]{{1,32}} or '_default'",
|
||||
l.UnsLineId));
|
||||
|
||||
foreach (var e in draft.Equipment)
|
||||
if (!IsValidSegment(e.Name))
|
||||
errors.Add(new("UnsSegmentInvalid",
|
||||
$"Equipment.Name '{e.Name}' does not match [a-z0-9-]{{1,32}} or '_default'",
|
||||
e.EquipmentId));
|
||||
}
|
||||
|
||||
/// <summary>Cluster.Enterprise + Site + area + line + equipment + 4 slashes ≤ 200 chars.</summary>
|
||||
private static void ValidatePathLength(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
// The cluster row isn't in the snapshot — we assume caller pre-validated Enterprise+Site
|
||||
// length and bound them as constants <= 64 chars each. Here we validate the dynamic portion.
|
||||
var areaById = draft.UnsAreas.ToDictionary(a => a.UnsAreaId);
|
||||
var lineById = draft.UnsLines.ToDictionary(l => l.UnsLineId);
|
||||
|
||||
foreach (var eq in draft.Equipment.Where(e => e.UnsLineId is not null))
|
||||
{
|
||||
if (!lineById.TryGetValue(eq.UnsLineId!, out var line)) continue;
|
||||
if (!areaById.TryGetValue(line.UnsAreaId, out var area)) continue;
|
||||
|
||||
// rough upper bound: Enterprise+Site at most 32+32; add dynamic segments + 4 slashes
|
||||
var len = 32 + 32 + area.Name.Length + line.Name.Length + eq.Name.Length + 4;
|
||||
if (len > MaxPathLength)
|
||||
errors.Add(new("PathTooLong",
|
||||
$"Equipment path exceeds {MaxPathLength} chars (approx {len})",
|
||||
eq.EquipmentId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEquipmentUuidImmutability(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var priorById = draft.PriorEquipment
|
||||
.GroupBy(e => e.EquipmentId)
|
||||
.ToDictionary(g => g.Key, g => g.First().EquipmentUuid);
|
||||
|
||||
foreach (var eq in draft.Equipment)
|
||||
{
|
||||
if (priorById.TryGetValue(eq.EquipmentId, out var priorUuid) && priorUuid != eq.EquipmentUuid)
|
||||
errors.Add(new("EquipmentUuidImmutable",
|
||||
$"EquipmentId '{eq.EquipmentId}' had UUID '{priorUuid}' in a prior generation; cannot change to '{eq.EquipmentUuid}'",
|
||||
eq.EquipmentId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSameClusterNamespaceBinding(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId);
|
||||
|
||||
foreach (var di in draft.DriverInstances)
|
||||
{
|
||||
if (!nsById.TryGetValue(di.NamespaceId, out var ns))
|
||||
{
|
||||
errors.Add(new("NamespaceUnresolved",
|
||||
$"DriverInstance '{di.DriverInstanceId}' references unknown NamespaceId '{di.NamespaceId}'",
|
||||
di.DriverInstanceId));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ns.ClusterId != di.ClusterId)
|
||||
errors.Add(new("BadCrossClusterNamespaceBinding",
|
||||
$"DriverInstance '{di.DriverInstanceId}' is in cluster '{di.ClusterId}' but references namespace in cluster '{ns.ClusterId}'",
|
||||
di.DriverInstanceId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateReservationPreflight(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var activeByKindValue = draft.ActiveReservations
|
||||
.ToDictionary(r => (r.Kind, r.Value), r => r.EquipmentUuid);
|
||||
|
||||
foreach (var eq in draft.Equipment)
|
||||
{
|
||||
if (eq.ZTag is not null &&
|
||||
activeByKindValue.TryGetValue((ReservationKind.ZTag, eq.ZTag), out var ztagOwner) &&
|
||||
ztagOwner != eq.EquipmentUuid)
|
||||
errors.Add(new("BadDuplicateExternalIdentifier",
|
||||
$"ZTag '{eq.ZTag}' is already reserved by EquipmentUuid '{ztagOwner}'",
|
||||
eq.EquipmentId));
|
||||
|
||||
if (eq.SAPID is not null &&
|
||||
activeByKindValue.TryGetValue((ReservationKind.SAPID, eq.SAPID), out var sapOwner) &&
|
||||
sapOwner != eq.EquipmentUuid)
|
||||
errors.Add(new("BadDuplicateExternalIdentifier",
|
||||
$"SAPID '{eq.SAPID}' is already reserved by EquipmentUuid '{sapOwner}'",
|
||||
eq.EquipmentId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Decision #125: EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID.</summary>
|
||||
public static string DeriveEquipmentId(Guid uuid) =>
|
||||
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
|
||||
|
||||
private static void ValidateEquipmentIdDerivation(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
foreach (var eq in draft.Equipment)
|
||||
{
|
||||
var expected = DeriveEquipmentId(eq.EquipmentUuid);
|
||||
if (!string.Equals(eq.EquipmentId, expected, StringComparison.Ordinal))
|
||||
errors.Add(new("EquipmentIdNotDerived",
|
||||
$"Equipment.EquipmentId '{eq.EquipmentId}' does not match the canonical derivation '{expected}'",
|
||||
eq.EquipmentId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateDriverNamespaceCompatibility(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId);
|
||||
|
||||
foreach (var di in draft.DriverInstances)
|
||||
{
|
||||
if (!nsById.TryGetValue(di.NamespaceId, out var ns)) continue;
|
||||
|
||||
var compat = ns.Kind switch
|
||||
{
|
||||
NamespaceKind.SystemPlatform => di.DriverType == "Galaxy",
|
||||
NamespaceKind.Equipment => di.DriverType != "Galaxy",
|
||||
_ => true,
|
||||
};
|
||||
|
||||
if (!compat)
|
||||
errors.Add(new("DriverNamespaceKindMismatch",
|
||||
$"DriverInstance '{di.DriverInstanceId}' ({di.DriverType}) is not allowed in {ns.Kind} namespace",
|
||||
di.DriverInstanceId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// One validation failure. <see cref="Code"/> is a stable machine-readable symbol
|
||||
/// (<c>BadCrossClusterNamespaceBinding</c>, <c>UnsSegmentInvalid</c>, …). <see cref="Context"/>
|
||||
/// carries the offending logical ID so the Admin UI can link straight to the row.
|
||||
/// </summary>
|
||||
public sealed record ValidationError(string Code, string Message, string? Context = null);
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0"/>
|
||||
<PackageReference Include="LiteDB" Version="5.0.21"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
System.Security.Cryptography.Xml reaches this project transitively from
|
||||
Microsoft.EntityFrameworkCore.Design → Microsoft.Build.Tasks.Core. EF Core Design is
|
||||
marked PrivateAssets=all (design-time only, never shipped at runtime), and we do not
|
||||
use XML digital signatures. Fix is only available in 11.0.0-preview. Suppress the two
|
||||
advisories until a stable 11.x is released or the transitive chain is updated.
|
||||
-->
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
80
src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs
Normal file
80
src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Process-local registry + lifecycle manager for loaded <see cref="IDriver"/> instances
|
||||
/// (decision #65). Phase 1 scaffold — per-process isolation for Tier C drivers (Galaxy, FOCAS)
|
||||
/// is implemented in Phase 2 via named-pipe RPC; this class handles in-process drivers today
|
||||
/// and exposes the same registration interface so the Tier C wrapper can slot in later.
|
||||
/// </summary>
|
||||
public sealed class DriverHost : IAsyncDisposable
|
||||
{
|
||||
private readonly Dictionary<string, IDriver> _drivers = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public IReadOnlyCollection<string> RegisteredDriverIds
|
||||
{
|
||||
get { lock (_lock) return [.. _drivers.Keys]; }
|
||||
}
|
||||
|
||||
public DriverHealth? GetHealth(string driverInstanceId)
|
||||
{
|
||||
lock (_lock)
|
||||
return _drivers.TryGetValue(driverInstanceId, out var d) ? d.GetHealth() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the driver and calls <see cref="IDriver.InitializeAsync"/>. If initialization
|
||||
/// throws, the driver is kept in the registry so the operator can retry; quality on its
|
||||
/// nodes will reflect <see cref="DriverState.Faulted"/> until <c>Reinitialize</c> succeeds.
|
||||
/// </summary>
|
||||
public async Task RegisterAsync(IDriver driver, string driverConfigJson, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(driver);
|
||||
var id = driver.DriverInstanceId;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_drivers.ContainsKey(id))
|
||||
throw new InvalidOperationException($"Driver '{id}' is already registered.");
|
||||
_drivers[id] = driver;
|
||||
}
|
||||
|
||||
try { await driver.InitializeAsync(driverConfigJson, ct); }
|
||||
catch
|
||||
{
|
||||
// Keep the driver registered — operator will see Faulted state and can reinitialize.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UnregisterAsync(string driverInstanceId, CancellationToken ct)
|
||||
{
|
||||
IDriver? driver;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_drivers.TryGetValue(driverInstanceId, out driver)) return;
|
||||
_drivers.Remove(driverInstanceId);
|
||||
}
|
||||
|
||||
try { await driver.ShutdownAsync(ct); }
|
||||
catch { /* shutdown is best-effort; logs elsewhere */ }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
List<IDriver> snapshot;
|
||||
lock (_lock)
|
||||
{
|
||||
snapshot = [.. _drivers.Values];
|
||||
_drivers.Clear();
|
||||
}
|
||||
|
||||
foreach (var driver in snapshot)
|
||||
{
|
||||
try { await driver.ShutdownAsync(CancellationToken.None); } catch { /* ignore */ }
|
||||
(driver as IDisposable)?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs
Normal file
37
src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa;
|
||||
|
||||
/// <summary>
|
||||
/// Generic, driver-agnostic backbone for populating the OPC UA address space from an
|
||||
/// <see cref="IDriver"/>. The Galaxy-specific subclass (<c>GalaxyNodeManager</c>) is deferred
|
||||
/// to Phase 2 per decision #62 — this class is the foundation that Phase 2 ports the v1
|
||||
/// <c>LmxNodeManager</c> logic into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Phase 1 status: scaffold only. The v1 <c>LmxNodeManager</c> in the legacy Host is unchanged
|
||||
/// so IntegrationTests continue to pass. Phase 2 will lift-and-shift its logic here, swapping
|
||||
/// <c>IMxAccessClient</c> for <see cref="IDriver"/> and <c>GalaxyAttributeInfo</c> for
|
||||
/// <see cref="DriverAttributeInfo"/>.
|
||||
/// </remarks>
|
||||
public abstract class GenericDriverNodeManager(IDriver driver)
|
||||
{
|
||||
protected IDriver Driver { get; } = driver ?? throw new ArgumentNullException(nameof(driver));
|
||||
|
||||
public string DriverInstanceId => Driver.DriverInstanceId;
|
||||
|
||||
/// <summary>
|
||||
/// Populates the address space by streaming nodes from the driver into the supplied builder.
|
||||
/// Driver exceptions are isolated per decision #12 — the driver's subtree is marked Faulted,
|
||||
/// but other drivers remain available.
|
||||
/// </summary>
|
||||
public async Task BuildAddressSpaceAsync(IAddressSpaceBuilder builder, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
if (Driver is not ITagDiscovery discovery)
|
||||
throw new NotSupportedException($"Driver '{Driver.DriverInstanceId}' does not implement ITagDiscovery.");
|
||||
|
||||
await discovery.DiscoverAsync(builder, ct);
|
||||
}
|
||||
}
|
||||
24
src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj
Normal file
24
src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
40
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeAcl.cs
Normal file
40
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeAcl.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <see cref="PipeSecurity"/> required by <c>driver-stability.md §"IPC Security"</c>:
|
||||
/// only the configured OtOpcUa server principal SID gets <c>ReadWrite | Synchronize</c>;
|
||||
/// LocalSystem and Administrators are explicitly denied. Any other authenticated user falls
|
||||
/// through to the implicit deny.
|
||||
/// </summary>
|
||||
public static class PipeAcl
|
||||
{
|
||||
public static PipeSecurity Create(SecurityIdentifier allowedSid)
|
||||
{
|
||||
if (allowedSid is null) throw new ArgumentNullException(nameof(allowedSid));
|
||||
|
||||
var security = new PipeSecurity();
|
||||
|
||||
security.AddAccessRule(new PipeAccessRule(
|
||||
allowedSid,
|
||||
PipeAccessRights.ReadWrite | PipeAccessRights.Synchronize,
|
||||
AccessControlType.Allow));
|
||||
|
||||
var localSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
||||
var admins = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
|
||||
if (allowedSid != localSystem)
|
||||
security.AddAccessRule(new PipeAccessRule(localSystem, PipeAccessRights.FullControl, AccessControlType.Deny));
|
||||
if (allowedSid != admins)
|
||||
security.AddAccessRule(new PipeAccessRule(admins, PipeAccessRights.FullControl, AccessControlType.Deny));
|
||||
|
||||
// Owner = allowed SID so the deny rules can't be removed without write-DACL rights.
|
||||
security.SetOwner(allowedSid);
|
||||
|
||||
return security;
|
||||
}
|
||||
}
|
||||
160
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeServer.cs
Normal file
160
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeServer.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
using System;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Accepts one client connection at a time on a named pipe with the strict ACL from
|
||||
/// <see cref="PipeAcl"/>. Verifies the peer SID and the per-process shared secret before any
|
||||
/// RPC frame is accepted. Per <c>driver-stability.md §"IPC Security"</c>.
|
||||
/// </summary>
|
||||
public sealed class PipeServer : IDisposable
|
||||
{
|
||||
private readonly string _pipeName;
|
||||
private readonly SecurityIdentifier _allowedSid;
|
||||
private readonly string _sharedSecret;
|
||||
private readonly ILogger _logger;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private NamedPipeServerStream? _current;
|
||||
|
||||
public PipeServer(string pipeName, SecurityIdentifier allowedSid, string sharedSecret, ILogger logger)
|
||||
{
|
||||
_pipeName = pipeName ?? throw new ArgumentNullException(nameof(pipeName));
|
||||
_allowedSid = allowedSid ?? throw new ArgumentNullException(nameof(allowedSid));
|
||||
_sharedSecret = sharedSecret ?? throw new ArgumentNullException(nameof(sharedSecret));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts one connection, performs Hello handshake, then dispatches frames to
|
||||
/// <paramref name="handler"/> until EOF or cancel. Returns when the client disconnects.
|
||||
/// </summary>
|
||||
public async Task RunOneConnectionAsync(IFrameHandler handler, CancellationToken ct)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token, ct);
|
||||
var acl = PipeAcl.Create(_allowedSid);
|
||||
|
||||
// .NET Framework 4.8 uses the legacy constructor overload that takes a PipeSecurity directly.
|
||||
_current = new NamedPipeServerStream(
|
||||
_pipeName,
|
||||
PipeDirection.InOut,
|
||||
maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous,
|
||||
inBufferSize: 64 * 1024,
|
||||
outBufferSize: 64 * 1024,
|
||||
pipeSecurity: acl);
|
||||
|
||||
try
|
||||
{
|
||||
await _current.WaitForConnectionAsync(linked.Token).ConfigureAwait(false);
|
||||
|
||||
if (!VerifyCaller(_current, out var reason))
|
||||
{
|
||||
_logger.Warning("IPC caller rejected: {Reason}", reason);
|
||||
_current.Disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
using var reader = new FrameReader(_current, leaveOpen: true);
|
||||
using var writer = new FrameWriter(_current, leaveOpen: true);
|
||||
|
||||
// First frame must be a Hello with the correct shared secret.
|
||||
var first = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
if (first is null || first.Value.Kind != MessageKind.Hello)
|
||||
{
|
||||
_logger.Warning("IPC first frame was not Hello; dropping");
|
||||
return;
|
||||
}
|
||||
|
||||
var hello = MessagePackSerializer.Deserialize<Hello>(first.Value.Body);
|
||||
if (!string.Equals(hello.SharedSecret, _sharedSecret, StringComparison.Ordinal))
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = false, RejectReason = "shared-secret-mismatch" },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
_logger.Warning("IPC Hello rejected: shared-secret-mismatch");
|
||||
return;
|
||||
}
|
||||
|
||||
if (hello.ProtocolMajor != Hello.CurrentMajor)
|
||||
{
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = false, RejectReason = $"major-version-mismatch-peer={hello.ProtocolMajor}-server={Hello.CurrentMajor}" },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
_logger.Warning("IPC Hello rejected: major mismatch peer={Peer} server={Server}",
|
||||
hello.ProtocolMajor, Hello.CurrentMajor);
|
||||
return;
|
||||
}
|
||||
|
||||
await writer.WriteAsync(MessageKind.HelloAck,
|
||||
new HelloAck { Accepted = true, HostName = Environment.MachineName },
|
||||
linked.Token).ConfigureAwait(false);
|
||||
|
||||
while (!linked.Token.IsCancellationRequested)
|
||||
{
|
||||
var frame = await reader.ReadFrameAsync(linked.Token).ConfigureAwait(false);
|
||||
if (frame is null) break;
|
||||
|
||||
await handler.HandleAsync(frame.Value.Kind, frame.Value.Body, writer, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_current.Dispose();
|
||||
_current = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the server continuously, handling one connection at a time. When a connection ends
|
||||
/// (clean or error), accepts the next.
|
||||
/// </summary>
|
||||
public async Task RunAsync(IFrameHandler handler, CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await RunOneConnectionAsync(handler, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
catch (Exception ex) { _logger.Error(ex, "IPC connection loop error — accepting next"); }
|
||||
}
|
||||
}
|
||||
|
||||
private bool VerifyCaller(NamedPipeServerStream pipe, out string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
pipe.RunAsClient(() =>
|
||||
{
|
||||
using var wi = WindowsIdentity.GetCurrent();
|
||||
if (wi.User is null)
|
||||
throw new InvalidOperationException("GetCurrent().User is null — cannot verify caller");
|
||||
if (wi.User != _allowedSid)
|
||||
throw new UnauthorizedAccessException(
|
||||
$"caller SID {wi.User.Value} does not match allowed {_allowedSid.Value}");
|
||||
});
|
||||
reason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) { reason = ex.Message; return false; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_current?.Dispose();
|
||||
_cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public interface IFrameHandler
|
||||
{
|
||||
Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder handler that responds to the framed IPC with error responses. Replaced by the
|
||||
/// real Galaxy-backed handler when the MXAccess code move (deferred) lands.
|
||||
/// </summary>
|
||||
public sealed class StubFrameHandler : IFrameHandler
|
||||
{
|
||||
public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
// Minimal lifecycle: heartbeat ack keeps the supervisor's liveness detector happy even
|
||||
// while the data-plane is stubbed, so integration tests of the supervisor can run end-to-end.
|
||||
if (kind == MessageKind.Heartbeat)
|
||||
{
|
||||
var hb = MessagePackSerializer.Deserialize<Heartbeat>(body);
|
||||
return writer.WriteAsync(MessageKind.HeartbeatAck,
|
||||
new HeartbeatAck { SequenceNumber = hb.SequenceNumber, UtcUnixMs = hb.UtcUnixMs }, ct);
|
||||
}
|
||||
|
||||
return writer.WriteAsync(MessageKind.ErrorResponse,
|
||||
new ErrorResponse { Code = "not-implemented", Message = $"Kind {kind} is stubbed — MXAccess lift deferred" },
|
||||
ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Shim — .NET Framework 4.8 doesn't ship with IsExternalInit, required for init-only setters +
|
||||
// positional records. Safe to add in our own namespace; the compiler accepts any type with this name.
|
||||
namespace System.Runtime.CompilerServices;
|
||||
|
||||
internal static class IsExternalInit;
|
||||
54
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Program.cs
Normal file
54
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Program.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the <c>OtOpcUaGalaxyHost</c> Windows service / console host. Reads the
|
||||
/// pipe name, allowed-SID, and shared secret from environment (passed by the supervisor at
|
||||
/// spawn time per <c>driver-stability.md</c>).
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
public static int Main(string[] args)
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.File(
|
||||
@"%ProgramData%\OtOpcUa\galaxy-host-.log".Replace("%ProgramData%", Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData)),
|
||||
rollingInterval: RollingInterval.Day)
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
var pipeName = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_PIPE") ?? "OtOpcUaGalaxy";
|
||||
var allowedSidValue = Environment.GetEnvironmentVariable("OTOPCUA_ALLOWED_SID")
|
||||
?? throw new InvalidOperationException("OTOPCUA_ALLOWED_SID not set — supervisor must pass the server principal SID");
|
||||
var sharedSecret = Environment.GetEnvironmentVariable("OTOPCUA_GALAXY_SECRET")
|
||||
?? throw new InvalidOperationException("OTOPCUA_GALAXY_SECRET not set — supervisor must pass the per-process secret at spawn time");
|
||||
|
||||
var allowedSid = new SecurityIdentifier(allowedSidValue);
|
||||
|
||||
using var server = new PipeServer(pipeName, allowedSid, sharedSecret, Log.Logger);
|
||||
using var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
|
||||
|
||||
Log.Information("OtOpcUaGalaxyHost starting — pipe={Pipe} allowedSid={Sid}", pipeName, allowedSidValue);
|
||||
|
||||
var handler = new StubFrameHandler();
|
||||
server.RunAsync(handler, cts.Token).GetAwaiter().GetResult();
|
||||
|
||||
Log.Information("OtOpcUaGalaxyHost stopped cleanly");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "OtOpcUaGalaxyHost fatal");
|
||||
return 2;
|
||||
}
|
||||
finally { Log.CloseAndFlush(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Runtime.ConstrainedExecution;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// SafeHandle-style lifetime wrapper for an <c>LMXProxyServer</c> COM connection. Per Task B.3
|
||||
/// + decision #65: <see cref="ReleaseHandle"/> must call <c>Marshal.ReleaseComObject</c> until
|
||||
/// refcount = 0, then <c>UnregisterProxy</c>. The finalizer runs as a
|
||||
/// <see cref="CriticalFinalizerObject"/> to honor AppDomain-unload ordering.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This scaffold accepts any RCW (tagged as <see cref="object"/>) so we can unit-test the
|
||||
/// release logic with a mock. The concrete wiring to <c>ArchestrA.MxAccess.LMXProxyServer</c>
|
||||
/// lands when the actual Galaxy code moves over (the part deferred to the parity gate).
|
||||
/// </remarks>
|
||||
public sealed class MxAccessHandle : SafeHandle
|
||||
{
|
||||
private object? _comObject;
|
||||
private readonly Action<object>? _unregister;
|
||||
|
||||
public MxAccessHandle(object comObject, Action<object>? unregister = null)
|
||||
: base(IntPtr.Zero, ownsHandle: true)
|
||||
{
|
||||
_comObject = comObject ?? throw new ArgumentNullException(nameof(comObject));
|
||||
_unregister = unregister;
|
||||
|
||||
// The pointer value itself doesn't matter — we're wrapping an RCW, not a native handle.
|
||||
SetHandle(new IntPtr(1));
|
||||
}
|
||||
|
||||
public override bool IsInvalid => handle == IntPtr.Zero;
|
||||
|
||||
public object? RawComObject => _comObject;
|
||||
|
||||
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
|
||||
protected override bool ReleaseHandle()
|
||||
{
|
||||
if (_comObject is null) return true;
|
||||
|
||||
try { _unregister?.Invoke(_comObject); }
|
||||
catch { /* swallow — we're in finalizer/cleanup; log elsewhere */ }
|
||||
|
||||
try
|
||||
{
|
||||
if (Marshal.IsComObject(_comObject))
|
||||
{
|
||||
while (Marshal.ReleaseComObject(_comObject) > 0) { /* loop until fully released */ }
|
||||
}
|
||||
}
|
||||
catch { /* swallow */ }
|
||||
|
||||
_comObject = null;
|
||||
SetHandle(IntPtr.Zero);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
91
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs
Normal file
91
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Dedicated STA thread that owns all <c>LMXProxyServer</c> COM instances. Work items are
|
||||
/// posted from any thread and dispatched on the STA. Per <c>driver-stability.md</c> Galaxy
|
||||
/// deep dive §"STA thread + Win32 message pump".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Phase 2 scaffold: uses a <see cref="BlockingCollection{T}"/> dispatcher instead of the real
|
||||
/// Win32 <c>GetMessage/DispatchMessage</c> pump. Real pump arrives when the v1 <c>StaComThread</c>
|
||||
/// is lifted — that's part of the deferred Galaxy code move. The apartment state and work
|
||||
/// dispatch semantics are identical so production code can be swapped in without changes.
|
||||
/// </remarks>
|
||||
public sealed class StaPump : IDisposable
|
||||
{
|
||||
private readonly Thread _thread;
|
||||
private readonly BlockingCollection<Action> _workQueue = new(new ConcurrentQueue<Action>());
|
||||
private readonly TaskCompletionSource<bool> _started = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private volatile bool _disposed;
|
||||
|
||||
public int ThreadId => _thread.ManagedThreadId;
|
||||
public DateTime LastDispatchedUtc { get; private set; } = DateTime.MinValue;
|
||||
public int QueueDepth => _workQueue.Count;
|
||||
|
||||
public StaPump(string name = "Galaxy.Sta")
|
||||
{
|
||||
_thread = new Thread(PumpLoop) { Name = name, IsBackground = true };
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
public Task WaitForStartedAsync() => _started.Task;
|
||||
|
||||
/// <summary>Posts a work item; resolves once it's executed on the STA thread.</summary>
|
||||
public Task<T> InvokeAsync<T>(Func<T> work)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(StaPump));
|
||||
|
||||
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_workQueue.Add(() =>
|
||||
{
|
||||
try { tcs.SetResult(work()); }
|
||||
catch (Exception ex) { tcs.SetException(ex); }
|
||||
});
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
public Task InvokeAsync(Action work) => InvokeAsync(() => { work(); return 0; });
|
||||
|
||||
/// <summary>
|
||||
/// Health probe — returns true if a no-op work item round-trips within <paramref name="timeout"/>.
|
||||
/// Used by the supervisor; timeout means the pump is wedged and a recycle is warranted.
|
||||
/// </summary>
|
||||
public async Task<bool> IsResponsiveAsync(TimeSpan timeout)
|
||||
{
|
||||
var task = InvokeAsync(() => { });
|
||||
var completed = await Task.WhenAny(task, Task.Delay(timeout)).ConfigureAwait(false);
|
||||
return completed == task;
|
||||
}
|
||||
|
||||
private void PumpLoop()
|
||||
{
|
||||
_started.TrySetResult(true);
|
||||
try
|
||||
{
|
||||
while (!_disposed)
|
||||
{
|
||||
if (_workQueue.TryTake(out var work, Timeout.Infinite))
|
||||
{
|
||||
work();
|
||||
LastDispatchedUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException) { /* CompleteAdding called during dispose */ }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_workQueue.CompleteAdding();
|
||||
_thread.Join(TimeSpan.FromSeconds(5));
|
||||
_workQueue.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
|
||||
|
||||
/// <summary>
|
||||
/// Galaxy-specific RSS watchdog per <c>driver-stability.md §"Memory Watchdog Thresholds"</c>.
|
||||
/// Baseline-relative + absolute caps. Sustained-slope detection uses a rolling 30-min window.
|
||||
/// Pluggable RSS source keeps it unit-testable.
|
||||
/// </summary>
|
||||
public sealed class MemoryWatchdog
|
||||
{
|
||||
/// <summary>Absolute hard ceiling — process is force-killed above this.</summary>
|
||||
public long HardCeilingBytes { get; init; } = 1_500L * 1024 * 1024;
|
||||
|
||||
/// <summary>Sustained slope (bytes/min) above which soft recycle is scheduled.</summary>
|
||||
public long SustainedSlopeBytesPerMinute { get; init; } = 5L * 1024 * 1024;
|
||||
|
||||
public TimeSpan SlopeWindow { get; init; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
private readonly long _baselineBytes;
|
||||
private readonly Queue<RssSample> _samples = new();
|
||||
|
||||
public MemoryWatchdog(long baselineBytes)
|
||||
{
|
||||
_baselineBytes = baselineBytes;
|
||||
}
|
||||
|
||||
/// <summary>Called every 30s with the current RSS. Returns the action the supervisor should take.</summary>
|
||||
public WatchdogAction Sample(long rssBytes, DateTime utcNow)
|
||||
{
|
||||
_samples.Enqueue(new RssSample(utcNow, rssBytes));
|
||||
while (_samples.Count > 0 && utcNow - _samples.Peek().TimestampUtc > SlopeWindow)
|
||||
_samples.Dequeue();
|
||||
|
||||
if (rssBytes >= HardCeilingBytes)
|
||||
return WatchdogAction.HardKill;
|
||||
|
||||
var softThreshold = Math.Max(_baselineBytes * 2, _baselineBytes + 200L * 1024 * 1024);
|
||||
var warnThreshold = Math.Max((long)(_baselineBytes * 1.5), _baselineBytes + 200L * 1024 * 1024);
|
||||
|
||||
if (rssBytes >= softThreshold) return WatchdogAction.SoftRecycle;
|
||||
if (rssBytes >= warnThreshold) return WatchdogAction.Warn;
|
||||
|
||||
if (_samples.Count >= 2)
|
||||
{
|
||||
var oldest = _samples.Peek();
|
||||
var span = (utcNow - oldest.TimestampUtc).TotalMinutes;
|
||||
if (span >= SlopeWindow.TotalMinutes * 0.9) // need ~full window to trust the slope
|
||||
{
|
||||
var delta = rssBytes - oldest.RssBytes;
|
||||
var bytesPerMin = delta / span;
|
||||
if (bytesPerMin >= SustainedSlopeBytesPerMinute)
|
||||
return WatchdogAction.SoftRecycle;
|
||||
}
|
||||
}
|
||||
|
||||
return WatchdogAction.None;
|
||||
}
|
||||
|
||||
private readonly record struct RssSample(DateTime TimestampUtc, long RssBytes);
|
||||
}
|
||||
|
||||
public enum WatchdogAction { None, Warn, SoftRecycle, HardKill }
|
||||
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.MemoryMappedFiles;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
|
||||
|
||||
/// <summary>
|
||||
/// Ring-buffer of the last <see cref="Capacity"/> IPC operations, written into a
|
||||
/// memory-mapped file. On hard crash the supervisor reads the MMF after the corpse is gone
|
||||
/// to see what was in flight. Thread-safe for the single-writer, multi-reader pattern.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// File layout:
|
||||
/// <code>
|
||||
/// [16-byte header: magic(4) | version(4) | capacity(4) | writeIndex(4)]
|
||||
/// [capacity × 256-byte entries: each is [8-byte utcUnixMs | 8-byte opKind | 240-byte UTF-8 message]]
|
||||
/// </code>
|
||||
/// </remarks>
|
||||
public sealed class PostMortemMmf : IDisposable
|
||||
{
|
||||
private const int Magic = 0x4F505043; // 'OPPC'
|
||||
private const int Version = 1;
|
||||
private const int HeaderBytes = 16;
|
||||
public const int EntryBytes = 256;
|
||||
private const int MessageOffset = 16;
|
||||
private const int MessageCapacity = EntryBytes - MessageOffset;
|
||||
|
||||
public int Capacity { get; }
|
||||
public string Path { get; }
|
||||
|
||||
private readonly MemoryMappedFile _mmf;
|
||||
private readonly MemoryMappedViewAccessor _accessor;
|
||||
private readonly object _writeGate = new();
|
||||
|
||||
public PostMortemMmf(string path, int capacity = 1000)
|
||||
{
|
||||
if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity));
|
||||
Capacity = capacity;
|
||||
Path = path;
|
||||
|
||||
var fileBytes = HeaderBytes + capacity * EntryBytes;
|
||||
Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!);
|
||||
|
||||
var fs = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read);
|
||||
fs.SetLength(fileBytes);
|
||||
_mmf = MemoryMappedFile.CreateFromFile(fs, null, fileBytes,
|
||||
MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: false);
|
||||
_accessor = _mmf.CreateViewAccessor(0, fileBytes, MemoryMappedFileAccess.ReadWrite);
|
||||
|
||||
// Initialize header if blank/garbage.
|
||||
if (_accessor.ReadInt32(0) != Magic)
|
||||
{
|
||||
_accessor.Write(0, Magic);
|
||||
_accessor.Write(4, Version);
|
||||
_accessor.Write(8, capacity);
|
||||
_accessor.Write(12, 0); // writeIndex
|
||||
}
|
||||
}
|
||||
|
||||
public void Write(long opKind, string message)
|
||||
{
|
||||
lock (_writeGate)
|
||||
{
|
||||
var idx = _accessor.ReadInt32(12);
|
||||
var offset = HeaderBytes + idx * EntryBytes;
|
||||
|
||||
_accessor.Write(offset + 0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
_accessor.Write(offset + 8, opKind);
|
||||
|
||||
var msgBytes = Encoding.UTF8.GetBytes(message ?? string.Empty);
|
||||
var copy = Math.Min(msgBytes.Length, MessageCapacity - 1);
|
||||
_accessor.WriteArray(offset + MessageOffset, msgBytes, 0, copy);
|
||||
_accessor.Write(offset + MessageOffset + copy, (byte)0); // null terminator
|
||||
|
||||
var next = (idx + 1) % Capacity;
|
||||
_accessor.Write(12, next);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads all entries in order (oldest → newest). Safe to call from another process.</summary>
|
||||
public PostMortemEntry[] ReadAll()
|
||||
{
|
||||
var magic = _accessor.ReadInt32(0);
|
||||
if (magic != Magic) return [];
|
||||
|
||||
var capacity = _accessor.ReadInt32(8);
|
||||
var writeIndex = _accessor.ReadInt32(12);
|
||||
|
||||
var entries = new PostMortemEntry[capacity];
|
||||
var count = 0;
|
||||
for (var i = 0; i < capacity; i++)
|
||||
{
|
||||
var slot = (writeIndex + i) % capacity;
|
||||
var offset = HeaderBytes + slot * EntryBytes;
|
||||
|
||||
var ts = _accessor.ReadInt64(offset + 0);
|
||||
if (ts == 0) continue; // unwritten
|
||||
|
||||
var op = _accessor.ReadInt64(offset + 8);
|
||||
var msgBuf = new byte[MessageCapacity];
|
||||
_accessor.ReadArray(offset + MessageOffset, msgBuf, 0, MessageCapacity);
|
||||
var nulTerm = Array.IndexOf<byte>(msgBuf, 0);
|
||||
var msg = Encoding.UTF8.GetString(msgBuf, 0, nulTerm < 0 ? MessageCapacity : nulTerm);
|
||||
|
||||
entries[count++] = new PostMortemEntry(ts, op, msg);
|
||||
}
|
||||
|
||||
Array.Resize(ref entries, count);
|
||||
return entries;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_accessor.Dispose();
|
||||
_mmf.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct PostMortemEntry(long UtcUnixMs, long OpKind, string Message);
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability;
|
||||
|
||||
/// <summary>
|
||||
/// Frequency-capped soft-recycle decision per <c>driver-stability.md §"Recycle Policy"</c>.
|
||||
/// Default cap: 1 soft recycle per hour. Scheduled recycle at 03:00 local; supervisor reads
|
||||
/// <see cref="ShouldSoftRecycleScheduled"/> to decide.
|
||||
/// </summary>
|
||||
public sealed class RecyclePolicy
|
||||
{
|
||||
public TimeSpan SoftRecycleCap { get; init; } = TimeSpan.FromHours(1);
|
||||
public int DailyRecycleHourLocal { get; init; } = 3;
|
||||
|
||||
private readonly List<DateTime> _recentRecyclesUtc = new();
|
||||
|
||||
/// <summary>Returns true if a soft recycle would be allowed under the frequency cap.</summary>
|
||||
public bool TryRequestSoftRecycle(DateTime utcNow, out string? reason)
|
||||
{
|
||||
_recentRecyclesUtc.RemoveAll(t => utcNow - t > SoftRecycleCap);
|
||||
if (_recentRecyclesUtc.Count > 0)
|
||||
{
|
||||
reason = $"soft-recycle frequency cap: last recycle was {(utcNow - _recentRecyclesUtc[_recentRecyclesUtc.Count - 1]).TotalMinutes:F1} min ago";
|
||||
return false;
|
||||
}
|
||||
_recentRecyclesUtc.Add(utcNow);
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ShouldSoftRecycleScheduled(DateTime localNow, ref DateTime lastScheduledDateLocal)
|
||||
{
|
||||
if (localNow.Hour != DailyRecycleHourLocal) return false;
|
||||
if (localNow.Date <= lastScheduledDateLocal.Date) return false;
|
||||
|
||||
lastScheduledDateLocal = localNow.Date;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<!-- Decision #23: x86 required for MXAccess COM interop. Currently AnyCPU is OK because
|
||||
the actual MXAccess code lift is deferred (it stays in the v1 Host until the Phase 2
|
||||
parity gate); flip to x86 when Task B.1 "move Galaxy code" actually executes. -->
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host</RootNamespace>
|
||||
<AssemblyName>OtOpcUa.Driver.Galaxy.Host</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.IO.Pipes.AccessControl" Version="5.0.0"/>
|
||||
<PackageReference Include="System.Memory" Version="4.5.5"/>
|
||||
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4"/>
|
||||
<PackageReference Include="Serilog" Version="4.2.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
144
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs
Normal file
144
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IDriver"/> implementation that forwards every capability over the Galaxy IPC
|
||||
/// channel to the out-of-process Host. Implements <see cref="ITagDiscovery"/> as the
|
||||
/// Phase 2 minimum; other capability interfaces (<see cref="IReadable"/>, etc.) will be wired
|
||||
/// in once the Host's MXAccess code lift is complete and end-to-end parity tests run.
|
||||
/// </summary>
|
||||
public sealed class GalaxyProxyDriver(GalaxyProxyOptions options)
|
||||
: IDriver, ITagDiscovery, IDisposable
|
||||
{
|
||||
private GalaxyIpcClient? _client;
|
||||
private long _sessionId;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public string DriverInstanceId => options.DriverInstanceId;
|
||||
public string DriverType => "Galaxy";
|
||||
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
_client = await GalaxyIpcClient.ConnectAsync(
|
||||
options.PipeName, options.SharedSecret, options.ConnectTimeout, cancellationToken);
|
||||
|
||||
var resp = await _client.CallAsync<OpenSessionRequest, OpenSessionResponse>(
|
||||
MessageKind.OpenSessionRequest,
|
||||
new OpenSessionRequest { DriverInstanceId = DriverInstanceId, DriverConfigJson = driverConfigJson },
|
||||
MessageKind.OpenSessionResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host OpenSession failed: {resp.Error}");
|
||||
|
||||
_sessionId = resp.SessionId;
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_client is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await _client.CallAsync<CloseSessionRequest, ErrorResponse>(
|
||||
MessageKind.CloseSessionRequest, new CloseSessionRequest { SessionId = _sessionId },
|
||||
MessageKind.ErrorResponse, cancellationToken);
|
||||
}
|
||||
catch { /* shutdown is best effort */ }
|
||||
|
||||
await _client.DisposeAsync();
|
||||
_client = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
|
||||
public long GetMemoryFootprint() => 0; // Tier C footprint is reported by the Host over IPC
|
||||
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
if (_client is null) throw new InvalidOperationException("Driver not initialized");
|
||||
|
||||
var resp = await _client.CallAsync<DiscoverHierarchyRequest, DiscoverHierarchyResponse>(
|
||||
MessageKind.DiscoverHierarchyRequest,
|
||||
new DiscoverHierarchyRequest { SessionId = _sessionId },
|
||||
MessageKind.DiscoverHierarchyResponse,
|
||||
cancellationToken);
|
||||
|
||||
if (!resp.Success)
|
||||
throw new InvalidOperationException($"Galaxy.Host DiscoverHierarchy failed: {resp.Error}");
|
||||
|
||||
foreach (var obj in resp.Objects)
|
||||
{
|
||||
var folder = builder.Folder(obj.ContainedName, obj.ContainedName);
|
||||
foreach (var attr in obj.Attributes)
|
||||
{
|
||||
folder.Variable(
|
||||
attr.AttributeName,
|
||||
attr.AttributeName,
|
||||
new DriverAttributeInfo(
|
||||
FullName: $"{obj.TagName}.{attr.AttributeName}",
|
||||
DriverDataType: MapDataType(attr.MxDataType),
|
||||
IsArray: attr.IsArray,
|
||||
ArrayDim: attr.ArrayDim,
|
||||
SecurityClass: MapSecurity(attr.SecurityClassification),
|
||||
IsHistorized: attr.IsHistorized));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DriverDataType MapDataType(int mxDataType) => mxDataType switch
|
||||
{
|
||||
0 => DriverDataType.Boolean,
|
||||
1 => DriverDataType.Int32,
|
||||
2 => DriverDataType.Float32,
|
||||
3 => DriverDataType.Float64,
|
||||
4 => DriverDataType.String,
|
||||
5 => DriverDataType.DateTime,
|
||||
_ => DriverDataType.String,
|
||||
};
|
||||
|
||||
private static SecurityClassification MapSecurity(int mxSec) => mxSec switch
|
||||
{
|
||||
0 => SecurityClassification.FreeAccess,
|
||||
1 => SecurityClassification.Operate,
|
||||
2 => SecurityClassification.SecuredWrite,
|
||||
3 => SecurityClassification.VerifiedWrite,
|
||||
4 => SecurityClassification.Tune,
|
||||
5 => SecurityClassification.Configure,
|
||||
6 => SecurityClassification.ViewOnly,
|
||||
_ => SecurityClassification.FreeAccess,
|
||||
};
|
||||
|
||||
public void Dispose() => _client?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public sealed class GalaxyProxyOptions
|
||||
{
|
||||
public required string DriverInstanceId { get; init; }
|
||||
public required string PipeName { get; init; }
|
||||
public required string SharedSecret { get; init; }
|
||||
public TimeSpan ConnectTimeout { get; init; } = TimeSpan.FromSeconds(10);
|
||||
}
|
||||
101
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyIpcClient.cs
Normal file
101
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyIpcClient.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System.IO.Pipes;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Client-side IPC channel to a running <c>Driver.Galaxy.Host</c>. Owns the data-plane pipe
|
||||
/// connection and serializes request/response round-trips. One instance per session.
|
||||
/// </summary>
|
||||
public sealed class GalaxyIpcClient : IAsyncDisposable
|
||||
{
|
||||
private readonly NamedPipeClientStream _stream;
|
||||
private readonly FrameReader _reader;
|
||||
private readonly FrameWriter _writer;
|
||||
private readonly SemaphoreSlim _callGate = new(1, 1);
|
||||
|
||||
private GalaxyIpcClient(NamedPipeClientStream stream)
|
||||
{
|
||||
_stream = stream;
|
||||
_reader = new FrameReader(stream, leaveOpen: true);
|
||||
_writer = new FrameWriter(stream, leaveOpen: true);
|
||||
}
|
||||
|
||||
/// <summary>Connects, sends Hello with the shared secret, and awaits HelloAck. Throws on rejection.</summary>
|
||||
public static async Task<GalaxyIpcClient> ConnectAsync(
|
||||
string pipeName, string sharedSecret, TimeSpan connectTimeout, CancellationToken ct)
|
||||
{
|
||||
var stream = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: pipeName,
|
||||
direction: PipeDirection.InOut,
|
||||
options: PipeOptions.Asynchronous);
|
||||
|
||||
await stream.ConnectAsync((int)connectTimeout.TotalMilliseconds, ct);
|
||||
|
||||
var client = new GalaxyIpcClient(stream);
|
||||
try
|
||||
{
|
||||
await client._writer.WriteAsync(MessageKind.Hello,
|
||||
new Hello { PeerName = "Galaxy.Proxy", SharedSecret = sharedSecret }, ct);
|
||||
|
||||
var ack = await client._reader.ReadFrameAsync(ct);
|
||||
if (ack is null || ack.Value.Kind != MessageKind.HelloAck)
|
||||
throw new InvalidOperationException("Did not receive HelloAck from Galaxy.Host");
|
||||
|
||||
var ackMsg = FrameReader.Deserialize<HelloAck>(ack.Value.Body);
|
||||
if (!ackMsg.Accepted)
|
||||
throw new UnauthorizedAccessException($"Galaxy.Host rejected Hello: {ackMsg.RejectReason}");
|
||||
|
||||
return client;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await client.DisposeAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Round-trips a request and returns the first frame of the response.</summary>
|
||||
public async Task<TResp> CallAsync<TReq, TResp>(
|
||||
MessageKind requestKind, TReq request, MessageKind expectedResponseKind, CancellationToken ct)
|
||||
{
|
||||
await _callGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await _writer.WriteAsync(requestKind, request, ct);
|
||||
|
||||
var frame = await _reader.ReadFrameAsync(ct);
|
||||
if (frame is null) throw new EndOfStreamException("IPC peer closed before response");
|
||||
|
||||
if (frame.Value.Kind == MessageKind.ErrorResponse)
|
||||
{
|
||||
var err = MessagePackSerializer.Deserialize<ErrorResponse>(frame.Value.Body);
|
||||
throw new GalaxyIpcException(err.Code, err.Message);
|
||||
}
|
||||
|
||||
if (frame.Value.Kind != expectedResponseKind)
|
||||
throw new InvalidOperationException(
|
||||
$"Expected {expectedResponseKind}, got {frame.Value.Kind}");
|
||||
|
||||
return MessagePackSerializer.Deserialize<TResp>(frame.Value.Body);
|
||||
}
|
||||
finally { _callGate.Release(); }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_callGate.Dispose();
|
||||
_reader.Dispose();
|
||||
_writer.Dispose();
|
||||
await _stream.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class GalaxyIpcException(string code, string message)
|
||||
: Exception($"[{code}] {message}")
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Respawn-with-backoff schedule per <c>driver-stability.md §"Crash-loop circuit breaker"</c>:
|
||||
/// 5s → 15s → 60s, capped. Reset on a successful (> <see cref="StableRunThreshold"/>)
|
||||
/// run.
|
||||
/// </summary>
|
||||
public sealed class Backoff
|
||||
{
|
||||
public static TimeSpan[] DefaultSequence { get; } =
|
||||
[TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(60)];
|
||||
|
||||
public TimeSpan StableRunThreshold { get; init; } = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly TimeSpan[] _sequence;
|
||||
private int _index;
|
||||
|
||||
public Backoff(TimeSpan[]? sequence = null) => _sequence = sequence ?? DefaultSequence;
|
||||
|
||||
public TimeSpan Next()
|
||||
{
|
||||
var delay = _sequence[Math.Min(_index, _sequence.Length - 1)];
|
||||
_index++;
|
||||
return delay;
|
||||
}
|
||||
|
||||
/// <summary>Called when the spawned process has stayed up past the stable threshold.</summary>
|
||||
public void RecordStableRun() => _index = 0;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Crash-loop circuit breaker per <c>driver-stability.md</c>:
|
||||
/// 3 crashes within 5 min → open with escalating cooldown 1h → 4h → 24h manual. A sticky
|
||||
/// alert stays until the operator explicitly resets.
|
||||
/// </summary>
|
||||
public sealed class CircuitBreaker
|
||||
{
|
||||
public int CrashesAllowedPerWindow { get; init; } = 3;
|
||||
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
public TimeSpan[] CooldownEscalation { get; init; } =
|
||||
[TimeSpan.FromHours(1), TimeSpan.FromHours(4), TimeSpan.MaxValue];
|
||||
|
||||
private readonly List<DateTime> _crashesUtc = [];
|
||||
private DateTime? _openSinceUtc;
|
||||
private int _escalationLevel;
|
||||
public bool StickyAlertActive { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Called by the supervisor each time the host process exits unexpectedly. Returns
|
||||
/// <c>false</c> when the breaker is open — supervisor must not respawn.
|
||||
/// </summary>
|
||||
public bool TryRecordCrash(DateTime utcNow, out TimeSpan cooldownRemaining)
|
||||
{
|
||||
if (_openSinceUtc is { } openedAt)
|
||||
{
|
||||
var cooldown = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
|
||||
if (cooldown == TimeSpan.MaxValue)
|
||||
{
|
||||
cooldownRemaining = TimeSpan.MaxValue;
|
||||
return false; // manual reset required
|
||||
}
|
||||
if (utcNow - openedAt < cooldown)
|
||||
{
|
||||
cooldownRemaining = cooldown - (utcNow - openedAt);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cooldown elapsed — close the breaker but keep the sticky alert per spec.
|
||||
_openSinceUtc = null;
|
||||
_escalationLevel++;
|
||||
}
|
||||
|
||||
_crashesUtc.RemoveAll(t => utcNow - t > Window);
|
||||
_crashesUtc.Add(utcNow);
|
||||
|
||||
if (_crashesUtc.Count > CrashesAllowedPerWindow)
|
||||
{
|
||||
_openSinceUtc = utcNow;
|
||||
StickyAlertActive = true;
|
||||
cooldownRemaining = CooldownEscalation[Math.Min(_escalationLevel, CooldownEscalation.Length - 1)];
|
||||
return false;
|
||||
}
|
||||
|
||||
cooldownRemaining = TimeSpan.Zero;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void ManualReset()
|
||||
{
|
||||
_crashesUtc.Clear();
|
||||
_openSinceUtc = null;
|
||||
_escalationLevel = 0;
|
||||
StickyAlertActive = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks missed heartbeats on the dedicated heartbeat pipe per
|
||||
/// <c>driver-stability.md §"Heartbeat between proxy and host"</c>: 2s cadence, 3 consecutive
|
||||
/// misses = host declared dead (~6s detection).
|
||||
/// </summary>
|
||||
public sealed class HeartbeatMonitor
|
||||
{
|
||||
public int MissesUntilDead { get; init; } = 3;
|
||||
|
||||
public TimeSpan Cadence { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
public int ConsecutiveMisses { get; private set; }
|
||||
public DateTime? LastAckUtc { get; private set; }
|
||||
|
||||
public void RecordAck(DateTime utcNow)
|
||||
{
|
||||
ConsecutiveMisses = 0;
|
||||
LastAckUtc = utcNow;
|
||||
}
|
||||
|
||||
public bool RecordMiss()
|
||||
{
|
||||
ConsecutiveMisses++;
|
||||
return ConsecutiveMisses >= MissesUntilDead;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AlarmSubscribeRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class GalaxyAlarmEvent
|
||||
{
|
||||
[Key(0)] public string EventId { get; set; } = string.Empty;
|
||||
[Key(1)] public string ObjectTagName { get; set; } = string.Empty;
|
||||
[Key(2)] public string AlarmName { get; set; } = string.Empty;
|
||||
[Key(3)] public int Severity { get; set; }
|
||||
|
||||
/// <summary>Per OPC UA Part 9 lifecycle: Active, Unacknowledged, Confirmed, Inactive, etc.</summary>
|
||||
[Key(4)] public string StateTransition { get; set; } = string.Empty;
|
||||
|
||||
[Key(5)] public string Message { get; set; } = string.Empty;
|
||||
[Key(6)] public long UtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class AlarmAckRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string EventId { get; set; } = string.Empty;
|
||||
[Key(2)] public string Comment { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// IPC-shape for a tag value snapshot. Per decision #13: value + StatusCode + source + server timestamps.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class GalaxyDataValue
|
||||
{
|
||||
[Key(0)] public string TagReference { get; set; } = string.Empty;
|
||||
[Key(1)] public byte[]? ValueBytes { get; set; }
|
||||
[Key(2)] public int ValueMessagePackType { get; set; }
|
||||
[Key(3)] public uint StatusCode { get; set; }
|
||||
[Key(4)] public long SourceTimestampUtcUnixMs { get; set; }
|
||||
[Key(5)] public long ServerTimestampUtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadValuesRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty<string>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ReadValuesResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteValuesRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public GalaxyDataValue[] Writes { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteValueResult
|
||||
{
|
||||
[Key(0)] public string TagReference { get; set; } = string.Empty;
|
||||
[Key(1)] public uint StatusCode { get; set; }
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class WriteValuesResponse
|
||||
{
|
||||
[Key(0)] public WriteValueResult[] Results { get; set; } = System.Array.Empty<WriteValueResult>();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DiscoverHierarchyRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IPC-shape for a Galaxy object. Proxy maps to/from <c>DriverAttributeInfo</c> (Core.Abstractions).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class GalaxyObjectInfo
|
||||
{
|
||||
[Key(0)] public string ContainedName { get; set; } = string.Empty;
|
||||
[Key(1)] public string TagName { get; set; } = string.Empty;
|
||||
[Key(2)] public string? ParentContainedName { get; set; }
|
||||
[Key(3)] public string TemplateCategory { get; set; } = string.Empty;
|
||||
[Key(4)] public GalaxyAttributeInfo[] Attributes { get; set; } = System.Array.Empty<GalaxyAttributeInfo>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class GalaxyAttributeInfo
|
||||
{
|
||||
[Key(0)] public string AttributeName { get; set; } = string.Empty;
|
||||
[Key(1)] public int MxDataType { get; set; }
|
||||
[Key(2)] public bool IsArray { get; set; }
|
||||
[Key(3)] public uint? ArrayDim { get; set; }
|
||||
[Key(4)] public int SecurityClassification { get; set; }
|
||||
[Key(5)] public bool IsHistorized { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class DiscoverHierarchyResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public GalaxyObjectInfo[] Objects { get; set; } = System.Array.Empty<GalaxyObjectInfo>();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Length-prefixed framing per decision #28. Each IPC frame is:
|
||||
/// <c>[4-byte big-endian length][1-byte message kind][MessagePack body]</c>.
|
||||
/// Length is the body size only; the kind byte is not part of the prefixed length.
|
||||
/// </summary>
|
||||
public static class Framing
|
||||
{
|
||||
public const int LengthPrefixSize = 4;
|
||||
public const int KindByteSize = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or
|
||||
/// misbehaving peer sending an oversized length prefix.
|
||||
/// </summary>
|
||||
public const int MaxFrameBodyBytes = 16 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire identifier for each contract. Values are stable — new contracts append.
|
||||
/// </summary>
|
||||
public enum MessageKind : byte
|
||||
{
|
||||
Hello = 0x01,
|
||||
HelloAck = 0x02,
|
||||
Heartbeat = 0x03,
|
||||
HeartbeatAck = 0x04,
|
||||
|
||||
OpenSessionRequest = 0x10,
|
||||
OpenSessionResponse = 0x11,
|
||||
CloseSessionRequest = 0x12,
|
||||
|
||||
DiscoverHierarchyRequest = 0x20,
|
||||
DiscoverHierarchyResponse = 0x21,
|
||||
|
||||
ReadValuesRequest = 0x30,
|
||||
ReadValuesResponse = 0x31,
|
||||
WriteValuesRequest = 0x32,
|
||||
WriteValuesResponse = 0x33,
|
||||
|
||||
SubscribeRequest = 0x40,
|
||||
SubscribeResponse = 0x41,
|
||||
UnsubscribeRequest = 0x42,
|
||||
OnDataChangeNotification = 0x43,
|
||||
|
||||
AlarmSubscribeRequest = 0x50,
|
||||
AlarmEvent = 0x51,
|
||||
AlarmAckRequest = 0x52,
|
||||
|
||||
HistoryReadRequest = 0x60,
|
||||
HistoryReadResponse = 0x61,
|
||||
|
||||
HostConnectivityStatus = 0x70,
|
||||
RuntimeStatusChange = 0x71,
|
||||
|
||||
RecycleHostRequest = 0xF0,
|
||||
RecycleStatusResponse = 0xF1,
|
||||
|
||||
ErrorResponse = 0xFE,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// First frame of every connection. Advertises protocol major/minor and the peer's feature set.
|
||||
/// Major mismatch is fatal; minor is advisory. Per Task A.3.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class Hello
|
||||
{
|
||||
public const int CurrentMajor = 1;
|
||||
public const int CurrentMinor = 0;
|
||||
|
||||
[Key(0)] public int ProtocolMajor { get; set; } = CurrentMajor;
|
||||
[Key(1)] public int ProtocolMinor { get; set; } = CurrentMinor;
|
||||
[Key(2)] public string PeerName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Per-process shared secret — verified on the Host side against the value passed by the supervisor at spawn time.</summary>
|
||||
[Key(3)] public string SharedSecret { get; set; } = string.Empty;
|
||||
|
||||
[Key(4)] public string[] Features { get; set; } = System.Array.Empty<string>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HelloAck
|
||||
{
|
||||
[Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor;
|
||||
[Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor;
|
||||
|
||||
/// <summary>True if the server accepted the hello; false + <see cref="RejectReason"/> filled if not.</summary>
|
||||
[Key(2)] public bool Accepted { get; set; }
|
||||
[Key(3)] public string? RejectReason { get; set; }
|
||||
|
||||
[Key(4)] public string HostName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty<string>();
|
||||
[Key(2)] public long StartUtcUnixMs { get; set; }
|
||||
[Key(3)] public long EndUtcUnixMs { get; set; }
|
||||
[Key(4)] public uint MaxValuesPerTag { get; set; } = 1000;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryTagValues
|
||||
{
|
||||
[Key(0)] public string TagReference { get; set; } = string.Empty;
|
||||
[Key(1)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HistoryReadResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public HistoryTagValues[] Tags { get; set; } = System.Array.Empty<HistoryTagValues>();
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OpenSessionRequest
|
||||
{
|
||||
[Key(0)] public string DriverInstanceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>JSON blob sourced from <c>DriverInstance.DriverConfig</c>.</summary>
|
||||
[Key(1)] public string DriverConfigJson { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OpenSessionResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public long SessionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class CloseSessionRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class Heartbeat
|
||||
{
|
||||
[Key(0)] public long SequenceNumber { get; set; }
|
||||
[Key(1)] public long UtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class HeartbeatAck
|
||||
{
|
||||
[Key(0)] public long SequenceNumber { get; set; }
|
||||
[Key(1)] public long UtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ErrorResponse
|
||||
{
|
||||
[Key(0)] public string Code { get; set; } = string.Empty;
|
||||
[Key(1)] public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
/// <summary>Per-host runtime status — per <c>driver-stability.md</c> Galaxy §"Connection Health Probe".</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class HostConnectivityStatus
|
||||
{
|
||||
[Key(0)] public string HostName { get; set; } = string.Empty;
|
||||
[Key(1)] public string RuntimeStatus { get; set; } = string.Empty; // Running | Stopped | Unknown
|
||||
[Key(2)] public long LastObservedUtcUnixMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RuntimeStatusChangeNotification
|
||||
{
|
||||
[Key(0)] public HostConnectivityStatus Status { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RecycleHostRequest
|
||||
{
|
||||
/// <summary>One of: Soft, Hard.</summary>
|
||||
[Key(0)] public string Kind { get; set; } = "Soft";
|
||||
[Key(1)] public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class RecycleStatusResponse
|
||||
{
|
||||
[Key(0)] public bool Accepted { get; set; }
|
||||
[Key(1)] public int GraceSeconds { get; set; } = 15;
|
||||
[Key(2)] public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SubscribeRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public string[] TagReferences { get; set; } = System.Array.Empty<string>();
|
||||
[Key(2)] public int RequestedIntervalMs { get; set; } = 1000;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class SubscribeResponse
|
||||
{
|
||||
[Key(0)] public bool Success { get; set; }
|
||||
[Key(1)] public string? Error { get; set; }
|
||||
[Key(2)] public long SubscriptionId { get; set; }
|
||||
[Key(3)] public int ActualIntervalMs { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class UnsubscribeRequest
|
||||
{
|
||||
[Key(0)] public long SessionId { get; set; }
|
||||
[Key(1)] public long SubscriptionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class OnDataChangeNotification
|
||||
{
|
||||
[Key(0)] public long SubscriptionId { get; set; }
|
||||
[Key(1)] public GalaxyDataValue[] Values { get; set; } = System.Array.Empty<GalaxyDataValue>();
|
||||
}
|
||||
67
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameReader.cs
Normal file
67
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameReader.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call
|
||||
/// <see cref="ReadFrameAsync"/> from multiple threads against the same instance.
|
||||
/// </summary>
|
||||
public sealed class FrameReader : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly bool _leaveOpen;
|
||||
|
||||
public FrameReader(Stream stream, bool leaveOpen = false)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
public async Task<(MessageKind Kind, byte[] Body)?> ReadFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||
if (!await ReadExactAsync(lengthPrefix, ct).ConfigureAwait(false))
|
||||
return null; // clean EOF on frame boundary
|
||||
|
||||
var length = (lengthPrefix[0] << 24) | (lengthPrefix[1] << 16) | (lengthPrefix[2] << 8) | lengthPrefix[3];
|
||||
if (length < 0 || length > Framing.MaxFrameBodyBytes)
|
||||
throw new InvalidDataException($"IPC frame length {length} out of range.");
|
||||
|
||||
var kindByte = _stream.ReadByte();
|
||||
if (kindByte < 0) throw new EndOfStreamException("EOF after length prefix, before kind byte.");
|
||||
|
||||
var body = new byte[length];
|
||||
if (!await ReadExactAsync(body, ct).ConfigureAwait(false))
|
||||
throw new EndOfStreamException("EOF mid-frame.");
|
||||
|
||||
return ((MessageKind)(byte)kindByte, body);
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(byte[] body) => MessagePackSerializer.Deserialize<T>(body);
|
||||
|
||||
private async Task<bool> ReadExactAsync(byte[] buffer, CancellationToken ct)
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < buffer.Length)
|
||||
{
|
||||
var read = await _stream.ReadAsync(buffer, offset, buffer.Length - offset, ct).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
if (offset == 0) return false;
|
||||
throw new EndOfStreamException($"Stream ended after reading {offset} of {buffer.Length} bytes.");
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_leaveOpen) _stream.Dispose();
|
||||
}
|
||||
}
|
||||
57
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameWriter.cs
Normal file
57
src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameWriter.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared;
|
||||
|
||||
/// <summary>
|
||||
/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via
|
||||
/// <see cref="SemaphoreSlim"/> — multiple producers (e.g. heartbeat + data-plane sharing a stream)
|
||||
/// get serialized writes.
|
||||
/// </summary>
|
||||
public sealed class FrameWriter : IDisposable
|
||||
{
|
||||
private readonly Stream _stream;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly bool _leaveOpen;
|
||||
|
||||
public FrameWriter(Stream stream, bool leaveOpen = false)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_leaveOpen = leaveOpen;
|
||||
}
|
||||
|
||||
public async Task WriteAsync<T>(MessageKind kind, T message, CancellationToken ct)
|
||||
{
|
||||
var body = MessagePackSerializer.Serialize(message, cancellationToken: ct);
|
||||
if (body.Length > Framing.MaxFrameBodyBytes)
|
||||
throw new InvalidOperationException(
|
||||
$"IPC frame body {body.Length} exceeds {Framing.MaxFrameBodyBytes} byte cap.");
|
||||
|
||||
var lengthPrefix = new byte[Framing.LengthPrefixSize];
|
||||
// Big-endian — easy to read in hex dumps.
|
||||
lengthPrefix[0] = (byte)((body.Length >> 24) & 0xFF);
|
||||
lengthPrefix[1] = (byte)((body.Length >> 16) & 0xFF);
|
||||
lengthPrefix[2] = (byte)((body.Length >> 8) & 0xFF);
|
||||
lengthPrefix[3] = (byte)( body.Length & 0xFF);
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(lengthPrefix, 0, lengthPrefix.Length, ct).ConfigureAwait(false);
|
||||
_stream.WriteByte((byte)kind);
|
||||
await _stream.WriteAsync(body, 0, body.Length, ct).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_gate.Dispose();
|
||||
if (!_leaveOpen) _stream.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Decision #32: MessagePack for IPC. Netstandard 2.0 consumable by both .NET 10 (Proxy) + .NET 4.8 (Host). -->
|
||||
<PackageReference Include="MessagePack" Version="2.5.187"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
64
src/ZB.MOM.WW.OtOpcUa.Server/NodeBootstrap.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Server/NodeBootstrap.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstraps a node: fetches the current generation from the central DB via
|
||||
/// <c>sp_GetCurrentGenerationForCluster</c>. If the DB is unreachable and a LiteDB cache entry
|
||||
/// exists, falls back to cached config per decision #79 (degraded-but-running).
|
||||
/// </summary>
|
||||
public sealed class NodeBootstrap(
|
||||
NodeOptions options,
|
||||
ILocalConfigCache localCache,
|
||||
ILogger<NodeBootstrap> logger)
|
||||
{
|
||||
public async Task<BootstrapResult> LoadCurrentGenerationAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var conn = new SqlConnection(options.ConfigDbConnectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c";
|
||||
cmd.Parameters.AddWithValue("@n", options.NodeId);
|
||||
cmd.Parameters.AddWithValue("@c", options.ClusterId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
if (!await reader.ReadAsync(ct))
|
||||
{
|
||||
logger.LogWarning("Cluster {Cluster} has no Published generation yet", options.ClusterId);
|
||||
return BootstrapResult.EmptyFromDb();
|
||||
}
|
||||
|
||||
var generationId = reader.GetInt64(0);
|
||||
logger.LogInformation("Bootstrapped from central DB: generation {GenerationId}", generationId);
|
||||
return BootstrapResult.FromDb(generationId);
|
||||
}
|
||||
catch (Exception ex) when (ex is SqlException or InvalidOperationException or TimeoutException)
|
||||
{
|
||||
logger.LogWarning(ex, "Central DB unreachable; trying LiteDB cache fallback (decision #79)");
|
||||
var cached = await localCache.GetMostRecentAsync(options.ClusterId, ct);
|
||||
if (cached is null)
|
||||
throw new BootstrapException(
|
||||
"Central DB unreachable and no local cache available — cannot bootstrap.", ex);
|
||||
|
||||
logger.LogWarning("Bootstrapping from cache: generation {GenerationId} cached at {At}",
|
||||
cached.GenerationId, cached.CachedAt);
|
||||
return BootstrapResult.FromCache(cached.GenerationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record BootstrapResult(long? GenerationId, BootstrapSource Source)
|
||||
{
|
||||
public static BootstrapResult FromDb(long g) => new(g, BootstrapSource.CentralDb);
|
||||
public static BootstrapResult FromCache(long g) => new(g, BootstrapSource.LocalCache);
|
||||
public static BootstrapResult EmptyFromDb() => new(null, BootstrapSource.CentralDb);
|
||||
}
|
||||
|
||||
public enum BootstrapSource { CentralDb, LocalCache }
|
||||
|
||||
public sealed class BootstrapException(string message, Exception inner) : Exception(message, inner);
|
||||
23
src/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap configuration read from <c>appsettings.json</c> (decision #18) — the minimum a
|
||||
/// node needs to reach the central config DB and identify itself. Everything else comes from
|
||||
/// the DB after bootstrap succeeds.
|
||||
/// </summary>
|
||||
public sealed class NodeOptions
|
||||
{
|
||||
public const string SectionName = "Node";
|
||||
|
||||
/// <summary>Stable node ID matching <c>ClusterNode.NodeId</c> in the central config DB.</summary>
|
||||
public required string NodeId { get; init; }
|
||||
|
||||
/// <summary>Cluster this node belongs to.</summary>
|
||||
public required string ClusterId { get; init; }
|
||||
|
||||
/// <summary>SQL Server connection string for the central config DB.</summary>
|
||||
public required string ConfigDbConnectionString { get; init; }
|
||||
|
||||
/// <summary>Path to the LiteDB local cache file.</summary>
|
||||
public string LocalCachePath { get; init; } = "config_cache.db";
|
||||
}
|
||||
45
src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs
Normal file
45
src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
/// <summary>
|
||||
/// BackgroundService that owns the OPC UA server lifecycle (decision #30, replacing TopShelf).
|
||||
/// Bootstraps config, starts the <see cref="DriverHost"/>, and runs until stopped.
|
||||
/// Phase 1 scope: bootstrap-only — the OPC UA transport layer that serves endpoints stays in
|
||||
/// the legacy Host until the Phase 2 cutover.
|
||||
/// </summary>
|
||||
public sealed class OpcUaServerService(
|
||||
NodeBootstrap bootstrap,
|
||||
DriverHost driverHost,
|
||||
ILogger<OpcUaServerService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation("OtOpcUa.Server starting");
|
||||
|
||||
var result = await bootstrap.LoadCurrentGenerationAsync(stoppingToken);
|
||||
logger.LogInformation("Bootstrap complete: source={Source} generation={Gen}", result.Source, result.GenerationId);
|
||||
|
||||
// Phase 1: no drivers are wired up at bootstrap — Galaxy still lives in legacy Host.
|
||||
// Phase 2 will register drivers here based on the fetched generation.
|
||||
|
||||
logger.LogInformation("OtOpcUa.Server running. Hosted drivers: {Count}", driverHost.RegisteredDriverIds.Count);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
logger.LogInformation("OtOpcUa.Server stopping");
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await base.StopAsync(cancellationToken);
|
||||
await driverHost.DisposeAsync();
|
||||
}
|
||||
}
|
||||
39
src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
Normal file
39
src/ZB.MOM.WW.OtOpcUa.Server/Program.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||||
using ZB.MOM.WW.OtOpcUa.Server;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/otopcua-.log", rollingInterval: RollingInterval.Day)
|
||||
.CreateLogger();
|
||||
|
||||
builder.Services.AddSerilog();
|
||||
builder.Services.AddWindowsService(o => o.ServiceName = "OtOpcUa");
|
||||
|
||||
var nodeSection = builder.Configuration.GetSection(NodeOptions.SectionName);
|
||||
var options = new NodeOptions
|
||||
{
|
||||
NodeId = nodeSection.GetValue<string>("NodeId")
|
||||
?? throw new InvalidOperationException("Node:NodeId not configured"),
|
||||
ClusterId = nodeSection.GetValue<string>("ClusterId")
|
||||
?? throw new InvalidOperationException("Node:ClusterId not configured"),
|
||||
ConfigDbConnectionString = nodeSection.GetValue<string>("ConfigDbConnectionString")
|
||||
?? throw new InvalidOperationException("Node:ConfigDbConnectionString not configured"),
|
||||
LocalCachePath = nodeSection.GetValue<string>("LocalCachePath") ?? "config_cache.db",
|
||||
};
|
||||
|
||||
builder.Services.AddSingleton(options);
|
||||
builder.Services.AddSingleton<ILocalConfigCache>(_ => new LiteDbConfigCache(options.LocalCachePath));
|
||||
builder.Services.AddSingleton<DriverHost>();
|
||||
builder.Services.AddSingleton<NodeBootstrap>();
|
||||
builder.Services.AddHostedService<OpcUaServerService>();
|
||||
|
||||
var host = builder.Build();
|
||||
await host.RunAsync();
|
||||
35
src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
Normal file
35
src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj
Normal file
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Server</RootNamespace>
|
||||
<AssemblyName>OtOpcUa.Server</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0"/>
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0"/>
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="9.0.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0"/>
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user