diff --git a/CLAUDE.md b/CLAUDE.md index ce0fd80b..d585b07b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -232,7 +232,7 @@ Assert the **effective** cluster config off a running `ActorSystem` (as `SplitBr **Phase 1 landed 2026-07-22 and changed a deploy-path behaviour.** `ConfigPublishCoordinator` sources its expected-ack set from **enabled `ClusterNode` rows**, not from `Akka.Cluster.State.Members` filtered by the `driver` role — central must be able to name a deployment's nodes without sharing a gossip ring with them. Consequences worth knowing before touching deploys: -- **A configured node that is switched off now FAILS the deployment** at the apply deadline instead of letting it seal green without that node. `ClusterNode.Enabled = 0` is the maintenance hatch; the deadline log names the silent nodes. +- **A configured node that is switched off now FAILS the deployment** at the apply deadline instead of letting it seal green without that node. The maintenance hatch is the **new `ClusterNode.MaintenanceMode` column**, NOT `Enabled = 0`: the live gate showed `Enabled = 0` is rejected outright by `DraftValidator.ValidateClusterTopology` (`ClusterEnabledNodeCountMismatch` — enabled count must equal `ServerCluster.NodeCount`), so on a 2-node pair — every cluster in the target topology — it can never be the hatch. `Enabled` = part of the declared topology; `MaintenanceMode` = do not expect it now. The deadline log names the silent nodes and points at the right flag. - **Every `ClusterNode` row is now *defined* to be a driver node.** The DB has no per-node role column and deliberately did not gain one (it would drift from `Cluster:Roles`). Giving an admin-only node a `ClusterNode` row makes every deploy time out. - **There is no such thing as a cluster-scoped deployment.** `Deployment` has no `ClusterId`, `ConfigComposer` always snapshots the whole DB, and `DeploymentArtifact.ResolveClusterScope` is *node-side* self-scoping of a fleet-wide artifact. The phase-1 plan asked for cluster-scope filtering of the ack set; it was dropped as describing a feature that does not exist. - `ClusterNode` gained `AkkaPort` (NOT NULL, 4053) + `GrpcPort` (nullable) as **central's dial targets** for Phases 2/5. They duplicate the node's own `Cluster:Port` / `Cluster:PublicHostname` with no schema-level enforcement, so `ClusterNodeAddressReconcilerActor` (admin singleton) logs an Error on drift. **Phase 2 must revisit it** — once the meshes split, an admin node cannot see site members and every site row would report `EnabledRowNotInCluster` forever. diff --git a/docs/plans/2026-07-21-per-cluster-mesh-phase1.md b/docs/plans/2026-07-21-per-cluster-mesh-phase1.md index 1c749f87..f5b078ca 100644 --- a/docs/plans/2026-07-21-per-cluster-mesh-phase1.md +++ b/docs/plans/2026-07-21-per-cluster-mesh-phase1.md @@ -12,8 +12,17 @@ sourcing its expected-ack set from those rows. **Architecture:** Additive schema change plus one substitution inside the coordinator. Nothing else moves; the deploy channel stays on DistributedPubSub until Phase 2. -**Status:** NOT STARTED. Read §"The decision this phase forces" before executing — it changes -deploy-seal semantics and should be confirmed, not assumed. +**Status: DONE 2026-07-22** (branch `feat/mesh-phase1`). Live gate: +[`2026-07-22-mesh-phase1-live-gate.md`](2026-07-22-mesh-phase1-live-gate.md) — **PASSED**, after gate +step 4 found that the escape hatch this phase depends on did not exist. Three deviations from the +plan below, all decided before implementing or forced by the gate: + +1. **Task 3's cluster-scope filtering was dropped** — there is no cluster-scoped deployment to filter + on. See the gate doc, step 2. +2. **No per-node role column.** Every `ClusterNode` row is now *defined* to be a driver node. +3. **Task 7 added:** `ClusterNode.MaintenanceMode`. `Enabled = 0` is rejected by + `DraftValidator.ValidateClusterTopology` on any 2-node pair, so it could never be the maintenance + hatch — which the plan called "the check that makes step 3 acceptable to ship". --- @@ -54,7 +63,7 @@ But it is a behaviour change, and it needs an operator escape hatch or a node ta maintenance blocks every deployment. `ClusterNode.Enabled` already exists and is exactly that hatch, so the query filters on it: **`Enabled = 1` rows are expected to ack; disabled rows are not.** -**Confirm this before executing Task 3.** If the preference is to keep sealing green past a down +**Confirmed 2026-07-22 before executing Task 3** — DB-derived with the hatch. (The hatch turned out to be `MaintenanceMode`, not `Enabled`; see Task 7.) If the preference is to keep sealing green past a down node, the alternative is to intersect the DB set with current membership, which preserves today's behaviour but does not survive the mesh split — it would have to be revisited in Phase 2 anyway. @@ -189,3 +198,25 @@ disagree on the rig. Run on `docker-dev`: hatch, and is the check that makes step 3 acceptable to ship. Record results in a gate doc beside this plan, per the Phase 1/Phase 2 precedent. + +## Task 7: `MaintenanceMode` — the escape hatch step 4 proved missing (ADDED 2026-07-22) + +**Classification:** high-risk — deploy path + schema +**Why:** gate step 4 returned `422 ClusterEnabledNodeCountMismatch`. `Enabled = 0` on one node of a +Warm/Hot pair fails `DraftValidator.ValidateClusterTopology` (enabled count must equal `NodeCount`), +so the hatch Task 3 relies on cannot be used on any 2-node cluster — i.e. every cluster in the target +topology. Task 3's behaviour change is only shippable with a hatch that works. + +**Decision:** split the meanings rather than weaken either rule. + +| Flag | Question | Enforced by | +|---|---|---| +| `Enabled` | part of the declared topology? | `DraftValidator` vs `ServerCluster.NodeCount` | +| `MaintenanceMode` | expected to participate right now? | `ConfigPublishCoordinator`, `ClusterNodeAddressReconciler` | + +Rejected: counting configured rather than enabled nodes (removes the InvalidTopology guard); +downgrading the rule to a warning (weakens a deploy gate for everyone); shipping without a hatch. + +**Done:** column + migration `AddClusterNodeMaintenanceMode`; both consumers filter on +`Enabled && !MaintenanceMode`; deadline + reconciler messages name the right flag; docs; gate step 4 +re-run green. diff --git a/docs/plans/2026-07-22-mesh-phase1-live-gate.md b/docs/plans/2026-07-22-mesh-phase1-live-gate.md new file mode 100644 index 00000000..f9f1eb0c --- /dev/null +++ b/docs/plans/2026-07-22-mesh-phase1-live-gate.md @@ -0,0 +1,134 @@ +# Per-cluster mesh Phase 1 — live gate record + +**Date:** 2026-07-22 · **Rig:** `docker-dev` (six-node single mesh) · **Branch:** `feat/mesh-phase1` +**Plan:** [`2026-07-21-per-cluster-mesh-phase1.md`](2026-07-21-per-cluster-mesh-phase1.md) + +**Result: PASSED**, after the gate found a blocker that changed the design (step 4). + +Offline tests cannot cover the substitution's real risk — that the DB set and the live set disagree on +a running fleet. This is that check. + +## Setup + +Migration applied by the rig's `migrator` service against the shared `OtOpcUa` database, then all six +host nodes restarted on the rebuilt image. **All six pre-existing `ClusterNode` rows inherited +`AkkaPort = 4053` from the migration's `DEFAULT 4053`** — the row-level evidence that the default does +its job for rows predating the column: + +``` +central-1:4053|central-1|4053|NULL|1 +central-2:4053|central-2|4053|NULL|1 +site-a-1:4053 |site-a-1 |4053|NULL|1 +site-a-2:4053 |site-a-2 |4053|NULL|1 +site-b-1:4053 |site-b-1 |4053|NULL|1 +site-b-2:4053 |site-b-2 |4053|NULL|1 + NodeId | Host |Akka|Grpc|En +``` + +## Step 1 — deploy with all six nodes up → **PASS** + +`POST /api/deployments` → `202 Accepted`, deployment `38f83b37`. + +`Deployment.Status = 2` (Sealed). Six `NodeDeploymentState` rows, every one `Status = 1` (Applied). +**No FK 547** — the DB-derived NodeIds satisfy the `NodeDeploymentState → ClusterNode` foreign key, +which is the standing proof that the DB set and the membership set agree on this rig. + +## Step 2 — *dropped, not skipped* + +The plan called for a cluster-scoped deploy. **There is no such thing.** `Deployment` has no +`ClusterId`, `ConfigComposer.SnapshotAndFlattenAsync` always snapshots the whole DB, and +`DeploymentArtifact.ResolveClusterScope` is *node-side* self-scoping of a fleet-wide artifact. Removed +from the plan rather than faked green. + +## Step 3 — deploy with one node stopped → **PASS** (the new behaviour) + +`docker stop otopcua-dev-site-b-2-1`, then deploy `37d72301`. + +`Deployment.Status = 4` (TimedOut) after the 2-minute apply deadline. Five nodes `Applied`, +`site-b-2:4053` still `Applying`. Under the membership rule this deployment would have **sealed +green** with five nodes and never mentioned the sixth. + +The operator-facing log, which the plan required to be legible: + +``` +[12:52:02 WRN] Deployment 37d723018e374c0eb04c7b6beccc3b3e timed out after 00:02:00 (5/6 acks +landed). No ack from: site-b-2:4053. Each is an enabled ClusterNode row — start the node, or set +ClusterNode.Enabled = 0 if it is down for maintenance, then redeploy +``` + +(The remedy in that message was corrected to `MaintenanceMode = 1` — see step 4.) + +## Step 4 — the escape hatch → **FAILED, then fixed and re-run → PASS** + +### First attempt: the hatch did not exist + +`UPDATE ClusterNode SET Enabled = 0 WHERE NodeId = 'site-b-2:4053'`, then deploy: + +``` +HTTP 422 +[ClusterEnabledNodeCountMismatch] Cluster 'SITE-B' declares NodeCount=2 but has 1 Enabled nodes. +Toggle the missing node(s) back on or change RedundancyMode/NodeCount to match. +``` + +`DraftValidator.ValidateClusterTopology` rejects the deployment **before it dispatches**. So +`Enabled = 0` cannot be used on a node of a 2-node cluster — and every cluster on this rig, and every +cluster in the program plan's target topology, is a 2-node pair. + +**This was a blocker, not a wrinkle.** The plan called step 4 *"the check that makes step 3 +acceptable to ship"*. Without it, a node down for maintenance blocks every deployment to its cluster, +with no remedy short of editing `NodeCount` / `RedundancyMode` — which changes the runtime redundancy +posture to work around a deploy gate. + +The two rules were each reasonable and contradictory together: the validator reads `Enabled` as *"part +of the declared topology"*, Phase 1 additionally read it as *"expect an ack"*. + +### Fix (Task 7): split the meanings + +New `ClusterNode.MaintenanceMode bit NOT NULL DEFAULT 0`. `Enabled` keeps its topology meaning and the +validator is untouched; the coordinator's expected-ack set and the address reconciler both filter on +`Enabled && !MaintenanceMode`. + +### Re-run: PASS + +`docker stop` `site-b-2` + `MaintenanceMode = 1`, then deploy `82c5e678`: + +- `202 Accepted` — the topology gate passes, because the node is still `Enabled` (`1|1`). +- `Deployment.Status = 2` (Sealed). +- **Five** `NodeDeploymentState` rows, all `Applied`. `site-b-2:4053` has no row at all — excluded + from the expected set rather than waited for and forgiven. + +## Task 4 (reconciler) — live behaviour, not a planned gate step + +Observed unprompted during the rolling restart, which is better evidence than a staged check: + +``` +[12:44:49 WRN] ClusterNode address check [EnabledRowNotInCluster] site-a-1:4053: ... +[12:44:49 WRN] ... site-a-2:4053 ... site-b-1:4053 ... site-b-2:4053 +[12:44:56 INF] ClusterNode addresses reconcile with cluster membership (6 driver members) +``` + +It warned per row while the site nodes were still starting, then converged to a single clean line — +and **logged nothing further**, confirming the change-detection guard suppresses repeats on the +5-minute sweep. + +Maintenance interaction, from the step 4 re-run: + +``` +[13:06:02 WRN] ... site-b-2:4053 ... ← node dropped, flag not yet set +[13:06:05 INF] ... reconcile with cluster membership (5 driver members) ← flag set: silent +``` + +The transient warning is honest — for those three seconds the row genuinely was enabled, present in +the expected-ack set, and absent from the cluster. + +## What this gate did not cover + +- **Phase 2's premise.** `AkkaPort` / `GrpcPort` are groundwork; nothing dials them yet, so "the value + is correct" is only checked by the reconciler comparing it to gossip. The first real exercise is + Phase 2. +- **The reconciler under split meshes.** On the current single mesh an admin node sees every driver + member. After Phase 6 it will not, and every site row would report `EnabledRowNotInCluster` + permanently. Recorded as a known limitation on the class and in the design doc. +- **`Enabled = 0` on a single-node (`RedundancyMode.None`) cluster.** Untested — the rig has no such + cluster. It would fail the same topology rule (0 enabled vs `NodeCount = 1`), so `MaintenanceMode` + is the hatch there too. diff --git a/docs/v2/config-db-schema.md b/docs/v2/config-db-schema.md index e931a08c..216a1512 100644 --- a/docs/v2/config-db-schema.md +++ b/docs/v2/config-db-schema.md @@ -154,6 +154,7 @@ CREATE TABLE dbo.ClusterNode ( ServiceLevelBase tinyint NOT NULL DEFAULT 200, DriverConfigOverridesJson nvarchar(max) NULL CHECK (DriverConfigOverridesJson IS NULL OR ISJSON(DriverConfigOverridesJson) = 1), Enabled bit NOT NULL DEFAULT 1, + MaintenanceMode bit NOT NULL DEFAULT 0, LastSeenAt datetime2(3) NULL, CreatedAt datetime2(3) NOT NULL DEFAULT SYSUTCDATETIME(), CreatedBy nvarchar(128) NOT NULL @@ -190,10 +191,27 @@ rows against observed cluster membership and logs an Error on mismatch. It reads than having each driver node assert its own row, because Phase 4 removes the driver nodes' ConfigDb connection entirely. -`Enabled` also gained a second meaning in Phase 1: it is the **expected-ack set** for a deployment. -`ConfigPublishCoordinator` no longer derives that set from cluster membership, so an enabled row whose -node is down now fails the deployment at the apply deadline instead of letting it seal green without -that node. Set `Enabled = 0` for a node taken down for maintenance. +#### `Enabled` vs `MaintenanceMode` — two flags, two questions + +Phase 1 made the rows the deploy path's **expected-ack set**: `ConfigPublishCoordinator` no longer +derives it from cluster membership, so a row whose node is down now fails the deployment at the apply +deadline instead of letting it seal green without that node. + +The escape hatch is **`MaintenanceMode = 1`**, not `Enabled = 0`: + +| Flag | Question it answers | Checked by | +|---|---|---| +| `Enabled` | Is this node part of the cluster's declared topology? | `DraftValidator.ValidateClusterTopology` — the enabled-node count must equal `ServerCluster.NodeCount` | +| `MaintenanceMode` | Should we expect it to participate right now? | `ConfigPublishCoordinator` (expected-ack set) and `ClusterNodeAddressReconciler` (absence warnings) | + +`Enabled = 0` on one node of a Warm/Hot pair is **rejected at the deploy gate** with +`ClusterEnabledNodeCountMismatch` — the enabled count drops to 1 while `NodeCount` stays 2. Since +every cluster in the target topology is a 2-node pair, `Enabled` cannot serve as the maintenance +hatch at all. The Phase 1 live gate found this; the two rules were independently reasonable and +contradictory together, so the meanings were split rather than either rule being weakened. + +A node in maintenance is still enabled, so the topology gate still passes, and the runtime redundancy +posture (`NodeCount` / `RedundancyMode`) is unchanged. `DriverConfigOverridesJson` shape: diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs index 52ecfddd..6214d08f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs @@ -63,8 +63,37 @@ public sealed class ClusterNode public string? DriverConfigOverridesJson { get; set; } /// Gets or sets a value indicating whether this node is enabled. + /// + /// Part of the declared topology. DraftValidator.ValidateClusterTopology requires + /// the enabled-node count to equal , so disabling one node + /// of a Warm/Hot pair is a deploy-blocking validation error by design — it would boot the + /// runtime into the InvalidTopology band. To take a node out of service temporarily, use + /// instead. + /// public bool Enabled { get; set; } = true; + /// + /// Node is temporarily out of service: still part of the cluster's declared topology, but not + /// expected to participate. A deployment does not wait for its ack, and the address reconciler + /// does not warn that it is absent. + /// + /// + /// + /// Separate from because the two answer different questions. + /// means "this node is part of the declared topology" and is checked + /// against ; this flag means "do not expect it right + /// now". Per-cluster mesh Phase 1 made an enabled row's ack mandatory, so without this a + /// node down for maintenance would block every deployment to its cluster — and clearing + /// to escape that is itself rejected by the topology gate on any + /// 2-node pair, which is every cluster in the target deployment. + /// + /// + /// Found by the Phase 1 live gate: the two rules were independently reasonable and + /// contradictory together. + /// + /// + public bool MaintenanceMode { get; set; } + /// Gets or sets the timestamp when this node was last seen. public DateTime? LastSeenAt { get; set; } diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260722125506_AddClusterNodeMaintenanceMode.Designer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260722125506_AddClusterNodeMaintenanceMode.Designer.cs new file mode 100644 index 00000000..752e3b23 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260722125506_AddClusterNodeMaintenanceMode.Designer.cs @@ -0,0 +1,1707 @@ +// +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("20260722125506_AddClusterNodeMaintenanceMode")] + partial class AddClusterNodeMaintenanceMode + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("nvarchar(max)"); + + b.Property("Xml") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("AkkaPort") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(4053); + + 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("GrpcPort") + .HasColumnType("int"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("LastSeenAt") + .HasColumnType("datetime2(3)"); + + b.Property("MaintenanceMode") + .HasColumnType("bit"); + + b.Property("OpcUaPort") + .HasColumnType("int"); + + b.Property("ServiceLevelBase") + .HasColumnType("tinyint"); + + b.HasKey("NodeId"); + + b.HasIndex("ApplicationUri") + .IsUnique() + .HasDatabaseName("UX_ClusterNode_ApplicationUri"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_ClusterNode_ClusterId"); + + 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.ConfigAuditLog", b => + { + b.Property("AuditId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("AuditId")); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DetailsJson") + .HasColumnType("nvarchar(max)"); + + b.Property("EventId") + .HasColumnType("uniqueidentifier"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("GenerationId") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Outcome") + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Principal") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Timestamp") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("AuditId"); + + b.HasIndex("EventId") + .IsUnique() + .HasDatabaseName("UX_ConfigAuditLog_EventId") + .HasFilter("[EventId] IS NOT NULL"); + + 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.ConfigEdit", b => + { + b.Property("EditId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("EditedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("EditedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExecutionId") + .HasColumnType("uniqueidentifier"); + + b.Property("FieldsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceNode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("EditId"); + + b.HasIndex("EditedAtUtc") + .HasDatabaseName("IX_ConfigEdit_EditedAt"); + + b.HasIndex("ExecutionId") + .HasDatabaseName("IX_ConfigEdit_Execution") + .HasFilter("[ExecutionId] IS NOT NULL"); + + b.HasIndex("EntityType", "EntityId") + .HasDatabaseName("IX_ConfigEdit_Entity"); + + b.ToTable("ConfigEdit", null, t => + { + t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", b => + { + b.Property("DeploymentId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ArtifactBlob") + .IsRequired() + .HasColumnType("varbinary(max)"); + + b.Property("CreatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RevisionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SealedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("DeploymentId"); + + b.HasIndex("CreatedAtUtc") + .HasDatabaseName("IX_Deployment_CreatedAt"); + + b.HasIndex("Status") + .HasDatabaseName("IX_Deployment_Status"); + + b.ToTable("Deployment", (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("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DeviceRowId"); + + b.HasIndex("DeviceId") + .IsUnique() + .HasDatabaseName("UX_Device_LogicalId") + .HasFilter("[DeviceId] IS NOT NULL"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_Device_Driver"); + + b.HasIndex("DriverInstanceId", "Name") + .IsUnique() + .HasDatabaseName("UX_Device_Driver_Name"); + + b.ToTable("Device", null, t => + { + t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverHostStatus", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Detail") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("StateChangedUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("NodeId", "DriverInstanceId", "HostName"); + + b.HasIndex("LastSeenUtc") + .HasDatabaseName("IX_DriverHostStatus_LastSeen"); + + b.HasIndex("NodeId") + .HasDatabaseName("IX_DriverHostStatus_Node"); + + b.ToTable("DriverHostStatus", (string)null); + }); + + 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("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ResilienceConfig") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("DriverInstanceRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_DriverInstance_Cluster"); + + b.HasIndex("DriverInstanceId") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_LogicalId") + .HasFilter("[DriverInstanceId] IS NOT NULL"); + + b.HasIndex("RawFolderId") + .HasDatabaseName("IX_DriverInstance_RawFolder"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_ClusterRoot_Name") + .HasFilter("[RawFolderId] IS NULL"); + + b.HasIndex("ClusterId", "RawFolderId", "Name") + .IsUnique() + .HasDatabaseName("UX_DriverInstance_Folder_Name") + .HasFilter("[RawFolderId] IS NOT NULL"); + + b.ToTable("DriverInstance", null, t => + { + t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1"); + + t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson", "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.DriverInstanceResilienceStatus", b => + { + b.Property("DriverInstanceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HostName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("BaselineFootprintBytes") + .HasColumnType("bigint"); + + b.Property("ConsecutiveFailures") + .HasColumnType("int"); + + b.Property("CurrentBulkheadDepth") + .HasColumnType("int"); + + b.Property("CurrentFootprintBytes") + .HasColumnType("bigint"); + + b.Property("LastCircuitBreakerOpenUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastRecycleUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastSampledUtc") + .HasColumnType("datetime2(3)"); + + b.HasKey("DriverInstanceId", "HostName"); + + b.HasIndex("LastSampledUtc") + .HasDatabaseName("IX_DriverResilience_LastSampled"); + + b.ToTable("DriverInstanceResilienceStatus", (string)null); + }); + + 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("DeviceManualUri") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + 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("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("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + 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("EquipmentId") + .IsUnique() + .HasDatabaseName("UX_Equipment_LogicalId") + .HasFilter("[EquipmentId] IS NOT NULL"); + + b.HasIndex("EquipmentUuid") + .IsUnique() + .HasDatabaseName("UX_Equipment_Uuid"); + + b.HasIndex("MachineCode") + .HasDatabaseName("IX_Equipment_MachineCode"); + + b.HasIndex("SAPID") + .HasDatabaseName("IX_Equipment_SAPID") + .HasFilter("[SAPID] IS NOT NULL"); + + b.HasIndex("UnsLineId") + .HasDatabaseName("IX_Equipment_Line"); + + b.HasIndex("ZTag") + .HasDatabaseName("IX_Equipment_ZTag") + .HasFilter("[ZTag] IS NOT NULL"); + + b.HasIndex("UnsLineId", "Name") + .IsUnique() + .HasDatabaseName("UX_Equipment_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.LdapGroupRoleMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ClusterId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("IsSystemWide") + .HasColumnType("bit"); + + b.Property("LdapGroup") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("Id"); + + b.HasIndex("ClusterId"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_LdapGroupRoleMapping_Group"); + + b.HasIndex("LdapGroup", "ClusterId") + .IsUnique() + .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster") + .HasFilter("[ClusterId] IS NOT NULL"); + + b.ToTable("LdapGroupRoleMapping", (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("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("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScopeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ScopeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.HasKey("NodeAclRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_NodeAcl_Cluster"); + + b.HasIndex("LdapGroup") + .HasDatabaseName("IX_NodeAcl_Group"); + + b.HasIndex("NodeAclId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_LogicalId") + .HasFilter("[NodeAclId] IS NOT NULL"); + + b.HasIndex("ScopeKind", "ScopeId") + .HasDatabaseName("IX_NodeAcl_Scope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.HasIndex("ClusterId", "LdapGroup", "ScopeKind", "ScopeId") + .IsUnique() + .HasDatabaseName("UX_NodeAcl_GroupScope") + .HasFilter("[ScopeId] IS NOT NULL"); + + b.ToTable("NodeAcl", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.Property("NodeId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeploymentId") + .HasColumnType("uniqueidentifier"); + + b.Property("AppliedAtUtc") + .HasColumnType("datetime2(3)"); + + b.Property("FailureReason") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("StartedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("NodeId", "DeploymentId"); + + b.HasIndex("DeploymentId") + .HasDatabaseName("IX_NodeDeploymentState_Deployment"); + + b.HasIndex("Status") + .HasDatabaseName("IX_NodeDeploymentState_Status"); + + b.ToTable("NodeDeploymentState", (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("IntervalMs") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("PollGroupRowId"); + + b.HasIndex("DriverInstanceId") + .HasDatabaseName("IX_PollGroup_Driver"); + + b.HasIndex("PollGroupId") + .IsUnique() + .HasDatabaseName("UX_PollGroup_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.RawFolder", b => + { + b.Property("RawFolderRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ClusterId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentRawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RawFolderId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("RawFolderRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_RawFolder_Cluster"); + + b.HasIndex("RawFolderId") + .IsUnique() + .HasDatabaseName("UX_RawFolder_LogicalId") + .HasFilter("[RawFolderId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_RawFolder_ClusterRoot_Name") + .HasFilter("[ParentRawFolderId] IS NULL"); + + b.HasIndex("ClusterId", "ParentRawFolderId", "Name") + .IsUnique() + .HasDatabaseName("UX_RawFolder_Parent_Name") + .HasFilter("[ParentRawFolderId] IS NOT NULL"); + + b.ToTable("RawFolder", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Script", b => + { + b.Property("ScriptRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("SourceHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("ScriptRowId"); + + b.HasIndex("ScriptId") + .IsUnique() + .HasDatabaseName("UX_Script_LogicalId") + .HasFilter("[ScriptId] IS NOT NULL"); + + b.HasIndex("SourceHash") + .HasDatabaseName("IX_Script_SourceHash"); + + b.ToTable("Script", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarm", b => + { + b.Property("ScriptedAlarmRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("AlarmType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("HistorizeToAveva") + .HasColumnType("bit"); + + b.Property("MessageTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PredicateScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Retain") + .HasColumnType("bit"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Severity") + .HasColumnType("int"); + + b.HasKey("ScriptedAlarmRowId"); + + b.HasIndex("PredicateScriptId") + .HasDatabaseName("IX_ScriptedAlarm_Script"); + + b.HasIndex("ScriptedAlarmId") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_LogicalId") + .HasFilter("[ScriptedAlarmId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_ScriptedAlarm_EquipmentPath"); + + b.ToTable("ScriptedAlarm", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')"); + + t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ScriptedAlarmState", b => + { + b.Property("ScriptedAlarmId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("AckedState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("CommentsJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ConfirmedState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("EnabledState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("LastAckComment") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastAckUser") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastAckUtc") + .HasColumnType("datetime2(3)"); + + b.Property("LastConfirmComment") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("LastConfirmUser") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("LastConfirmUtc") + .HasColumnType("datetime2(3)"); + + b.Property("ShelvingExpiresUtc") + .HasColumnType("datetime2(3)"); + + b.Property("ShelvingState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("nvarchar(16)"); + + b.Property("UpdatedAtUtc") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2(3)") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.HasKey("ScriptedAlarmId"); + + b.ToTable("ScriptedAlarmState", null, t => + { + t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1"); + }); + }); + + 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") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("PollGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("TagConfig") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("TagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("WriteIdempotent") + .HasColumnType("bit"); + + b.HasKey("TagRowId"); + + b.HasIndex("TagId") + .IsUnique() + .HasDatabaseName("UX_Tag_LogicalId") + .HasFilter("[TagId] IS NOT NULL"); + + b.HasIndex("DeviceId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Device_Name") + .HasFilter("[TagGroupId] IS NULL"); + + b.HasIndex("DeviceId", "TagGroupId") + .HasDatabaseName("IX_Tag_Device"); + + b.HasIndex("DeviceId", "TagGroupId", "Name") + .IsUnique() + .HasDatabaseName("UX_Tag_Group_Name") + .HasFilter("[TagGroupId] IS NOT NULL"); + + b.ToTable("Tag", null, t => + { + t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1"); + }); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.TagGroup", b => + { + b.Property("TagGroupRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ParentTagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TagGroupId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("TagGroupRowId"); + + b.HasIndex("DeviceId") + .HasDatabaseName("IX_TagGroup_Device"); + + b.HasIndex("TagGroupId") + .IsUnique() + .HasDatabaseName("UX_TagGroup_LogicalId") + .HasFilter("[TagGroupId] IS NOT NULL"); + + b.HasIndex("DeviceId", "Name") + .IsUnique() + .HasDatabaseName("UX_TagGroup_DeviceRoot_Name") + .HasFilter("[ParentTagGroupId] IS NULL"); + + b.HasIndex("DeviceId", "ParentTagGroupId", "Name") + .IsUnique() + .HasDatabaseName("UX_TagGroup_Parent_Name") + .HasFilter("[ParentTagGroupId] IS NOT NULL"); + + b.ToTable("TagGroup", (string)null); + }); + + 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("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsAreaRowId"); + + b.HasIndex("ClusterId") + .HasDatabaseName("IX_UnsArea_Cluster"); + + b.HasIndex("UnsAreaId") + .IsUnique() + .HasDatabaseName("UX_UnsArea_LogicalId") + .HasFilter("[UnsAreaId] IS NOT NULL"); + + b.HasIndex("ClusterId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsArea_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("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Notes") + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("UnsAreaId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsLineId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsLineRowId"); + + b.HasIndex("UnsAreaId") + .HasDatabaseName("IX_UnsLine_Area"); + + b.HasIndex("UnsLineId") + .IsUnique() + .HasDatabaseName("UX_UnsLine_LogicalId") + .HasFilter("[UnsLineId] IS NOT NULL"); + + b.HasIndex("UnsAreaId", "Name") + .IsUnique() + .HasDatabaseName("UX_UnsLine_AreaName"); + + b.ToTable("UnsLine", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsTagReference", b => + { + b.Property("UnsTagReferenceRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("DisplayNameOverride") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TagId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UnsTagReferenceId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("UnsTagReferenceRowId"); + + b.HasIndex("EquipmentId") + .HasDatabaseName("IX_UnsTagReference_Equipment"); + + b.HasIndex("TagId") + .HasDatabaseName("IX_UnsTagReference_Tag"); + + b.HasIndex("UnsTagReferenceId") + .IsUnique() + .HasDatabaseName("UX_UnsTagReference_LogicalId") + .HasFilter("[UnsTagReferenceId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "TagId") + .IsUnique() + .HasDatabaseName("UX_UnsTagReference_Equip_Tag"); + + b.ToTable("UnsTagReference", (string)null); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.VirtualTag", b => + { + b.Property("VirtualTagRowId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier") + .HasDefaultValueSql("NEWSEQUENTIALID()"); + + b.Property("ChangeTriggered") + .HasColumnType("bit"); + + b.Property("DataType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("EquipmentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Historize") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScriptId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("TimerIntervalMs") + .HasColumnType("int"); + + b.Property("VirtualTagId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("VirtualTagRowId"); + + b.HasIndex("ScriptId") + .HasDatabaseName("IX_VirtualTag_Script"); + + b.HasIndex("VirtualTagId") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_LogicalId") + .HasFilter("[VirtualTagId] IS NOT NULL"); + + b.HasIndex("EquipmentId", "Name") + .IsUnique() + .HasDatabaseName("UX_VirtualTag_EquipmentPath"); + + b.ToTable("VirtualTag", null, t => + { + t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50"); + + t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT 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.DriverInstance", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster") + .WithMany() + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment") + .WithMany() + .HasForeignKey("DeploymentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Deployment"); + + b.Navigation("Node"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b => + { + b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", null) + .WithMany("RawFolders") + .HasForeignKey("ClusterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + 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.Navigation("Cluster"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ClusterNode", b => + { + b.Navigation("Credentials"); + }); + + modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b => + { + b.Navigation("Nodes"); + + b.Navigation("RawFolders"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260722125506_AddClusterNodeMaintenanceMode.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260722125506_AddClusterNodeMaintenanceMode.cs new file mode 100644 index 00000000..5c308d97 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260722125506_AddClusterNodeMaintenanceMode.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations +{ + /// + public partial class AddClusterNodeMaintenanceMode : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MaintenanceMode", + table: "ClusterNode", + type: "bit", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MaintenanceMode", + table: "ClusterNode"); + } + } +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs index 289ae370..42ebaf07 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/OtOpcUaConfigDbContextModelSnapshot.cs @@ -92,6 +92,9 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations b.Property("LastSeenAt") .HasColumnType("datetime2(3)"); + b.Property("MaintenanceMode") + .HasColumnType("bit"); + b.Property("OpcUaPort") .HasColumnType("int"); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs index 07c17a3b..d955dc58 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Coordinators/ConfigPublishCoordinator.cs @@ -27,8 +27,16 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Coordinators; /// Behaviour change: a configured node that is switched off is now expected, so a /// deployment dispatched while it is down fails at the apply deadline instead of sealing green /// without it. That is deliberate — under the membership rule the operator was told the fleet was -/// deployed when it was not. ClusterNode.Enabled = 0 is the escape hatch for a node taken -/// down for maintenance. +/// deployed when it was not. ClusterNode.MaintenanceMode = 1 is the escape hatch for a node +/// taken down for maintenance. +/// +/// +/// Not Enabled = 0. That was the intended hatch until the Phase 1 live gate found it +/// unusable: DraftValidator.ValidateClusterTopology requires the enabled-node count to equal +/// ServerCluster.NodeCount, so clearing Enabled on either node of a Warm/Hot pair +/// rejects the deployment outright — and every cluster in the target topology is a pair. The two +/// rules were independently reasonable and contradictory together, so the meanings were split: +/// Enabled = part of the declared topology, MaintenanceMode = do not expect it now. /// /// /// Every ClusterNode row is assumed to be a driver node. The membership rule filtered @@ -271,8 +279,8 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers var missing = _expectedAcks.Where(n => !_acks.ContainsKey(n)).Select(n => n.Value).Order().ToList(); _log.Warning( "Deployment {Id} timed out after {Deadline} ({Acked}/{Total} acks landed). " + - "No ack from: {MissingNodes}. Each is an enabled ClusterNode row — start the node, or " + - "set ClusterNode.Enabled = 0 if it is down for maintenance, then redeploy", + "No ack from: {MissingNodes}. Each is an enabled, non-maintenance ClusterNode row — start " + + "the node, or set ClusterNode.MaintenanceMode = 1 if it is down for maintenance, then redeploy", _current.Value, _applyDeadline, _acks.Count, _expectedAcks.Count, string.Join(", ", missing)); ResetForNext(); } @@ -316,7 +324,7 @@ public sealed class ConfigPublishCoordinator : ReceiveActor, IWithTimers private static HashSet DiscoverDriverNodes(OtOpcUaConfigDbContext db) => db.ClusterNodes .AsNoTracking() - .Where(n => n.Enabled) + .Where(n => n.Enabled && !n.MaintenanceMode) .Select(n => n.NodeId) .ToList() .Select(NodeId.Parse) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconciler.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconciler.cs index d35204d6..342408dc 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconciler.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconciler.cs @@ -75,21 +75,22 @@ public static class ClusterNodeAddressReconciler /// them would report a mismatch for correct configuration. /// /// Every ClusterNode row, enabled or not. - /// - /// The subset of that is enabled. Disabled rows are deliberately - /// still matched against membership (a disabled row for a running node is worth knowing) but - /// never reported as — being absent - /// is the whole point of disabling one. + /// + /// The subset of that should currently be in the cluster: enabled and + /// not in maintenance. Excluded rows are deliberately still matched against membership (a + /// disabled row for a running node is worth knowing) but never reported as + /// — being absent is the whole point + /// of disabling one or putting it in maintenance. /// /// Every disagreement found, ordered by node id for stable logging. public static IReadOnlyList Reconcile( IReadOnlyCollection<(string Host, int Port)> observedDriverMembers, IReadOnlyCollection rows, - IReadOnlyCollection enabledNodeIds) + IReadOnlyCollection expectedPresentNodeIds) { var byNodeId = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var row in rows) byNodeId[row.NodeId] = row; - var enabled = new HashSet(enabledNodeIds, StringComparer.OrdinalIgnoreCase); + var expectedPresent = new HashSet(expectedPresentNodeIds, StringComparer.OrdinalIgnoreCase); var findings = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -121,12 +122,12 @@ public static class ClusterNodeAddressReconciler foreach (var row in rows) { - if (seen.Contains(row.NodeId) || !enabled.Contains(row.NodeId)) continue; + if (seen.Contains(row.NodeId) || !expectedPresent.Contains(row.NodeId)) continue; findings.Add(new ClusterNodeAddressMismatch( AddressMismatchKind.EnabledRowNotInCluster, row.NodeId, $"enabled ClusterNode row {row.NodeId} has no matching driver member; the next " + "deployment will fail at the apply deadline waiting for it. Start the node, or set " + - "ClusterNode.Enabled = 0 while it is down for maintenance")); + "ClusterNode.MaintenanceMode = 1 while it is down for maintenance")); } return findings diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconcilerActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconcilerActor.cs index fefba4c0..cb6326d7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconcilerActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/Fleet/ClusterNodeAddressReconcilerActor.cs @@ -89,8 +89,11 @@ public sealed class ClusterNodeAddressReconcilerActor : ReceiveActor, IWithTimer rows = db.ClusterNodes.AsNoTracking() .Select(n => new ClusterNodeAddress(n.NodeId, n.Host, n.AkkaPort)) .ToList(); + // Same predicate the coordinator's expected-ack set uses — a node the deploy path will + // not wait for must not be reported as missing either, or the maintenance hatch trades a + // failed deployment for a permanent warning. enabled = db.ClusterNodes.AsNoTracking() - .Where(n => n.Enabled).Select(n => n.NodeId).ToList(); + .Where(n => n.Enabled && !n.MaintenanceMode).Select(n => n.NodeId).ToList(); } catch (Exception ex) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigPublishCoordinatorDbSourcedAcksTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigPublishCoordinatorDbSourcedAcksTests.cs index 73ebc7d7..95d8a4ac 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigPublishCoordinatorDbSourcedAcksTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/ConfigPublishCoordinatorDbSourcedAcksTests.cs @@ -48,7 +48,7 @@ public sealed class ConfigPublishCoordinatorDbSourcedAcksTests : ControlPlaneAct { var dbFactory = NewInMemoryDbFactory(); var deploymentId = SeedDispatchingDeployment(dbFactory); - SeedNodes(dbFactory, ("site-a-1:4053", true)); + SeedNodes(dbFactory, ("site-a-1:4053", true, false)); var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout)); actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId())); @@ -73,16 +73,18 @@ public sealed class ConfigPublishCoordinatorDbSourcedAcksTests : ControlPlaneAct } /// - /// The Enabled = 0 escape hatch: a disabled row is NOT expected, so a deployment seals - /// without it. This is what makes the "a stopped node now fails the deploy" behaviour change - /// acceptable to ship — without it, a node down for maintenance blocks every deployment. + /// A disabled row is NOT expected, so a deployment seals without it — for a node genuinely + /// decommissioned rather than temporarily down. This is not the maintenance hatch: the + /// topology gate rejects a deployment whose enabled-node count differs from the cluster's + /// NodeCount, so on any 2-node pair clearing Enabled never reaches this code. + /// See . /// [Fact] public void Disabled_ClusterNode_rows_are_not_expected_to_ack() { var dbFactory = NewInMemoryDbFactory(); var deploymentId = SeedDispatchingDeployment(dbFactory); - SeedNodes(dbFactory, ("site-a-1:4053", true), ("site-a-2:4053", false)); + SeedNodes(dbFactory, ("site-a-1:4053", true, false), ("site-a-2:4053", false, false)); var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout)); actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId())); @@ -116,7 +118,7 @@ public sealed class ConfigPublishCoordinatorDbSourcedAcksTests : ControlPlaneAct { var dbFactory = NewInMemoryDbFactory(); var deploymentId = SeedDispatchingDeployment(dbFactory); - SeedNodes(dbFactory, ("site-a-1:4053", true)); + SeedNodes(dbFactory, ("site-a-1:4053", true, false)); var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout)); actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId())); @@ -158,7 +160,7 @@ public sealed class ConfigPublishCoordinatorDbSourcedAcksTests : ControlPlaneAct { var dbFactory = NewInMemoryDbFactory(); var deploymentId = SeedDispatchingDeployment(dbFactory); - SeedNodes(dbFactory, ("site-a-1:4053", false), ("site-a-2:4053", false)); + SeedNodes(dbFactory, ("site-a-1:4053", false, false), ("site-a-2:4053", false, false)); var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout)); actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId())); @@ -171,9 +173,50 @@ public sealed class ConfigPublishCoordinatorDbSourcedAcksTests : ControlPlaneAct }, duration: TimeSpan.FromSeconds(3)); } + /// + /// The maintenance hatch, and the reason it is a separate flag. The Phase 1 live gate set + /// Enabled = 0 on one node of a 2-node pair and the deployment was rejected outright by + /// DraftValidator.ValidateClusterTopology (enabled-node count must equal + /// NodeCount) — so the intended escape hatch could not be used on any pair, which is + /// every cluster in the target topology. MaintenanceMode excludes a node from the + /// expected-ack set while leaving it enabled, so the topology gate still passes. + /// + [Fact] + public void MaintenanceMode_rows_are_not_expected_to_ack_while_staying_enabled() + { + var dbFactory = NewInMemoryDbFactory(); + var deploymentId = SeedDispatchingDeployment(dbFactory); + SeedNodes(dbFactory, ("site-a-1:4053", true, false), ("site-a-2:4053", true, true)); + + // The topology gate counts Enabled, so both nodes still count — this is what Enabled = 0 + // broke. Assert it here so the fix cannot be "quietly disable it after all". + using (var check = dbFactory.CreateDbContext()) + { + check.ClusterNodes.Count(n => n.Enabled).ShouldBe(2); + } + + var actor = Sys.ActorOf(ConfigPublishCoordinator.Props(dbFactory, NoTimeout)); + actor.Tell(new DispatchDeployment(deploymentId, TestRevision, CorrelationId.NewId())); + + AwaitAssert(() => + { + using var db = dbFactory.CreateDbContext(); + db.NodeDeploymentStates.Select(s => s.NodeId).ShouldBe(new[] { "site-a-1:4053" }); + }, duration: TimeSpan.FromSeconds(3)); + + actor.Tell(new ApplyAck(deploymentId, NodeId.Parse("site-a-1:4053"), + ApplyAckOutcome.Applied, null, CorrelationId.NewId())); + + AwaitAssert(() => + { + using var db = dbFactory.CreateDbContext(); + db.Deployments.Single().Status.ShouldBe(DeploymentStatus.Sealed); + }, duration: TimeSpan.FromSeconds(3)); + } + private static void SeedNodes( IDbContextFactory dbFactory, - params (string NodeId, bool Enabled)[] nodes) + params (string NodeId, bool Enabled, bool Maintenance)[] nodes) { using var db = dbFactory.CreateDbContext(); db.ServerClusters.Add(new ServerCluster @@ -186,7 +229,7 @@ public sealed class ConfigPublishCoordinatorDbSourcedAcksTests : ControlPlaneAct RedundancyMode = RedundancyMode.Warm, CreatedBy = "test", }); - foreach (var (nodeId, enabled) in nodes) + foreach (var (nodeId, enabled, maintenance) in nodes) { db.ClusterNodes.Add(new ClusterNode { @@ -195,6 +238,7 @@ public sealed class ConfigPublishCoordinatorDbSourcedAcksTests : ControlPlaneAct Host = nodeId.Split(':')[0], ApplicationUri = $"urn:OtOpcUa:{nodeId}", Enabled = enabled, + MaintenanceMode = maintenance, CreatedBy = "test", }); }