diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 33fe998..07d4185 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -1,6 +1,13 @@ + + + + + + + @@ -9,6 +16,13 @@ + + + + + + + diff --git a/docs/v2/dev-environment.md b/docs/v2/dev-environment.md index a615b80..c0bf25c 100644 --- a/docs/v2/dev-environment.md +++ b/docs/v2/dev-environment.md @@ -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: diff --git a/docs/v2/implementation/phase-2-partial-exit-evidence.md b/docs/v2/implementation/phase-2-partial-exit-evidence.md new file mode 100644 index 0000000..efa7599 --- /dev/null +++ b/docs/v2/implementation/phase-2-partial-exit-evidence.md @@ -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. diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor new file mode 100644 index 0000000..96b0ea4 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/App.razor @@ -0,0 +1,18 @@ +@* Root Blazor component. *@ + + + + + + OtOpcUa Admin + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..5c12aab --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Layout/MainLayout.razor @@ -0,0 +1,17 @@ +@* ScadaLink-parity sidebar layout per decision #102 (Bootstrap 5, dark sidebar, main content area). *@ +@inherits LayoutComponentBase + +
+ +
+ @Body +
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters.razor new file mode 100644 index 0000000..d04e751 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Clusters.razor @@ -0,0 +1,42 @@ +@page "/clusters" +@using ZB.MOM.WW.OtOpcUa.Admin.Services +@using ZB.MOM.WW.OtOpcUa.Configuration.Entities +@inject ClusterService ClusterSvc + +

Clusters

+ +@if (_clusters is null) +{ +

Loading…

+} +else if (_clusters.Count == 0) +{ +

No clusters yet. Use the stored-proc sp_PublishGeneration workflow to bootstrap.

+} +else +{ + + + + @foreach (var c in _clusters) + { + + + + + + + + } + +
ClusterIdNameEnterprise/SiteRedundancyModeEnabled
@c.ClusterId@c.Name@c.Enterprise / @c.Site@c.RedundancyMode@(c.Enabled ? "Yes" : "No")
+} + +@code { + private List? _clusters; + + protected override async Task OnInitializedAsync() + { + _clusters = await ClusterSvc.ListAsync(CancellationToken.None); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor new file mode 100644 index 0000000..111c238 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Pages/Home.razor @@ -0,0 +1,16 @@ +@page "/" + +

OtOpcUa fleet overview

+

Phase 1 scaffold — full dashboard lands in Phase 1 Stream E completion.

+ +
+
+
Clusters
Manage
+
+
+
Generations
Manage
+
+
+
Equipment
Manage
+
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor new file mode 100644 index 0000000..c23e1d4 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/Routes.razor @@ -0,0 +1,11 @@ +@using Microsoft.AspNetCore.Components.Routing +@using ZB.MOM.WW.OtOpcUa.Admin.Components.Layout + + + + + + +

Not found.

+
+
diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor new file mode 100644 index 0000000..d17869a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Components/_Imports.razor @@ -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 diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs new file mode 100644 index 0000000..9c9fec6 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Program.cs @@ -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(opt => + opt.UseSqlServer(builder.Configuration.GetConnectionString("ConfigDb") + ?? throw new InvalidOperationException("ConnectionStrings:ConfigDb not configured"))); + +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.UseSerilogRequestLogging(); +app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); +app.UseAntiforgery(); + +app.MapRazorComponents().AddInteractiveServerRenderMode(); + +await app.RunAsync(); + +// Public for WebApplicationFactory testability. +public partial class Program; diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs new file mode 100644 index 0000000..f67f1e5 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/AdminRoles.cs @@ -0,0 +1,16 @@ +namespace ZB.MOM.WW.OtOpcUa.Admin.Services; + +/// +/// The three admin roles per admin-ui.md §"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 (NodePermissions) govern OPC UA clients; +/// these roles govern the Admin UI itself. +/// +public static class AdminRoles +{ + public const string ConfigViewer = "ConfigViewer"; + public const string ConfigEditor = "ConfigEditor"; + public const string FleetAdmin = "FleetAdmin"; + + public static IReadOnlyList All => [ConfigViewer, ConfigEditor, FleetAdmin]; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterService.cs new file mode 100644 index 0000000..d9b08b9 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/ClusterService.cs @@ -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; + +/// +/// 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 dbo schema means this +/// service connects as a DB owner during dev — production swaps in a read-only view grant). +/// +public sealed class ClusterService(OtOpcUaConfigDbContext db) +{ + public Task> ListAsync(CancellationToken ct) => + db.ServerClusters.AsNoTracking().OrderBy(c => c.ClusterId).ToListAsync(ct); + + public Task FindAsync(string clusterId, CancellationToken ct) => + db.ServerClusters.AsNoTracking().FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct); + + public async Task CreateAsync(ServerCluster cluster, string createdBy, CancellationToken ct) + { + cluster.CreatedAt = DateTime.UtcNow; + cluster.CreatedBy = createdBy; + db.ServerClusters.Add(cluster); + await db.SaveChangesAsync(ct); + return cluster; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs new file mode 100644 index 0000000..bbbf9ab --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/Services/GenerationService.cs @@ -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; + +/// +/// Owns the draft → diff → publish workflow (decision #89). Publish + rollback call into the +/// stored procedures; diff queries sp_ComputeGenerationDiff. +/// +public sealed class GenerationService(OtOpcUaConfigDbContext db) +{ + public async Task 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> 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> ComputeDiffAsync(long from, long to, CancellationToken ct) + { + var results = new List(); + 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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj b/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj new file mode 100644 index 0000000..91eb25a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/ZB.MOM.WW.OtOpcUa.Admin.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + latest + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Admin + OtOpcUa.Admin + + + + + + + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json b/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json new file mode 100644 index 0000000..9ae83f6 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/appsettings.json @@ -0,0 +1,8 @@ +{ + "ConnectionStrings": { + "ConfigDb": "Server=localhost,14330;Database=OtOpcUaConfig;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;" + }, + "Serilog": { + "MinimumLevel": "Information" + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css b/src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css new file mode 100644 index 0000000..3ec2fc2 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Admin/wwwroot/app.css @@ -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; } diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs new file mode 100644 index 0000000..585ed19 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs @@ -0,0 +1,19 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; + +/// +/// 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. +/// +public sealed class ApplyCallbacks +{ + public Func, CancellationToken, Task>? OnNamespace { get; init; } + public Func, CancellationToken, Task>? OnDriver { get; init; } + public Func, CancellationToken, Task>? OnDevice { get; init; } + public Func, CancellationToken, Task>? OnEquipment { get; init; } + public Func, CancellationToken, Task>? OnPollGroup { get; init; } + public Func, CancellationToken, Task>? OnTag { get; init; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs new file mode 100644 index 0000000..56f3618 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs @@ -0,0 +1,8 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; + +public enum ChangeKind +{ + Added, + Removed, + Modified, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationApplier.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationApplier.cs new file mode 100644 index 0000000..8e0e000 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationApplier.cs @@ -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 ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct) + { + var diff = GenerationDiffer.Compute(from, to); + var errors = new List(); + + // 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( + IReadOnlyList> changes, + ChangeKind kind, + Func, CancellationToken, Task>? callback, + List 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}"); } + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs new file mode 100644 index 0000000..6813f62 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs @@ -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; + +/// +/// Per-entity diff computed locally on the node. The enumerable order matches the dependency +/// order expected by : namespace → driver → device → equipment → +/// poll group → tag → ACL, with Removed processed before Added inside each bucket so cascades +/// settle before new rows appear. +/// +public sealed record GenerationDiff( + IReadOnlyList> Namespaces, + IReadOnlyList> Drivers, + IReadOnlyList> Devices, + IReadOnlyList> Equipment, + IReadOnlyList> PollGroups, + IReadOnlyList> Tags); + +public sealed record EntityChange(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> DiffById( + IReadOnlyList from, IReadOnlyList to, + Func id, Func equal) + { + var fromById = from.ToDictionary(id); + var toById = to.ToDictionary(id); + var result = new List>(); + + 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; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/IGenerationApplier.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/IGenerationApplier.cs new file mode 100644 index 0000000..257fc4b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/IGenerationApplier.cs @@ -0,0 +1,23 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply; + +/// +/// Applies a 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 so the Configuration project stays free +/// of a Core/Server dependency (interface independence per decision #59). +/// +public interface IGenerationApplier +{ + Task ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct); +} + +public sealed record ApplyResult( + bool Succeeded, + GenerationDiff Diff, + IReadOnlyList Errors) +{ + public static ApplyResult Ok(GenerationDiff diff) => new(true, diff, []); + public static ApplyResult Fail(GenerationDiff diff, IReadOnlyList errors) => new(false, diff, errors); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/DesignTimeDbContextFactory.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..a5707a3 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/DesignTimeDbContextFactory.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace ZB.MOM.WW.OtOpcUa.Configuration; + +/// +/// Used by dotnet ef at design time (migrations, scaffolding). Reads the connection string +/// from the OTOPCUA_CONFIG_CONNECTION environment variable, falling back to the local dev +/// container on localhost:1433. +/// +public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + // 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() + .UseSqlServer(connection, sql => sql.MigrationsAssembly(typeof(OtOpcUaConfigDbContext).Assembly.FullName)) + .Options; + + return new OtOpcUaConfigDbContext(options); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs new file mode 100644 index 0000000..f86fbb4 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs @@ -0,0 +1,51 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// Physical OPC UA server node within a . +public sealed class ClusterNode +{ + /// Stable per-machine logical ID, e.g. "LINE3-OPCUA-A". + public required string NodeId { get; set; } + + public required string ClusterId { get; set; } + + public required RedundancyRole RedundancyRole { get; set; } + + /// Machine hostname / IP. + public required string Host { get; set; } + + public int OpcUaPort { get; set; } = 4840; + + public int DashboardPort { get; set; } = 8081; + + /// + /// OPC UA ApplicationUri — 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 at runtime — silent rewrite on + /// hostname change would break all client trust. + /// + public required string ApplicationUri { get; set; } + + /// Primary = 200, Secondary = 150 by default. + public byte ServiceLevelBase { get; set; } = 200; + + /// + /// 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. + /// + 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 Credentials { get; set; } = []; + public ClusterNodeGenerationState? GenerationState { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeCredential.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeCredential.cs new file mode 100644 index 0000000..c6824ea --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeCredential.cs @@ -0,0 +1,29 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// Authenticates a to the central config DB. +/// Per decision #83 — credentials bind to NodeId, not ClusterId. +/// +public sealed class ClusterNodeCredential +{ + public Guid CredentialId { get; set; } + + public required string NodeId { get; set; } + + public required CredentialKind Kind { get; set; } + + /// Login name / cert thumbprint / SID / gMSA name. + 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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeGenerationState.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeGenerationState.cs new file mode 100644 index 0000000..f66bc73 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNodeGenerationState.cs @@ -0,0 +1,26 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// Tracks which generation each node has applied. Per-node (not per-cluster) — both nodes of a +/// 2-node cluster track independently per decision #84. +/// +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; } + + /// Updated on every poll for liveness detection. + public DateTime? LastSeenAt { get; set; } + + public ClusterNode? Node { get; set; } + public ConfigGeneration? CurrentGeneration { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs new file mode 100644 index 0000000..35eaa89 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigAuditLog.cs @@ -0,0 +1,25 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// 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). +/// +public sealed class ConfigAuditLog +{ + public long AuditId { get; set; } + + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + public required string Principal { get; set; } + + /// DraftCreated | DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled | ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt | OpcUaAccessDenied | … + 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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigGeneration.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigGeneration.cs new file mode 100644 index 0000000..eb9da1a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ConfigGeneration.cs @@ -0,0 +1,32 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// Atomic, immutable snapshot of one cluster's configuration. +/// Per decision #82 — cluster-scoped, not fleet-scoped. +/// +public sealed class ConfigGeneration +{ + /// Monotonically increasing ID, generated by IDENTITY(1, 1). + 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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs new file mode 100644 index 0000000..603005b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs @@ -0,0 +1,23 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// Per-device row for multi-device drivers (Modbus, AB CIP). Optional for single-device drivers. +public sealed class Device +{ + public Guid DeviceRowId { get; set; } + + public long GenerationId { get; set; } + + public required string DeviceId { get; set; } + + /// Logical FK to . + public required string DriverInstanceId { get; set; } + + public required string Name { get; set; } + + public bool Enabled { get; set; } = true; + + /// Schemaless per-driver-type device config (host, port, unit ID, slot, etc.). + public required string DeviceConfig { get; set; } + + public ConfigGeneration? Generation { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs new file mode 100644 index 0000000..52dac9e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstance.cs @@ -0,0 +1,32 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// One driver instance in a cluster's generation. JSON config is schemaless per-driver-type. +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; } + + /// + /// Logical FK to . Same-cluster binding enforced by + /// sp_ValidateDraft per decision #122: Namespace.ClusterId must equal DriverInstance.ClusterId. + /// + public required string NamespaceId { get; set; } + + public required string Name { get; set; } + + /// Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient + public required string DriverType { get; set; } + + public bool Enabled { get; set; } = true; + + /// Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91). + public required string DriverConfig { get; set; } + + public ConfigGeneration? Generation { get; set; } + public ServerCluster? Cluster { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs new file mode 100644 index 0000000..adc68ae --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs @@ -0,0 +1,64 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// 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). +/// +public sealed class Equipment +{ + public Guid EquipmentRowId { get; set; } + + public long GenerationId { get; set; } + + /// + /// System-generated stable internal logical ID. Format: 'EQ-' + first 12 hex chars of EquipmentUuid. + /// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI (decision #125). + /// + public required string EquipmentId { get; set; } + + /// UUIDv4, IMMUTABLE across all generations of the same EquipmentId. Downstream-consumer join key. + public Guid EquipmentUuid { get; set; } + + /// Logical FK to the driver providing data for this equipment. + public required string DriverInstanceId { get; set; } + + /// Optional logical FK to a multi-device driver's device. + public string? DeviceId { get; set; } + + /// Logical FK to . + public required string UnsLineId { get; set; } + + /// UNS level 5 segment, matches ^[a-z0-9-]{1,32}$. + public required string Name { get; set; } + + // Operator-facing / external-system identifiers (decision #116) + + /// Operator colloquial id (e.g. "machine_001"). Unique within cluster. Required. + public required string MachineCode { get; set; } + + /// ERP equipment id. Unique fleet-wide via . Primary browse identifier in Admin UI. + public string? ZTag { get; set; } + + /// SAP PM equipment id. Unique fleet-wide via . + 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; } + + /// Nullable hook for future schemas-repo template ID (decision #112). + public string? EquipmentClassRef { get; set; } + + public bool Enabled { get; set; } = true; + + public ConfigGeneration? Generation { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ExternalIdReservation.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ExternalIdReservation.cs new file mode 100644 index 0000000..cd4c789 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ExternalIdReservation.cs @@ -0,0 +1,36 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// 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. +/// +public sealed class ExternalIdReservation +{ + public Guid ReservationId { get; set; } + + public required ReservationKind Kind { get; set; } + + public required string Value { get; set; } + + /// The equipment that owns this reservation. Stays bound even when equipment is disabled. + public Guid EquipmentUuid { get; set; } + + /// First cluster to publish this reservation. + 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; + + /// Non-null when explicitly released by FleetAdmin (audit-logged, requires reason). + public DateTime? ReleasedAt { get; set; } + + public string? ReleasedBy { get; set; } + + public string? ReleaseReason { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs new file mode 100644 index 0000000..fea7459 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs @@ -0,0 +1,31 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// OPC UA namespace served by a cluster. Generation-versioned per decision #123 — +/// namespaces are content (affect what consumers see at the endpoint), not topology. +/// +public sealed class Namespace +{ + public Guid NamespaceRowId { get; set; } + + public long GenerationId { get; set; } + + /// Stable logical ID across generations, e.g. "LINE3-OPCUA-equipment". + public required string NamespaceId { get; set; } + + public required string ClusterId { get; set; } + + public required NamespaceKind Kind { get; set; } + + /// E.g. "urn:zb:warsaw-west:equipment". Unique fleet-wide per generation. + 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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs new file mode 100644 index 0000000..57cb906 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs @@ -0,0 +1,32 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// One ACL grant: an LDAP group gets a set of at a specific scope. +/// Generation-versioned per decision #130. See acl-design.md for evaluation algorithm. +/// +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; } + + /// NULL when = ; otherwise the scoped entity's logical ID. + public string? ScopeId { get; set; } + + /// Bitmask of . Stored as int in SQL. + public required NodePermissions PermissionFlags { get; set; } + + public string? Notes { get; set; } + + public ConfigGeneration? Generation { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs new file mode 100644 index 0000000..856fad2 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs @@ -0,0 +1,19 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// Driver-scoped polling group. Tags reference it via . +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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ServerCluster.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ServerCluster.cs new file mode 100644 index 0000000..08f429a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ServerCluster.cs @@ -0,0 +1,42 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// Top-level deployment unit. 1 or 2 members. +/// Per config-db-schema.md ServerCluster table. +/// +public sealed class ServerCluster +{ + /// Stable logical ID, e.g. "LINE3-OPCUA". + public required string ClusterId { get; set; } + + public required string Name { get; set; } + + /// UNS level 1. Canonical org value: "zb" per decision #140. + public required string Enterprise { get; set; } + + /// UNS level 2, e.g. "warsaw-west". + 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 Nodes { get; set; } = []; + public ICollection Namespaces { get; set; } = []; + public ICollection Generations { get; set; } = []; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs new file mode 100644 index 0000000..35f2c17 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs @@ -0,0 +1,47 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// +/// One canonical tag (signal) in a cluster's generation. Per decision #110: +/// is REQUIRED when the driver is in an Equipment-kind namespace +/// and NULL when in SystemPlatform-kind namespace (Galaxy hierarchy preserved). +/// +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; } + + /// + /// Required when driver is in Equipment-kind namespace; NULL when in SystemPlatform-kind. + /// Cross-table invariant enforced by sp_ValidateDraft (decision #110). + /// + public string? EquipmentId { get; set; } + + public required string Name { get; set; } + + /// Only used when is NULL (SystemPlatform namespace). + public string? FolderPath { get; set; } + + /// OPC UA built-in type name (Boolean / Int32 / Float / etc.). + public required string DataType { get; set; } + + public required TagAccessLevel AccessLevel { get; set; } + + /// Per decisions #44–45 — opt-in for write retry eligibility. + public bool WriteIdempotent { get; set; } + + public string? PollGroupId { get; set; } + + /// Register address / scaling / poll group / byte-order / etc. — schemaless per driver type. + public required string TagConfig { get; set; } + + public ConfigGeneration? Generation { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs new file mode 100644 index 0000000..d1b0bd0 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs @@ -0,0 +1,21 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// UNS level-3 segment. Generation-versioned per decision #115. +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; } + + /// UNS level 3 segment: matches ^[a-z0-9-]{1,32}$ OR equals literal _default. + public required string Name { get; set; } + + public string? Notes { get; set; } + + public ConfigGeneration? Generation { get; set; } + public ServerCluster? Cluster { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs new file mode 100644 index 0000000..1a41b74 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs @@ -0,0 +1,21 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +/// UNS level-4 segment. Generation-versioned per decision #115. +public sealed class UnsLine +{ + public Guid UnsLineRowId { get; set; } + + public long GenerationId { get; set; } + + public required string UnsLineId { get; set; } + + /// Logical FK to ; resolved within the same generation. + public required string UnsAreaId { get; set; } + + /// UNS level 4 segment: matches ^[a-z0-9-]{1,32}$ OR equals literal _default. + public required string Name { get; set; } + + public string? Notes { get; set; } + + public ConfigGeneration? Generation { get; set; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs new file mode 100644 index 0000000..df5369e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs @@ -0,0 +1,10 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// Credential kind for . Per decision #83. +public enum CredentialKind +{ + SqlLogin, + ClientCertThumbprint, + ADPrincipal, + gMSA, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/GenerationStatus.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/GenerationStatus.cs new file mode 100644 index 0000000..1ff8847 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/GenerationStatus.cs @@ -0,0 +1,10 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// Generation lifecycle state. Draft → Published → Superseded | RolledBack. +public enum GenerationStatus +{ + Draft, + Published, + Superseded, + RolledBack, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs new file mode 100644 index 0000000..74718cd --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs @@ -0,0 +1,25 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// OPC UA namespace kind per decision #107. One of each kind per cluster per generation. +public enum NamespaceKind +{ + /// + /// 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. + /// + Equipment, + + /// + /// System Platform namespace — Galaxy / MXAccess processed data (v1 LmxOpcUa folded in). + /// UNS rules do NOT apply; Galaxy hierarchy preserved as v1 expressed it. + /// + SystemPlatform, + + /// + /// 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. + /// + Simulated, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeAclScopeKind.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeAclScopeKind.cs new file mode 100644 index 0000000..b6ad45c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeAclScopeKind.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// ACL scope level. Per acl-design.md §"Scope Hierarchy". +public enum NodeAclScopeKind +{ + Cluster, + Namespace, + UnsArea, + UnsLine, + Equipment, + Tag, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs new file mode 100644 index 0000000..44bc0ca --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs @@ -0,0 +1,10 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// Status tracked per node in . +public enum NodeApplyStatus +{ + Applied, + RolledBack, + Failed, + InProgress, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs new file mode 100644 index 0000000..aee9777 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs @@ -0,0 +1,37 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// +/// OPC UA client data-path permissions per acl-design.md. +/// Stored as int bitmask in . +/// +[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, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyMode.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyMode.cs new file mode 100644 index 0000000..d2c1134 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyMode.cs @@ -0,0 +1,17 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// +/// Cluster redundancy mode per OPC UA Part 5 §6.5. Persisted as string in +/// ServerCluster.RedundancyMode with a CHECK constraint coupling to NodeCount. +/// +public enum RedundancyMode +{ + /// Single-node cluster. Required when NodeCount = 1. + None, + + /// Warm redundancy (non-transparent). Two-node cluster. + Warm, + + /// Hot redundancy (non-transparent). Two-node cluster. + Hot, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyRole.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyRole.cs new file mode 100644 index 0000000..e0e9ece --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyRole.cs @@ -0,0 +1,9 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// Per-node redundancy role within a cluster. Per decision #84. +public enum RedundancyRole +{ + Primary, + Secondary, + Standalone, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/ReservationKind.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/ReservationKind.cs new file mode 100644 index 0000000..936a17a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/ReservationKind.cs @@ -0,0 +1,8 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// External-ID reservation kind. Per decision #124. +public enum ReservationKind +{ + ZTag, + SAPID, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/TagAccessLevel.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/TagAccessLevel.cs new file mode 100644 index 0000000..da6c1df --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/TagAccessLevel.cs @@ -0,0 +1,8 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +/// Tag-level OPC UA access level baseline. Further narrowed per-user by NodeAcl grants. +public enum TagAccessLevel +{ + Read, + ReadWrite, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSnapshot.cs new file mode 100644 index 0000000..9d67ce4 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSnapshot.cs @@ -0,0 +1,15 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; + +/// +/// 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 sp_GetGenerationContent +/// result; the local cache doesn't inspect the shape, it just round-trips bytes. +/// +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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ILocalConfigCache.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ILocalConfigCache.cs new file mode 100644 index 0000000..6c44f60 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ILocalConfigCache.cs @@ -0,0 +1,12 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; + +/// +/// 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). +/// +public interface ILocalConfigCache +{ + Task GetMostRecentAsync(string clusterId, CancellationToken ct = default); + Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default); + Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/LiteDbConfigCache.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/LiteDbConfigCache.cs new file mode 100644 index 0000000..eca7a73 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/LiteDbConfigCache.cs @@ -0,0 +1,89 @@ +using LiteDB; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; + +/// +/// LiteDB-backed . One file per node (default +/// config_cache.db), one collection per snapshot. Corruption surfaces as +/// on construction or read — callers should +/// delete and re-fetch from the central DB (decision #80). +/// +public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable +{ + private const string CollectionName = "generations"; + private readonly LiteDatabase _db; + private readonly ILiteCollection _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(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 GetMostRecentAsync(string clusterId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + var snapshot = _col + .Find(s => s.ClusterId == clusterId) + .OrderByDescending(s => s.GenerationId) + .FirstOrDefault(); + return Task.FromResult(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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.Designer.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.Designer.cs new file mode 100644 index 0000000..02f69a7 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.Designer.cs @@ -0,0 +1,1208 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260417212220_InitialSchema")] + partial class InitialSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("RedundancyRole") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster") + .HasFilter("[RedundancyRole] = 'Primary'"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CurrentGenerationId") + .HasColumnType("bigint"); + + b.Property("LastAppliedAt") + .HasColumnType("datetime2(3)"); + + b.Property("LastAppliedError") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LastAppliedStatus") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId"); + + b.HasIndex("CurrentGenerationId") + .HasDatabaseName("IX_ClusterNodeGenerationState_Generation"); + + b.ToTable("ClusterNodeGenerationState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.Property("GenerationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GenerationId")); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ParentGenerationId") + .HasColumnType("bigint"); + + b.Property("PublishedAt") + .HasColumnType("datetime2(3)"); + + b.Property("PublishedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("GenerationId"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster") + .HasFilter("[Status] = 'Draft'"); + + b.HasIndex("ParentGenerationId"); + + b.HasIndex("ClusterId", "Status", "GenerationId") + .IsDescending(false, false, true) + .HasDatabaseName("IX_ConfigGeneration_Cluster_Published"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("ClusterId", "Status", "GenerationId"), new[] { "PublishedAt" }); + + b.ToTable("ConfigGeneration", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("GenerationId", "DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_Generation_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Device_Generation_Driver"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_DriverInstance_Generation_Cluster"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Generation_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceId") + .HasDatabaseName("IX_DriverInstance_Generation_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Equipment_Generation_Driver"); + + b.HasIndex("GenerationId", "EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_Uuid"); + + b.HasIndex("GenerationId", "MachineCode") + .HasDatabaseName("IX_Equipment_Generation_MachineCode"); + + b.HasIndex("GenerationId", "SAPID") + .HasDatabaseName("IX_Equipment_Generation_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId") + .HasDatabaseName("IX_Equipment_Generation_Line"); + + b.HasIndex("GenerationId", "ZTag") + .HasDatabaseName("IX_Equipment_Generation_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.Property("NamespaceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NamespaceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NamespaceUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_Namespace_Generation_Cluster"); + + b.HasIndex("GenerationId", "NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_NamespaceUri"); + + b.HasIndex("GenerationId", "ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind"); + + b.HasIndex("GenerationId", "NamespaceId", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.ToTable("Namespace", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_NodeAcl_Generation_Cluster"); + + b.HasIndex("GenerationId", "LdapGroup") + .HasDatabaseName("IX_NodeAcl_Generation_Group"); + + b.HasIndex("GenerationId", "NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Generation_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Generation_Driver"); + + b.HasIndex("GenerationId", "PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_Generation_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FolderPath") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("GenerationId", "EquipmentId") + .HasDatabaseName("IX_Tag_Generation_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Generation_Driver_Device"); + + b.HasIndex("GenerationId", "EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_FolderPath") + .HasFilter("[EquipmentId] IS NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_UnsArea_Generation_Cluster"); + + b.HasIndex("GenerationId", "UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("GenerationId", "UnsAreaId") + .HasDatabaseName("IX_UnsLine_Generation_Area"); + + b.HasIndex("GenerationId", "UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "CurrentGeneration") + .WithMany() + .HasForeignKey("CurrentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithOne("GenerationState") + .HasForeignKey("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", "NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurrentGeneration"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Generations") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Parent") + .WithMany() + .HasForeignKey("ParentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Cluster"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Namespaces") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + + b.Navigation("GenerationState"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Generations"); + + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.cs new file mode 100644 index 0000000..571d3b9 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.cs @@ -0,0 +1,811 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ConfigAuditLog", + columns: table => new + { + AuditId = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Timestamp = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + Principal = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + EventType = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + NodeId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + GenerationId = table.Column(type: "bigint", nullable: true), + DetailsJson = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + Kind = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + Value = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + EquipmentUuid = table.Column(type: "uniqueidentifier", nullable: false), + ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + FirstPublishedAt = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + FirstPublishedBy = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + LastPublishedAt = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + ReleasedAt = table.Column(type: "datetime2(3)", nullable: true), + ReleasedBy = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + ReleaseReason = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Enterprise = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Site = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + NodeCount = table.Column(type: "tinyint", nullable: false), + RedundancyMode = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + Notes = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + CreatedAt = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + CreatedBy = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + ModifiedAt = table.Column(type: "datetime2(3)", nullable: true), + ModifiedBy = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), + ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + RedundancyRole = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + Host = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + OpcUaPort = table.Column(type: "int", nullable: false), + DashboardPort = table.Column(type: "int", nullable: false), + ApplicationUri = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + ServiceLevelBase = table.Column(type: "tinyint", nullable: false), + DriverConfigOverridesJson = table.Column(type: "nvarchar(max)", nullable: true), + Enabled = table.Column(type: "bit", nullable: false), + LastSeenAt = table.Column(type: "datetime2(3)", nullable: true), + CreatedAt = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + CreatedBy = table.Column(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(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Status = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + ParentGenerationId = table.Column(type: "bigint", nullable: true), + PublishedAt = table.Column(type: "datetime2(3)", nullable: true), + PublishedBy = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + Notes = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + CreatedAt = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + CreatedBy = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + NodeId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Kind = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Value = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + RotatedAt = table.Column(type: "datetime2(3)", nullable: true), + CreatedAt = table.Column(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + CreatedBy = table.Column(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(type: "nvarchar(64)", maxLength: 64, nullable: false), + CurrentGenerationId = table.Column(type: "bigint", nullable: true), + LastAppliedAt = table.Column(type: "datetime2(3)", nullable: true), + LastAppliedStatus = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: true), + LastAppliedError = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: true), + LastSeenAt = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + GenerationId = table.Column(type: "bigint", nullable: false), + DeviceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + DriverInstanceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + DeviceConfig = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + GenerationId = table.Column(type: "bigint", nullable: false), + DriverInstanceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + NamespaceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + DriverType = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + DriverConfig = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + GenerationId = table.Column(type: "bigint", nullable: false), + EquipmentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + EquipmentUuid = table.Column(type: "uniqueidentifier", nullable: false), + DriverInstanceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + DeviceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + UnsLineId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + MachineCode = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ZTag = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + SAPID = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + Manufacturer = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + Model = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + SerialNumber = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + HardwareRevision = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: true), + SoftwareRevision = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: true), + YearOfConstruction = table.Column(type: "smallint", nullable: true), + AssetLocation = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ManufacturerUri = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + DeviceManualUri = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + EquipmentClassRef = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + Enabled = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + GenerationId = table.Column(type: "bigint", nullable: false), + NamespaceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Kind = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + NamespaceUri = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + Notes = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + GenerationId = table.Column(type: "bigint", nullable: false), + NodeAclId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + LdapGroup = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + ScopeKind = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + ScopeId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + PermissionFlags = table.Column(type: "int", nullable: false), + Notes = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + GenerationId = table.Column(type: "bigint", nullable: false), + PollGroupId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + DriverInstanceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + IntervalMs = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + GenerationId = table.Column(type: "bigint", nullable: false), + TagId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + DriverInstanceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + DeviceId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + EquipmentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + FolderPath = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: true), + DataType = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + AccessLevel = table.Column(type: "nvarchar(16)", maxLength: 16, nullable: false), + WriteIdempotent = table.Column(type: "bit", nullable: false), + PollGroupId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + TagConfig = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + GenerationId = table.Column(type: "bigint", nullable: false), + UnsAreaId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + ClusterId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Notes = table.Column(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(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"), + GenerationId = table.Column(type: "bigint", nullable: false), + UnsLineId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + UnsAreaId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Name = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Notes = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.Designer.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.Designer.cs new file mode 100644 index 0000000..7943c0e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.Designer.cs @@ -0,0 +1,1208 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260417215224_StoredProcedures")] + partial class StoredProcedures + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("RedundancyRole") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster") + .HasFilter("[RedundancyRole] = 'Primary'"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CurrentGenerationId") + .HasColumnType("bigint"); + + b.Property("LastAppliedAt") + .HasColumnType("datetime2(3)"); + + b.Property("LastAppliedError") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LastAppliedStatus") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId"); + + b.HasIndex("CurrentGenerationId") + .HasDatabaseName("IX_ClusterNodeGenerationState_Generation"); + + b.ToTable("ClusterNodeGenerationState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.Property("GenerationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GenerationId")); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ParentGenerationId") + .HasColumnType("bigint"); + + b.Property("PublishedAt") + .HasColumnType("datetime2(3)"); + + b.Property("PublishedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("GenerationId"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster") + .HasFilter("[Status] = 'Draft'"); + + b.HasIndex("ParentGenerationId"); + + b.HasIndex("ClusterId", "Status", "GenerationId") + .IsDescending(false, false, true) + .HasDatabaseName("IX_ConfigGeneration_Cluster_Published"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("ClusterId", "Status", "GenerationId"), new[] { "PublishedAt" }); + + b.ToTable("ConfigGeneration", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("GenerationId", "DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_Generation_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Device_Generation_Driver"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_DriverInstance_Generation_Cluster"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Generation_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceId") + .HasDatabaseName("IX_DriverInstance_Generation_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Equipment_Generation_Driver"); + + b.HasIndex("GenerationId", "EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_Uuid"); + + b.HasIndex("GenerationId", "MachineCode") + .HasDatabaseName("IX_Equipment_Generation_MachineCode"); + + b.HasIndex("GenerationId", "SAPID") + .HasDatabaseName("IX_Equipment_Generation_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId") + .HasDatabaseName("IX_Equipment_Generation_Line"); + + b.HasIndex("GenerationId", "ZTag") + .HasDatabaseName("IX_Equipment_Generation_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.Property("NamespaceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NamespaceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NamespaceUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_Namespace_Generation_Cluster"); + + b.HasIndex("GenerationId", "NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_NamespaceUri"); + + b.HasIndex("GenerationId", "ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind"); + + b.HasIndex("GenerationId", "NamespaceId", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.ToTable("Namespace", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_NodeAcl_Generation_Cluster"); + + b.HasIndex("GenerationId", "LdapGroup") + .HasDatabaseName("IX_NodeAcl_Generation_Group"); + + b.HasIndex("GenerationId", "NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Generation_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Generation_Driver"); + + b.HasIndex("GenerationId", "PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_Generation_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FolderPath") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("GenerationId", "EquipmentId") + .HasDatabaseName("IX_Tag_Generation_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Generation_Driver_Device"); + + b.HasIndex("GenerationId", "EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_FolderPath") + .HasFilter("[EquipmentId] IS NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_UnsArea_Generation_Cluster"); + + b.HasIndex("GenerationId", "UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("GenerationId", "UnsAreaId") + .HasDatabaseName("IX_UnsLine_Generation_Area"); + + b.HasIndex("GenerationId", "UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "CurrentGeneration") + .WithMany() + .HasForeignKey("CurrentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithOne("GenerationState") + .HasForeignKey("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", "NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurrentGeneration"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Generations") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Parent") + .WithMany() + .HasForeignKey("ParentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Cluster"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Namespaces") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + + b.Navigation("GenerationState"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Generations"); + + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.cs new file mode 100644 index 0000000..33f3571 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.cs @@ -0,0 +1,473 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations; + +/// +/// Stored procedures per config-db-schema.md §"Stored Procedures". 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. +/// +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 +"; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.Designer.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.Designer.cs new file mode 100644 index 0000000..c5c2450 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.Designer.cs @@ -0,0 +1,1208 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + [Migration("20260417220857_AuthorizationGrants")] + partial class AuthorizationGrants + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("RedundancyRole") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster") + .HasFilter("[RedundancyRole] = 'Primary'"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CurrentGenerationId") + .HasColumnType("bigint"); + + b.Property("LastAppliedAt") + .HasColumnType("datetime2(3)"); + + b.Property("LastAppliedError") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LastAppliedStatus") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId"); + + b.HasIndex("CurrentGenerationId") + .HasDatabaseName("IX_ClusterNodeGenerationState_Generation"); + + b.ToTable("ClusterNodeGenerationState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.Property("GenerationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GenerationId")); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ParentGenerationId") + .HasColumnType("bigint"); + + b.Property("PublishedAt") + .HasColumnType("datetime2(3)"); + + b.Property("PublishedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("GenerationId"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster") + .HasFilter("[Status] = 'Draft'"); + + b.HasIndex("ParentGenerationId"); + + b.HasIndex("ClusterId", "Status", "GenerationId") + .IsDescending(false, false, true) + .HasDatabaseName("IX_ConfigGeneration_Cluster_Published"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("ClusterId", "Status", "GenerationId"), new[] { "PublishedAt" }); + + b.ToTable("ConfigGeneration", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("GenerationId", "DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_Generation_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Device_Generation_Driver"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_DriverInstance_Generation_Cluster"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Generation_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceId") + .HasDatabaseName("IX_DriverInstance_Generation_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Equipment_Generation_Driver"); + + b.HasIndex("GenerationId", "EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_Uuid"); + + b.HasIndex("GenerationId", "MachineCode") + .HasDatabaseName("IX_Equipment_Generation_MachineCode"); + + b.HasIndex("GenerationId", "SAPID") + .HasDatabaseName("IX_Equipment_Generation_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId") + .HasDatabaseName("IX_Equipment_Generation_Line"); + + b.HasIndex("GenerationId", "ZTag") + .HasDatabaseName("IX_Equipment_Generation_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.Property("NamespaceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NamespaceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NamespaceUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_Namespace_Generation_Cluster"); + + b.HasIndex("GenerationId", "NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_NamespaceUri"); + + b.HasIndex("GenerationId", "ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind"); + + b.HasIndex("GenerationId", "NamespaceId", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.ToTable("Namespace", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_NodeAcl_Generation_Cluster"); + + b.HasIndex("GenerationId", "LdapGroup") + .HasDatabaseName("IX_NodeAcl_Generation_Group"); + + b.HasIndex("GenerationId", "NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Generation_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Generation_Driver"); + + b.HasIndex("GenerationId", "PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_Generation_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FolderPath") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("GenerationId", "EquipmentId") + .HasDatabaseName("IX_Tag_Generation_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Generation_Driver_Device"); + + b.HasIndex("GenerationId", "EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_FolderPath") + .HasFilter("[EquipmentId] IS NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_UnsArea_Generation_Cluster"); + + b.HasIndex("GenerationId", "UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("GenerationId", "UnsAreaId") + .HasDatabaseName("IX_UnsLine_Generation_Area"); + + b.HasIndex("GenerationId", "UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "CurrentGeneration") + .WithMany() + .HasForeignKey("CurrentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithOne("GenerationState") + .HasForeignKey("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", "NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurrentGeneration"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Generations") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Parent") + .WithMany() + .HasForeignKey("ParentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Cluster"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Namespaces") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + + b.Navigation("GenerationState"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Generations"); + + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.cs new file mode 100644 index 0000000..3bb0d38 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations; + +/// +/// Creates the two DB roles per config-db-schema.md §"Authorization Model" and grants +/// EXECUTE on the appropriate stored procedures. Deliberately grants no direct table DML — all +/// writes funnel through the procs, which authenticate via SUSER_SNAME(). +/// 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. +/// +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; +"); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs new file mode 100644 index 0000000..d7912e1 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -0,0 +1,1205 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using ZB.MOM.WW.OtOpcUa.Configuration; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + [DbContext(typeof(OtOpcUaConfigDbContext))] + partial class OtOpcUaConfigDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ApplicationUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("DashboardPort") + .HasColumnType("int"); + + b.Property("DriverConfigOverridesJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("RedundancyRole") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster") + .HasFilter("[RedundancyRole] = 'Primary'"); + + b.ToTable("ClusterNode", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.Property("CredentialId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NodeId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RotatedAt") + .HasColumnType("datetime2(3)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.HasKey("CredentialId"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ClusterNodeCredential_Value") + .HasFilter("[Enabled] = 1"); + + b.HasIndex("NodeId", "Enabled") + .HasDatabaseName("IX_ClusterNodeCredential_NodeId"); + + b.ToTable("ClusterNodeCredential", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CurrentGenerationId") + .HasColumnType("bigint"); + + b.Property("LastAppliedAt") + .HasColumnType("datetime2(3)"); + + b.Property("LastAppliedError") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("LastAppliedStatus") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId"); + + b.HasIndex("CurrentGenerationId") + .HasDatabaseName("IX_ClusterNodeGenerationState_Generation"); + + b.ToTable("ClusterNodeGenerationState", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("GenerationId") + .HasDatabaseName("IX_ConfigAuditLog_Generation") + .HasFilter("[GenerationId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time"); + + b.ToTable("ConfigAuditLog", null, t => + { + t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.Property("GenerationId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("GenerationId")); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("ParentGenerationId") + .HasColumnType("bigint"); + + b.Property("PublishedAt") + .HasColumnType("datetime2(3)"); + + b.Property("PublishedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("GenerationId"); + + b.HasIndex("ClusterId") + .IsUnique() + .HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster") + .HasFilter("[Status] = 'Draft'"); + + b.HasIndex("ParentGenerationId"); + + b.HasIndex("ClusterId", "Status", "GenerationId") + .IsDescending(false, false, true) + .HasDatabaseName("IX_ConfigGeneration_Cluster_Published"); + + SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("ClusterId", "Status", "GenerationId"), new[] { "PublishedAt" }); + + b.ToTable("ConfigGeneration", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.Property("DeviceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("GenerationId", "DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_Generation_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Device_Generation_Driver"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.Property("DriverInstanceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NamespaceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_DriverInstance_Generation_Cluster"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Generation_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceId") + .HasDatabaseName("IX_DriverInstance_Generation_Namespace"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.Property("EquipmentRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AssetLocation") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentClassRef") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("HardwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("MachineCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Manufacturer") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ManufacturerUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Model") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SAPID") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SerialNumber") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SoftwareRevision") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UnsLineId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("YearOfConstruction") + .HasColumnType("smallint"); + + b.Property("ZTag") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EquipmentRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_Equipment_Generation_Driver"); + + b.HasIndex("GenerationId", "EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_Uuid"); + + b.HasIndex("GenerationId", "MachineCode") + .HasDatabaseName("IX_Equipment_Generation_MachineCode"); + + b.HasIndex("GenerationId", "SAPID") + .HasDatabaseName("IX_Equipment_Generation_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId") + .HasDatabaseName("IX_Equipment_Generation_Line"); + + b.HasIndex("GenerationId", "ZTag") + .HasDatabaseName("IX_Equipment_Generation_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_Generation_LinePath"); + + b.ToTable("Equipment", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b => + { + b.Property("ReservationId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentUuid") + .HasColumnType("uniqueidentifier"); + + b.Property("FirstPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("FirstPublishedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastPublishedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("ReleaseReason") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ReleasedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ReleasedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ReservationId"); + + b.HasIndex("EquipmentUuid") + .HasDatabaseName("IX_ExternalIdReservation_Equipment"); + + b.HasIndex("Kind", "Value") + .IsUnique() + .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active") + .HasFilter("[ReleasedAt] IS NULL"); + + b.ToTable("ExternalIdReservation", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.Property("NamespaceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("NamespaceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("NamespaceUri") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("NamespaceRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_Namespace_Generation_Cluster"); + + b.HasIndex("GenerationId", "NamespaceId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.HasIndex("GenerationId", "NamespaceUri") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_NamespaceUri"); + + b.HasIndex("GenerationId", "ClusterId", "Kind") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_Cluster_Kind"); + + b.HasIndex("GenerationId", "NamespaceId", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster") + .HasFilter("[NamespaceId] IS NOT NULL"); + + b.ToTable("Namespace", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.Property("NodeAclRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NodeAclId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("PermissionFlags") + .HasColumnType("int"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_NodeAcl_Generation_Cluster"); + + b.HasIndex("GenerationId", "LdapGroup") + .HasDatabaseName("IX_NodeAcl_Generation_Group"); + + b.HasIndex("GenerationId", "NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Generation_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_Generation_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.Property("PollGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("GenerationId", "DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Generation_Driver"); + + b.HasIndex("GenerationId", "PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_Generation_LogicalId") + .HasFilter("[PollGroupId] IS NOT NULL"); + + b.ToTable("PollGroup", null, t => + { + t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("Enterprise") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ModifiedAt") + .HasColumnType("datetime2(3)"); + + b.Property("ModifiedBy") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NodeCount") + .HasColumnType("tinyint"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("RedundancyMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Site") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("ClusterId"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("UX_ServerCluster_Name"); + + b.HasIndex("Site") + .HasDatabaseName("IX_ServerCluster_Site"); + + b.ToTable("ServerCluster", null, t => + { + t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.Property("TagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AccessLevel") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DeviceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("EquipmentId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FolderPath") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("GenerationId", "EquipmentId") + .HasDatabaseName("IX_Tag_Generation_Equipment") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "DeviceId") + .HasDatabaseName("IX_Tag_Generation_Driver_Device"); + + b.HasIndex("GenerationId", "EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_EquipmentPath") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("GenerationId", "DriverInstanceId", "FolderPath", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Generation_FolderPath") + .HasFilter("[EquipmentId] IS NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.Property("UnsAreaRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId"); + + b.HasIndex("GenerationId", "ClusterId") + .HasDatabaseName("IX_UnsArea_Generation_Cluster"); + + b.HasIndex("GenerationId", "UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("GenerationId", "ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_Generation_ClusterName"); + + b.ToTable("UnsArea", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.Property("UnsLineRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("GenerationId", "UnsAreaId") + .HasDatabaseName("IX_UnsLine_Generation_Area"); + + b.HasIndex("GenerationId", "UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("GenerationId", "UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_Generation_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Nodes") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeCredential", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany("Credentials") + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "CurrentGeneration") + .WithMany() + .HasForeignKey("CurrentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithOne("GenerationState") + .HasForeignKey("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNodeGenerationState", "NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurrentGeneration"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Generations") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Parent") + .WithMany() + .HasForeignKey("ParentGenerationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Cluster"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Device", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Equipment", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany("Namespaces") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.PollGroup", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Tag", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsArea", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsLine", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ConfigGeneration", "Generation") + .WithMany() + .HasForeignKey("GenerationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Generation"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + + b.Navigation("GenerationState"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Generations"); + + b.Navigation("Namespaces"); + + b.Navigation("Nodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs new file mode 100644 index 0000000..ecec6d6 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs @@ -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; + +/// +/// Central config DB context. Schema matches docs/v2/config-db-schema.md exactly — +/// any divergence is a defect caught by the SchemaComplianceTests introspection check. +/// +public sealed class OtOpcUaConfigDbContext(DbContextOptions options) + : DbContext(options) +{ + public DbSet ServerClusters => Set(); + public DbSet ClusterNodes => Set(); + public DbSet ClusterNodeCredentials => Set(); + public DbSet ConfigGenerations => Set(); + public DbSet Namespaces => Set(); + public DbSet UnsAreas => Set(); + public DbSet UnsLines => Set(); + public DbSet DriverInstances => Set(); + public DbSet Devices => Set(); + public DbSet Equipment => Set(); + public DbSet Tags => Set(); + public DbSet PollGroups => Set(); + public DbSet NodeAcls => Set(); + public DbSet ClusterNodeGenerationStates => Set(); + public DbSet ConfigAuditLogs => Set(); + public DbSet ExternalIdReservations => Set(); + + 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(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().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(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().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(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().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(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().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(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().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(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(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(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(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(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(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().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(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(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().HasMaxLength(16); + e.Property(x => x.ScopeId).HasMaxLength(64); + e.Property(x => x.PermissionFlags).HasConversion(); + 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(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().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(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(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(e => + { + e.ToTable("ExternalIdReservation"); + e.HasKey(x => x.ReservationId); + e.Property(x => x.ReservationId).HasDefaultValueSql("NEWSEQUENTIALID()"); + e.Property(x => x.Kind).HasConversion().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"); + }); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs new file mode 100644 index 0000000..9de011c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs @@ -0,0 +1,28 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +/// +/// Inputs for draft validation. Contains the draft's rows plus the minimum prior-generation +/// rows needed for cross-generation invariants (EquipmentUuid stability, UnsArea identity). +/// +public sealed class DraftSnapshot +{ + public required long GenerationId { get; init; } + public required string ClusterId { get; init; } + + public IReadOnlyList Namespaces { get; init; } = []; + public IReadOnlyList DriverInstances { get; init; } = []; + public IReadOnlyList Devices { get; init; } = []; + public IReadOnlyList UnsAreas { get; init; } = []; + public IReadOnlyList UnsLines { get; init; } = []; + public IReadOnlyList Equipment { get; init; } = []; + public IReadOnlyList Tags { get; init; } = []; + public IReadOnlyList PollGroups { get; init; } = []; + + /// Prior Equipment rows (any generation, same cluster) for stability checks. + public IReadOnlyList PriorEquipment { get; init; } = []; + + /// Active reservations (ReleasedAt IS NULL) for pre-flight. + public IReadOnlyList ActiveReservations { get; init; } = []; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs new file mode 100644 index 0000000..68b7bde --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs @@ -0,0 +1,176 @@ +using System.Text.RegularExpressions; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +/// +/// Managed-code pre-publish validator per decision #91. Complements the structural checks in +/// sp_ValidateDraft — 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). +/// +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 Validate(DraftSnapshot draft) + { + var errors = new List(); + + 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 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)); + } + + /// Cluster.Enterprise + Site + area + line + equipment + 4 slashes ≤ 200 chars. + private static void ValidatePathLength(DraftSnapshot draft, List 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 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 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 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)); + } + } + + /// Decision #125: EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID. + public static string DeriveEquipmentId(Guid uuid) => + "EQ-" + uuid.ToString("N")[..12].ToLowerInvariant(); + + private static void ValidateEquipmentIdDerivation(DraftSnapshot draft, List 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 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)); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/ValidationError.cs b/src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/ValidationError.cs new file mode 100644 index 0000000..e03003b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/ValidationError.cs @@ -0,0 +1,8 @@ +namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +/// +/// One validation failure. is a stable machine-readable symbol +/// (BadCrossClusterNamespaceBinding, UnsSegmentInvalid, …). +/// carries the offending logical ID so the Admin UI can link straight to the row. +/// +public sealed record ValidationError(string Code, string Message, string? Context = null); diff --git a/src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj b/src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj new file mode 100644 index 0000000..33a1e4a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Configuration/ZB.MOM.WW.OtOpcUa.Configuration.csproj @@ -0,0 +1,41 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Configuration + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs b/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs new file mode 100644 index 0000000..e37d7cc --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core/Hosting/DriverHost.cs @@ -0,0 +1,80 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.Hosting; + +/// +/// Process-local registry + lifecycle manager for loaded 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. +/// +public sealed class DriverHost : IAsyncDisposable +{ + private readonly Dictionary _drivers = new(); + private readonly object _lock = new(); + + public IReadOnlyCollection RegisteredDriverIds + { + get { lock (_lock) return [.. _drivers.Keys]; } + } + + public DriverHealth? GetHealth(string driverInstanceId) + { + lock (_lock) + return _drivers.TryGetValue(driverInstanceId, out var d) ? d.GetHealth() : null; + } + + /// + /// Registers the driver and calls . If initialization + /// throws, the driver is kept in the registry so the operator can retry; quality on its + /// nodes will reflect until Reinitialize succeeds. + /// + 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 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(); + } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs new file mode 100644 index 0000000..1672ee1 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core/OpcUa/GenericDriverNodeManager.cs @@ -0,0 +1,37 @@ +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Core.OpcUa; + +/// +/// Generic, driver-agnostic backbone for populating the OPC UA address space from an +/// . The Galaxy-specific subclass (GalaxyNodeManager) is deferred +/// to Phase 2 per decision #62 — this class is the foundation that Phase 2 ports the v1 +/// LmxNodeManager logic into. +/// +/// +/// Phase 1 status: scaffold only. The v1 LmxNodeManager in the legacy Host is unchanged +/// so IntegrationTests continue to pass. Phase 2 will lift-and-shift its logic here, swapping +/// IMxAccessClient for and GalaxyAttributeInfo for +/// . +/// +public abstract class GenericDriverNodeManager(IDriver driver) +{ + protected IDriver Driver { get; } = driver ?? throw new ArgumentNullException(nameof(driver)); + + public string DriverInstanceId => Driver.DriverInstanceId; + + /// + /// 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. + /// + 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); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj b/src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj new file mode 100644 index 0000000..b31947c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Core/ZB.MOM.WW.OtOpcUa.Core.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Core + + + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeAcl.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeAcl.cs new file mode 100644 index 0000000..ba8dc62 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeAcl.cs @@ -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; + +/// +/// Builds the required by driver-stability.md §"IPC Security": +/// only the configured OtOpcUa server principal SID gets ReadWrite | Synchronize; +/// LocalSystem and Administrators are explicitly denied. Any other authenticated user falls +/// through to the implicit deny. +/// +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; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeServer.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeServer.cs new file mode 100644 index 0000000..b9b281b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/PipeServer.cs @@ -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; + +/// +/// Accepts one client connection at a time on a named pipe with the strict ACL from +/// . Verifies the peer SID and the per-process shared secret before any +/// RPC frame is accepted. Per driver-stability.md §"IPC Security". +/// +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)); + } + + /// + /// Accepts one connection, performs Hello handshake, then dispatches frames to + /// until EOF or cancel. Returns when the client disconnects. + /// + 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(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; + } + } + + /// + /// Runs the server continuously, handling one connection at a time. When a connection ends + /// (clean or error), accepts the next. + /// + 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); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/StubFrameHandler.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/StubFrameHandler.cs new file mode 100644 index 0000000..0ba149b --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Ipc/StubFrameHandler.cs @@ -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; + +/// +/// 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. +/// +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(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); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/IsExternalInit.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/IsExternalInit.cs new file mode 100644 index 0000000..ec909f1 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/IsExternalInit.cs @@ -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; diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Program.cs new file mode 100644 index 0000000..04972ed --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Program.cs @@ -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; + +/// +/// Entry point for the OtOpcUaGalaxyHost Windows service / console host. Reads the +/// pipe name, allowed-SID, and shared secret from environment (passed by the supervisor at +/// spawn time per driver-stability.md). +/// +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(); } + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/MxAccessHandle.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/MxAccessHandle.cs new file mode 100644 index 0000000..80a4b07 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/MxAccessHandle.cs @@ -0,0 +1,58 @@ +using System; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; + +/// +/// SafeHandle-style lifetime wrapper for an LMXProxyServer COM connection. Per Task B.3 +/// + decision #65: must call Marshal.ReleaseComObject until +/// refcount = 0, then UnregisterProxy. The finalizer runs as a +/// to honor AppDomain-unload ordering. +/// +/// +/// This scaffold accepts any RCW (tagged as ) so we can unit-test the +/// release logic with a mock. The concrete wiring to ArchestrA.MxAccess.LMXProxyServer +/// lands when the actual Galaxy code moves over (the part deferred to the parity gate). +/// +public sealed class MxAccessHandle : SafeHandle +{ + private object? _comObject; + private readonly Action? _unregister; + + public MxAccessHandle(object comObject, Action? 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; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs new file mode 100644 index 0000000..3d2a78e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Sta/StaPump.cs @@ -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; + +/// +/// Dedicated STA thread that owns all LMXProxyServer COM instances. Work items are +/// posted from any thread and dispatched on the STA. Per driver-stability.md Galaxy +/// deep dive §"STA thread + Win32 message pump". +/// +/// +/// Phase 2 scaffold: uses a dispatcher instead of the real +/// Win32 GetMessage/DispatchMessage pump. Real pump arrives when the v1 StaComThread +/// 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. +/// +public sealed class StaPump : IDisposable +{ + private readonly Thread _thread; + private readonly BlockingCollection _workQueue = new(new ConcurrentQueue()); + private readonly TaskCompletionSource _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; + + /// Posts a work item; resolves once it's executed on the STA thread. + public Task InvokeAsync(Func work) + { + if (_disposed) throw new ObjectDisposedException(nameof(StaPump)); + + var tcs = new TaskCompletionSource(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; }); + + /// + /// Health probe — returns true if a no-op work item round-trips within . + /// Used by the supervisor; timeout means the pump is wedged and a recycle is warranted. + /// + public async Task 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(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/MemoryWatchdog.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/MemoryWatchdog.cs new file mode 100644 index 0000000..5777c36 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/MemoryWatchdog.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; + +/// +/// Galaxy-specific RSS watchdog per driver-stability.md §"Memory Watchdog Thresholds". +/// Baseline-relative + absolute caps. Sustained-slope detection uses a rolling 30-min window. +/// Pluggable RSS source keeps it unit-testable. +/// +public sealed class MemoryWatchdog +{ + /// Absolute hard ceiling — process is force-killed above this. + public long HardCeilingBytes { get; init; } = 1_500L * 1024 * 1024; + + /// Sustained slope (bytes/min) above which soft recycle is scheduled. + public long SustainedSlopeBytesPerMinute { get; init; } = 5L * 1024 * 1024; + + public TimeSpan SlopeWindow { get; init; } = TimeSpan.FromMinutes(30); + + private readonly long _baselineBytes; + private readonly Queue _samples = new(); + + public MemoryWatchdog(long baselineBytes) + { + _baselineBytes = baselineBytes; + } + + /// Called every 30s with the current RSS. Returns the action the supervisor should take. + 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 } diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/PostMortemMmf.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/PostMortemMmf.cs new file mode 100644 index 0000000..abe98c7 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/PostMortemMmf.cs @@ -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; + +/// +/// Ring-buffer of the last 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. +/// +/// +/// File layout: +/// +/// [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]] +/// +/// +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); + } + } + + /// Reads all entries in order (oldest → newest). Safe to call from another process. + 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(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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/RecyclePolicy.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/RecyclePolicy.cs new file mode 100644 index 0000000..35cc834 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Stability/RecyclePolicy.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; + +/// +/// Frequency-capped soft-recycle decision per driver-stability.md §"Recycle Policy". +/// Default cap: 1 soft recycle per hour. Scheduled recycle at 03:00 local; supervisor reads +/// to decide. +/// +public sealed class RecyclePolicy +{ + public TimeSpan SoftRecycleCap { get; init; } = TimeSpan.FromHours(1); + public int DailyRecycleHourLocal { get; init; } = 3; + + private readonly List _recentRecyclesUtc = new(); + + /// Returns true if a soft recycle would be allowed under the frequency cap. + 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; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj new file mode 100644 index 0000000..565eb6c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj @@ -0,0 +1,36 @@ + + + + Exe + net48 + + AnyCPU + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host + OtOpcUa.Driver.Galaxy.Host + + + + + + + + + + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs new file mode 100644 index 0000000..5890e48 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/GalaxyProxyDriver.cs @@ -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; + +/// +/// implementation that forwards every capability over the Galaxy IPC +/// channel to the out-of-process Host. Implements as the +/// Phase 2 minimum; other capability interfaces (, etc.) will be wired +/// in once the Host's MXAccess code lift is complete and end-to-end parity tests run. +/// +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( + 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( + 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( + 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); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyIpcClient.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyIpcClient.cs new file mode 100644 index 0000000..0912524 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Ipc/GalaxyIpcClient.cs @@ -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; + +/// +/// Client-side IPC channel to a running Driver.Galaxy.Host. Owns the data-plane pipe +/// connection and serializes request/response round-trips. One instance per session. +/// +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); + } + + /// Connects, sends Hello with the shared secret, and awaits HelloAck. Throws on rejection. + public static async Task 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(ack.Value.Body); + if (!ackMsg.Accepted) + throw new UnauthorizedAccessException($"Galaxy.Host rejected Hello: {ackMsg.RejectReason}"); + + return client; + } + catch + { + await client.DisposeAsync(); + throw; + } + } + + /// Round-trips a request and returns the first frame of the response. + public async Task CallAsync( + 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(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(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; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/Backoff.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/Backoff.cs new file mode 100644 index 0000000..785c458 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/Backoff.cs @@ -0,0 +1,29 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; + +/// +/// Respawn-with-backoff schedule per driver-stability.md §"Crash-loop circuit breaker": +/// 5s → 15s → 60s, capped. Reset on a successful (> ) +/// run. +/// +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; + } + + /// Called when the spawned process has stayed up past the stable threshold. + public void RecordStableRun() => _index = 0; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/CircuitBreaker.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/CircuitBreaker.cs new file mode 100644 index 0000000..7f391af --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/CircuitBreaker.cs @@ -0,0 +1,68 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; + +/// +/// Crash-loop circuit breaker per driver-stability.md: +/// 3 crashes within 5 min → open with escalating cooldown 1h → 4h → 24h manual. A sticky +/// alert stays until the operator explicitly resets. +/// +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 _crashesUtc = []; + private DateTime? _openSinceUtc; + private int _escalationLevel; + public bool StickyAlertActive { get; private set; } + + /// + /// Called by the supervisor each time the host process exits unexpectedly. Returns + /// false when the breaker is open — supervisor must not respawn. + /// + 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; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/HeartbeatMonitor.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/HeartbeatMonitor.cs new file mode 100644 index 0000000..f4bee22 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/Supervisor/HeartbeatMonitor.cs @@ -0,0 +1,28 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; + +/// +/// Tracks missed heartbeats on the dedicated heartbeat pipe per +/// driver-stability.md §"Heartbeat between proxy and host": 2s cadence, 3 consecutive +/// misses = host declared dead (~6s detection). +/// +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; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj new file mode 100644 index 0000000..6859a4c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy + + + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Alarms.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Alarms.cs new file mode 100644 index 0000000..caafacb --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Alarms.cs @@ -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; } + + /// Per OPC UA Part 9 lifecycle: Active, Unacknowledged, Confirmed, Inactive, etc. + [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; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/DataValues.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/DataValues.cs new file mode 100644 index 0000000..8a2ce92 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/DataValues.cs @@ -0,0 +1,53 @@ +using MessagePack; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +/// +/// IPC-shape for a tag value snapshot. Per decision #13: value + StatusCode + source + server timestamps. +/// +[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(); +} + +[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(); +} + +[MessagePackObject] +public sealed class WriteValuesRequest +{ + [Key(0)] public long SessionId { get; set; } + [Key(1)] public GalaxyDataValue[] Writes { get; set; } = System.Array.Empty(); +} + +[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(); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs new file mode 100644 index 0000000..7ba7170 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Discovery.cs @@ -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; } +} + +/// +/// IPC-shape for a Galaxy object. Proxy maps to/from DriverAttributeInfo (Core.Abstractions). +/// +[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(); +} + +[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(); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs new file mode 100644 index 0000000..9694762 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Framing.cs @@ -0,0 +1,61 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +/// +/// Length-prefixed framing per decision #28. Each IPC frame is: +/// [4-byte big-endian length][1-byte message kind][MessagePack body]. +/// Length is the body size only; the kind byte is not part of the prefixed length. +/// +public static class Framing +{ + public const int LengthPrefixSize = 4; + public const int KindByteSize = 1; + + /// + /// Maximum permitted body length (16 MiB). Protects the receiver from a hostile or + /// misbehaving peer sending an oversized length prefix. + /// + public const int MaxFrameBodyBytes = 16 * 1024 * 1024; +} + +/// +/// Wire identifier for each contract. Values are stable — new contracts append. +/// +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, +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Hello.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Hello.cs new file mode 100644 index 0000000..1077356 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Hello.cs @@ -0,0 +1,36 @@ +using MessagePack; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +/// +/// 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. +/// +[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; + + /// Per-process shared secret — verified on the Host side against the value passed by the supervisor at spawn time. + [Key(3)] public string SharedSecret { get; set; } = string.Empty; + + [Key(4)] public string[] Features { get; set; } = System.Array.Empty(); +} + +[MessagePackObject] +public sealed class HelloAck +{ + [Key(0)] public int ProtocolMajor { get; set; } = Hello.CurrentMajor; + [Key(1)] public int ProtocolMinor { get; set; } = Hello.CurrentMinor; + + /// True if the server accepted the hello; false + filled if not. + [Key(2)] public bool Accepted { get; set; } + [Key(3)] public string? RejectReason { get; set; } + + [Key(4)] public string HostName { get; set; } = string.Empty; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs new file mode 100644 index 0000000..6f10fe4 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/History.cs @@ -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(); + [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(); +} + +[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(); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Lifecycle.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Lifecycle.cs new file mode 100644 index 0000000..1ecc6f0 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Lifecycle.cs @@ -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; + + /// JSON blob sourced from DriverInstance.DriverConfig. + [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; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Probe.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Probe.cs new file mode 100644 index 0000000..2f0a3bc --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Probe.cs @@ -0,0 +1,34 @@ +using MessagePack; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +/// Per-host runtime status — per driver-stability.md Galaxy §"Connection Health Probe". +[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 +{ + /// One of: Soft, Hard. + [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; } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Subscriptions.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Subscriptions.cs new file mode 100644 index 0000000..f655755 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/Contracts/Subscriptions.cs @@ -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(); + [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(); +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameReader.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameReader.cs new file mode 100644 index 0000000..45c476c --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameReader.cs @@ -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; + +/// +/// Reads length-prefixed, kind-tagged frames from a stream. Single-consumer — do not call +/// from multiple threads against the same instance. +/// +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(byte[] body) => MessagePackSerializer.Deserialize(body); + + private async Task 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(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameWriter.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameWriter.cs new file mode 100644 index 0000000..f0b34f9 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/FrameWriter.cs @@ -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; + +/// +/// Writes length-prefixed, kind-tagged MessagePack frames to a stream. Thread-safe via +/// — multiple producers (e.g. heartbeat + data-plane sharing a stream) +/// get serialized writes. +/// +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(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(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj new file mode 100644 index 0000000..dada37e --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj @@ -0,0 +1,23 @@ + + + + netstandard2.0 + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared + + + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/NodeBootstrap.cs b/src/ZB.MOM.WW.OtOpcUa.Server/NodeBootstrap.cs new file mode 100644 index 0000000..9faca23 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/NodeBootstrap.cs @@ -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; + +/// +/// Bootstraps a node: fetches the current generation from the central DB via +/// sp_GetCurrentGenerationForCluster. If the DB is unreachable and a LiteDB cache entry +/// exists, falls back to cached config per decision #79 (degraded-but-running). +/// +public sealed class NodeBootstrap( + NodeOptions options, + ILocalConfigCache localCache, + ILogger logger) +{ + public async Task 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); diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs b/src/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs new file mode 100644 index 0000000..0127e73 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/NodeOptions.cs @@ -0,0 +1,23 @@ +namespace ZB.MOM.WW.OtOpcUa.Server; + +/// +/// Bootstrap configuration read from appsettings.json (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. +/// +public sealed class NodeOptions +{ + public const string SectionName = "Node"; + + /// Stable node ID matching ClusterNode.NodeId in the central config DB. + public required string NodeId { get; init; } + + /// Cluster this node belongs to. + public required string ClusterId { get; init; } + + /// SQL Server connection string for the central config DB. + public required string ConfigDbConnectionString { get; init; } + + /// Path to the LiteDB local cache file. + public string LocalCachePath { get; init; } = "config_cache.db"; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs new file mode 100644 index 0000000..c4721d9 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUaServerService.cs @@ -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; + +/// +/// BackgroundService that owns the OPC UA server lifecycle (decision #30, replacing TopShelf). +/// Bootstraps config, starts the , 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. +/// +public sealed class OpcUaServerService( + NodeBootstrap bootstrap, + DriverHost driverHost, + ILogger 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(); + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs new file mode 100644 index 0000000..c6ade3d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Program.cs @@ -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("NodeId") + ?? throw new InvalidOperationException("Node:NodeId not configured"), + ClusterId = nodeSection.GetValue("ClusterId") + ?? throw new InvalidOperationException("Node:ClusterId not configured"), + ConfigDbConnectionString = nodeSection.GetValue("ConfigDbConnectionString") + ?? throw new InvalidOperationException("Node:ConfigDbConnectionString not configured"), + LocalCachePath = nodeSection.GetValue("LocalCachePath") ?? "config_cache.db", +}; + +builder.Services.AddSingleton(options); +builder.Services.AddSingleton(_ => new LiteDbConfigCache(options.LocalCachePath)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + +var host = builder.Build(); +await host.RunAsync(); diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj new file mode 100644 index 0000000..1b7791d --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/ZB.MOM.WW.OtOpcUa.Server.csproj @@ -0,0 +1,35 @@ + + + + Exe + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + ZB.MOM.WW.OtOpcUa.Server + OtOpcUa.Server + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json b/src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json new file mode 100644 index 0000000..8d6ec16 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/appsettings.json @@ -0,0 +1,11 @@ +{ + "Serilog": { + "MinimumLevel": "Information" + }, + "Node": { + "NodeId": "node-dev-a", + "ClusterId": "cluster-dev", + "ConfigDbConnectionString": "Server=localhost,14330;Database=OtOpcUaConfig;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;", + "LocalCachePath": "config_cache.db" + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/AdminRolesTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/AdminRolesTests.cs new file mode 100644 index 0000000..ea604e4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/AdminRolesTests.cs @@ -0,0 +1,18 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Admin.Services; + +namespace ZB.MOM.WW.OtOpcUa.Admin.Tests; + +[Trait("Category", "Unit")] +public sealed class AdminRolesTests +{ + [Fact] + public void All_contains_three_canonical_roles() + { + AdminRoles.All.Count.ShouldBe(3); + AdminRoles.All.ShouldContain(AdminRoles.ConfigViewer); + AdminRoles.All.ShouldContain(AdminRoles.ConfigEditor); + AdminRoles.All.ShouldContain(AdminRoles.FleetAdmin); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj new file mode 100644 index 0000000..7956b2d --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Admin.Tests/ZB.MOM.WW.OtOpcUa.Admin.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Admin.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/AuthorizationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/AuthorizationTests.cs new file mode 100644 index 0000000..e5f405e --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/AuthorizationTests.cs @@ -0,0 +1,162 @@ +using Microsoft.Data.SqlClient; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +/// +/// Creates two throwaway DB users — one in OtOpcUaNode, one in OtOpcUaAdmin — +/// and verifies the grants/denies from the AuthorizationGrants migration. +/// +[Trait("Category", "Authorization")] +[Collection(nameof(SchemaComplianceCollection))] +public sealed class AuthorizationTests +{ + private readonly SchemaComplianceFixture _fixture; + + public AuthorizationTests(SchemaComplianceFixture fixture) => _fixture = fixture; + + [Fact] + public void Node_role_can_execute_GetCurrentGenerationForCluster_but_not_PublishGeneration() + { + var (user, password) = CreateUserInRole(_fixture, "Node"); + + try + { + using var conn = OpenAs(user, password); + + Should.Throw(() => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "EXEC dbo.sp_PublishGeneration @ClusterId='x', @DraftGenerationId=1"; + cmd.ExecuteNonQuery(); + }).Message.ShouldContain("permission", Case.Insensitive); + + // Calling a granted proc authenticates; the proc itself will RAISERROR with Unauthorized + // because our test principal isn't bound to any node — that's expected. + var ex = Should.Throw(() => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId='n', @ClusterId='c'"; + cmd.ExecuteNonQuery(); + }); + ex.Message.ShouldContain("Unauthorized"); + } + finally + { + DropUser(_fixture, user); + } + } + + [Fact] + public void Node_role_cannot_SELECT_from_tables_directly() + { + var (user, password) = CreateUserInRole(_fixture, "Node"); + + try + { + using var conn = OpenAs(user, password); + var ex = Should.Throw(() => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM dbo.ConfigGeneration"; + cmd.ExecuteScalar(); + }); + ex.Message.ShouldContain("permission", Case.Insensitive); + } + finally + { + DropUser(_fixture, user); + } + } + + [Fact] + public void Admin_role_can_execute_PublishGeneration() + { + var (user, password) = CreateUserInRole(_fixture, "Admin"); + + try + { + using var conn = OpenAs(user, password); + // Calling the proc is permitted; content-level errors (missing draft) are OK — they + // prove the grant succeeded (we got past the permission check into the proc body). + var ex = Should.Throw(() => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "EXEC dbo.sp_PublishGeneration @ClusterId='no-such-cluster', @DraftGenerationId=9999"; + cmd.ExecuteNonQuery(); + }); + ex.Message.ShouldNotContain("permission", Case.Insensitive); + } + finally + { + DropUser(_fixture, user); + } + } + + /// Creates a SQL login + DB user in the given role and returns its credentials. + private static (string User, string Password) CreateUserInRole(SchemaComplianceFixture fx, string role) + { + var user = $"tst_{role.ToLower()}_{Guid.NewGuid():N}"[..24]; + const string password = "TestUser_2026!"; + var dbRole = role == "Node" ? "OtOpcUaNode" : "OtOpcUaAdmin"; + + // Create the login in master, the user in the test DB, and add it to the role. + using (var conn = new SqlConnection( + new SqlConnectionStringBuilder(fx.ConnectionString) { InitialCatalog = "master" }.ConnectionString)) + { + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"CREATE LOGIN [{user}] WITH PASSWORD = '{password}', CHECK_POLICY = OFF;"; + cmd.ExecuteNonQuery(); + } + + using (var conn = fx.OpenConnection()) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" +CREATE USER [{user}] FOR LOGIN [{user}]; +ALTER ROLE {dbRole} ADD MEMBER [{user}];"; + cmd.ExecuteNonQuery(); + } + + return (user, password); + } + + private static void DropUser(SchemaComplianceFixture fx, string user) + { + try + { + using var dbConn = fx.OpenConnection(); + using var cmd1 = dbConn.CreateCommand(); + cmd1.CommandText = $"IF DATABASE_PRINCIPAL_ID('{user}') IS NOT NULL DROP USER [{user}];"; + cmd1.ExecuteNonQuery(); + } + catch { /* swallow — fixture disposes the DB anyway */ } + + try + { + using var master = new SqlConnection( + new SqlConnectionStringBuilder(fx.ConnectionString) { InitialCatalog = "master" }.ConnectionString); + master.Open(); + using var cmd = master.CreateCommand(); + cmd.CommandText = $"IF SUSER_ID('{user}') IS NOT NULL DROP LOGIN [{user}];"; + cmd.ExecuteNonQuery(); + } + catch { /* ignore */ } + } + + private SqlConnection OpenAs(string user, string password) + { + var cs = new SqlConnectionStringBuilder(_fixture.ConnectionString) + { + UserID = user, + Password = password, + IntegratedSecurity = false, + }.ConnectionString; + + var conn = new SqlConnection(cs); + conn.Open(); + return conn; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs new file mode 100644 index 0000000..64a7fc4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorTests.cs @@ -0,0 +1,148 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +[Trait("Category", "Unit")] +public sealed class DraftValidatorTests +{ + [Theory] + [InlineData("valid-name", true)] + [InlineData("line-01", true)] + [InlineData("_default", true)] + [InlineData("UPPER", false)] + [InlineData("with space", false)] + [InlineData("", false)] + public void UnsSegment_rule_accepts_lowercase_or_default_only(string name, bool shouldPass) + { + var uuid = Guid.NewGuid(); + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + Equipment = + [ + new Equipment + { + EquipmentUuid = uuid, + EquipmentId = DraftValidator.DeriveEquipmentId(uuid), + Name = name, + DriverInstanceId = "d", + UnsLineId = "line-a", + MachineCode = "m", + }, + ], + }; + + var errors = DraftValidator.Validate(draft); + var hasUnsError = errors.Any(e => e.Code == "UnsSegmentInvalid"); + hasUnsError.ShouldBe(!shouldPass); + } + + [Fact] + public void Cross_cluster_namespace_binding_is_rejected() + { + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c-A", + Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }], + DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }], + }; + + var errors = DraftValidator.Validate(draft); + errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding"); + } + + [Fact] + public void Same_cluster_namespace_binding_is_accepted() + { + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c-A", + Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-A", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }], + DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "ModbusTcp", DriverConfig = "{}" }], + }; + + DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "BadCrossClusterNamespaceBinding"); + } + + [Fact] + public void EquipmentUuid_change_across_generations_is_rejected() + { + var oldUuid = Guid.Parse("11111111-1111-1111-1111-111111111111"); + var newUuid = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var eid = DraftValidator.DeriveEquipmentId(oldUuid); + + var draft = new DraftSnapshot + { + GenerationId = 2, ClusterId = "c", + Equipment = [new Equipment { EquipmentUuid = newUuid, EquipmentId = eid, Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }], + PriorEquipment = [new Equipment { EquipmentUuid = oldUuid, EquipmentId = eid, Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }], + }; + + DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentUuidImmutable"); + } + + [Fact] + public void ZTag_reserved_by_different_uuid_is_rejected() + { + var uuid = Guid.NewGuid(); + var otherUuid = Guid.NewGuid(); + + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m", ZTag = "ZT-001" }], + ActiveReservations = [new ExternalIdReservation { Kind = ReservationKind.ZTag, Value = "ZT-001", EquipmentUuid = otherUuid, ClusterId = "c", FirstPublishedBy = "t" }], + }; + + DraftValidator.Validate(draft).ShouldContain(e => e.Code == "BadDuplicateExternalIdentifier"); + } + + [Fact] + public void EquipmentId_that_does_not_match_derivation_is_rejected() + { + var uuid = Guid.NewGuid(); + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-operator-typed", Name = "eq", DriverInstanceId = "d", UnsLineId = "line-a", MachineCode = "m" }], + }; + + DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived"); + } + + [Fact] + public void Galaxy_driver_in_Equipment_namespace_is_rejected() + { + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c", + Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }], + DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c", NamespaceId = "ns-1", Name = "drv", DriverType = "Galaxy", DriverConfig = "{}" }], + }; + + DraftValidator.Validate(draft).ShouldContain(e => e.Code == "DriverNamespaceKindMismatch"); + } + + [Fact] + public void Draft_with_three_violations_surfaces_all_three() + { + var uuid = Guid.NewGuid(); + var draft = new DraftSnapshot + { + GenerationId = 1, ClusterId = "c-A", + Namespaces = [new Namespace { NamespaceId = "ns-1", ClusterId = "c-B", NamespaceUri = "urn:x", Kind = NamespaceKind.Equipment }], + DriverInstances = [new DriverInstance { DriverInstanceId = "d-1", ClusterId = "c-A", NamespaceId = "ns-1", Name = "drv", DriverType = "Galaxy", DriverConfig = "{}" }], + Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-wrong", Name = "BAD NAME", DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "m" }], + }; + + var errors = DraftValidator.Validate(draft); + errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding"); + errors.ShouldContain(e => e.Code == "DriverNamespaceKindMismatch"); + errors.ShouldContain(e => e.Code == "EquipmentIdNotDerived"); + errors.ShouldContain(e => e.Code == "UnsSegmentInvalid"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationApplierTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationApplierTests.cs new file mode 100644 index 0000000..b5f00a9 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/GenerationApplierTests.cs @@ -0,0 +1,131 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Apply; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Configuration.Validation; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +[Trait("Category", "Unit")] +public sealed class GenerationApplierTests +{ + private static DraftSnapshot SnapshotWith( + IReadOnlyList? drivers = null, + IReadOnlyList? equipment = null, + IReadOnlyList? tags = null) => new() + { + GenerationId = 1, ClusterId = "c", + DriverInstances = drivers ?? [], + Equipment = equipment ?? [], + Tags = tags ?? [], + }; + + private static DriverInstance Driver(string id) => + new() { DriverInstanceId = id, ClusterId = "c", NamespaceId = "ns", Name = id, DriverType = "ModbusTcp", DriverConfig = "{}" }; + + private static Equipment Eq(string id, Guid uuid) => + new() { EquipmentUuid = uuid, EquipmentId = id, DriverInstanceId = "d", UnsLineId = "line-a", Name = id, MachineCode = id }; + + private static Tag Tag(string id, string name) => + new() { TagId = id, DriverInstanceId = "d", Name = name, FolderPath = "/a", DataType = "Int32", AccessLevel = TagAccessLevel.Read, TagConfig = "{}" }; + + [Fact] + public void Diff_from_empty_to_one_driver_five_equipment_fifty_tags_is_all_Added() + { + var uuid = (int i) => Guid.Parse($"00000000-0000-0000-0000-{i:000000000000}"); + var equipment = Enumerable.Range(1, 5).Select(i => Eq($"eq-{i}", uuid(i))).ToList(); + var tags = Enumerable.Range(1, 50).Select(i => Tag($"tag-{i}", $"T{i}")).ToList(); + + var diff = GenerationDiffer.Compute(from: null, + to: SnapshotWith(drivers: [Driver("d-1")], equipment: equipment, tags: tags)); + + diff.Drivers.Count.ShouldBe(1); + diff.Drivers.ShouldAllBe(c => c.Kind == ChangeKind.Added); + diff.Equipment.Count.ShouldBe(5); + diff.Equipment.ShouldAllBe(c => c.Kind == ChangeKind.Added); + diff.Tags.Count.ShouldBe(50); + diff.Tags.ShouldAllBe(c => c.Kind == ChangeKind.Added); + } + + [Fact] + public void Diff_flags_single_tag_name_change_as_Modified_only_for_that_tag() + { + var before = SnapshotWith(tags: [Tag("tag-1", "Old"), Tag("tag-2", "Keep")]); + var after = SnapshotWith(tags: [Tag("tag-1", "New"), Tag("tag-2", "Keep")]); + + var diff = GenerationDiffer.Compute(before, after); + + diff.Tags.Count.ShouldBe(1); + diff.Tags[0].Kind.ShouldBe(ChangeKind.Modified); + diff.Tags[0].LogicalId.ShouldBe("tag-1"); + } + + [Fact] + public void Diff_flags_Removed_equipment_and_its_tags() + { + var uuid1 = Guid.NewGuid(); + var before = SnapshotWith( + equipment: [Eq("eq-1", uuid1), Eq("eq-2", Guid.NewGuid())], + tags: [Tag("tag-1", "A"), Tag("tag-2", "B")]); + var after = SnapshotWith( + equipment: [Eq("eq-2", before.Equipment[1].EquipmentUuid)], + tags: [Tag("tag-2", "B")]); + + var diff = GenerationDiffer.Compute(before, after); + + diff.Equipment.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "eq-1"); + diff.Tags.ShouldContain(c => c.Kind == ChangeKind.Removed && c.LogicalId == "tag-1"); + } + + [Fact] + public async Task Apply_dispatches_callbacks_in_dependency_order_and_survives_idempotent_retry() + { + var callLog = new List(); + var applier = new GenerationApplier(new ApplyCallbacks + { + OnDriver = (c, _) => { callLog.Add($"drv:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; }, + OnEquipment = (c, _) => { callLog.Add($"eq:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; }, + OnTag = (c, _) => { callLog.Add($"tag:{c.Kind}:{c.LogicalId}"); return Task.CompletedTask; }, + }); + + var to = SnapshotWith( + drivers: [Driver("d-1")], + equipment: [Eq("eq-1", Guid.NewGuid())], + tags: [Tag("tag-1", "A")]); + + var result1 = await applier.ApplyAsync(from: null, to, CancellationToken.None); + result1.Succeeded.ShouldBeTrue(); + + // Driver Added must come before Equipment Added must come before Tag Added + var drvIdx = callLog.FindIndex(s => s.StartsWith("drv:Added")); + var eqIdx = callLog.FindIndex(s => s.StartsWith("eq:Added")); + var tagIdx = callLog.FindIndex(s => s.StartsWith("tag:Added")); + drvIdx.ShouldBeLessThan(eqIdx); + eqIdx.ShouldBeLessThan(tagIdx); + + // Idempotent retry: re-applying the same diff must not blow up + var countBefore = callLog.Count; + var result2 = await applier.ApplyAsync(from: null, to, CancellationToken.None); + result2.Succeeded.ShouldBeTrue(); + callLog.Count.ShouldBe(countBefore * 2); + } + + [Fact] + public async Task Apply_collects_errors_from_failing_callback_without_aborting() + { + var applier = new GenerationApplier(new ApplyCallbacks + { + OnTag = (c, _) => + c.LogicalId == "tag-bad" + ? throw new InvalidOperationException("simulated") + : Task.CompletedTask, + }); + + var to = SnapshotWith(tags: [Tag("tag-ok", "A"), Tag("tag-bad", "B")]); + var result = await applier.ApplyAsync(from: null, to, CancellationToken.None); + + result.Succeeded.ShouldBeFalse(); + result.Errors.ShouldContain(e => e.Contains("tag-bad") && e.Contains("simulated")); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LiteDbConfigCacheTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LiteDbConfigCacheTests.cs new file mode 100644 index 0000000..3316656 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/LiteDbConfigCacheTests.cs @@ -0,0 +1,107 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +[Trait("Category", "Unit")] +public sealed class LiteDbConfigCacheTests : IDisposable +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"otopcua-cache-test-{Guid.NewGuid():N}.db"); + + public void Dispose() + { + if (File.Exists(_dbPath)) File.Delete(_dbPath); + } + + private GenerationSnapshot Snapshot(string cluster, long gen) => new() + { + ClusterId = cluster, + GenerationId = gen, + CachedAt = DateTime.UtcNow, + PayloadJson = $"{{\"g\":{gen}}}", + }; + + [Fact] + public async Task Roundtrip_preserves_payload() + { + using var cache = new LiteDbConfigCache(_dbPath); + var put = Snapshot("c-1", 42); + await cache.PutAsync(put); + + var got = await cache.GetMostRecentAsync("c-1"); + got.ShouldNotBeNull(); + got!.GenerationId.ShouldBe(42); + got.PayloadJson.ShouldBe(put.PayloadJson); + } + + [Fact] + public async Task GetMostRecent_returns_latest_when_multiple_generations_present() + { + using var cache = new LiteDbConfigCache(_dbPath); + foreach (var g in new long[] { 10, 20, 15 }) + await cache.PutAsync(Snapshot("c-1", g)); + + var got = await cache.GetMostRecentAsync("c-1"); + got!.GenerationId.ShouldBe(20); + } + + [Fact] + public async Task GetMostRecent_returns_null_for_unknown_cluster() + { + using var cache = new LiteDbConfigCache(_dbPath); + (await cache.GetMostRecentAsync("ghost")).ShouldBeNull(); + } + + [Fact] + public async Task Prune_keeps_latest_N_and_drops_older() + { + using var cache = new LiteDbConfigCache(_dbPath); + for (long g = 1; g <= 15; g++) + await cache.PutAsync(Snapshot("c-1", g)); + + await cache.PruneOldGenerationsAsync("c-1", keepLatest: 10); + + (await cache.GetMostRecentAsync("c-1"))!.GenerationId.ShouldBe(15); + + // Drop them one by one and count — should be exactly 10 remaining + var count = 0; + while (await cache.GetMostRecentAsync("c-1") is not null) + { + count++; + await cache.PruneOldGenerationsAsync("c-1", keepLatest: Math.Max(0, 10 - count)); + if (count > 20) break; // safety + } + count.ShouldBe(10); + } + + [Fact] + public async Task Put_same_cluster_generation_twice_replaces_not_duplicates() + { + using var cache = new LiteDbConfigCache(_dbPath); + var first = Snapshot("c-1", 1); + first.PayloadJson = "{\"v\":1}"; + await cache.PutAsync(first); + + var second = Snapshot("c-1", 1); + second.PayloadJson = "{\"v\":2}"; + await cache.PutAsync(second); + + (await cache.GetMostRecentAsync("c-1"))!.PayloadJson.ShouldBe("{\"v\":2}"); + } + + [Fact] + public void Corrupt_file_surfaces_as_LocalConfigCacheCorruptException() + { + // Write a file large enough to look like a LiteDB page but with garbage contents so page + // deserialization fails on the first read probe. + File.WriteAllBytes(_dbPath, new byte[8192]); + Array.Fill(File.ReadAllBytes(_dbPath), 0xAB); + using (var fs = File.OpenWrite(_dbPath)) + { + fs.Write(new byte[8192].Select(_ => (byte)0xAB).ToArray()); + } + + Should.Throw(() => new LiteDbConfigCache(_dbPath)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs new file mode 100644 index 0000000..2897431 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceFixture.cs @@ -0,0 +1,68 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +/// +/// Spins up a dedicated test database, applies the EF migrations against it, and exposes a +/// factory. Disposed at collection teardown (drops the DB). +/// Gated by the OTOPCUA_CONFIG_TEST_SERVER env var so CI runs can opt in explicitly; +/// local runs default to the dev container on localhost:14330. +/// +public sealed class SchemaComplianceFixture : IDisposable +{ + private const string DefaultServer = "localhost,14330"; + private const string DefaultSaPassword = "OtOpcUaDev_2026!"; + + public string DatabaseName { get; } + public string ConnectionString { get; } + + public SchemaComplianceFixture() + { + var server = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SERVER") ?? DefaultServer; + var saPassword = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_TEST_SA_PASSWORD") ?? DefaultSaPassword; + + DatabaseName = $"OtOpcUaConfig_Test_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}"; + ConnectionString = + $"Server={server};Database={DatabaseName};User Id=sa;Password={saPassword};TrustServerCertificate=True;Encrypt=False;"; + + var options = new DbContextOptionsBuilder() + .UseSqlServer(ConnectionString) + .Options; + + using var ctx = new OtOpcUaConfigDbContext(options); + ctx.Database.Migrate(); + } + + public SqlConnection OpenConnection() + { + var conn = new SqlConnection(ConnectionString); + conn.Open(); + return conn; + } + + public void Dispose() + { + var masterConnection = + new SqlConnectionStringBuilder(ConnectionString) { InitialCatalog = "master" }.ConnectionString; + + using var conn = new SqlConnection(masterConnection); + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = $@" +IF DB_ID(N'{DatabaseName}') IS NOT NULL +BEGIN + ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [{DatabaseName}]; +END"; + cmd.ExecuteNonQuery(); + } +} + +[CollectionDefinition(nameof(SchemaComplianceCollection))] +public sealed class SchemaComplianceCollection : ICollectionFixture +{ +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs new file mode 100644 index 0000000..2a792d4 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/SchemaComplianceTests.cs @@ -0,0 +1,172 @@ +using Microsoft.Data.SqlClient; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +/// +/// Introspects the applied schema via sys.* / INFORMATION_SCHEMA.* to confirm that +/// the Fluent-API DbContext produces the exact structure specified in +/// docs/v2/config-db-schema.md. Any change here is a deliberate decision — update the +/// schema doc first, then these tests. +/// +[Trait("Category", "SchemaCompliance")] +[Collection(nameof(SchemaComplianceCollection))] +public sealed class SchemaComplianceTests +{ + private readonly SchemaComplianceFixture _fixture; + + public SchemaComplianceTests(SchemaComplianceFixture fixture) => _fixture = fixture; + + [Fact] + public void All_expected_tables_exist() + { + var expected = new[] + { + "ServerCluster", "ClusterNode", "ClusterNodeCredential", "ClusterNodeGenerationState", + "ConfigGeneration", "ConfigAuditLog", + "Namespace", "UnsArea", "UnsLine", + "DriverInstance", "Device", "Equipment", "Tag", "PollGroup", + "NodeAcl", "ExternalIdReservation", + }; + + var actual = QueryStrings(@" +SELECT name FROM sys.tables WHERE name <> '__EFMigrationsHistory' ORDER BY name;").ToHashSet(); + + foreach (var table in expected) + actual.ShouldContain(table, $"missing table: {table}"); + + actual.Count.ShouldBe(expected.Length); + } + + [Fact] + public void Filtered_unique_indexes_match_schema_spec() + { + // (IndexName, Filter, Uniqueness) tuples — from OtOpcUaConfigDbContext Fluent config. + // Kept here as a spec-level source of truth; the test ensures EF generated them verbatim. + var expected = new[] + { + ("UX_ClusterNode_Primary_Per_Cluster", "([RedundancyRole]='Primary')"), + ("UX_ClusterNodeCredential_Value", "([Enabled]=(1))"), + ("UX_ConfigGeneration_Draft_Per_Cluster", "([Status]='Draft')"), + ("UX_ExternalIdReservation_KindValue_Active", "([ReleasedAt] IS NULL)"), + }; + + var rows = QueryRows(@" +SELECT i.name AS IndexName, i.filter_definition +FROM sys.indexes i +WHERE i.is_unique = 1 AND i.has_filter = 1;", + r => (Name: r.GetString(0), Filter: r.IsDBNull(1) ? null : r.GetString(1))); + + foreach (var (name, filter) in expected) + { + var match = rows.FirstOrDefault(x => x.Name == name); + match.Name.ShouldBe(name, $"missing filtered unique index: {name}"); + NormalizeFilter(match.Filter).ShouldBe(NormalizeFilter(filter), + $"filter predicate for {name} drifted"); + } + } + + [Fact] + public void Check_constraints_match_schema_spec() + { + var expected = new[] + { + "CK_ServerCluster_RedundancyMode_NodeCount", + "CK_Device_DeviceConfig_IsJson", + "CK_DriverInstance_DriverConfig_IsJson", + "CK_PollGroup_IntervalMs_Min", + "CK_Tag_TagConfig_IsJson", + "CK_ConfigAuditLog_DetailsJson_IsJson", + }; + + var actual = QueryStrings("SELECT name FROM sys.check_constraints ORDER BY name;").ToHashSet(); + + foreach (var ck in expected) + actual.ShouldContain(ck, $"missing CHECK constraint: {ck}"); + } + + [Fact] + public void Json_check_constraints_use_IsJson_function() + { + var rows = QueryRows(@" +SELECT cc.name, cc.definition +FROM sys.check_constraints cc +WHERE cc.name LIKE 'CK_%_IsJson';", + r => (Name: r.GetString(0), Definition: r.GetString(1))); + + rows.Count.ShouldBeGreaterThanOrEqualTo(4); + + foreach (var (name, definition) in rows) + definition.ShouldContain("isjson(", Case.Insensitive, + $"{name} definition does not call ISJSON: {definition}"); + } + + [Fact] + public void ConfigGeneration_Status_uses_nvarchar_enum_storage() + { + var rows = QueryRows(@" +SELECT c.COLUMN_NAME, c.DATA_TYPE, c.CHARACTER_MAXIMUM_LENGTH +FROM INFORMATION_SCHEMA.COLUMNS c +WHERE c.TABLE_NAME = 'ConfigGeneration' AND c.COLUMN_NAME = 'Status';", + r => (Column: r.GetString(0), Type: r.GetString(1), Length: r.IsDBNull(2) ? (int?)null : r.GetInt32(2))); + + rows.Count.ShouldBe(1); + rows[0].Type.ShouldBe("nvarchar"); + rows[0].Length.ShouldNotBeNull(); + } + + [Fact] + public void Equipment_carries_Opc40010_identity_fields() + { + var columns = QueryStrings(@" +SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Equipment';") + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var col in new[] + { + "EquipmentUuid", "EquipmentId", "MachineCode", "ZTag", "SAPID", + "Manufacturer", "Model", "SerialNumber", + }) + columns.ShouldContain(col, $"Equipment missing expected column: {col}"); + } + + [Fact] + public void Namespace_has_same_cluster_invariant_index() + { + // Decision #122: namespace logical IDs unique within a cluster + generation. The composite + // unique index enforces that trust boundary. + var indexes = QueryStrings(@" +SELECT i.name +FROM sys.indexes i +JOIN sys.tables t ON i.object_id = t.object_id +WHERE t.name = 'Namespace' AND i.is_unique = 1;").ToList(); + + indexes.ShouldContain("UX_Namespace_Generation_LogicalId_Cluster"); + } + + private List QueryStrings(string sql) + { + using var conn = _fixture.OpenConnection(); + using var cmd = new SqlCommand(sql, conn); + using var reader = cmd.ExecuteReader(); + var result = new List(); + while (reader.Read()) + result.Add(reader.GetString(0)); + return result; + } + + private List QueryRows(string sql, Func project) + { + using var conn = _fixture.OpenConnection(); + using var cmd = new SqlCommand(sql, conn); + using var reader = cmd.ExecuteReader(); + var result = new List(); + while (reader.Read()) + result.Add(project(reader)); + return result; + } + + private static string? NormalizeFilter(string? filter) => + filter?.Replace(" ", string.Empty).Replace("(", string.Empty).Replace(")", string.Empty).ToLowerInvariant(); +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/StoredProceduresTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/StoredProceduresTests.cs new file mode 100644 index 0000000..3942088 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/StoredProceduresTests.cs @@ -0,0 +1,222 @@ +using Microsoft.Data.SqlClient; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests; + +/// +/// Happy-path + representative error-path coverage per Task B.2 acceptance. Each test seeds its +/// own cluster + node + credential, creates a draft, exercises one proc, then cleans up at the +/// fixture level (the fixture drops the DB in Dispose). +/// +[Trait("Category", "StoredProcedures")] +[Collection(nameof(SchemaComplianceCollection))] +public sealed class StoredProceduresTests +{ + private readonly SchemaComplianceFixture _fixture; + + public StoredProceduresTests(SchemaComplianceFixture fixture) => _fixture = fixture; + + [Fact] + public void Publish_then_GetCurrent_returns_the_published_generation() + { + using var conn = _fixture.OpenConnection(); + var (clusterId, nodeId, _, draftId) = SeedClusterWithDraft(conn, suffix: "pub1"); + + Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g", + ("c", clusterId), ("g", draftId)); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c"; + cmd.Parameters.AddWithValue("n", nodeId); + cmd.Parameters.AddWithValue("c", clusterId); + using var r = cmd.ExecuteReader(); + r.Read().ShouldBeTrue("proc should return exactly one row"); + r.GetInt64(0).ShouldBe(draftId); + r.GetString(2).ShouldBe("Published"); + } + + [Fact] + public void GetCurrent_rejects_caller_not_bound_to_node() + { + using var conn = _fixture.OpenConnection(); + var (clusterId, _, _, _) = SeedClusterWithDraft(conn, suffix: "unauth"); + + var ex = Should.Throw(() => + Exec(conn, "EXEC dbo.sp_GetCurrentGenerationForCluster @NodeId=@n, @ClusterId=@c", + ("n", "ghost-node"), ("c", clusterId))); + ex.Message.ShouldContain("Unauthorized"); + } + + [Fact] + public void Publish_second_draft_supersedes_first() + { + using var conn = _fixture.OpenConnection(); + var (clusterId, _, _, draft1) = SeedClusterWithDraft(conn, suffix: "sup"); + Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g", + ("c", clusterId), ("g", draft1)); + + var draft2 = CreateDraft(conn, clusterId); + Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g", + ("c", clusterId), ("g", draft2)); + + var status1 = Scalar(conn, + "SELECT Status FROM dbo.ConfigGeneration WHERE GenerationId = @g", ("g", draft1)); + var status2 = Scalar(conn, + "SELECT Status FROM dbo.ConfigGeneration WHERE GenerationId = @g", ("g", draft2)); + status1.ShouldBe("Superseded"); + status2.ShouldBe("Published"); + } + + [Fact] + public void Publish_rejects_non_draft_generation() + { + using var conn = _fixture.OpenConnection(); + var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "twice"); + Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g", + ("c", clusterId), ("g", draftId)); + + var ex = Should.Throw(() => + Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g", + ("c", clusterId), ("g", draftId))); + ex.Message.ShouldContain("not in Draft"); + } + + [Fact] + public void ValidateDraft_rejects_orphan_tag() + { + using var conn = _fixture.OpenConnection(); + var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "orphan"); + Exec(conn, @"INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig) + VALUES (@g, 'tag-1', 'missing-driver', 'X', 'Int32', 'Read', 0, '{}')", + ("g", draftId)); + + var ex = Should.Throw(() => + Exec(conn, "EXEC dbo.sp_ValidateDraft @DraftGenerationId=@g", ("g", draftId))); + ex.Message.ShouldContain("unresolved DriverInstanceId"); + } + + [Fact] + public void Rollback_creates_new_published_generation_and_clones_rows() + { + using var conn = _fixture.OpenConnection(); + var (clusterId, _, _, draftId) = SeedClusterWithDraft(conn, suffix: "rb"); + SeedMinimalDriverRow(conn, draftId, clusterId, driverInstanceId: "drv-a"); + Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g", + ("c", clusterId), ("g", draftId)); + + Exec(conn, "EXEC dbo.sp_RollbackToGeneration @ClusterId=@c, @TargetGenerationId=@g, @Notes='test'", + ("c", clusterId), ("g", draftId)); + + var newlyPublishedCount = Scalar(conn, + @"SELECT COUNT(*) FROM dbo.ConfigGeneration + WHERE ClusterId = @c AND Status = 'Published' AND GenerationId <> @g", + ("c", clusterId), ("g", draftId)); + newlyPublishedCount.ShouldBe(1); + + var driverClonedCount = Scalar(conn, + @"SELECT COUNT(*) FROM dbo.DriverInstance di + JOIN dbo.ConfigGeneration cg ON cg.GenerationId = di.GenerationId + WHERE cg.ClusterId = @c AND cg.Status = 'Published' AND di.DriverInstanceId = 'drv-a'", + ("c", clusterId)); + driverClonedCount.ShouldBe(1); + } + + [Fact] + public void ComputeDiff_returns_Added_for_driver_present_only_in_target() + { + using var conn = _fixture.OpenConnection(); + var (clusterId, _, _, draft1) = SeedClusterWithDraft(conn, suffix: "diff"); + Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g", + ("c", clusterId), ("g", draft1)); + + var draft2 = CreateDraft(conn, clusterId); + SeedMinimalDriverRow(conn, draft2, clusterId, driverInstanceId: "drv-added"); + Exec(conn, "EXEC dbo.sp_PublishGeneration @ClusterId=@c, @DraftGenerationId=@g", + ("c", clusterId), ("g", draft2)); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = "EXEC dbo.sp_ComputeGenerationDiff @FromGenerationId=@f, @ToGenerationId=@t"; + cmd.Parameters.AddWithValue("f", draft1); + cmd.Parameters.AddWithValue("t", draft2); + using var r = cmd.ExecuteReader(); + var diffs = new List<(string Table, string Id, string Kind)>(); + while (r.Read()) + diffs.Add((r.GetString(0), r.GetString(1), r.GetString(2))); + + diffs.ShouldContain(d => d.Table == "DriverInstance" && d.Id == "drv-added" && d.Kind == "Added"); + } + + [Fact] + public void ReleaseReservation_requires_nonempty_reason() + { + using var conn = _fixture.OpenConnection(); + var ex = Should.Throw(() => + Exec(conn, "EXEC dbo.sp_ReleaseExternalIdReservation @Kind='ZTag', @Value='X', @ReleaseReason=''")); + ex.Message.ShouldContain("ReleaseReason is required"); + } + + // ---- helpers ---- + + /// Creates a cluster, one node, one credential bound to the current SUSER_SNAME(), and an empty Draft. + private static (string ClusterId, string NodeId, string Credential, long DraftId) + SeedClusterWithDraft(SqlConnection conn, string suffix) + { + var clusterId = $"cluster-{suffix}"; + var nodeId = $"node-{suffix}-a"; + + // Every test uses the same SUSER_SNAME() ('sa' by default), and the credential unique index + // is filtered on Enabled=1 across (Kind, Value) globally. To avoid collisions across tests + // sharing one DB, we disable old credentials first. + Exec(conn, "UPDATE dbo.ClusterNodeCredential SET Enabled = 0 WHERE Value = SUSER_SNAME();"); + + Exec(conn, + @"INSERT dbo.ServerCluster (ClusterId, Name, Enterprise, Site, RedundancyMode, NodeCount, Enabled, CreatedBy) + VALUES (@c, @c, 'zb', @s, 'None', 1, 1, SUSER_SNAME()); + INSERT dbo.ClusterNode (NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort, ApplicationUri, ServiceLevelBase, Enabled, CreatedBy) + VALUES (@n, @c, 'Primary', 'localhost', 4840, 5001, CONCAT('urn:localhost:', @s), 200, 1, SUSER_SNAME()); + INSERT dbo.ClusterNodeCredential (NodeId, Kind, Value, Enabled, CreatedBy) + VALUES (@n, 'SqlLogin', SUSER_SNAME(), 1, SUSER_SNAME());", + ("c", clusterId), ("n", nodeId), ("s", suffix)); + + var draftId = CreateDraft(conn, clusterId); + return (clusterId, nodeId, "sa", draftId); + } + + private static long CreateDraft(SqlConnection conn, string clusterId) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @" +INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy) +VALUES (@c, 'Draft', SYSUTCDATETIME(), SUSER_SNAME()); +SELECT CAST(SCOPE_IDENTITY() AS bigint);"; + cmd.Parameters.AddWithValue("c", clusterId); + return (long)cmd.ExecuteScalar()!; + } + + private static void SeedMinimalDriverRow(SqlConnection conn, long genId, string clusterId, string driverInstanceId) + { + Exec(conn, + @"INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled) + VALUES (@g, @ns, @c, 'Equipment', 'urn:ns', 1); + INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig) + VALUES (@g, @drv, @c, @ns, 'drv', 'ModbusTcp', 1, '{}');", + ("g", genId), ("c", clusterId), ("ns", $"ns-{driverInstanceId}"), ("drv", driverInstanceId)); + } + + private static void Exec(SqlConnection conn, string sql, params (string Name, object Value)[] parameters) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value); + cmd.ExecuteNonQuery(); + } + + private static T Scalar(SqlConnection conn, string sql, params (string Name, object Value)[] parameters) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + foreach (var (name, value) in parameters) cmd.Parameters.AddWithValue(name, value); + return (T)cmd.ExecuteScalar()!; + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj new file mode 100644 index 0000000..79b7a2b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests/ZB.MOM.WW.OtOpcUa.Configuration.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Configuration.Tests + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs new file mode 100644 index 0000000..8118be3 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/DriverHostTests.cs @@ -0,0 +1,80 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; + +namespace ZB.MOM.WW.OtOpcUa.Core.Tests; + +[Trait("Category", "Unit")] +public sealed class DriverHostTests +{ + private sealed class StubDriver(string id, bool failInit = false) : IDriver + { + public string DriverInstanceId { get; } = id; + public string DriverType => "Stub"; + public bool Initialized { get; private set; } + public bool ShutDown { get; private set; } + + public Task InitializeAsync(string _, CancellationToken ct) + { + if (failInit) throw new InvalidOperationException("boom"); + Initialized = true; + return Task.CompletedTask; + } + + public Task ReinitializeAsync(string _, CancellationToken ct) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken ct) { ShutDown = true; return Task.CompletedTask; } + public DriverHealth GetHealth() => + new(Initialized ? DriverState.Healthy : DriverState.Unknown, null, null); + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; + } + + [Fact] + public async Task Register_initializes_driver_and_tracks_health() + { + await using var host = new DriverHost(); + var driver = new StubDriver("d-1"); + + await host.RegisterAsync(driver, "{}", CancellationToken.None); + + host.RegisteredDriverIds.ShouldContain("d-1"); + driver.Initialized.ShouldBeTrue(); + host.GetHealth("d-1")!.State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task Register_rethrows_init_failure_but_keeps_driver_registered() + { + await using var host = new DriverHost(); + var driver = new StubDriver("d-bad", failInit: true); + + await Should.ThrowAsync(() => + host.RegisterAsync(driver, "{}", CancellationToken.None)); + + host.RegisteredDriverIds.ShouldContain("d-bad"); + } + + [Fact] + public async Task Duplicate_registration_throws() + { + await using var host = new DriverHost(); + await host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None); + + await Should.ThrowAsync(() => + host.RegisterAsync(new StubDriver("d-1"), "{}", CancellationToken.None)); + } + + [Fact] + public async Task Unregister_shuts_down_and_removes() + { + await using var host = new DriverHost(); + var driver = new StubDriver("d-1"); + await host.RegisterAsync(driver, "{}", CancellationToken.None); + + await host.UnregisterAsync("d-1", CancellationToken.None); + + host.RegisteredDriverIds.ShouldNotContain("d-1"); + driver.ShutDown.ShouldBeTrue(); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj new file mode 100644 index 0000000..e8f5b87 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Core.Tests/ZB.MOM.WW.OtOpcUa.Core.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Core.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MemoryWatchdogTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MemoryWatchdogTests.cs new file mode 100644 index 0000000..faaa094 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/MemoryWatchdogTests.cs @@ -0,0 +1,64 @@ +using System; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; + +[Trait("Category", "Unit")] +public sealed class MemoryWatchdogTests +{ + private const long Mb = 1024 * 1024; + + [Fact] + public void Baseline_sample_returns_None() + { + var w = new MemoryWatchdog(baselineBytes: 300 * Mb); + w.Sample(320 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.None); + } + + [Fact] + public void Warn_threshold_uses_larger_of_1_5x_or_plus_200MB() + { + // Baseline 300 → warn threshold = max(450, 500) = 500 MB + var w = new MemoryWatchdog(baselineBytes: 300 * Mb); + w.Sample(499 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.None); + w.Sample(500 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.Warn); + } + + [Fact] + public void Soft_recycle_triggers_at_2x_or_plus_200MB_whichever_larger() + { + // Baseline 400 → soft = max(800, 600) = 800 MB + var w = new MemoryWatchdog(baselineBytes: 400 * Mb); + w.Sample(799 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.Warn); + w.Sample(800 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.SoftRecycle); + } + + [Fact] + public void Hard_kill_triggers_at_absolute_ceiling() + { + var w = new MemoryWatchdog(baselineBytes: 1000 * Mb); + w.Sample(1501 * Mb, DateTime.UtcNow).ShouldBe(WatchdogAction.HardKill); + } + + [Fact] + public void Sustained_slope_triggers_soft_recycle_before_absolute_threshold() + { + // Baseline 1000 MB → warn = 1200, soft = 2000 (absolute). Slope 6 MB/min over 30 min = 180 MB + // delta — still well below the absolute soft threshold; slope detector must fire on its own. + var w = new MemoryWatchdog(baselineBytes: 1000 * Mb) { SustainedSlopeBytesPerMinute = 5 * Mb }; + var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); + + long rss = 1050 * Mb; + var slopeFired = false; + for (var i = 0; i <= 35; i++) + { + var action = w.Sample(rss, t0.AddMinutes(i)); + if (action == WatchdogAction.SoftRecycle) { slopeFired = true; break; } + rss += 6 * Mb; + } + + slopeFired.ShouldBeTrue("slope detector should fire once the 30-min window fills"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/PostMortemMmfTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/PostMortemMmfTests.cs new file mode 100644 index 0000000..aa3aa34 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/PostMortemMmfTests.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; + +[Trait("Category", "Unit")] +public sealed class PostMortemMmfTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"mmf-test-{Guid.NewGuid():N}.bin"); + + public void Dispose() + { + if (File.Exists(_path)) File.Delete(_path); + } + + [Fact] + public void Write_then_read_round_trips_entries_in_oldest_first_order() + { + using (var mmf = new PostMortemMmf(_path, capacity: 10)) + { + mmf.Write(0x30, "read tag-1"); + mmf.Write(0x30, "read tag-2"); + mmf.Write(0x32, "write tag-3"); + } + + using var reopen = new PostMortemMmf(_path, capacity: 10); + var entries = reopen.ReadAll(); + entries.Length.ShouldBe(3); + entries[0].Message.ShouldBe("read tag-1"); + entries[1].Message.ShouldBe("read tag-2"); + entries[2].Message.ShouldBe("write tag-3"); + entries[0].OpKind.ShouldBe(0x30L); + } + + [Fact] + public void Ring_buffer_wraps_and_oldest_entry_is_overwritten() + { + using var mmf = new PostMortemMmf(_path, capacity: 3); + mmf.Write(1, "A"); + mmf.Write(2, "B"); + mmf.Write(3, "C"); + mmf.Write(4, "D"); // overwrites A + + var entries = mmf.ReadAll(); + entries.Length.ShouldBe(3); + entries[0].Message.ShouldBe("B"); + entries[1].Message.ShouldBe("C"); + entries[2].Message.ShouldBe("D"); + } + + [Fact] + public void Message_longer_than_capacity_is_truncated_safely() + { + using var mmf = new PostMortemMmf(_path, capacity: 2); + var huge = new string('x', 500); + mmf.Write(0, huge); + + var entries = mmf.ReadAll(); + entries[0].Message.Length.ShouldBeLessThan(PostMortemMmf.EntryBytes); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/RecyclePolicyTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/RecyclePolicyTests.cs new file mode 100644 index 0000000..263c841 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/RecyclePolicyTests.cs @@ -0,0 +1,51 @@ +using System; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Stability; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; + +[Trait("Category", "Unit")] +public sealed class RecyclePolicyTests +{ + [Fact] + public void First_soft_recycle_is_allowed() + { + var p = new RecyclePolicy(); + p.TryRequestSoftRecycle(DateTime.UtcNow, out var reason).ShouldBeTrue(); + reason.ShouldBeNull(); + } + + [Fact] + public void Second_soft_recycle_within_cap_is_blocked() + { + var p = new RecyclePolicy(); + var t0 = DateTime.UtcNow; + p.TryRequestSoftRecycle(t0, out _).ShouldBeTrue(); + p.TryRequestSoftRecycle(t0.AddMinutes(30), out var reason).ShouldBeFalse(); + reason.ShouldContain("frequency cap"); + } + + [Fact] + public void Recycle_after_cap_elapses_is_allowed_again() + { + var p = new RecyclePolicy(); + var t0 = DateTime.UtcNow; + p.TryRequestSoftRecycle(t0, out _).ShouldBeTrue(); + p.TryRequestSoftRecycle(t0.AddHours(1).AddMinutes(1), out _).ShouldBeTrue(); + } + + [Fact] + public void Scheduled_recycle_fires_once_per_day_at_local_3am() + { + var p = new RecyclePolicy(); + var last = DateTime.MinValue; + + p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 2, 59, 0), ref last).ShouldBeFalse(); + p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 3, 0, 0), ref last).ShouldBeTrue(); + p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 17, 3, 30, 0), ref last).ShouldBeFalse( + "already fired today"); + p.ShouldSoftRecycleScheduled(new DateTime(2026, 4, 18, 3, 0, 0), ref last).ShouldBeTrue( + "next day fires again"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/StaPumpTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/StaPumpTests.cs new file mode 100644 index 0000000..9510cc6 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/StaPumpTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; + +[Trait("Category", "Unit")] +public sealed class StaPumpTests +{ + [Fact] + public async Task InvokeAsync_runs_work_on_the_STA_thread() + { + using var pump = new StaPump(); + await pump.WaitForStartedAsync(); + + var apartment = await pump.InvokeAsync(() => Thread.CurrentThread.GetApartmentState()); + apartment.ShouldBe(ApartmentState.STA); + } + + [Fact] + public async Task Responsiveness_probe_returns_true_under_healthy_pump() + { + using var pump = new StaPump(); + await pump.WaitForStartedAsync(); + + (await pump.IsResponsiveAsync(TimeSpan.FromSeconds(2))).ShouldBeTrue(); + } + + [Fact] + public async Task Responsiveness_probe_returns_false_when_pump_is_wedged() + { + using var pump = new StaPump(); + await pump.WaitForStartedAsync(); + + // Wedge the pump with an infinite work item on the STA thread. + var wedge = new ManualResetEventSlim(); + _ = pump.InvokeAsync(() => wedge.Wait()); + + var responsive = await pump.IsResponsiveAsync(TimeSpan.FromMilliseconds(500)); + responsive.ShouldBeFalse(); + + wedge.Set(); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj new file mode 100644 index 0000000..8fa34bb --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj @@ -0,0 +1,31 @@ + + + + net48 + enable + latest + false + true + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/BackoffTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/BackoffTests.cs new file mode 100644 index 0000000..5579ef2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/BackoffTests.cs @@ -0,0 +1,28 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; + +[Trait("Category", "Unit")] +public sealed class BackoffTests +{ + [Fact] + public void Default_sequence_is_5_15_60_seconds_capped() + { + var b = new Backoff(); + b.Next().ShouldBe(TimeSpan.FromSeconds(5)); + b.Next().ShouldBe(TimeSpan.FromSeconds(15)); + b.Next().ShouldBe(TimeSpan.FromSeconds(60)); + b.Next().ShouldBe(TimeSpan.FromSeconds(60), "capped once past the last entry"); + } + + [Fact] + public void RecordStableRun_resets_to_the_first_delay() + { + var b = new Backoff(); + b.Next(); b.Next(); + b.RecordStableRun(); + b.Next().ShouldBe(TimeSpan.FromSeconds(5)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/CircuitBreakerTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/CircuitBreakerTests.cs new file mode 100644 index 0000000..5493862 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/CircuitBreakerTests.cs @@ -0,0 +1,78 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; + +[Trait("Category", "Unit")] +public sealed class CircuitBreakerTests +{ + [Fact] + public void First_three_crashes_within_window_allow_respawn() + { + var breaker = new CircuitBreaker(); + var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); + + breaker.TryRecordCrash(t0, out _).ShouldBeTrue(); + breaker.TryRecordCrash(t0.AddSeconds(30), out _).ShouldBeTrue(); + breaker.TryRecordCrash(t0.AddSeconds(60), out _).ShouldBeTrue(); + } + + [Fact] + public void Fourth_crash_within_window_opens_breaker_with_sticky_alert() + { + var breaker = new CircuitBreaker(); + var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); + + for (var i = 0; i < 3; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _); + + breaker.TryRecordCrash(t0.AddSeconds(120), out var remaining).ShouldBeFalse(); + remaining.ShouldBe(TimeSpan.FromHours(1)); + breaker.StickyAlertActive.ShouldBeTrue(); + } + + [Fact] + public void Cooldown_escalates_1h_then_4h_then_manual() + { + var breaker = new CircuitBreaker(); + var t0 = new DateTime(2026, 4, 17, 12, 0, 0, DateTimeKind.Utc); + + // Open once. + for (var i = 0; i < 4; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _); + + // Cooldown starts when the breaker opens (the 4th crash, at t0+90s). Jump past 1h from there. + var openedAt = t0.AddSeconds(90); + var afterFirstCooldown = openedAt.AddHours(1).AddMinutes(1); + breaker.TryRecordCrash(afterFirstCooldown, out _).ShouldBeTrue("cooldown elapsed, breaker closes for a try"); + + // Second trip: within 5 min, breaker opens again with 4h cooldown. The crash that trips + // it is the 3rd retry since the cooldown closed (afterFirstCooldown itself counted as 1). + breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(30), out _).ShouldBeTrue(); + breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(60), out _).ShouldBeTrue(); + breaker.TryRecordCrash(afterFirstCooldown.AddSeconds(90), out var cd2).ShouldBeFalse( + "4th crash within window reopens the breaker"); + cd2.ShouldBe(TimeSpan.FromHours(4)); + + // Third trip: 4h elapsed, breaker closes for a try, then reopens with MaxValue (manual only). + var reopenedAt = afterFirstCooldown.AddSeconds(90); + var afterSecondCooldown = reopenedAt.AddHours(4).AddMinutes(1); + breaker.TryRecordCrash(afterSecondCooldown, out _).ShouldBeTrue(); + breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(30), out _).ShouldBeTrue(); + breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(60), out _).ShouldBeTrue(); + breaker.TryRecordCrash(afterSecondCooldown.AddSeconds(90), out var cd3).ShouldBeFalse(); + cd3.ShouldBe(TimeSpan.MaxValue); + } + + [Fact] + public void ManualReset_clears_sticky_alert_and_crash_history() + { + var breaker = new CircuitBreaker(); + var t0 = DateTime.UtcNow; + for (var i = 0; i < 4; i++) breaker.TryRecordCrash(t0.AddSeconds(i * 30), out _); + + breaker.ManualReset(); + breaker.StickyAlertActive.ShouldBeFalse(); + + breaker.TryRecordCrash(t0.AddMinutes(10), out _).ShouldBeTrue(); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HeartbeatMonitorTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HeartbeatMonitorTests.cs new file mode 100644 index 0000000..a05f0f0 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/HeartbeatMonitorTests.cs @@ -0,0 +1,40 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Supervisor; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests; + +[Trait("Category", "Unit")] +public sealed class HeartbeatMonitorTests +{ + [Fact] + public void Single_miss_does_not_declare_dead() + { + var m = new HeartbeatMonitor(); + m.RecordMiss().ShouldBeFalse(); + m.RecordMiss().ShouldBeFalse(); + } + + [Fact] + public void Three_consecutive_misses_declare_host_dead() + { + var m = new HeartbeatMonitor(); + m.RecordMiss().ShouldBeFalse(); + m.RecordMiss().ShouldBeFalse(); + m.RecordMiss().ShouldBeTrue(); + } + + [Fact] + public void Ack_resets_the_miss_counter() + { + var m = new HeartbeatMonitor(); + m.RecordMiss(); + m.RecordMiss(); + + m.RecordAck(DateTime.UtcNow); + + m.ConsecutiveMisses.ShouldBe(0); + m.RecordMiss().ShouldBeFalse(); + m.RecordMiss().ShouldBeFalse(); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/IpcHandshakeIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/IpcHandshakeIntegrationTests.cs new file mode 100644 index 0000000..3c58be6 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/IpcHandshakeIntegrationTests.cs @@ -0,0 +1,91 @@ +using System.IO.Pipes; +using System.Security.Principal; +using Serilog; +using Serilog.Core; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Ipc; +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.Tests; + +/// +/// End-to-end IPC test: (from Galaxy.Host) accepts a connection from +/// the Proxy's . Verifies the Hello handshake, shared-secret +/// check, and heartbeat round-trip. Uses the current user's SID so the ACL allows the +/// localhost test process. Skipped on non-Windows (pipe ACL is Windows-only). +/// +[Trait("Category", "Integration")] +public sealed class IpcHandshakeIntegrationTests +{ + [Fact] + public async Task Hello_handshake_with_correct_secret_succeeds_and_heartbeat_round_trips() + { + if (!OperatingSystem.IsWindows()) return; // pipe ACL is Windows-only + if (IsAdministrator()) return; // ACL explicitly denies Administrators — skip on admin shells + + using var currentIdentity = WindowsIdentity.GetCurrent(); + var allowedSid = currentIdentity.User!; + var pipeName = $"OtOpcUaGalaxyTest-{Guid.NewGuid():N}"; + const string secret = "test-secret-2026"; + Logger log = new LoggerConfiguration().CreateLogger(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var server = new PipeServer(pipeName, allowedSid, secret, log); + var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token)); + + await using var client = await GalaxyIpcClient.ConnectAsync( + pipeName, secret, TimeSpan.FromSeconds(5), cts.Token); + + // Heartbeat round-trip via the stub handler. + var ack = await client.CallAsync( + MessageKind.Heartbeat, + new Heartbeat { SequenceNumber = 42, UtcUnixMs = 1000 }, + MessageKind.HeartbeatAck, + cts.Token); + ack.SequenceNumber.ShouldBe(42L); + + cts.Cancel(); + try { await serverTask; } catch (OperationCanceledException) { } + server.Dispose(); + } + + [Fact] + public async Task Hello_with_wrong_secret_is_rejected() + { + if (!OperatingSystem.IsWindows()) return; + if (IsAdministrator()) return; + + using var currentIdentity = WindowsIdentity.GetCurrent(); + var allowedSid = currentIdentity.User!; + var pipeName = $"OtOpcUaGalaxyTest-{Guid.NewGuid():N}"; + Logger log = new LoggerConfiguration().CreateLogger(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var server = new PipeServer(pipeName, allowedSid, "real-secret", log); + var serverTask = Task.Run(() => server.RunOneConnectionAsync(new StubFrameHandler(), cts.Token)); + + await Should.ThrowAsync(() => + GalaxyIpcClient.ConnectAsync(pipeName, "wrong-secret", TimeSpan.FromSeconds(5), cts.Token)); + + cts.Cancel(); + try { await serverTask; } catch { /* server loop ends */ } + server.Dispose(); + } + + /// + /// The production ACL explicitly denies Administrators. On dev boxes the interactive user + /// is often an Administrator, so the allow rule gets overridden by the deny — the pipe + /// refuses the connection. Skip in that case; the production install runs as a dedicated + /// non-admin service account. + /// + private static bool IsAdministrator() + { + if (!OperatingSystem.IsWindows()) return false; + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj new file mode 100644 index 0000000..f90149b --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ContractRoundTripTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ContractRoundTripTests.cs new file mode 100644 index 0000000..2992650 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ContractRoundTripTests.cs @@ -0,0 +1,68 @@ +using System.Reflection; +using MessagePack; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests; + +[Trait("Category", "Unit")] +public sealed class ContractRoundTripTests +{ + /// + /// Every MessagePack contract in the Shared project must round-trip. Byte-for-byte equality + /// on re-serialization proves the contract is deterministic — critical for the Hello + /// version-negotiation hash and for debugging wire dumps. + /// + [Fact] + public void All_MessagePackObject_contracts_round_trip_byte_for_byte() + { + var contractTypes = typeof(Hello).Assembly.GetTypes() + .Where(t => t.GetCustomAttribute() is not null) + .ToList(); + + contractTypes.Count.ShouldBeGreaterThan(15, "scan should find all contracts"); + + foreach (var type in contractTypes) + { + var instance = Activator.CreateInstance(type); + var bytes1 = MessagePackSerializer.Serialize(type, instance); + var hydrated = MessagePackSerializer.Deserialize(type, bytes1); + var bytes2 = MessagePackSerializer.Serialize(type, hydrated); + + bytes2.ShouldBe(bytes1, $"{type.Name} did not round-trip byte-for-byte"); + } + } + + [Fact] + public void Hello_default_reports_current_protocol_version() + { + var h = new Hello { PeerName = "Proxy", SharedSecret = "x" }; + h.ProtocolMajor.ShouldBe(Hello.CurrentMajor); + h.ProtocolMinor.ShouldBe(Hello.CurrentMinor); + } + + [Fact] + public void OpenSessionRequest_round_trips_values() + { + var req = new OpenSessionRequest { DriverInstanceId = "gal-1", DriverConfigJson = "{\"x\":1}" }; + var bytes = MessagePackSerializer.Serialize(req); + var hydrated = MessagePackSerializer.Deserialize(bytes); + + hydrated.DriverInstanceId.ShouldBe("gal-1"); + hydrated.DriverConfigJson.ShouldBe("{\"x\":1}"); + } + + [Fact] + public void Contracts_reference_only_BCL_and_MessagePack() + { + var asm = typeof(Hello).Assembly; + var references = asm.GetReferencedAssemblies() + .Select(n => n.Name!) + .Where(n => !n.StartsWith("System.") && n != "mscorlib" && n != "netstandard") + .ToList(); + + // Only MessagePack should appear outside BCL — no System.Text.Json, no EF, no AspNetCore. + references.ShouldAllBe(n => n == "MessagePack" || n == "MessagePack.Annotations"); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/FramingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/FramingTests.cs new file mode 100644 index 0000000..3b1c143 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/FramingTests.cs @@ -0,0 +1,74 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests; + +[Trait("Category", "Unit")] +public sealed class FramingTests +{ + [Fact] + public async Task FrameWriter_FrameReader_round_trip_preserves_kind_and_body() + { + using var ms = new MemoryStream(); + + using (var writer = new FrameWriter(ms, leaveOpen: true)) + { + await writer.WriteAsync(MessageKind.Hello, + new Hello { PeerName = "p", SharedSecret = "s" }, TestContext.Current.CancellationToken); + await writer.WriteAsync(MessageKind.Heartbeat, + new Heartbeat { SequenceNumber = 7, UtcUnixMs = 42 }, TestContext.Current.CancellationToken); + } + + ms.Position = 0; + using var reader = new FrameReader(ms, leaveOpen: true); + + var f1 = (await reader.ReadFrameAsync(TestContext.Current.CancellationToken))!.Value; + f1.Kind.ShouldBe(MessageKind.Hello); + FrameReader.Deserialize(f1.Body).PeerName.ShouldBe("p"); + + var f2 = (await reader.ReadFrameAsync(TestContext.Current.CancellationToken))!.Value; + f2.Kind.ShouldBe(MessageKind.Heartbeat); + FrameReader.Deserialize(f2.Body).SequenceNumber.ShouldBe(7L); + + var eof = await reader.ReadFrameAsync(TestContext.Current.CancellationToken); + eof.ShouldBeNull(); + } + + [Fact] + public async Task FrameReader_rejects_frames_larger_than_the_cap() + { + using var ms = new MemoryStream(); + var evilLen = Framing.MaxFrameBodyBytes + 1; + ms.Write(new byte[] + { + (byte)((evilLen >> 24) & 0xFF), + (byte)((evilLen >> 16) & 0xFF), + (byte)((evilLen >> 8) & 0xFF), + (byte)( evilLen & 0xFF), + }, 0, 4); + ms.WriteByte((byte)MessageKind.Hello); + ms.Position = 0; + + using var reader = new FrameReader(ms, leaveOpen: true); + await Should.ThrowAsync(() => + reader.ReadFrameAsync(TestContext.Current.CancellationToken).AsTask()); + } + + private static class TestContext + { + public static TestContextHelper Current { get; } = new(); + } + + private sealed class TestContextHelper + { + public CancellationToken CancellationToken => CancellationToken.None; + } +} + +file static class TaskExtensions +{ + public static Task AsTask(this ValueTask vt) => vt.AsTask(); + public static Task AsTask(this Task t) => t; +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj new file mode 100644 index 0000000..3d644f5 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/NodeBootstrapTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/NodeBootstrapTests.cs new file mode 100644 index 0000000..84ddac1 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/NodeBootstrapTests.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.LocalCache; +using ZB.MOM.WW.OtOpcUa.Server; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +[Trait("Category", "Unit")] +public sealed class NodeBootstrapTests +{ + private sealed class StubCache : ILocalConfigCache + { + public GenerationSnapshot? Stored { get; set; } + public Task GetMostRecentAsync(string _, CancellationToken __) => Task.FromResult(Stored); + public Task PutAsync(GenerationSnapshot _, CancellationToken __) => Task.CompletedTask; + public Task PruneOldGenerationsAsync(string _, int __, CancellationToken ___) => Task.CompletedTask; + } + + [Fact] + public async Task Falls_back_to_cache_when_DB_unreachable() + { + var cache = new StubCache + { + Stored = new GenerationSnapshot + { + ClusterId = "c", GenerationId = 42, CachedAt = DateTime.UtcNow, PayloadJson = "{}", + }, + }; + + var bootstrap = new NodeBootstrap( + new NodeOptions + { + NodeId = "n", + ClusterId = "c", + ConfigDbConnectionString = "Server=127.0.0.1,59999;Database=nope;User Id=x;Password=x;TrustServerCertificate=True;Connect Timeout=1;", + }, + cache, + NullLogger.Instance); + + var result = await bootstrap.LoadCurrentGenerationAsync(CancellationToken.None); + + result.Source.ShouldBe(BootstrapSource.LocalCache); + result.GenerationId.ShouldBe(42); + } + + [Fact] + public async Task Throws_BootstrapException_when_DB_unreachable_and_cache_empty() + { + var bootstrap = new NodeBootstrap( + new NodeOptions + { + NodeId = "n", + ClusterId = "c", + ConfigDbConnectionString = "Server=127.0.0.1,59999;Database=nope;User Id=x;Password=x;TrustServerCertificate=True;Connect Timeout=1;", + }, + new StubCache(), + NullLogger.Instance); + + await Should.ThrowAsync(() => + bootstrap.LoadCurrentGenerationAsync(CancellationToken.None)); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj new file mode 100644 index 0000000..83ff431 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/ZB.MOM.WW.OtOpcUa.Server.Tests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Server.Tests + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + +