v3 batch1 WP1: greenfield DbContext + V3Initial squash + seeds + tests

DbContext (OtOpcUaConfigDbContext):
- Drop Namespace/EquipmentImportBatch/EquipmentImportRow DbSets + Configure methods.
- Add RawFolder/TagGroup/UnsTagReference DbSets + Configure methods with sibling-name
  filtered unique indexes over nullable parents (two-filtered pattern per level).
- Reshape DriverInstance (RawFolderId + UX_DriverInstance_Folder_Name/_ClusterRoot_Name),
  Tag (required DeviceId, nullable TagGroupId, UX_Tag_Group_Name/_Device_Name, IX_Tag_Device),
  Equipment (drop DriverInstanceId/DeviceId + IX_Equipment_Driver), Device (UX_Device_Driver_Name).

Migration squash -> V3Initial:
- Delete the whole Migrations/ folder; scaffold one V3Initial (applies clean to a fresh DB).
- Port the hand-written procs + AuthorizationGrants into V3Initial.StoredProcedures.cs. The v1
  generation-model procs (ConfigGeneration/GenerationId, dead since V2HostingAlignment) are
  rewritten as v3-schema-valid retirement stubs that preserve the two-role EXECUTE-grant surface
  + AuthorizationTests behaviors; sp_ComputeGenerationDiff now references the v3 tables;
  sp_ReleaseExternalIdReservation ported verbatim.

Compile-fixes for the build gate (Wave-C-owned, minimal + TODO(v3 WP4) markers):
- DraftSnapshot/DraftSnapshotFactory: drop the retired Namespaces list.
- DraftValidator: delete namespace-binding + Galaxy-FullName rules; collision rule now VirtualTag-only.

Seeds -> v3 schema:
- seed-clusters.sql summary SELECTs; all 5 scripts/smoke/seed-*.sql rewritten (DriverInstance
  RawFolderId, Device+DeviceConfig endpoint, Tag DeviceId, UnsTagReference; no ConfigGeneration/
  Namespace/sp_PublishGeneration). Verified: all seeds apply clean against a fresh V3Initial DB.

Tests (Configuration.Tests, 107 pass):
- SchemaComplianceTests rewritten to v3 tables + filtered indexes.
- New RawSchemaIntegrityTests: DB-enforced sibling-name uniqueness per raw level + UnsTagReference
  (Equipment,Tag) uniqueness.
- Delete DraftValidatorGalaxyFullNameCorpusTests (WP2/WP6 replaces); fix DraftValidatorTests +
  DraftSnapshotFactoryTests to v3 shapes; repoint scripting-migration existence test to V3Initial.
This commit is contained in:
Joseph Doherty
2026-07-15 19:19:21 -04:00
parent cb720bb8c3
commit 4d39b98564
54 changed files with 2523 additions and 30115 deletions
+4 -4
View File
@@ -123,13 +123,13 @@ COMMIT TRANSACTION;
-- Summary (logged by sqlcmd output)
------------------------------------------------------------------------------
-- v3: the Namespace table is retired; DriverInstance is folder-scoped (RawFolderId) and Tags
-- are device-scoped (DeviceId + optional TagGroupId). This seed only creates clusters + nodes +
-- LDAP role mappings, so the summary reflects just those. Raw-tree config (RawFolder/DriverInstance/
-- Device/TagGroup/Tag) is authored via the AdminUI /raw page in later batches.
SELECT ClusterId, Name, NodeCount, RedundancyMode FROM dbo.ServerCluster ORDER BY ClusterId;
SELECT NodeId, ClusterId, Host, OpcUaPort, ApplicationUri, ServiceLevelBase
FROM dbo.ClusterNode ORDER BY ClusterId, NodeId;
SELECT NamespaceId, ClusterId, Kind, NamespaceUri FROM dbo.Namespace ORDER BY ClusterId, NamespaceId;
SELECT DriverInstanceId, ClusterId, DriverType, NamespaceId, Name
FROM dbo.DriverInstance ORDER BY ClusterId, DriverInstanceId;
SELECT TagId, DriverInstanceId, FolderPath, Name, DataType FROM dbo.Tag ORDER BY DriverInstanceId, FolderPath, Name;
------------------------------------------------------------------------------
-- LDAP group -> AdminUI role mappings (shared dev GLAuth, 10.100.0.35)
+43 -97
View File
@@ -1,128 +1,74 @@
-- AB CIP e2e smoke seed — closes #211 (umbrella #209).
-- AB CIP (Logix) e2e smoke seed — v3 schema (Raw tree: DriverInstance → Device → Tag; UNS by ref).
--
-- One-cluster seed pointing at the ab_server ControlLogix fixture
-- (`docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d`).
-- Publishes a single `TestDINT:DInt` tag under NodeId `ns=<N>;s=TestDINT`
-- (ab_server seeds this tag by default).
--
-- Usage:
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-abcip-smoke.sql
--
-- After seeding, point appsettings at this cluster:
-- Node:NodeId = "abcip-smoke-node"
-- Node:ClusterId = "abcip-smoke"
-- Then start server + run `./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"`.
-- Idempotent DELETE-and-recreate pointing at the CIP fixture (ab://127.0.0.1:44818/1,0). v3: the
-- ConfigGeneration/Namespace model + sp_PublishGeneration are gone; the per-device HostAddress moved
-- from DriverConfig.Devices[] into the Device's DeviceConfig (the deviceHostAddress tag key is dropped
-- — the tag's DeviceId FK owns placement now).
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 'abcip-smoke';
DECLARE @NodeId nvarchar(64) = 'abcip-smoke-node';
DECLARE @DrvId nvarchar(64) = 'abcip-smoke-drv';
DECLARE @NsId nvarchar(64) = 'abcip-smoke-ns';
DECLARE @DevId nvarchar(64) = 'abcip-smoke-dev';
DECLARE @AreaId nvarchar(64) = 'abcip-smoke-area';
DECLARE @LineId nvarchar(64) = 'abcip-smoke-line';
DECLARE @EqId nvarchar(64) = 'abcip-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '41BC12E0-41BC-412E-841B-C12E041BC12E';
DECLARE @TagId nvarchar(64) = 'abcip-smoke-tag-testdint';
DECLARE @EqUuid uniqueidentifier = 'ABC17000-ABC1-45E0-9ABC-17000ABC1700';
DECLARE @Tag nvarchar(64) = 'abcip-smoke-tag-dint';
DECLARE @RefId nvarchar(64) = 'abcip-smoke-ref-dint';
BEGIN TRAN;
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.UnsTagReference WHERE UnsTagReferenceId = @RefId;
DELETE FROM dbo.Tag WHERE TagId = @Tag;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.Device WHERE DeviceId = @DevId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'AB CIP Smoke', 'zb', 'lab', 1, 'None', 1, 'abcip-smoke');
VALUES (@ClusterId, 'AbCip Smoke', 'zb', 'lab', 1, 'None', 1, 'abcip-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
INSERT dbo.ClusterNode(NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
'urn:OtOpcUa:abcip-smoke-node', 200, 1, 'abcip-smoke');
VALUES (@NodeId, @ClusterId, 'localhost', 4840, 15050, 'urn:OtOpcUa:abcip-smoke-node', 200, 1, 'abcip-smoke');
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'abcip-smoke');
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 'abcip-smoke');
SET @Gen = SCOPE_IDENTITY();
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:abcip-smoke:eq', 1);
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 'abcip-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'ab-sim', 'abcip-001', 1);
-- AB CIP DriverInstance — single ControlLogix device at the ab_server fixture
-- gateway. DriverConfig shape mirrors AbCipDriverConfigDto.
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ab-server-smoke', 'AbCip', N'{
"TimeoutMs": 2000,
"Devices": [
{
"HostAddress": "ab://127.0.0.1:44818/1,0",
"PlcFamily": "ControlLogix",
"DeviceName": "ab-server"
}
],
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000 },
"Tags": [
{
"Name": "TestDINT",
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
"TagPath": "TestDINT",
"DataType": "DInt",
"Writable": true,
"WriteIdempotent": true
}
]
-- DriverConfig keeps protocol/channel settings; the per-device Devices[] list is retired in favor
-- of Device rows carrying HostAddress in DeviceConfig.
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES (@DrvId, @ClusterId, NULL, 'logix-smoke', 'AbCip', N'{
"EnableControllerBrowse": false,
"ProbeTimeoutSeconds": 5
}', 1);
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'TestDINT', 'Int32', 'ReadWrite',
N'{"FullName":"TestDINT","DataType":"DInt"}', 1);
-- DeviceConfig shape finalized against reworked options in Wave B (WP5).
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES (@DevId, @DrvId, 'plc', 1, N'{ "HostAddress": "ab://127.0.0.1:44818/1,0" }');
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'AB CIP smoke — task #211';
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@Tag, @DevId, NULL, 'TestDINT', 'DInt', 'ReadWrite', 1,
N'{ "address": "TestDINT", "dataType": "DInt" }');
INSERT dbo.UnsArea(UnsAreaId, ClusterId, Name) VALUES (@AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(UnsLineId, UnsAreaId, Name) VALUES (@LineId, @AreaId, 'abcip-line');
INSERT dbo.Equipment(EquipmentId, EquipmentUuid, UnsLineId, Name, MachineCode, Enabled)
VALUES (@EqId, @EqUuid, @LineId, 'logix-sim', 'abcip-001', 1);
INSERT dbo.UnsTagReference(UnsTagReferenceId, EquipmentId, TagId, SortOrder)
VALUES (@RefId, @EqId, @Tag, 0);
COMMIT;
PRINT '';
PRINT 'AB CIP smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId;
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "abcip-smoke-node"';
PRINT ' Node:ClusterId = "abcip-smoke"';
PRINT ' 2. docker compose -f tests/.../AbCip.IntegrationTests/Docker/docker-compose.yml --profile controllogix up -d';
PRINT ' 3. dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. ./scripts/e2e/test-abcip.ps1 -BridgeNodeId "ns=2;s=TestDINT"';
PRINT 'AbCip smoke seed complete (v3 schema). Cluster: ' + @ClusterId + ' Node: ' + @NodeId;
+43 -94
View File
@@ -1,125 +1,74 @@
-- AB Legacy e2e smoke seed — closes #213 (umbrella #209).
-- AB Legacy (PCCC / SLC-500, MicroLogix) e2e smoke seed — v3 schema (DriverInstance → Device → Tag).
--
-- Works against the ab_server PCCC Docker fixture (one of the slc500 /
-- micrologix / plc5 compose profiles) or real SLC 500 / MicroLogix / PLC-5
-- hardware. Default HostAddress below points at the Docker fixture with a
-- `/1,0` cip-path; libplctag's ab_server rejects empty paths before routing
-- to the PCCC dispatcher. Real hardware uses an empty path — change the
-- HostAddress to `ab://<plc-ip>:44818/` (note the trailing slash with nothing
-- after) before running the seed for that setup.
--
-- Usage:
-- docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/Docker/docker-compose.yml --profile slc500 up -d
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-ablegacy-smoke.sql
-- Idempotent DELETE-and-recreate pointing at the fixture (ab://127.0.0.1:44818/1,0). To target real
-- hardware, edit the Device's DeviceConfig HostAddress to end with /<empty> (trailing slash, no path).
-- v3: the ConfigGeneration/Namespace model + sp_PublishGeneration are gone; the per-device HostAddress
-- moved from DriverConfig.Devices[] into the Device's DeviceConfig (the deviceHostAddress tag key is
-- dropped — the tag's DeviceId FK owns placement now).
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 'ablegacy-smoke';
DECLARE @NodeId nvarchar(64) = 'ablegacy-smoke-node';
DECLARE @DrvId nvarchar(64) = 'ablegacy-smoke-drv';
DECLARE @NsId nvarchar(64) = 'ablegacy-smoke-ns';
DECLARE @DevId nvarchar(64) = 'ablegacy-smoke-dev';
DECLARE @AreaId nvarchar(64) = 'ablegacy-smoke-area';
DECLARE @LineId nvarchar(64) = 'ablegacy-smoke-line';
DECLARE @EqId nvarchar(64) = 'ablegacy-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '5A1D2030-5A1D-4203-A5A1-D20305A1D203';
DECLARE @TagId nvarchar(64) = 'ablegacy-smoke-tag-n7_5';
DECLARE @EqUuid uniqueidentifier = 'AB1E9AC0-AB1E-45E0-9AB1-E9AC0AB1E9AC';
DECLARE @Tag nvarchar(64) = 'ablegacy-smoke-tag-n7-5';
DECLARE @RefId nvarchar(64) = 'ablegacy-smoke-ref-n7-5';
BEGIN TRAN;
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.UnsTagReference WHERE UnsTagReferenceId = @RefId;
DELETE FROM dbo.Tag WHERE TagId = @Tag;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.Device WHERE DeviceId = @DevId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'AB Legacy Smoke', 'zb', 'lab', 1, 'None', 1, 'ablegacy-smoke');
VALUES (@ClusterId, 'AbLegacy Smoke', 'zb', 'lab', 1, 'None', 1, 'ablegacy-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
INSERT dbo.ClusterNode(NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
'urn:OtOpcUa:ablegacy-smoke-node', 200, 1, 'ablegacy-smoke');
VALUES (@NodeId, @ClusterId, 'localhost', 4840, 15050, 'urn:OtOpcUa:ablegacy-smoke-node', 200, 1, 'ablegacy-smoke');
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'ablegacy-smoke');
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 'ablegacy-smoke');
SET @Gen = SCOPE_IDENTITY();
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:ablegacy-smoke:eq', 1);
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 'ablegacy-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'slc-sim', 'ablegacy-001', 1);
-- AB Legacy DriverInstance — SLC 500 target. Replace the placeholder gateway
-- `192.168.1.10` with the real PLC / RSEmulate host before running.
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'ablegacy-smoke', 'AbLegacy', N'{
"TimeoutMs": 2000,
"Devices": [
{
"HostAddress": "ab://127.0.0.1:44818/1,0",
"PlcFamily": "Slc500",
"DeviceName": "slc-500"
}
],
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "S:0" },
"Tags": [
{
"Name": "N7_5",
"DeviceHostAddress": "ab://127.0.0.1:44818/1,0",
"Address": "N7:5",
"DataType": "Int",
"Writable": true,
"WriteIdempotent": true
}
]
-- DriverConfig keeps protocol/channel settings; per-device HostAddress moves to DeviceConfig.
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES (@DrvId, @ClusterId, NULL, 'slc-smoke', 'AbLegacy', N'{
"ProbeTimeoutSeconds": 5,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "S:0" }
}', 1);
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'N7_5', 'Int16', 'ReadWrite',
N'{"FullName":"N7_5","Address":"N7:5","DataType":"Int"}', 1);
-- DeviceConfig shape finalized against reworked options in Wave B (WP5).
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES (@DevId, @DrvId, 'plc', 1, N'{ "HostAddress": "ab://127.0.0.1:44818/1,0" }');
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'AB Legacy smoke — task #213';
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@Tag, @DevId, NULL, 'N7_5', 'Int', 'ReadWrite', 1,
N'{ "address": "N7:5", "dataType": "Int" }');
INSERT dbo.UnsArea(UnsAreaId, ClusterId, Name) VALUES (@AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(UnsLineId, UnsAreaId, Name) VALUES (@LineId, @AreaId, 'ablegacy-line');
INSERT dbo.Equipment(EquipmentId, EquipmentUuid, UnsLineId, Name, MachineCode, Enabled)
VALUES (@EqId, @EqUuid, @LineId, 'slc-sim', 'ablegacy-001', 1);
INSERT dbo.UnsTagReference(UnsTagReferenceId, EquipmentId, TagId, SortOrder)
VALUES (@RefId, @EqId, @Tag, 0);
COMMIT;
PRINT '';
PRINT 'AB Legacy smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId;
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'NOTE: default points at the ab_server slc500 Docker fixture with a /1,0';
PRINT ' cip-path (required by ab_server). For real SLC/MicroLogix/PLC-5';
PRINT ' hardware, edit the DriverConfig HostAddress to end with /<empty>';
PRINT ' e.g. "ab://<plc-ip>:44818/" and re-run this seed.';
PRINT 'AbLegacy smoke seed complete (v3 schema). Cluster: ' + @ClusterId + ' Node: ' + @NodeId;
+54 -109
View File
@@ -1,156 +1,101 @@
-- Modbus e2e smoke seed — closes #210 (umbrella #209).
-- Modbus e2e smoke seed — v3 schema (Raw tree: DriverInstance → Device → Tag; UNS by reference).
--
-- Idempotent — DROP-and-recreate of one cluster's worth of Modbus test config:
-- * 1 ServerCluster ('modbus-smoke') + ClusterNode ('modbus-smoke-node')
-- * 1 ConfigGeneration (Draft → Published at the end)
-- * 1 Namespace + UnsArea + UnsLine + Equipment
-- * 1 Modbus DriverInstance pointing at the pymodbus standard fixture
-- (127.0.0.1:5020 per tests/.../Modbus.IntegrationTests/Docker)
-- * 1 Tag at HR[200]:UInt16 (HR[100] is auto-increment in standard.json,
-- Idempotent — DELETE-and-recreate of one cluster's worth of Modbus test config:
-- * 1 ServerCluster ('modbus-smoke') + ClusterNode ('modbus-smoke-node') + SqlLogin credential
-- * 1 Modbus DriverInstance at the cluster root (RawFolderId NULL)
-- * 1 Device carrying the pymodbus endpoint in DeviceConfig
-- * 1 Tag at HR[200]:UInt16 under the device (HR[100] is auto-increment in standard.json,
-- unusable as a write target — the e2e script uses HR[200] for that reason)
-- * 1 UnsArea/UnsLine/Equipment + a UnsTagReference projecting the raw tag into UNS
--
-- v3 changes vs. the pre-v2 original: the ConfigGeneration/GenerationId/Namespace model and the
-- sp_PublishGeneration publish step are gone (deploy is a C# ControlPlane operation now); the
-- connection endpoint moved from DriverConfig into the Device's DeviceConfig.
--
-- Usage:
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-modbus-smoke.sql
--
-- After seeding, update src/.../Server/appsettings.json:
-- Node:NodeId = "modbus-smoke-node"
-- Node:ClusterId = "modbus-smoke"
--
-- Then start the simulator + server + run the e2e script:
-- docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d
-- dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Server
-- ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"
-- sqlcmd -S "10.100.0.35,14330" -d <cfgdb> -U sa -P "<pw>" -i scripts/smoke/seed-modbus-smoke.sql
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 'modbus-smoke';
DECLARE @NodeId nvarchar(64) = 'modbus-smoke-node';
DECLARE @DrvId nvarchar(64) = 'modbus-smoke-drv';
DECLARE @NsId nvarchar(64) = 'modbus-smoke-ns';
DECLARE @DevId nvarchar(64) = 'modbus-smoke-dev';
DECLARE @AreaId nvarchar(64) = 'modbus-smoke-area';
DECLARE @LineId nvarchar(64) = 'modbus-smoke-line';
DECLARE @EqId nvarchar(64) = 'modbus-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '72BD5A10-72BD-45A1-B72B-D5A1072BD5A1';
DECLARE @TagHr200 nvarchar(64) = 'modbus-smoke-tag-hr200';
DECLARE @RefId nvarchar(64) = 'modbus-smoke-ref-hr200';
BEGIN TRAN;
-- Clean prior smoke state (child rows first).
DELETE FROM dbo.Tag WHERE TagId IN (@TagHr200);
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.UnsTagReference WHERE UnsTagReferenceId = @RefId;
DELETE FROM dbo.Tag WHERE TagId = @TagHr200;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.Device WHERE DeviceId = @DevId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
-- `UX_ClusterNodeCredential_Value` is a unique index on (Kind, Value) WHERE
-- Enabled=1, so a `sa` login can only bind to one node at a time. Drop any
-- prior smoke cluster's binding before we claim the login for this one.
-- `UX_ClusterNodeCredential_Value` is a unique index on (Kind, Value) WHERE Enabled=1, so the
-- `sa` login can only bind to one node at a time. Drop any prior smoke binding first.
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
-- 1. Cluster + Node.
-- 1. Cluster + Node + credential binding.
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'Modbus Smoke', 'zb', 'lab', 1, 'None', 1, 'modbus-smoke');
-- DashboardPort 15050 rather than 5000 — HttpListener on :5000 requires
-- URL-ACL reservation or admin rights on Windows (HttpListenerException 32).
-- 15000+ ports are unreserved by default. Safe to change back when deploying
-- with a netsh urlacl grant or reverse-proxy fronting :5000.
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
INSERT dbo.ClusterNode(NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
VALUES (@NodeId, @ClusterId, 'localhost', 4840, 15050,
'urn:OtOpcUa:modbus-smoke-node', 200, 1, 'modbus-smoke');
-- Bind the SQL login this smoke test connects as to the node identity. The
-- sp_GetCurrentGenerationForCluster + sp_UpdateClusterNodeGenerationState
-- sprocs raise RAISERROR('Unauthorized: caller %s is not bound to NodeId %s')
-- when this row is missing. `Kind='SqlLogin'` / `Value='sa'` matches the
-- container's SA user; rotate Value for real deployments using a non-SA login.
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 'modbus-smoke');
-- 2. Draft generation.
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 'modbus-smoke');
SET @Gen = SCOPE_IDENTITY();
-- 3. Namespace.
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:modbus-smoke:eq', 1);
-- 4. UNS hierarchy.
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 'modbus-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'modbus-sim', 'modbus-001', 1);
-- 5. Modbus DriverInstance. DriverConfig mirrors ModbusDriverConfigDto
-- (mapped to ModbusDriverOptions by ModbusDriverFactoryExtensions).
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'pymodbus-smoke', 'Modbus', N'{
"Host": "127.0.0.1",
"Port": 5020,
"UnitId": 1,
-- 2. Modbus DriverInstance at the cluster root (RawFolderId NULL). DriverConfig keeps protocol/
-- channel settings only — the endpoint (Host/Port/UnitId) now lives in the Device's DeviceConfig.
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES (@DrvId, @ClusterId, NULL, 'pymodbus-smoke', 'Modbus', N'{
"TimeoutMs": 2000,
"AutoReconnect": true,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": 0 },
"Tags": [
{
"Name": "HR200",
"Region": "HoldingRegisters",
"Address": 200,
"DataType": "UInt16",
"Writable": true,
"WriteIdempotent": true
}
]
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": 0 }
}', 1);
-- 6. Tag row bound to the Equipment. Driver reports the same tag via
-- DiscoverAsync + the walker maps the UnsArea/Line/Equipment/Tag path to the
-- driver's folder/variable (NodeId ends up ns=<driver-ns>;s=HR200 per
-- ModbusDriver.DiscoverAsync using FullName = tag.Name).
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagHr200, @DrvId, @EqId, 'HR200', 'UInt16', 'ReadWrite',
N'{"FullName":"HR200","DataType":"UInt16"}', 1);
-- 3. Device — endpoint. DeviceConfig shape finalized against reworked options in Wave B (WP5).
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES (@DevId, @DrvId, 'plc', 1, N'{ "Host": "127.0.0.1", "Port": 5020, "UnitId": 1 }');
-- 7. Publish the generation — flips Status Draft → Published, merges
-- ExternalIdReservation, claims cluster write lock.
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'Modbus smoke — task #210';
-- 4. Raw tag under the device (identity = RawPath; TagConfig carries the driver address only).
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@TagHr200, @DevId, NULL, 'HR200', 'UInt16', 'ReadWrite', 1,
N'{ "region": "HoldingRegisters", "address": 200, "dataType": "UInt16", "writable": true }');
-- 5. UNS hierarchy + a reference projecting the raw tag into the equipment.
INSERT dbo.UnsArea(UnsAreaId, ClusterId, Name) VALUES (@AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(UnsLineId, UnsAreaId, Name) VALUES (@LineId, @AreaId, 'modbus-line');
INSERT dbo.Equipment(EquipmentId, EquipmentUuid, UnsLineId, Name, MachineCode, Enabled)
VALUES (@EqId, @EqUuid, @LineId, 'modbus-sim', 'modbus-001', 1);
INSERT dbo.UnsTagReference(UnsTagReferenceId, EquipmentId, TagId, SortOrder)
VALUES (@RefId, @EqId, @TagHr200, 0);
COMMIT;
PRINT '';
PRINT 'Modbus smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId;
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT 'Modbus smoke seed complete (v3 schema).';
PRINT ' Cluster: ' + @ClusterId + ' Node: ' + @NodeId;
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "modbus-smoke-node"';
PRINT ' Node:ClusterId = "modbus-smoke"';
PRINT ' 1. Set Server appsettings Node:NodeId = "modbus-smoke-node" / Node:ClusterId = "modbus-smoke"';
PRINT ' 2. docker compose -f tests/.../Modbus.IntegrationTests/Docker/docker-compose.yml --profile standard up -d';
PRINT ' 3. dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. ./scripts/e2e/test-modbus.ps1 -BridgeNodeId "ns=2;s=HR200"';
PRINT ' 4. Deploy the config via the AdminUI/ControlPlane, then ./scripts/e2e/test-modbus.ps1';
+70 -136
View File
@@ -1,186 +1,120 @@
-- Phase 7 live OPC UA E2E smoke seed (task #240).
-- Phase 7 live OPC UA E2E smoke seed — v3 schema (Raw tree + UNS-by-reference + scripting).
--
-- Idempotent — DROP-and-recreate of one cluster's worth of test config:
-- * 1 ServerCluster ('p7-smoke')
-- * 1 ClusterNode ('p7-smoke-node')
-- * 1 ConfigGeneration (created Draft, then flipped to Published at the end)
-- * 1 Namespace (Equipment kind)
-- * 1 UnsArea / UnsLine / Equipment / Tag — Tag bound to a real Galaxy attribute
-- * 1 DriverInstance (Galaxy)
-- * 1 Script + 1 VirtualTag using it
-- * 1 Script + 1 ScriptedAlarm using it
-- Idempotent DELETE-and-recreate of one cluster's worth of test config:
-- * 1 ServerCluster ('p7-smoke') + ClusterNode + SqlLogin credential
-- * 1 Galaxy (GalaxyMxGateway) DriverInstance at the cluster root + 1 Device (gateway endpoint)
-- * 1 raw Tag bound to a Galaxy attribute (identity = RawPath; address in TagConfig.attributeRef)
-- * 1 UnsArea / UnsLine / Equipment + a UnsTagReference projecting the raw tag into UNS
-- * 1 Script + 1 VirtualTag using it; 1 Script + 1 ScriptedAlarm using it
--
-- Drop & re-create deletes ALL rows scoped to the cluster (in dependency order)
-- so re-running this script after a code change starts from a clean state.
-- Table-level CHECK constraints are validated on insert; if a constraint is
-- violated this script aborts with the offending row's column.
--
-- Usage:
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-phase-7-smoke.sql
-- v3 changes: ConfigGeneration/GenerationId/Namespace model + sp_PublishGeneration are gone (deploy
-- is a C# ControlPlane operation); Galaxy driver type is 'GalaxyMxGateway'; the gateway endpoint
-- lives in the Device's DeviceConfig; ctx.GetTag / alarm-message tokens take a RawPath. The exact
-- Galaxy TagConfig key ('attributeRef') + scripting RawPath contract are finalized in WP2/WP3/WP4.
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 'p7-smoke';
DECLARE @NodeId nvarchar(64) = 'p7-smoke-node';
DECLARE @DrvId nvarchar(64) = 'p7-smoke-galaxy';
DECLARE @NsId nvarchar(64) = 'p7-smoke-ns';
DECLARE @DevId nvarchar(64) = 'p7-smoke-dev';
DECLARE @AreaId nvarchar(64) = 'p7-smoke-area';
DECLARE @LineId nvarchar(64) = 'p7-smoke-line';
DECLARE @EqId nvarchar(64) = 'p7-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '5B2CF10D-5B2C-4F10-B5B2-CF10D5B2CF10';
DECLARE @TagId nvarchar(64) = 'p7-smoke-tag-source';
DECLARE @RefId nvarchar(64) = 'p7-smoke-ref-source';
DECLARE @VtScript nvarchar(64) = 'p7-smoke-script-vt';
DECLARE @AlScript nvarchar(64) = 'p7-smoke-script-al';
DECLARE @VtId nvarchar(64) = 'p7-smoke-vt-derived';
DECLARE @AlId nvarchar(64) = 'p7-smoke-al-overtemp';
-- RawPath of the source tag: <driver>/<device>/<tag> (cluster root, no folder/group).
DECLARE @RawPath nvarchar(256) = N'galaxy-smoke/plc/Source';
BEGIN TRAN;
-- Wipe any prior smoke state. Order matters: child rows first.
DELETE s FROM dbo.ScriptedAlarmState s
WHERE s.ScriptedAlarmId = @AlId;
DELETE FROM dbo.ScriptedAlarm WHERE ScriptedAlarmId = @AlId;
DELETE FROM dbo.VirtualTag WHERE VirtualTagId = @VtId;
DELETE FROM dbo.Script WHERE ScriptId IN (@VtScript, @AlScript);
DELETE FROM dbo.Tag WHERE TagId = @TagId;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
-- Wipe prior smoke state. Order matters: child rows first.
DELETE FROM dbo.ScriptedAlarmState WHERE ScriptedAlarmId = @AlId;
DELETE FROM dbo.ScriptedAlarm WHERE ScriptedAlarmId = @AlId;
DELETE FROM dbo.VirtualTag WHERE VirtualTagId = @VtId;
DELETE FROM dbo.Script WHERE ScriptId IN (@VtScript, @AlScript);
DELETE FROM dbo.UnsTagReference WHERE UnsTagReferenceId = @RefId;
DELETE FROM dbo.Tag WHERE TagId = @TagId;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.Device WHERE DeviceId = @DevId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
-- 1. Cluster + Node
-- 1. Cluster + Node + credential binding.
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'P7 Smoke', 'zb', 'lab', 1, 'None', 1, 'p7-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
INSERT dbo.ClusterNode(NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 5000,
'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke');
VALUES (@NodeId, @ClusterId, 'localhost', 4840, 5000, 'urn:OtOpcUa:p7-smoke-node', 200, 1, 'p7-smoke');
-- sp_GetCurrentGenerationForCluster gates access by SUSER_SNAME() against
-- ClusterNodeCredential; without this binding the Server bootstrap fails with
-- `Unauthorized: caller sa is not bound to NodeId p7-smoke-node`. Dev Docker
-- SQL runs with `sa`; production deploys would rotate to a per-node login.
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, N'SqlLogin', N'sa', 1, N'p7-smoke');
-- 2. Generation (created Draft, flipped to Published at the end so insert order
-- constraints (one Draft per cluster, etc.) don't fight us).
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 'p7-smoke');
SET @Gen = SCOPE_IDENTITY();
-- 3. Namespace
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:p7-smoke:eq', 1);
-- 4. UNS hierarchy
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 'galaxy-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 'reactor-1', 'p7-rx-001', 1);
-- 5. Driver — Galaxy proxy. DriverConfig JSON tells the proxy how to reach the
-- already-running OtOpcUaGalaxyHost. Secret + pipe name match
-- .local/galaxy-host-secret.txt + the OtOpcUaGalaxyHost service env.
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'galaxy-smoke', 'Galaxy', N'{
"DriverInstanceId": "p7-smoke-galaxy",
"PipeName": "OtOpcUaGalaxy",
"SharedSecret": "4hgDJ4jLcKXmOmD1Ara8xtE8N3R47Q2y1Xf/Eama/Fk=",
-- 2. Galaxy driver at the cluster root. DriverConfig keeps non-endpoint settings; the mxaccessgw
-- gateway endpoint lives in the Device's DeviceConfig (shape finalized in Wave B / WP5).
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES (@DrvId, @ClusterId, NULL, 'galaxy-smoke', 'GalaxyMxGateway', N'{
"ConnectTimeoutMs": 10000
}', 1);
-- 6. One driver-sourced Tag bound to the Equipment. TagConfig is the Galaxy
-- fullRef; the EquipmentNodeWalker reads the JSON's FullName field and
-- hands that string to the Galaxy driver as the MXAccess reference.
-- Default points at TestMachine_001.TestHistoryValue — the live dev-box
-- Galaxy ships it as Int32 + writable (security_classification=Operate)
-- + historized (HistoryExtension primitive), so the E2E script can
-- exercise read + write + subscribe + alarm + history against the
-- same attribute. Swap to any other writable historized attribute on
-- this Galaxy by re-running `gr/queries/attributes_extended.sql` with
-- `WHERE is_historized=1 AND security_classification > 0`.
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'Source', 'Int32', 'ReadWrite',
N'{"FullName":"TestMachine_001.TestHistoryValue","DataType":"Int32"}', 0);
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES (@DevId, @DrvId, 'plc', 1, N'{ "GatewayAddress": "http://127.0.0.1:5001", "ClientName": "p7-smoke" }');
-- 7. Scripts (SourceHash is SHA-256 of SourceCode, computed externally — using
-- a placeholder here; the engine recomputes on first use anyway).
--
-- MachineStatus predicate — the dynamic "is the machine running?" status
-- derived from the raw Source value. Boolean: true when Source > 0. Matches
-- the shape Aveva operators typically want on a machine-status tile (green
-- when above zero, otherwise grey) without needing a separate threshold
-- attribute. Rename + re-threshold here to mirror site semantics.
INSERT dbo.Script(GenerationId, ScriptId, Name, SourceCode, SourceHash, Language)
-- 3. Raw tag bound to a Galaxy attribute. Identity is its RawPath; the MXAccess reference lives in
-- TagConfig.attributeRef (v3 Galaxy address key; finalized in WP2/WP3).
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@TagId, @DevId, NULL, 'Source', 'Int32', 'ReadWrite', 0,
N'{ "attributeRef": "TestMachine_001.TestHistoryValue", "dataType": "Int32" }');
-- 4. UNS hierarchy + reference into the equipment.
INSERT dbo.UnsArea(UnsAreaId, ClusterId, Name) VALUES (@AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(UnsLineId, UnsAreaId, Name) VALUES (@LineId, @AreaId, 'galaxy-line');
INSERT dbo.Equipment(EquipmentId, EquipmentUuid, UnsLineId, Name, MachineCode, Enabled)
VALUES (@EqId, @EqUuid, @LineId, 'reactor-1', 'p7-rx-001', 1);
INSERT dbo.UnsTagReference(UnsTagReferenceId, EquipmentId, TagId, SortOrder)
VALUES (@RefId, @EqId, @TagId, 0);
-- 5. Scripts. ctx.GetTag takes a RawPath in v3 (SourceHash placeholder; the engine recomputes).
INSERT dbo.Script(ScriptId, Name, SourceCode, SourceHash, Language)
VALUES
(@Gen, @VtScript, 'machine-status-predicate',
N'return System.Convert.ToInt32(ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 0;',
(@VtScript, 'machine-status-predicate',
N'return System.Convert.ToInt32(ctx.GetTag("' + @RawPath + N'").Value) > 0;',
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp'),
(@Gen, @AlScript, 'overtemp-predicate',
N'return System.Convert.ToInt32(ctx.GetTag("/lab-floor/galaxy-line/reactor-1/Source").Value) > 50;',
(@AlScript, 'overtemp-predicate',
N'return System.Convert.ToInt32(ctx.GetTag("' + @RawPath + N'").Value) > 50;',
'0000000000000000000000000000000000000000000000000000000000000000', 'CSharp');
-- 8. VirtualTag — MachineStatus boolean computed by Roslyn each time Source
-- changes. Historized so the dashboard can plot a running/idle timeline
-- next to the raw TestHistoryValue trend from Aveva Historian.
INSERT dbo.VirtualTag(GenerationId, VirtualTagId, EquipmentId, Name, DataType,
-- 6. VirtualTag — MachineStatus boolean computed on each Source change.
INSERT dbo.VirtualTag(VirtualTagId, EquipmentId, Name, DataType,
ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled)
VALUES (@Gen, @VtId, @EqId, 'MachineStatus', 'Boolean', @VtScript, 1, NULL, 1, 1);
VALUES (@VtId, @EqId, 'MachineStatus', 'Boolean', @VtScript, 1, NULL, 1, 1);
-- 9. ScriptedAlarm — Active when Source > 50.
INSERT dbo.ScriptedAlarm(GenerationId, ScriptedAlarmId, EquipmentId, Name, AlarmType,
-- 7. ScriptedAlarm — Active when Source > 50. {token} is a RawPath in v3.
INSERT dbo.ScriptedAlarm(ScriptedAlarmId, EquipmentId, Name, AlarmType,
Severity, MessageTemplate, PredicateScriptId,
HistorizeToAveva, Retain, Enabled)
VALUES (@Gen, @AlId, @EqId, 'OverTemp', 'LimitAlarm', 800,
N'Reactor source value {/lab-floor/galaxy-line/reactor-1/Source} exceeded 50',
VALUES (@AlId, @EqId, 'OverTemp', 'LimitAlarm', 800,
N'Reactor source value {' + @RawPath + N'} exceeded 50',
@AlScript, 1, 1, 1);
-- 10. Publish — flip the generation Status. sp_PublishGeneration takes
-- concurrency locks + does ExternalIdReservation merging; we drive it via
-- EXEC rather than UPDATE so the rest of the publish workflow runs.
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'Phase 7 live smoke — task #240';
COMMIT;
PRINT '';
PRINT 'Phase 7 smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId + ' (set Node:NodeId in appsettings.json)';
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Edit src/Server/ZB.MOM.WW.OtOpcUa.Server/appsettings.json:';
PRINT ' Node:NodeId = "p7-smoke-node"';
PRINT ' Node:ClusterId = "p7-smoke"';
PRINT ' 2. Edit the placeholder Galaxy attribute in dbo.Tag.TagConfig above';
PRINT ' so it points at a real attribute on this Galaxy — replace';
PRINT ' REPLACE_WITH_REAL_GALAXY_ATTRIBUTE with e.g. "Plant1.Reactor1.Temp".';
PRINT ' 3. Start the Server in a non-elevated shell so the Galaxy.Host pipe ACL';
PRINT ' accepts the connection:';
PRINT ' dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. Validate via Client.CLI per docs/v2/implementation/phase-7-e2e-smoke.md';
PRINT 'Phase 7 smoke seed complete (v3 schema). Cluster: ' + @ClusterId + ' Node: ' + @NodeId;
PRINT 'Next: set Server Node:NodeId/ClusterId, deploy via the ControlPlane, then run the Client.CLI checks.';
+41 -94
View File
@@ -1,127 +1,74 @@
-- S7 e2e smoke seed — closes #212 (umbrella #209).
-- S7 e2e smoke seed — v3 schema (Raw tree: DriverInstance → Device → Tag; UNS by reference).
--
-- One-cluster seed pointing at the python-snap7 fixture
-- (`docker compose -f tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/Docker/docker-compose.yml --profile s7_1500 up -d`).
-- python-snap7 listens on port 1102 (non-priv); real S7 CPUs listen on 102.
-- Publishes one Int16 tag at DB1.DBW0 under `ns=<N>;s=DB1_DBW0` (driver
-- sanitises the dot for browse names — see S7Driver.DiscoverAsync).
-- Idempotent DELETE-and-recreate of one cluster's worth of S7 test config pointing at the snap7
-- fixture (127.0.0.1:1102). v3: the ConfigGeneration/Namespace model + sp_PublishGeneration are
-- gone; the endpoint (Host/Port/Rack/Slot) moved from DriverConfig into the Device's DeviceConfig.
--
-- Usage:
-- sqlcmd -S "localhost,14330" -d OtOpcUaConfig -U sa -P "OtOpcUaDev_2026!" \
-- -i scripts/smoke/seed-s7-smoke.sql
--
-- After seeding:
-- Node:NodeId = "s7-smoke-node"
-- Node:ClusterId = "s7-smoke"
-- Then start server + run `./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"`.
-- After seeding: set Server appsettings Node:NodeId/ClusterId, deploy via the ControlPlane, then
-- `./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"`.
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
DECLARE @ClusterId nvarchar(64) = 's7-smoke';
DECLARE @NodeId nvarchar(64) = 's7-smoke-node';
DECLARE @DrvId nvarchar(64) = 's7-smoke-drv';
DECLARE @NsId nvarchar(64) = 's7-smoke-ns';
DECLARE @DevId nvarchar(64) = 's7-smoke-dev';
DECLARE @AreaId nvarchar(64) = 's7-smoke-area';
DECLARE @LineId nvarchar(64) = 's7-smoke-line';
DECLARE @EqId nvarchar(64) = 's7-smoke-eq';
DECLARE @EqUuid uniqueidentifier = '17BD5A10-17BD-417B-917B-D5A1017BD5A1';
DECLARE @TagId nvarchar(64) = 's7-smoke-tag-db1dbw0';
DECLARE @EqUuid uniqueidentifier = '5717A5E0-5717-45E0-9571-7A5E05717A5E';
DECLARE @Tag nvarchar(64) = 's7-smoke-tag-db1dbw0';
DECLARE @RefId nvarchar(64) = 's7-smoke-ref-db1dbw0';
BEGIN TRAN;
DELETE FROM dbo.Tag WHERE TagId IN (@TagId);
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.Namespace WHERE NamespaceId = @NsId;
DELETE FROM dbo.ConfigGeneration WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNodeGenerationState WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.UnsTagReference WHERE UnsTagReferenceId = @RefId;
DELETE FROM dbo.Tag WHERE TagId = @Tag;
DELETE FROM dbo.Equipment WHERE EquipmentId = @EqId;
DELETE FROM dbo.UnsLine WHERE UnsLineId = @LineId;
DELETE FROM dbo.UnsArea WHERE UnsAreaId = @AreaId;
DELETE FROM dbo.Device WHERE DeviceId = @DevId;
DELETE FROM dbo.DriverInstance WHERE DriverInstanceId = @DrvId;
DELETE FROM dbo.ClusterNodeCredential WHERE NodeId = @NodeId;
DELETE FROM dbo.ClusterNode WHERE NodeId = @NodeId;
DELETE FROM dbo.ServerCluster WHERE ClusterId = @ClusterId;
DELETE FROM dbo.ClusterNodeCredential WHERE Kind = 'SqlLogin' AND Value = 'sa';
INSERT dbo.ServerCluster(ClusterId, Name, Enterprise, Site, NodeCount, RedundancyMode, Enabled, CreatedBy)
VALUES (@ClusterId, 'S7 Smoke', 'zb', 'lab', 1, 'None', 1, 's7-smoke');
INSERT dbo.ClusterNode(NodeId, ClusterId, RedundancyRole, Host, OpcUaPort, DashboardPort,
INSERT dbo.ClusterNode(NodeId, ClusterId, Host, OpcUaPort, DashboardPort,
ApplicationUri, ServiceLevelBase, Enabled, CreatedBy)
VALUES (@NodeId, @ClusterId, 'Primary', 'localhost', 4840, 15050,
'urn:OtOpcUa:s7-smoke-node', 200, 1, 's7-smoke');
-- Dashboard moved off :5000 (Windows URL-ACL).
VALUES (@NodeId, @ClusterId, 'localhost', 4840, 15050, 'urn:OtOpcUa:s7-smoke-node', 200, 1, 's7-smoke');
INSERT dbo.ClusterNodeCredential(NodeId, Kind, Value, Enabled, CreatedBy)
VALUES (@NodeId, 'SqlLogin', 'sa', 1, 's7-smoke');
DECLARE @Gen bigint;
INSERT dbo.ConfigGeneration(ClusterId, Status, CreatedBy)
VALUES (@ClusterId, 'Draft', 's7-smoke');
SET @Gen = SCOPE_IDENTITY();
INSERT dbo.Namespace(GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled)
VALUES (@Gen, @NsId, @ClusterId, 'Equipment', 'urn:s7-smoke:eq', 1);
INSERT dbo.UnsArea(GenerationId, UnsAreaId, ClusterId, Name)
VALUES (@Gen, @AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(GenerationId, UnsLineId, UnsAreaId, Name)
VALUES (@Gen, @LineId, @AreaId, 's7-line');
INSERT dbo.Equipment(GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, UnsLineId,
Name, MachineCode, Enabled)
VALUES (@Gen, @EqId, @EqUuid, @DrvId, @LineId, 's7-sim', 's7-001', 1);
-- S7 DriverInstance — python-snap7 S7-1500 profile, slot 0, port 1102.
-- DriverConfig shape mirrors S7DriverConfigDto.
INSERT dbo.DriverInstance(GenerationId, DriverInstanceId, ClusterId, NamespaceId,
Name, DriverType, DriverConfig, Enabled)
VALUES (@Gen, @DrvId, @ClusterId, @NsId, 'snap7-smoke', 'S7', N'{
"Host": "127.0.0.1",
"Port": 1102,
"CpuType": "S71500",
"Rack": 0,
"Slot": 0,
"TimeoutMs": 5000,
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "MW0" },
"Tags": [
{
"Name": "DB1_DBW0",
"Address": "DB1.DBW0",
"DataType": "Int16",
"Writable": true,
"WriteIdempotent": true
}
]
-- DriverConfig keeps protocol/channel settings; endpoint moves to DeviceConfig.
INSERT dbo.DriverInstance(DriverInstanceId, ClusterId, RawFolderId, Name, DriverType, DriverConfig, Enabled)
VALUES (@DrvId, @ClusterId, NULL, 'snap7-smoke', 'S7', N'{
"Probe": { "Enabled": true, "IntervalMs": 5000, "TimeoutMs": 2000, "ProbeAddress": "MW0" }
}', 1);
INSERT dbo.Tag(GenerationId, TagId, DriverInstanceId, EquipmentId, Name, DataType,
AccessLevel, TagConfig, WriteIdempotent)
VALUES (@Gen, @TagId, @DrvId, @EqId, 'DB1_DBW0', 'Int16', 'ReadWrite',
N'{"FullName":"DB1_DBW0","Address":"DB1.DBW0","DataType":"Int16"}', 1);
-- DeviceConfig shape finalized against reworked options in Wave B (WP5).
INSERT dbo.Device(DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
VALUES (@DevId, @DrvId, 'plc', 1, N'{ "Host": "127.0.0.1", "Port": 1102, "Rack": 0, "Slot": 0 }');
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @Gen,
@Notes = N'S7 smoke — task #212';
INSERT dbo.Tag(TagId, DeviceId, TagGroupId, Name, DataType, AccessLevel, WriteIdempotent, TagConfig)
VALUES (@Tag, @DevId, NULL, 'DB1_DBW0', 'Int16', 'ReadWrite', 1,
N'{ "address": "DB1.DBW0", "dataType": "Int16" }');
INSERT dbo.UnsArea(UnsAreaId, ClusterId, Name) VALUES (@AreaId, @ClusterId, 'lab-floor');
INSERT dbo.UnsLine(UnsLineId, UnsAreaId, Name) VALUES (@LineId, @AreaId, 's7-line');
INSERT dbo.Equipment(EquipmentId, EquipmentUuid, UnsLineId, Name, MachineCode, Enabled)
VALUES (@EqId, @EqUuid, @LineId, 's7-sim', 's7-001', 1);
INSERT dbo.UnsTagReference(UnsTagReferenceId, EquipmentId, TagId, SortOrder)
VALUES (@RefId, @EqId, @Tag, 0);
COMMIT;
PRINT '';
PRINT 'S7 smoke seed complete.';
PRINT ' Cluster: ' + @ClusterId;
PRINT ' Node: ' + @NodeId;
PRINT ' Generation: ' + CONVERT(nvarchar(20), @Gen);
PRINT '';
PRINT 'Next steps:';
PRINT ' 1. Set src/.../Server/appsettings.json Node:NodeId = "s7-smoke-node"';
PRINT ' Node:ClusterId = "s7-smoke"';
PRINT ' 2. docker compose -f tests/.../S7.IntegrationTests/Docker/docker-compose.yml --profile s7_1500 up -d';
PRINT ' 3. dotnet run --project src/Server/ZB.MOM.WW.OtOpcUa.Server';
PRINT ' 4. ./scripts/e2e/test-s7.ps1 -BridgeNodeId "ns=2;s=DB1_DBW0" -S7Host "127.0.0.1:1102"';
PRINT 'S7 smoke seed complete (v3 schema). Cluster: ' + @ClusterId + ' Node: ' + @NodeId;
File diff suppressed because it is too large Load Diff
@@ -1,811 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ConfigAuditLog",
columns: table => new
{
AuditId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Timestamp = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
Principal = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
EventType = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
GenerationId = table.Column<long>(type: "bigint", nullable: true),
DetailsJson = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ConfigAuditLog", x => x.AuditId);
table.CheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
});
migrationBuilder.CreateTable(
name: "ExternalIdReservation",
columns: table => new
{
ReservationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
Kind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
Value = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
FirstPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
FirstPublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
LastPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
ReleasedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
ReleasedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
ReleaseReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalIdReservation", x => x.ReservationId);
});
migrationBuilder.CreateTable(
name: "ServerCluster",
columns: table => new
{
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Enterprise = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Site = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
NodeCount = table.Column<byte>(type: "tinyint", nullable: false),
RedundancyMode = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
ModifiedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
ModifiedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ServerCluster", x => x.ClusterId);
table.CheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
});
migrationBuilder.CreateTable(
name: "ClusterNode",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
RedundancyRole = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
Host = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
OpcUaPort = table.Column<int>(type: "int", nullable: false),
DashboardPort = table.Column<int>(type: "int", nullable: false),
ApplicationUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ServiceLevelBase = table.Column<byte>(type: "tinyint", nullable: false),
DriverConfigOverridesJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
Enabled = table.Column<bool>(type: "bit", nullable: false),
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ClusterNode", x => x.NodeId);
table.ForeignKey(
name: "FK_ClusterNode_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ConfigGeneration",
columns: table => new
{
GenerationId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ParentGenerationId = table.Column<long>(type: "bigint", nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
PublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConfigGeneration", x => x.GenerationId);
table.ForeignKey(
name: "FK_ConfigGeneration_ConfigGeneration_ParentGenerationId",
column: x => x.ParentGenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ConfigGeneration_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ClusterNodeCredential",
columns: table => new
{
CredentialId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Value = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
RotatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ClusterNodeCredential", x => x.CredentialId);
table.ForeignKey(
name: "FK_ClusterNodeCredential_ClusterNode_NodeId",
column: x => x.NodeId,
principalTable: "ClusterNode",
principalColumn: "NodeId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ClusterNodeGenerationState",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CurrentGenerationId = table.Column<long>(type: "bigint", nullable: true),
LastAppliedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastAppliedStatus = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
LastAppliedError = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ClusterNodeGenerationState", x => x.NodeId);
table.ForeignKey(
name: "FK_ClusterNodeGenerationState_ClusterNode_NodeId",
column: x => x.NodeId,
principalTable: "ClusterNode",
principalColumn: "NodeId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ClusterNodeGenerationState_ConfigGeneration_CurrentGenerationId",
column: x => x.CurrentGenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Device",
columns: table => new
{
DeviceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
DeviceConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Device", x => x.DeviceRowId);
table.CheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
table.ForeignKey(
name: "FK_Device_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "DriverInstance",
columns: table => new
{
DriverInstanceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
DriverType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
DriverConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverInstance", x => x.DriverInstanceRowId);
table.CheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
table.ForeignKey(
name: "FK_DriverInstance_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_DriverInstance_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Equipment",
columns: table => new
{
EquipmentRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
MachineCode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ZTag = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
SAPID = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Manufacturer = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Model = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
SerialNumber = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
HardwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
SoftwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
YearOfConstruction = table.Column<short>(type: "smallint", nullable: true),
AssetLocation = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
EquipmentClassRef = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Equipment", x => x.EquipmentRowId);
table.ForeignKey(
name: "FK_Equipment_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Namespace",
columns: table => new
{
NamespaceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
NamespaceUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Namespace", x => x.NamespaceRowId);
table.ForeignKey(
name: "FK_Namespace_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Namespace_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NodeAcl",
columns: table => new
{
NodeAclRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
NodeAclId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
LdapGroup = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ScopeKind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ScopeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
PermissionFlags = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NodeAcl", x => x.NodeAclRowId);
table.ForeignKey(
name: "FK_NodeAcl_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "PollGroup",
columns: table => new
{
PollGroupRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
IntervalMs = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PollGroup", x => x.PollGroupRowId);
table.CheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
table.ForeignKey(
name: "FK_PollGroup_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Tag",
columns: table => new
{
TagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
TagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
FolderPath = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
AccessLevel = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
WriteIdempotent = table.Column<bool>(type: "bit", nullable: false),
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
TagConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tag", x => x.TagRowId);
table.CheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
table.ForeignKey(
name: "FK_Tag_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "UnsArea",
columns: table => new
{
UnsAreaRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UnsArea", x => x.UnsAreaRowId);
table.ForeignKey(
name: "FK_UnsArea_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_UnsArea_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "UnsLine",
columns: table => new
{
UnsLineRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UnsLine", x => x.UnsLineRowId);
table.ForeignKey(
name: "FK_UnsLine_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "UX_ClusterNode_ApplicationUri",
table: "ClusterNode",
column: "ApplicationUri",
unique: true);
migrationBuilder.CreateIndex(
name: "UX_ClusterNode_Primary_Per_Cluster",
table: "ClusterNode",
column: "ClusterId",
unique: true,
filter: "[RedundancyRole] = 'Primary'");
migrationBuilder.CreateIndex(
name: "IX_ClusterNodeCredential_NodeId",
table: "ClusterNodeCredential",
columns: new[] { "NodeId", "Enabled" });
migrationBuilder.CreateIndex(
name: "UX_ClusterNodeCredential_Value",
table: "ClusterNodeCredential",
columns: new[] { "Kind", "Value" },
unique: true,
filter: "[Enabled] = 1");
migrationBuilder.CreateIndex(
name: "IX_ClusterNodeGenerationState_Generation",
table: "ClusterNodeGenerationState",
column: "CurrentGenerationId");
migrationBuilder.CreateIndex(
name: "IX_ConfigAuditLog_Cluster_Time",
table: "ConfigAuditLog",
columns: new[] { "ClusterId", "Timestamp" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_ConfigAuditLog_Generation",
table: "ConfigAuditLog",
column: "GenerationId",
filter: "[GenerationId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ConfigGeneration_Cluster_Published",
table: "ConfigGeneration",
columns: new[] { "ClusterId", "Status", "GenerationId" },
descending: new[] { false, false, true })
.Annotation("SqlServer:Include", new[] { "PublishedAt" });
migrationBuilder.CreateIndex(
name: "IX_ConfigGeneration_ParentGenerationId",
table: "ConfigGeneration",
column: "ParentGenerationId");
migrationBuilder.CreateIndex(
name: "UX_ConfigGeneration_Draft_Per_Cluster",
table: "ConfigGeneration",
column: "ClusterId",
unique: true,
filter: "[Status] = 'Draft'");
migrationBuilder.CreateIndex(
name: "IX_Device_Generation_Driver",
table: "Device",
columns: new[] { "GenerationId", "DriverInstanceId" });
migrationBuilder.CreateIndex(
name: "UX_Device_Generation_LogicalId",
table: "Device",
columns: new[] { "GenerationId", "DeviceId" },
unique: true,
filter: "[DeviceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_DriverInstance_ClusterId",
table: "DriverInstance",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_DriverInstance_Generation_Cluster",
table: "DriverInstance",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "IX_DriverInstance_Generation_Namespace",
table: "DriverInstance",
columns: new[] { "GenerationId", "NamespaceId" });
migrationBuilder.CreateIndex(
name: "UX_DriverInstance_Generation_LogicalId",
table: "DriverInstance",
columns: new[] { "GenerationId", "DriverInstanceId" },
unique: true,
filter: "[DriverInstanceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_Driver",
table: "Equipment",
columns: new[] { "GenerationId", "DriverInstanceId" });
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_Line",
table: "Equipment",
columns: new[] { "GenerationId", "UnsLineId" });
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_MachineCode",
table: "Equipment",
columns: new[] { "GenerationId", "MachineCode" });
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_SAPID",
table: "Equipment",
columns: new[] { "GenerationId", "SAPID" },
filter: "[SAPID] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_ZTag",
table: "Equipment",
columns: new[] { "GenerationId", "ZTag" },
filter: "[ZTag] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Equipment_Generation_LinePath",
table: "Equipment",
columns: new[] { "GenerationId", "UnsLineId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_Equipment_Generation_LogicalId",
table: "Equipment",
columns: new[] { "GenerationId", "EquipmentId" },
unique: true,
filter: "[EquipmentId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Equipment_Generation_Uuid",
table: "Equipment",
columns: new[] { "GenerationId", "EquipmentUuid" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExternalIdReservation_Equipment",
table: "ExternalIdReservation",
column: "EquipmentUuid");
migrationBuilder.CreateIndex(
name: "UX_ExternalIdReservation_KindValue_Active",
table: "ExternalIdReservation",
columns: new[] { "Kind", "Value" },
unique: true,
filter: "[ReleasedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Namespace_ClusterId",
table: "Namespace",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_Namespace_Generation_Cluster",
table: "Namespace",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_Cluster_Kind",
table: "Namespace",
columns: new[] { "GenerationId", "ClusterId", "Kind" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_LogicalId",
table: "Namespace",
columns: new[] { "GenerationId", "NamespaceId" },
unique: true,
filter: "[NamespaceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_LogicalId_Cluster",
table: "Namespace",
columns: new[] { "GenerationId", "NamespaceId", "ClusterId" },
unique: true,
filter: "[NamespaceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_NamespaceUri",
table: "Namespace",
columns: new[] { "GenerationId", "NamespaceUri" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NodeAcl_Generation_Cluster",
table: "NodeAcl",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "IX_NodeAcl_Generation_Group",
table: "NodeAcl",
columns: new[] { "GenerationId", "LdapGroup" });
migrationBuilder.CreateIndex(
name: "IX_NodeAcl_Generation_Scope",
table: "NodeAcl",
columns: new[] { "GenerationId", "ScopeKind", "ScopeId" },
filter: "[ScopeId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_NodeAcl_Generation_GroupScope",
table: "NodeAcl",
columns: new[] { "GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId" },
unique: true,
filter: "[ScopeId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_NodeAcl_Generation_LogicalId",
table: "NodeAcl",
columns: new[] { "GenerationId", "NodeAclId" },
unique: true,
filter: "[NodeAclId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_PollGroup_Generation_Driver",
table: "PollGroup",
columns: new[] { "GenerationId", "DriverInstanceId" });
migrationBuilder.CreateIndex(
name: "UX_PollGroup_Generation_LogicalId",
table: "PollGroup",
columns: new[] { "GenerationId", "PollGroupId" },
unique: true,
filter: "[PollGroupId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ServerCluster_Site",
table: "ServerCluster",
column: "Site");
migrationBuilder.CreateIndex(
name: "UX_ServerCluster_Name",
table: "ServerCluster",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Tag_Generation_Driver_Device",
table: "Tag",
columns: new[] { "GenerationId", "DriverInstanceId", "DeviceId" });
migrationBuilder.CreateIndex(
name: "IX_Tag_Generation_Equipment",
table: "Tag",
columns: new[] { "GenerationId", "EquipmentId" },
filter: "[EquipmentId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Tag_Generation_EquipmentPath",
table: "Tag",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true,
filter: "[EquipmentId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Tag_Generation_FolderPath",
table: "Tag",
columns: new[] { "GenerationId", "DriverInstanceId", "FolderPath", "Name" },
unique: true,
filter: "[EquipmentId] IS NULL");
migrationBuilder.CreateIndex(
name: "UX_Tag_Generation_LogicalId",
table: "Tag",
columns: new[] { "GenerationId", "TagId" },
unique: true,
filter: "[TagId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_UnsArea_ClusterId",
table: "UnsArea",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_UnsArea_Generation_Cluster",
table: "UnsArea",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "UX_UnsArea_Generation_ClusterName",
table: "UnsArea",
columns: new[] { "GenerationId", "ClusterId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_UnsArea_Generation_LogicalId",
table: "UnsArea",
columns: new[] { "GenerationId", "UnsAreaId" },
unique: true,
filter: "[UnsAreaId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_UnsLine_Generation_Area",
table: "UnsLine",
columns: new[] { "GenerationId", "UnsAreaId" });
migrationBuilder.CreateIndex(
name: "UX_UnsLine_Generation_AreaName",
table: "UnsLine",
columns: new[] { "GenerationId", "UnsAreaId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_UnsLine_Generation_LogicalId",
table: "UnsLine",
columns: new[] { "GenerationId", "UnsLineId" },
unique: true,
filter: "[UnsLineId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ClusterNodeCredential");
migrationBuilder.DropTable(
name: "ClusterNodeGenerationState");
migrationBuilder.DropTable(
name: "ConfigAuditLog");
migrationBuilder.DropTable(
name: "Device");
migrationBuilder.DropTable(
name: "DriverInstance");
migrationBuilder.DropTable(
name: "Equipment");
migrationBuilder.DropTable(
name: "ExternalIdReservation");
migrationBuilder.DropTable(
name: "Namespace");
migrationBuilder.DropTable(
name: "NodeAcl");
migrationBuilder.DropTable(
name: "PollGroup");
migrationBuilder.DropTable(
name: "Tag");
migrationBuilder.DropTable(
name: "UnsArea");
migrationBuilder.DropTable(
name: "UnsLine");
migrationBuilder.DropTable(
name: "ClusterNode");
migrationBuilder.DropTable(
name: "ConfigGeneration");
migrationBuilder.DropTable(
name: "ServerCluster");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,510 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
/// <summary>
/// Stored procedures per <c>config-db-schema.md §"Stored Procedures"</c>. All node + admin DB
/// access funnels through these — direct table writes are revoked in the AuthorizationGrants
/// migration that follows. CREATE OR ALTER style so procs version with the schema.
/// </summary>
public partial class StoredProcedures : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.GetCurrentGenerationForCluster);
migrationBuilder.Sql(Procs.GetGenerationContent);
migrationBuilder.Sql(Procs.RegisterNodeGenerationApplied);
migrationBuilder.Sql(Procs.ValidateDraft);
migrationBuilder.Sql(Procs.PublishGeneration);
migrationBuilder.Sql(Procs.RollbackToGeneration);
migrationBuilder.Sql(Procs.ComputeGenerationDiff);
migrationBuilder.Sql(Procs.ReleaseExternalIdReservation);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
foreach (var name in new[]
{
"sp_ReleaseExternalIdReservation", "sp_ComputeGenerationDiff", "sp_RollbackToGeneration",
"sp_PublishGeneration", "sp_ValidateDraft", "sp_RegisterNodeGenerationApplied",
"sp_GetGenerationContent", "sp_GetCurrentGenerationForCluster",
})
{
migrationBuilder.Sql($"IF OBJECT_ID(N'dbo.{name}', N'P') IS NOT NULL DROP PROCEDURE dbo.{name};");
}
}
private static class Procs
{
public const string GetCurrentGenerationForCluster = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster
@NodeId nvarchar(64),
@ClusterId nvarchar(64)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNodeCredential
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
BEGIN
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
RETURN;
END
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNode
WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1)
BEGIN
RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId);
RETURN;
END
SELECT TOP 1 GenerationId, ClusterId, Status, PublishedAt, PublishedBy, Notes
FROM dbo.ConfigGeneration
WHERE ClusterId = @ClusterId AND Status = 'Published'
ORDER BY GenerationId DESC;
END
";
public const string GetGenerationContent = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent
@NodeId nvarchar(64),
@GenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
DECLARE @ClusterId nvarchar(64);
SELECT @ClusterId = ClusterId FROM dbo.ConfigGeneration WHERE GenerationId = @GenerationId;
IF @ClusterId IS NULL
BEGIN
RAISERROR('GenerationId %I64d not found', 16, 1, @GenerationId);
RETURN;
END
IF NOT EXISTS (
SELECT 1
FROM dbo.ClusterNodeCredential c
JOIN dbo.ClusterNode n ON n.NodeId = c.NodeId
WHERE c.NodeId = @NodeId AND c.Value = @Caller AND c.Enabled = 1
AND n.ClusterId = @ClusterId AND n.Enabled = 1)
BEGIN
RAISERROR('Forbidden: caller %s not bound to a node in ClusterId %s', 16, 1, @Caller, @ClusterId);
RETURN;
END
SELECT * FROM dbo.Namespace WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.UnsArea WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.UnsLine WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.DriverInstance WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Device WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Equipment WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.PollGroup WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Tag WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.NodeAcl WHERE GenerationId = @GenerationId;
END
";
public const string RegisterNodeGenerationApplied = @"
CREATE OR ALTER PROCEDURE dbo.sp_RegisterNodeGenerationApplied
@NodeId nvarchar(64),
@GenerationId bigint,
@Status nvarchar(16),
@Error nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNodeCredential
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
BEGIN
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
RETURN;
END
MERGE dbo.ClusterNodeGenerationState AS tgt
USING (SELECT @NodeId AS NodeId) AS src ON tgt.NodeId = src.NodeId
WHEN MATCHED THEN UPDATE SET
CurrentGenerationId = @GenerationId,
LastAppliedAt = SYSUTCDATETIME(),
LastAppliedStatus = @Status,
LastAppliedError = @Error,
LastSeenAt = SYSUTCDATETIME()
WHEN NOT MATCHED THEN INSERT
(NodeId, CurrentGenerationId, LastAppliedAt, LastAppliedStatus, LastAppliedError, LastSeenAt)
VALUES (@NodeId, @GenerationId, SYSUTCDATETIME(), @Status, @Error, SYSUTCDATETIME());
-- Build DetailsJson via STRING_ESCAPE so a @Status containing a double-quote/backslash cannot
-- produce malformed JSON (which would fail CK_ConfigAuditLog_DetailsJson_IsJson and abort the
-- transaction) or inject extra JSON structure into the audit record.
INSERT dbo.ConfigAuditLog (Principal, EventType, NodeId, GenerationId, DetailsJson)
VALUES (@Caller, 'NodeApplied', @NodeId, @GenerationId,
CONCAT('{""status"":""', STRING_ESCAPE(@Status, 'json'), '""}'));
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;
-- Transaction-nesting awareness: if a caller (e.g. sp_RollbackToGeneration) already
-- holds a transaction, we use SAVE TRANSACTION so our failure path rolls back only to
-- the savepoint instead of issuing a bare ROLLBACK that wipes the caller's transaction
-- (which sets @@TRANCOUNT = 0 and causes error 3902 on the caller's subsequent COMMIT).
DECLARE @OwnsTxn bit = 0;
DECLARE @SaveName nvarchar(32) = N'sp_PublishGeneration';
IF @@TRANCOUNT = 0
BEGIN
BEGIN TRANSACTION;
SET @OwnsTxn = 1;
END
ELSE
BEGIN
SAVE TRANSACTION sp_PublishGeneration;
END
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);
IF @OwnsTxn = 1 ROLLBACK;
ELSE ROLLBACK TRANSACTION sp_PublishGeneration;
RETURN;
END
-- sp_ValidateDraft signals every rejection with RAISERROR(..., 16, 1) — a severity-16 error is
-- NOT batch-aborting and SET XACT_ABORT ON does not abort the transaction for it, so without a
-- TRY/CATCH control would return here and the draft would publish despite failed validation.
-- Catch the validation error, roll back the publish transaction (only to our savepoint when a
-- caller owns the outer transaction), and re-raise so the caller sees the real validation failure.
BEGIN TRY
EXEC dbo.sp_ValidateDraft @DraftGenerationId = @DraftGenerationId;
END TRY
BEGIN CATCH
IF @OwnsTxn = 1 ROLLBACK;
ELSE ROLLBACK TRANSACTION sp_PublishGeneration;
THROW;
END CATCH
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 in Draft status (was it already published?)', 16, 1, @DraftGenerationId, @ClusterId);
IF @OwnsTxn = 1 ROLLBACK;
ELSE ROLLBACK TRANSACTION sp_PublishGeneration;
RETURN;
END
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);
IF @OwnsTxn = 1 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;
-- @TargetGenerationId is a bigint, but build the JSON value via an explicit numeric CONVERT so
-- the emitted token is always a bare JSON number — never reliant on implicit string coercion.
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId, DetailsJson)
VALUES (SUSER_SNAME(), 'RolledBack', @ClusterId, @NewGenId,
CONCAT('{""rolledBackTo"":', CONVERT(nvarchar(20), CONVERT(bigint, @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
-- Escape both caller-supplied values via STRING_ESCAPE so quotes/backslashes cannot break the
-- JSON document or inject additional structure into the audit record.
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
CONCAT('{""kind"":""', STRING_ESCAPE(@Kind, 'json'),
'"",""value"":""', STRING_ESCAPE(@Value, 'json'), '""}'));
END
";
}
}
@@ -1,55 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
/// <summary>
/// Creates the two DB roles per <c>config-db-schema.md §"Authorization Model"</c> and grants
/// EXECUTE on the appropriate stored procedures. Deliberately grants no direct table DML — all
/// writes funnel through the procs, which authenticate via <c>SUSER_SNAME()</c>.
/// Principals (SQL logins, gMSA users, cert-mapped users) are provisioned by the DBA outside
/// this migration and then added to one of the two roles.
/// </summary>
public partial class AuthorizationGrants : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NULL
CREATE ROLE OtOpcUaNode;
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NULL
CREATE ROLE OtOpcUaAdmin;
");
migrationBuilder.Sql(@"
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_RegisterNodeGenerationApplied TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ValidateDraft TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_PublishGeneration TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_RollbackToGeneration TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ComputeGenerationDiff TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ReleaseExternalIdReservation TO OtOpcUaAdmin;
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaNode;
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaAdmin;
DENY SELECT ON SCHEMA::dbo TO OtOpcUaNode;
-- Admins may SELECT for reporting views in the future — grant views explicitly, not the schema.
DENY SELECT ON SCHEMA::dbo TO OtOpcUaAdmin;
");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NOT NULL
DROP ROLE OtOpcUaNode;
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NOT NULL
DROP ROLE OtOpcUaAdmin;
");
}
}
@@ -1,49 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverHostStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DriverHostStatus",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
State = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
StateChangedUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
LastSeenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
Detail = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName });
});
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_LastSeen",
table: "DriverHostStatus",
column: "LastSeenUtc");
migrationBuilder.CreateIndex(
name: "IX_DriverHostStatus_Node",
table: "DriverHostStatus",
column: "NodeId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DriverHostStatus");
}
}
}
@@ -1,46 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverInstanceResilienceStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DriverInstanceResilienceStatus",
columns: table => new
{
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
LastCircuitBreakerOpenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
ConsecutiveFailures = table.Column<int>(type: "int", nullable: false),
CurrentBulkheadDepth = table.Column<int>(type: "int", nullable: false),
LastRecycleUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
BaselineFootprintBytes = table.Column<long>(type: "bigint", nullable: false),
CurrentFootprintBytes = table.Column<long>(type: "bigint", nullable: false),
LastSampledUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverInstanceResilienceStatus", x => new { x.DriverInstanceId, x.HostName });
});
migrationBuilder.CreateIndex(
name: "IX_DriverResilience_LastSampled",
table: "DriverInstanceResilienceStatus",
column: "LastSampledUtc");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DriverInstanceResilienceStatus");
}
}
}
@@ -1,62 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddLdapGroupRoleMapping : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LdapGroupRoleMapping",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LdapGroup = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
Role = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
IsSystemWide = table.Column<bool>(type: "bit", nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LdapGroupRoleMapping", x => x.Id);
table.ForeignKey(
name: "FK_LdapGroupRoleMapping_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_LdapGroupRoleMapping_ClusterId",
table: "LdapGroupRoleMapping",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_LdapGroupRoleMapping_Group",
table: "LdapGroupRoleMapping",
column: "LdapGroup");
migrationBuilder.CreateIndex(
name: "UX_LdapGroupRoleMapping_Group_Cluster",
table: "LdapGroupRoleMapping",
columns: new[] { "LdapGroup", "ClusterId" },
unique: true,
filter: "[ClusterId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LdapGroupRoleMapping");
}
}
}
@@ -1,37 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddDriverInstanceResilienceConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ResilienceConfig",
table: "DriverInstance",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddCheckConstraint(
name: "CK_DriverInstance_ResilienceConfig_IsJson",
table: "DriverInstance",
sql: "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropCheckConstraint(
name: "CK_DriverInstance_ResilienceConfig_IsJson",
table: "DriverInstance");
migrationBuilder.DropColumn(
name: "ResilienceConfig",
table: "DriverInstance");
}
}
}
@@ -1,91 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddEquipmentImportBatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EquipmentImportBatch",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
CreatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
RowsStaged = table.Column<int>(type: "int", nullable: false),
RowsAccepted = table.Column<int>(type: "int", nullable: false),
RowsRejected = table.Column<int>(type: "int", nullable: false),
FinalisedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EquipmentImportBatch", x => x.Id);
});
migrationBuilder.CreateTable(
name: "EquipmentImportRow",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BatchId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LineNumberInFile = table.Column<int>(type: "int", nullable: false),
IsAccepted = table.Column<bool>(type: "bit", nullable: false),
RejectReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
ZTag = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
MachineCode = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
SAPID = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EquipmentUuid = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
UnsAreaName = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
UnsLineName = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Manufacturer = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Model = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
SerialNumber = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
HardwareRevision = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
SoftwareRevision = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
YearOfConstruction = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: true),
AssetLocation = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EquipmentImportRow", x => x.Id);
table.ForeignKey(
name: "FK_EquipmentImportRow_EquipmentImportBatch_BatchId",
column: x => x.BatchId,
principalTable: "EquipmentImportBatch",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_EquipmentImportBatch_Creator_Finalised",
table: "EquipmentImportBatch",
columns: new[] { "CreatedBy", "FinalisedAtUtc" });
migrationBuilder.CreateIndex(
name: "IX_EquipmentImportRow_Batch",
table: "EquipmentImportRow",
column: "BatchId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EquipmentImportRow");
migrationBuilder.DropTable(
name: "EquipmentImportBatch");
}
}
}
@@ -1,172 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Extends <c>dbo.sp_ComputeGenerationDiff</c> to emit <c>NodeAcl</c> rows alongside the
/// existing Namespace/DriverInstance/Equipment/Tag output — closes the final slice of
/// task #196 (DiffViewer ACL section). Logical id for NodeAcl is a composite
/// <c>LdapGroup|ScopeKind|ScopeId</c> triple so a Change row surfaces whether the grant
/// shifted permissions, moved scope, or was added/removed outright.
/// </summary>
/// <inheritdoc />
public partial class ExtendComputeGenerationDiffWithNodeAcl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV1);
}
private static class Procs
{
/// <summary>V2 — adds the NodeAcl section to the diff output.</summary>
public const string ComputeGenerationDiffV2 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), 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(128), 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(128), 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(128), 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(128), 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;
-- NodeAcl section. Logical id is the (LdapGroup, ScopeKind, ScopeId) triple so the diff
-- distinguishes same row with new permissions (Modified via CHECKSUM on PermissionFlags + Notes)
-- from a scope move (which surfaces as Added + Removed of different logical ids).
WITH f AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', 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
";
/// <summary>V1 — exact proc shipped in migration 20260417215224_StoredProcedures. Restored on Down().</summary>
public const string ComputeGenerationDiffV1 = @"
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
";
}
}
}
@@ -1,186 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddPhase7ScriptingTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Script",
columns: table => new
{
ScriptRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
SourceCode = table.Column<string>(type: "nvarchar(max)", nullable: false),
SourceHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Language = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Script", x => x.ScriptRowId);
table.ForeignKey(
name: "FK_Script_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ScriptedAlarm",
columns: table => new
{
ScriptedAlarmRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
AlarmType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Severity = table.Column<int>(type: "int", nullable: false),
MessageTemplate = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
PredicateScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
HistorizeToAveva = table.Column<bool>(type: "bit", nullable: false),
Retain = table.Column<bool>(type: "bit", nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ScriptedAlarm", x => x.ScriptedAlarmRowId);
table.CheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
table.CheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
table.ForeignKey(
name: "FK_ScriptedAlarm_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ScriptedAlarmState",
columns: table => new
{
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EnabledState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
AckedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ConfirmedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ShelvingState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ShelvingExpiresUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastAckUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
LastAckComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
LastAckUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastConfirmUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
LastConfirmComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
LastConfirmUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
CommentsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_ScriptedAlarmState", x => x.ScriptedAlarmId);
table.CheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
});
migrationBuilder.CreateTable(
name: "VirtualTag",
columns: table => new
{
VirtualTagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
VirtualTagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ChangeTriggered = table.Column<bool>(type: "bit", nullable: false),
TimerIntervalMs = table.Column<int>(type: "int", nullable: true),
Historize = table.Column<bool>(type: "bit", nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VirtualTag", x => x.VirtualTagRowId);
table.CheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
table.CheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
table.ForeignKey(
name: "FK_VirtualTag_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_Script_Generation_SourceHash",
table: "Script",
columns: new[] { "GenerationId", "SourceHash" });
migrationBuilder.CreateIndex(
name: "UX_Script_Generation_LogicalId",
table: "Script",
columns: new[] { "GenerationId", "ScriptId" },
unique: true,
filter: "[ScriptId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ScriptedAlarm_Generation_Script",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "PredicateScriptId" });
migrationBuilder.CreateIndex(
name: "UX_ScriptedAlarm_Generation_EquipmentPath",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_ScriptedAlarm_Generation_LogicalId",
table: "ScriptedAlarm",
columns: new[] { "GenerationId", "ScriptedAlarmId" },
unique: true,
filter: "[ScriptedAlarmId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_VirtualTag_Generation_Script",
table: "VirtualTag",
columns: new[] { "GenerationId", "ScriptId" });
migrationBuilder.CreateIndex(
name: "UX_VirtualTag_Generation_EquipmentPath",
table: "VirtualTag",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_VirtualTag_Generation_LogicalId",
table: "VirtualTag",
columns: new[] { "GenerationId", "VirtualTagId" },
unique: true,
filter: "[VirtualTagId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Script");
migrationBuilder.DropTable(
name: "ScriptedAlarm");
migrationBuilder.DropTable(
name: "ScriptedAlarmState");
migrationBuilder.DropTable(
name: "VirtualTag");
}
}
}
@@ -1,232 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Phase 7 follow-up (task #241) — extends <c>dbo.sp_ComputeGenerationDiff</c> to emit
/// Script / VirtualTag / ScriptedAlarm rows alongside the existing Namespace /
/// DriverInstance / Equipment / Tag / NodeAcl output. Admin DiffViewer now shows
/// Phase 7 changes between generations.
/// </summary>
/// <remarks>
/// Logical ids: ScriptId, VirtualTagId, ScriptedAlarmId — stable across generations
/// so a Script whose source changes surfaces as Modified (CHECKSUM picks up the
/// SourceHash delta) while a renamed script surfaces as Modified on Name alone.
/// ScriptedAlarmState is deliberately excluded — it's not generation-scoped, so
/// diffing it between generations is meaningless.
/// </remarks>
/// <inheritdoc />
public partial class ExtendComputeGenerationDiffWithPhase7 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV3);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
}
private static class Procs
{
/// <summary>V3 — adds Script / VirtualTag / ScriptedAlarm sections.</summary>
public const string ComputeGenerationDiffV3 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), 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(128), 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(128), 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(128), 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(128), 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 CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', 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;
-- Phase 7 Script section. CHECKSUM picks up source changes via SourceHash + rename
-- via Name; Language future-proofs for non-C# engines. Same Name + same Source =
-- Unchanged (identical hash).
WITH f AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @FromGenerationId),
t AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Script', CONVERT(nvarchar(128), 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;
-- Phase 7 VirtualTag section.
WITH f AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @FromGenerationId),
t AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'VirtualTag', CONVERT(nvarchar(128), 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;
-- Phase 7 ScriptedAlarm section. ScriptedAlarmState (operator ack trail) is
-- logical-id keyed outside the generation scope + intentionally excluded here
-- diffing ack state between generations is semantically meaningless.
WITH f AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @FromGenerationId),
t AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'ScriptedAlarm', CONVERT(nvarchar(128), 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
";
/// <summary>V2 — restores the pre-Phase-7 proc on Down().</summary>
public const string ComputeGenerationDiffV2 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), 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(128), 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(128), 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(128), 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(128), 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 CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
t AS (
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'NodeAcl', 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
";
}
}
}
@@ -1,120 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Admin-008: adds <c>@ReleasedBy</c> parameter to
/// <c>dbo.sp_ReleaseExternalIdReservation</c> so the operator principal name (the LDAP
/// sign-in) is recorded in <c>ExternalIdReservation.ReleasedBy</c> and the
/// <c>ConfigAuditLog.Principal</c> column.
///
/// Prior to this migration the proc used <c>SUSER_SNAME()</c> for both columns, which
/// recorded the shared SQL service account rather than the Admin-UI operator who performed
/// the release — making the audit trail useless for attribution. The stored proc now
/// accepts <c>@ReleasedBy nvarchar(128)</c> and uses it for both columns; validation
/// rejects a null/empty value the same way <c>@ReleaseReason</c> is validated.
/// </summary>
/// <inheritdoc />
public partial class AddReleasedByToReleaseExternalIdReservation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ReleaseExternalIdReservationV2);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.ReleaseExternalIdReservationV1);
}
private static class Procs
{
/// <summary>V2 — accepts <c>@ReleasedBy</c> for proper operator attribution.</summary>
public const string ReleaseExternalIdReservationV2 = @"
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
@Kind nvarchar(16),
@Value nvarchar(64),
@ReleaseReason nvarchar(512),
@ReleasedBy nvarchar(128)
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
IF @ReleasedBy IS NULL OR LEN(@ReleasedBy) = 0
BEGIN
RAISERROR('ReleasedBy is required', 16, 1);
RETURN;
END
UPDATE dbo.ExternalIdReservation
SET ReleasedAt = SYSUTCDATETIME(),
ReleasedBy = @ReleasedBy,
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
-- Escape all caller-supplied values via STRING_ESCAPE so quotes/backslashes cannot break the
-- JSON document or inject additional structure into the audit record.
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
VALUES (@ReleasedBy, 'ExternalIdReleased',
CONCAT('{""kind"":""', STRING_ESCAPE(@Kind, 'json'),
'"",""value"":""', STRING_ESCAPE(@Value, 'json'), '""}'));
END
";
/// <summary>V1 — original proc (uses SUSER_SNAME() for attribution). Restored on Down().</summary>
public const string ReleaseExternalIdReservationV1 = @"
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
-- Escape both caller-supplied values via STRING_ESCAPE so quotes/backslashes cannot break the
-- JSON document or inject additional structure into the audit record.
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
CONCAT('{""kind"":""', STRING_ESCAPE(@Kind, 'json'),
'"",""value"":""', STRING_ESCAPE(@Value, 'json'), '""}'));
END
";
}
}
}
@@ -1,50 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class AddConfigAuditLogEventIdColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CorrelationId",
table: "ConfigAuditLog",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "EventId",
table: "ConfigAuditLog",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateIndex(
name: "UX_ConfigAuditLog_EventId",
table: "ConfigAuditLog",
column: "EventId",
unique: true,
filter: "[EventId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "UX_ConfigAuditLog_EventId",
table: "ConfigAuditLog");
migrationBuilder.DropColumn(
name: "CorrelationId",
table: "ConfigAuditLog");
migrationBuilder.DropColumn(
name: "EventId",
table: "ConfigAuditLog");
}
}
}
@@ -1,39 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Task 1.7 — canonicalizes the control-plane admin role VALUES persisted in the
/// <c>LdapGroupRoleMapping.Role</c> column. The column stores the <c>AdminRole</c> enum
/// member name as a string (EF <c>HasConversion&lt;string&gt;</c>, <c>nvarchar(32)</c>);
/// renaming the enum members (<c>ConfigViewer → Viewer</c>, <c>ConfigEditor → Designer</c>,
/// <c>FleetAdmin → Administrator</c>) therefore requires rewriting existing rows so the C#
/// enum and the stored strings stay in sync.
/// </summary>
/// <remarks>
/// This is a pure DATA migration: the schema (column type, length, indexes) is unchanged,
/// so the model snapshot is byte-identical to the prior migration. The new canonical strings
/// ("Viewer" = 6, "Designer" = 8, "Administrator" = 13 chars) all fit the existing
/// <c>nvarchar(32)</c> column. Enforcement semantics are preserved — it is a rename only.
/// </remarks>
public partial class CanonicalizeAdminRoles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Viewer' WHERE [Role] = N'ConfigViewer';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Designer' WHERE [Role] = N'ConfigEditor';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'Administrator' WHERE [Role] = N'FleetAdmin';");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'FleetAdmin' WHERE [Role] = N'Administrator';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigEditor' WHERE [Role] = N'Designer';");
migrationBuilder.Sql("UPDATE [LdapGroupRoleMapping] SET [Role] = N'ConfigViewer' WHERE [Role] = N'Viewer';");
}
}
}
@@ -1,35 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Task 2.2 — adds the nullable <c>Outcome</c> column to <c>ConfigAuditLog</c> for the
/// canonical <c>ZB.MOM.WW.Audit.AuditOutcome</c> (stored as its enum member name,
/// <c>nvarchar(16)</c>, mirroring how <c>AdminRole</c> is persisted). Purely additive:
/// nullable with no backfill, so existing rows and the bespoke stored-procedure audit
/// path (which does not derive an outcome) keep writing NULL.
/// </summary>
public partial class AddConfigAuditLogOutcome : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Outcome",
table: "ConfigAuditLog",
type: "nvarchar(16)",
maxLength: 16,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Outcome",
table: "ConfigAuditLog");
}
}
}
@@ -1,42 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class NullableEquipmentDriverInstanceId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "DriverInstanceId",
table: "Equipment",
type: "nvarchar(64)",
maxLength: 64,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(64)",
oldMaxLength: 64);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// WARNING: this rollback converts any existing NULL (driver-less) rows to "" for DriverInstanceId.
// Only safe when no driver-less equipment exists in the database.
migrationBuilder.AlterColumn<string>(
name: "DriverInstanceId",
table: "Equipment",
type: "nvarchar(64)",
maxLength: 64,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "nvarchar(64)",
oldMaxLength: 64,
oldNullable: true);
}
}
}
@@ -1,181 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Forward-only DATA-cleanup migration for the Galaxy clean break: the
/// <c>NamespaceKind.SystemPlatform</c> enum member was removed (Galaxy is now an
/// Equipment-kind driver). <c>Namespace.Kind</c> is persisted as a string via
/// <c>HasConversion&lt;string&gt;</c>, so any leftover row with <c>Kind = 'SystemPlatform'</c>
/// would throw at materialisation time (no matching enum member) the moment the new code
/// reads the table. This deletes those namespaces plus everything that hung off them.
/// </summary>
/// <remarks>
/// <para>
/// There is NO schema/model change in this migration — the model snapshot is unchanged.
/// The unique index <c>UX_Namespace_Cluster_Kind</c> (unique on <c>ClusterId, Kind</c>) is
/// intentionally KEPT: <c>Simulated</c> remains a reserved kind, so one-of-each-kind-per-cluster
/// is still the correct constraint.
/// </para>
/// <para>
/// The cross-table relationships in this graph are LOGICAL foreign keys (string Id columns,
/// e.g. <c>DriverInstance.NamespaceId</c> → <c>Namespace.NamespaceId</c>,
/// <c>Tag.DriverInstanceId</c> → <c>DriverInstance.DriverInstanceId</c>) enforced by
/// <c>sp_ValidateDraft</c>, NOT physical DB FK constraints — only the <c>*.ClusterId</c>
/// relationships are real DB FKs. So no FK constraint can be violated here. The deletes are
/// nonetheless ordered child→parent so no orphaned rows are left behind, and every statement
/// is keyed off the set of SystemPlatform namespaces, so on a DB with zero such rows every
/// DELETE simply affects 0 rows (idempotent / safe to run repeatedly).
/// </para>
/// <para>
/// Two tables carry LOGICAL refs with no physical FK and so were easy to miss: <c>NodeAcl</c>
/// (per-scope grants keyed by <c>ScopeKind</c> + <c>ScopeId</c>) is cleaned for the
/// Namespace/Equipment/FolderSegment/Tag scopes that get deleted, and
/// <c>ExternalIdReservation</c> (fleet-wide ZTag/SAPID reservations keyed by
/// <c>EquipmentUuid</c>) is RELEASED — not deleted — for the affected equipment so the
/// reserved external IDs free up while the audit row survives. Both run before the entity
/// rows they reference are removed so the id subqueries still resolve.
/// </para>
/// </remarks>
/// <inheritdoc />
public partial class CleanupSystemPlatformNamespaces : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Delete in child -> parent order. Everything is keyed off the set of namespaces whose
// Kind = 'SystemPlatform' (the string form persisted by HasConversion<string>), funnelled
// through the DriverInstances that lived in those namespaces.
migrationBuilder.Sql(@"
-- Defensive: SystemPlatform namespaces historically held only EquipmentId-NULL mirror tags, but a
-- stray equipment-scoped tag / Equipment could be bound to a SystemPlatform-namespace driver. Clean
-- both, plus anything that FKs into the affected Equipment (VirtualTag / ScriptedAlarm / state).
-- Logical-reference cleanup that must run FIRST -------------------------------------------
-- NodeAcl rows carry purely LOGICAL scope refs (ScopeKind + ScopeId, no physical FK). When the
-- scoped entity is deleted below the ScopeId dangles and the ACL evaluator would still match it,
-- silently granting/denying on a node that no longer exists. ScopeKind persists as a string via
-- HasConversion<string>, so the values below ('Namespace'/'Equipment'/'FolderSegment'/'Tag') are
-- the literal enum member names. These deletes must read the entity-id subqueries while those rows
-- still exist, hence they run before the entity DELETEs further down.
-- Namespace-scoped grants pointing at the SystemPlatform namespaces themselves.
DELETE FROM [NodeAcl]
WHERE [ScopeKind] = 'Namespace'
AND [ScopeId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform');
-- Equipment- and FolderSegment-scoped grants pointing at Equipment that hangs off the affected
-- DriverInstances (both share Equipment.EquipmentId as the ScopeId).
DELETE FROM [NodeAcl]
WHERE [ScopeKind] IN ('Equipment', 'FolderSegment')
AND [ScopeId] IN (
SELECT [EquipmentId] FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform')));
-- Tag-scoped grants pointing at Tags bound to the affected DriverInstances (ScopeId = Tag.TagId).
DELETE FROM [NodeAcl]
WHERE [ScopeKind] = 'Tag'
AND [ScopeId] IN (
SELECT [TagId] FROM [Tag]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform')));
-- ExternalIdReservation is NOT generation-versioned and must outlive equipment deletion as a
-- RELEASE, not a delete: active rows (ReleasedAt IS NULL) hold a fleet-wide unique reservation of
-- the ZTag/SAPID under a filtered unique index. If we left them active after dropping the
-- Equipment, the same external IDs could never be reused. Release them so the IDs free up while the
-- audit row survives. Run BEFORE the Equipment DELETE so the EquipmentUuid subquery still resolves.
UPDATE [ExternalIdReservation]
SET [ReleasedAt] = SYSUTCDATETIME(),
[ReleasedBy] = 'CleanupSystemPlatformNamespaces',
[ReleaseReason] = 'Equipment removed by SystemPlatform-namespace cleanup migration (retired NamespaceKind).'
WHERE [ReleasedAt] IS NULL
AND [EquipmentUuid] IN (
SELECT [EquipmentUuid] FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform')));
-- Grandchildren of the affected Equipment ------------------------------------------------
-- ScriptedAlarmState is keyed by ScriptedAlarm.ScriptedAlarmId; remove before its ScriptedAlarm.
DELETE FROM [ScriptedAlarmState]
WHERE [ScriptedAlarmId] IN (
SELECT [ScriptedAlarmId] FROM [ScriptedAlarm]
WHERE [EquipmentId] IN (
SELECT [EquipmentId] FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'))));
DELETE FROM [ScriptedAlarm]
WHERE [EquipmentId] IN (
SELECT [EquipmentId] FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform')));
DELETE FROM [VirtualTag]
WHERE [EquipmentId] IN (
SELECT [EquipmentId] FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform')));
-- Children of the affected DriverInstances ------------------------------------------------
-- Tags bound to those drivers: both EquipmentId-NULL mirror tags AND any equipment-scoped tag.
DELETE FROM [Tag]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'));
DELETE FROM [PollGroup]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'));
DELETE FROM [Device]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'));
DELETE FROM [Equipment]
WHERE [DriverInstanceId] IN (
SELECT [DriverInstanceId] FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform'));
-- The DriverInstances themselves ----------------------------------------------------------
DELETE FROM [DriverInstance]
WHERE [NamespaceId] IN (
SELECT [NamespaceId] FROM [Namespace] WHERE [Kind] = 'SystemPlatform');
-- The SystemPlatform namespaces (the rows that would crash EF on read) --------------------
DELETE FROM [Namespace] WHERE [Kind] = 'SystemPlatform';
");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Forward-only data cleanup: the deleted SystemPlatform namespaces and their dependent
// rows cannot be reconstructed (the data is gone, and SystemPlatform is no longer a valid
// NamespaceKind). Intentionally a no-op rather than a throw, so rolling back a LATER
// migration does not blow up on this one.
}
}
}
@@ -12,8 +12,8 @@ using ZB.MOM.WW.OtOpcUa.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
[DbContext(typeof(OtOpcUaConfigDbContext))]
[Migration("20260526081556_V2HostingAlignment")]
partial class V2HostingAlignment
[Migration("20260715230637_V3Initial")]
partial class V3Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -168,9 +168,15 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid?>("CorrelationId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DetailsJson")
.HasColumnType("nvarchar(max)");
b.Property<Guid?>("EventId")
.HasColumnType("uniqueidentifier");
b.Property<string>("EventType")
.IsRequired()
.HasMaxLength(64)
@@ -183,6 +189,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Outcome")
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<string>("Principal")
.IsRequired()
.HasMaxLength(128)
@@ -195,6 +205,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
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");
@@ -361,6 +376,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
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");
@@ -440,8 +459,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NamespaceId")
.IsRequired()
b.Property<string>("RawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -464,8 +482,18 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasDatabaseName("UX_DriverInstance_LogicalId")
.HasFilter("[DriverInstanceId] IS NOT NULL");
b.HasIndex("NamespaceId")
.HasDatabaseName("IX_DriverInstance_Namespace");
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 =>
{
@@ -525,19 +553,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("DeviceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DeviceManualUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("DriverInstanceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
@@ -610,9 +629,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasKey("EquipmentRowId");
b.HasIndex("DriverInstanceId")
.HasDatabaseName("IX_Equipment_Driver");
b.HasIndex("EquipmentId")
.IsUnique()
.HasDatabaseName("UX_Equipment_LogicalId")
@@ -643,148 +659,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("Equipment", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("FinalisedAtUtc")
.HasColumnType("datetime2(3)");
b.Property<int>("RowsAccepted")
.HasColumnType("int");
b.Property<int>("RowsRejected")
.HasColumnType("int");
b.Property<int>("RowsStaged")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedBy", "FinalisedAtUtc")
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
b.ToTable("EquipmentImportBatch", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AssetLocation")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<Guid>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DeviceManualUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("EquipmentUuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("HardwareRevision")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsAccepted")
.HasColumnType("bit");
b.Property<int>("LineNumberInFile")
.HasColumnType("int");
b.Property<string>("MachineCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Manufacturer")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("ManufacturerUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Model")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("RejectReason")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("SAPID")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SerialNumber")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("SoftwareRevision")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsAreaName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsLineName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("YearOfConstruction")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("ZTag")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("Id");
b.HasIndex("BatchId")
.HasDatabaseName("IX_EquipmentImportRow_Batch");
b.ToTable("EquipmentImportRow", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b =>
{
b.Property<Guid>("ReservationId")
@@ -894,66 +768,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("LdapGroupRoleMapping", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b =>
{
b.Property<Guid>("NamespaceRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("NamespaceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("NamespaceUri")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.HasKey("NamespaceRowId");
b.HasIndex("ClusterId")
.HasDatabaseName("IX_Namespace_Cluster");
b.HasIndex("NamespaceId")
.IsUnique()
.HasDatabaseName("UX_Namespace_LogicalId")
.HasFilter("[NamespaceId] IS NOT NULL");
b.HasIndex("NamespaceUri")
.IsUnique()
.HasDatabaseName("UX_Namespace_NamespaceUri");
b.HasIndex("ClusterId", "Kind")
.IsUnique()
.HasDatabaseName("UX_Namespace_Cluster_Kind");
b.ToTable("Namespace", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b =>
{
b.Property<Guid>("NodeAclRowId")
@@ -1109,6 +923,63 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b =>
{
b.Property<Guid>("RawFolderRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ParentRawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("RawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("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<Guid>("ScriptRowId")
@@ -1388,22 +1259,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasColumnType("nvarchar(32)");
b.Property<string>("DeviceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DriverInstanceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("EquipmentId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FolderPath")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
@@ -1423,6 +1282,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TagGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("TagId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -1432,27 +1295,23 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasKey("TagRowId");
b.HasIndex("EquipmentId")
.HasDatabaseName("IX_Tag_Equipment")
.HasFilter("[EquipmentId] IS NOT NULL");
b.HasIndex("TagId")
.IsUnique()
.HasDatabaseName("UX_Tag_LogicalId")
.HasFilter("[TagId] IS NOT NULL");
b.HasIndex("DriverInstanceId", "DeviceId")
.HasDatabaseName("IX_Tag_Driver_Device");
b.HasIndex("EquipmentId", "Name")
b.HasIndex("DeviceId", "Name")
.IsUnique()
.HasDatabaseName("UX_Tag_EquipmentPath")
.HasFilter("[EquipmentId] IS NOT NULL");
.HasDatabaseName("UX_Tag_Device_Name")
.HasFilter("[TagGroupId] IS NULL");
b.HasIndex("DriverInstanceId", "FolderPath", "Name")
b.HasIndex("DeviceId", "TagGroupId")
.HasDatabaseName("IX_Tag_Device");
b.HasIndex("DeviceId", "TagGroupId", "Name")
.IsUnique()
.HasDatabaseName("UX_Tag_FolderPath")
.HasFilter("[EquipmentId] IS NULL");
.HasDatabaseName("UX_Tag_Group_Name")
.HasFilter("[TagGroupId] IS NOT NULL");
b.ToTable("Tag", null, t =>
{
@@ -1460,6 +1319,63 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.TagGroup", b =>
{
b.Property<Guid>("TagGroupRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ParentTagGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("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<Guid>("UnsAreaRowId")
@@ -1556,6 +1472,60 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("UnsLine", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsTagReference", b =>
{
b.Property<Guid>("UnsTagReferenceRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("DisplayNameOverride")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("TagId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("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<Guid>("VirtualTagRowId")
@@ -1660,17 +1630,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Cluster");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch")
.WithMany("Rows")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
@@ -1681,17 +1640,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Cluster");
});
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.Navigation("Cluster");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment")
@@ -1711,6 +1659,15 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
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")
@@ -1727,16 +1684,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Credentials");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
{
b.Navigation("Rows");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
{
b.Navigation("Namespaces");
b.Navigation("Nodes");
b.Navigation("RawFolders");
});
#pragma warning restore 612, 618
}
@@ -0,0 +1,285 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <summary>
/// Hand-authored SQL squashed into <c>V3Initial</c> from the retired per-migration proc files
/// (<c>*_StoredProcedures.cs</c>, <c>*_ExtendComputeGenerationDiffWith*.cs</c>) and
/// <c>AuthorizationGrants.cs</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Port audit (v3 WP1).</b> The v1 stored procs were built entirely on the
/// <c>ConfigGeneration</c> / <c>GenerationId</c> / <c>ClusterNodeGenerationState</c>
/// generation model, which <c>V2HostingAlignment</c> (2026-05-26) dropped outright — so
/// those procs have been vestigial since v2 (SQL Server's deferred name resolution let them
/// keep existing while referencing phantom tables). No production code calls them; the v2/v3
/// deploy + diff path is entirely C# (ControlPlane <c>ConfigComposer</c>,
/// <c>AddressSpaceChangeClassifier</c>, <c>DraftValidator</c>). Their only consumer is
/// <c>AuthorizationTests</c>, which exercises the DB-role EXECUTE grants and expects
/// RAISERROR / permission failures — never a successful data operation.
/// </para>
/// <para>
/// Under greenfield v3 the two-role authorization model (<c>OtOpcUaNode</c> /
/// <c>OtOpcUaAdmin</c> + EXECUTE grants) is <b>retained</b> as a real security feature, so the
/// eight proc <b>names</b> must exist for the grants + tests to bind. Each proc is rewritten
/// here to be schema-valid against the v3 schema (references NO dropped table/column):
/// </para>
/// <list type="bullet">
/// <item><c>sp_GetCurrentGenerationForCluster</c> keeps its <c>ClusterNodeCredential</c> +
/// <c>ClusterNode</c> "Unauthorized" auth check (both tables retained), then RAISERRORs a
/// v3-retirement message — preserving the exact behavior AuthorizationTests asserts.</item>
/// <item><c>sp_PublishGeneration</c> is granted to <c>OtOpcUaAdmin</c> only and RAISERRORs a
/// retirement message (never the word "permission"), so the Node-denied vs Admin-allowed
/// grant assertions still hold.</item>
/// <item><c>sp_ComputeGenerationDiff</c> is rewritten to reference the v3 tables
/// (RawFolder / DriverInstance / Device / TagGroup / Tag / Equipment / UnsTagReference /
/// NodeAcl) as a schema-valid current-state signature inventory. A genuine
/// generation-to-generation diff is architecturally impossible now (no GenerationId column);
/// the real diff lives in the C# ControlPlane.</item>
/// <item><c>sp_ReleaseExternalIdReservation</c> is ported verbatim — it only touches retained
/// tables (<c>ExternalIdReservation</c>, <c>ConfigAuditLog</c>).</item>
/// </list>
/// </remarks>
public partial class V3Initial
{
private static void CreateStoredProcedures(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(GetCurrentGenerationForCluster);
migrationBuilder.Sql(GetGenerationContent);
migrationBuilder.Sql(RegisterNodeGenerationApplied);
migrationBuilder.Sql(ValidateDraft);
migrationBuilder.Sql(PublishGeneration);
migrationBuilder.Sql(RollbackToGeneration);
migrationBuilder.Sql(ComputeGenerationDiff);
migrationBuilder.Sql(ReleaseExternalIdReservation);
}
private static void DropStoredProcedures(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 void CreateAuthorizationGrants(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;
");
}
private static void DropAuthorizationGrants(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;
");
}
// ------------------------------------------------------------------------------------------
// v3 retirement stubs. The generation model is gone (see class remarks); these preserve the
// authorization surface + the AuthorizationTests behaviors while referencing only v3 tables.
// ------------------------------------------------------------------------------------------
private const string GetCurrentGenerationForCluster = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster
@NodeId nvarchar(64),
@ClusterId nvarchar(64)
AS
BEGIN
SET NOCOUNT ON;
-- Auth surface retained verbatim (ClusterNodeCredential + ClusterNode are v3 tables). An
-- unbound caller still gets 'Unauthorized', which AuthorizationTests asserts.
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
RAISERROR('sp_GetCurrentGenerationForCluster is retired under v3: config/deploy state moved to the C# ControlPlane (Deployment table).', 16, 1);
END
";
private const string GetGenerationContent = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent
@NodeId nvarchar(64),
@GenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
RAISERROR('sp_GetGenerationContent is retired under v3: generation content is sealed into Deployment.ArtifactBlob by the C# ControlPlane.', 16, 1);
END
";
private 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;
RAISERROR('sp_RegisterNodeGenerationApplied is retired under v3: node-apply state is tracked in NodeDeploymentState by the C# ControlPlane.', 16, 1);
END
";
private const string ValidateDraft = @"
CREATE OR ALTER PROCEDURE dbo.sp_ValidateDraft
@DraftGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
RAISERROR('sp_ValidateDraft is retired under v3: draft validation runs in the managed DraftValidator (Configuration.Validation).', 16, 1);
END
";
private const string PublishGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_PublishGeneration
@ClusterId nvarchar(64),
@DraftGenerationId bigint,
@Notes nvarchar(1024) = NULL
AS
BEGIN
SET NOCOUNT ON;
-- Message intentionally omits the word 'permission' so AuthorizationTests can distinguish a
-- grant failure (Node role) from a body-level error (Admin role reaching the proc body).
RAISERROR('sp_PublishGeneration is retired under v3: publishing is done by the C# ControlPlane (ConfigComposer + Deployment).', 16, 1);
END
";
private const string RollbackToGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_RollbackToGeneration
@ClusterId nvarchar(64),
@TargetGenerationId bigint,
@Notes nvarchar(1024) = NULL
AS
BEGIN
SET NOCOUNT ON;
RAISERROR('sp_RollbackToGeneration is retired under v3: rollback is a C# ControlPlane operation over the Deployment history.', 16, 1);
END
";
// Rewritten to the v3 schema. There is no GenerationId to diff (the per-generation table model
// was dropped in v2), so this emits a current-state signature inventory across the v3 tables —
// referencing RawFolder, DriverInstance, Device, TagGroup, Tag, Equipment, UnsTagReference, and
// NodeAcl. The genuine generation-to-generation diff now lives in the C# ControlPlane
// (AddressSpaceChangeClassifier); this proc is retained only to carry the OtOpcUaAdmin EXECUTE
// grant and to keep the v3 table surface referenced from SQL.
private const string ComputeGenerationDiff = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
-- @FromGenerationId / @ToGenerationId are ignored: the v3 config tables are single live-state
-- (no GenerationId column). Emit a current-state signature per row so the shape (TableName,
-- LogicalId, Sig) still round-trips through any legacy caller.
SELECT TableName, LogicalId, Sig FROM (
SELECT 'RawFolder' AS TableName, RawFolderId AS LogicalId, CHECKSUM(ClusterId, ParentRawFolderId, Name, SortOrder) AS Sig FROM dbo.RawFolder
UNION ALL
SELECT 'DriverInstance', DriverInstanceId, CHECKSUM(ClusterId, RawFolderId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) FROM dbo.DriverInstance
UNION ALL
SELECT 'Device', DeviceId, CHECKSUM(DriverInstanceId, Name, Enabled, CONVERT(varchar(max), DeviceConfig)) FROM dbo.Device
UNION ALL
SELECT 'TagGroup', TagGroupId, CHECKSUM(DeviceId, ParentTagGroupId, Name, SortOrder) FROM dbo.TagGroup
UNION ALL
SELECT 'Tag', TagId, CHECKSUM(DeviceId, TagGroupId, PollGroupId, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) FROM dbo.Tag
UNION ALL
SELECT 'Equipment', EquipmentId, CHECKSUM(EquipmentUuid, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) FROM dbo.Equipment
UNION ALL
SELECT 'UnsTagReference', UnsTagReferenceId, CHECKSUM(EquipmentId, TagId, DisplayNameOverride, SortOrder) FROM dbo.UnsTagReference
UNION ALL
SELECT 'NodeAcl', NodeAclId, CHECKSUM(ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes) FROM dbo.NodeAcl
) AS inventory;
END
";
// Ported verbatim — touches only retained tables (ExternalIdReservation, ConfigAuditLog).
private 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
-- Escape both caller-supplied values via STRING_ESCAPE so quotes/backslashes cannot break the
-- JSON document or inject additional structure into the audit record.
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
CONCAT('{""kind"":""', STRING_ESCAPE(@Kind, 'json'),
'"",""value"":""', STRING_ESCAPE(@Value, 'json'), '""}'));
END
";
}
}
File diff suppressed because it is too large Load Diff
@@ -373,6 +373,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
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");
@@ -452,8 +456,7 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NamespaceId")
.IsRequired()
b.Property<string>("RawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -476,8 +479,18 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasDatabaseName("UX_DriverInstance_LogicalId")
.HasFilter("[DriverInstanceId] IS NOT NULL");
b.HasIndex("NamespaceId")
.HasDatabaseName("IX_DriverInstance_Namespace");
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 =>
{
@@ -537,18 +550,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("DeviceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DeviceManualUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("DriverInstanceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
@@ -621,9 +626,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasKey("EquipmentRowId");
b.HasIndex("DriverInstanceId")
.HasDatabaseName("IX_Equipment_Driver");
b.HasIndex("EquipmentId")
.IsUnique()
.HasDatabaseName("UX_Equipment_LogicalId")
@@ -654,148 +656,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("Equipment", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("datetime2(3)");
b.Property<string>("CreatedBy")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<DateTime?>("FinalisedAtUtc")
.HasColumnType("datetime2(3)");
b.Property<int>("RowsAccepted")
.HasColumnType("int");
b.Property<int>("RowsRejected")
.HasColumnType("int");
b.Property<int>("RowsStaged")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CreatedBy", "FinalisedAtUtc")
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
b.ToTable("EquipmentImportBatch", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("AssetLocation")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<Guid>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("DeviceManualUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("EquipmentUuid")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("HardwareRevision")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsAccepted")
.HasColumnType("bit");
b.Property<int>("LineNumberInFile")
.HasColumnType("int");
b.Property<string>("MachineCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Manufacturer")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("ManufacturerUri")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Model")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("RejectReason")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("SAPID")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SerialNumber")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("SoftwareRevision")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsAreaName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("UnsLineName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("YearOfConstruction")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("ZTag")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.HasKey("Id");
b.HasIndex("BatchId")
.HasDatabaseName("IX_EquipmentImportRow_Batch");
b.ToTable("EquipmentImportRow", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ExternalIdReservation", b =>
{
b.Property<Guid>("ReservationId")
@@ -905,66 +765,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("LdapGroupRoleMapping", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Namespace", b =>
{
b.Property<Guid>("NamespaceRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("NamespaceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("NamespaceUri")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Notes")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.HasKey("NamespaceRowId");
b.HasIndex("ClusterId")
.HasDatabaseName("IX_Namespace_Cluster");
b.HasIndex("NamespaceId")
.IsUnique()
.HasDatabaseName("UX_Namespace_LogicalId")
.HasFilter("[NamespaceId] IS NOT NULL");
b.HasIndex("NamespaceUri")
.IsUnique()
.HasDatabaseName("UX_Namespace_NamespaceUri");
b.HasIndex("ClusterId", "Kind")
.IsUnique()
.HasDatabaseName("UX_Namespace_Cluster_Kind");
b.ToTable("Namespace", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeAcl", b =>
{
b.Property<Guid>("NodeAclRowId")
@@ -1120,6 +920,63 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.RawFolder", b =>
{
b.Property<Guid>("RawFolderRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("ClusterId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ParentRawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("RawFolderId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("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<Guid>("ScriptRowId")
@@ -1399,22 +1256,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.HasColumnType("nvarchar(32)");
b.Property<string>("DeviceId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("DriverInstanceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("EquipmentId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FolderPath")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
@@ -1434,6 +1279,10 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TagGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("TagId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
@@ -1443,27 +1292,23 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.HasKey("TagRowId");
b.HasIndex("EquipmentId")
.HasDatabaseName("IX_Tag_Equipment")
.HasFilter("[EquipmentId] IS NOT NULL");
b.HasIndex("TagId")
.IsUnique()
.HasDatabaseName("UX_Tag_LogicalId")
.HasFilter("[TagId] IS NOT NULL");
b.HasIndex("DriverInstanceId", "DeviceId")
.HasDatabaseName("IX_Tag_Driver_Device");
b.HasIndex("EquipmentId", "Name")
b.HasIndex("DeviceId", "Name")
.IsUnique()
.HasDatabaseName("UX_Tag_EquipmentPath")
.HasFilter("[EquipmentId] IS NOT NULL");
.HasDatabaseName("UX_Tag_Device_Name")
.HasFilter("[TagGroupId] IS NULL");
b.HasIndex("DriverInstanceId", "FolderPath", "Name")
b.HasIndex("DeviceId", "TagGroupId")
.HasDatabaseName("IX_Tag_Device");
b.HasIndex("DeviceId", "TagGroupId", "Name")
.IsUnique()
.HasDatabaseName("UX_Tag_FolderPath")
.HasFilter("[EquipmentId] IS NULL");
.HasDatabaseName("UX_Tag_Group_Name")
.HasFilter("[TagGroupId] IS NOT NULL");
b.ToTable("Tag", null, t =>
{
@@ -1471,6 +1316,63 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
});
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.TagGroup", b =>
{
b.Property<Guid>("TagGroupRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ParentTagGroupId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("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<Guid>("UnsAreaRowId")
@@ -1567,6 +1469,60 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.ToTable("UnsLine", (string)null);
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.UnsTagReference", b =>
{
b.Property<Guid>("UnsTagReferenceRowId")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasDefaultValueSql("NEWSEQUENTIALID()");
b.Property<string>("DisplayNameOverride")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("EquipmentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("TagId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("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<Guid>("VirtualTagRowId")
@@ -1671,17 +1627,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Cluster");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportRow", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", "Batch")
.WithMany("Rows")
.HasForeignKey("BatchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Batch");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.LdapGroupRoleMapping", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", "Cluster")
@@ -1692,17 +1637,6 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Cluster");
});
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.Navigation("Cluster");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.NodeDeploymentState", b =>
{
b.HasOne("ZB.MOM.WW.OtOpcUa.Configuration.Entities.Deployment", "Deployment")
@@ -1722,6 +1656,15 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
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")
@@ -1738,16 +1681,11 @@ namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
b.Navigation("Credentials");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.EquipmentImportBatch", b =>
{
b.Navigation("Rows");
});
modelBuilder.Entity("ZB.MOM.WW.OtOpcUa.Configuration.Entities.ServerCluster", b =>
{
b.Navigation("Namespaces");
b.Navigation("Nodes");
b.Navigation("RawFolders");
});
#pragma warning restore 612, 618
}
@@ -18,8 +18,12 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
/// <summary>Gets the DbSet of cluster node credentials.</summary>
public DbSet<ClusterNodeCredential> ClusterNodeCredentials => Set<ClusterNodeCredential>();
/// <summary>Gets the DbSet of namespaces.</summary>
public DbSet<Namespace> Namespaces => Set<Namespace>();
/// <summary>Gets the DbSet of v3 Raw-tree driver-organizing folders.</summary>
public DbSet<RawFolder> RawFolders => Set<RawFolder>();
/// <summary>Gets the DbSet of v3 Raw-tree tag groups (nestable under a device).</summary>
public DbSet<TagGroup> TagGroups => Set<TagGroup>();
/// <summary>Gets the DbSet of v3 UNS tag references (equipment → raw tag projection).</summary>
public DbSet<UnsTagReference> UnsTagReferences => Set<UnsTagReference>();
/// <summary>Gets the DbSet of UNS areas.</summary>
public DbSet<UnsArea> UnsAreas => Set<UnsArea>();
/// <summary>Gets the DbSet of UNS lines.</summary>
@@ -46,10 +50,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
public DbSet<DriverInstanceResilienceStatus> DriverInstanceResilienceStatuses => Set<DriverInstanceResilienceStatus>();
/// <summary>Gets the DbSet of LDAP group role mappings.</summary>
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
/// <summary>Gets the DbSet of equipment import batches.</summary>
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
/// <summary>Gets the DbSet of equipment import rows.</summary>
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
/// <summary>Gets the DbSet of scripts.</summary>
public DbSet<Script> Scripts => Set<Script>();
/// <summary>Gets the DbSet of virtual tags.</summary>
@@ -79,13 +79,15 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureServerCluster(modelBuilder);
ConfigureClusterNode(modelBuilder);
ConfigureClusterNodeCredential(modelBuilder);
ConfigureNamespace(modelBuilder);
ConfigureRawFolder(modelBuilder);
ConfigureUnsArea(modelBuilder);
ConfigureUnsLine(modelBuilder);
ConfigureDriverInstance(modelBuilder);
ConfigureDevice(modelBuilder);
ConfigureTagGroup(modelBuilder);
ConfigureEquipment(modelBuilder);
ConfigureTag(modelBuilder);
ConfigureUnsTagReference(modelBuilder);
ConfigurePollGroup(modelBuilder);
ConfigureNodeAcl(modelBuilder);
ConfigureConfigAuditLog(modelBuilder);
@@ -93,7 +95,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
ConfigureDriverHostStatus(modelBuilder);
ConfigureDriverInstanceResilienceStatus(modelBuilder);
ConfigureLdapGroupRoleMapping(modelBuilder);
ConfigureEquipmentImportBatch(modelBuilder);
ConfigureScript(modelBuilder);
ConfigureVirtualTag(modelBuilder);
ConfigureScriptedAlarm(modelBuilder);
@@ -183,32 +184,39 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
});
}
private static void ConfigureNamespace(ModelBuilder modelBuilder)
private static void ConfigureRawFolder(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Namespace>(e =>
modelBuilder.Entity<RawFolder>(e =>
{
e.ToTable("Namespace");
e.HasKey(x => x.NamespaceRowId);
e.Property(x => x.NamespaceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.NamespaceId).HasMaxLength(64);
e.ToTable("RawFolder");
e.HasKey(x => x.RawFolderRowId);
e.Property(x => x.RawFolderRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.RawFolderId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.NamespaceUri).HasMaxLength(256);
e.Property(x => x.Notes).HasMaxLength(1024);
e.Property(x => x.ParentRawFolderId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Cluster).WithMany(c => c.Namespaces)
// Cluster-rooted. Restrict so a cluster with folders cannot be deleted out from under
// them (matches the OnDelete(Restrict) convention across the config schema). Child FKs
// (DriverInstance.RawFolderId, RawFolder.ParentRawFolderId) are logical-only, so
// non-empty-folder-delete is enforced at the service layer in later batches.
e.HasOne<ServerCluster>().WithMany(c => c.RawFolders)
.HasForeignKey(x => x.ClusterId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ClusterId, x.Kind }).IsUnique()
.HasDatabaseName("UX_Namespace_Cluster_Kind");
e.HasIndex(x => x.NamespaceUri).IsUnique()
.HasDatabaseName("UX_Namespace_NamespaceUri");
e.HasIndex(x => x.NamespaceId).IsUnique()
.HasDatabaseName("UX_Namespace_LogicalId");
e.HasIndex(x => x.ClusterId)
.HasDatabaseName("IX_Namespace_Cluster");
e.HasIndex(x => x.RawFolderId).IsUnique().HasDatabaseName("UX_RawFolder_LogicalId");
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_RawFolder_Cluster");
// Sibling-name uniqueness. Two filtered indexes cover the nullable ParentRawFolderId:
// one for nested folders (Parent NOT NULL), one for cluster-root folders (Parent NULL),
// mirroring the Tag filtered-index convention.
e.HasIndex(x => new { x.ClusterId, x.ParentRawFolderId, x.Name }).IsUnique()
.HasFilter("[ParentRawFolderId] IS NOT NULL")
.HasDatabaseName("UX_RawFolder_Parent_Name");
e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique()
.HasFilter("[ParentRawFolderId] IS NULL")
.HasDatabaseName("UX_RawFolder_ClusterRoot_Name");
});
}
@@ -267,7 +275,7 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
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.RawFolderId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DriverType).HasMaxLength(32);
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
@@ -277,8 +285,18 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_DriverInstance_Cluster");
e.HasIndex(x => x.NamespaceId).HasDatabaseName("IX_DriverInstance_Namespace");
e.HasIndex(x => x.RawFolderId).HasDatabaseName("IX_DriverInstance_RawFolder");
e.HasIndex(x => x.DriverInstanceId).IsUnique().HasDatabaseName("UX_DriverInstance_LogicalId");
// v3 sibling-name uniqueness (a RawPath segment). Two filtered indexes cover the nullable
// RawFolderId: nested drivers keyed by (ClusterId, RawFolderId, Name), cluster-root drivers
// by (ClusterId, Name).
e.HasIndex(x => new { x.ClusterId, x.RawFolderId, x.Name }).IsUnique()
.HasFilter("[RawFolderId] IS NOT NULL")
.HasDatabaseName("UX_DriverInstance_Folder_Name");
e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique()
.HasFilter("[RawFolderId] IS NULL")
.HasDatabaseName("UX_DriverInstance_ClusterRoot_Name");
});
}
@@ -300,6 +318,38 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Device_Driver");
e.HasIndex(x => x.DeviceId).IsUnique().HasDatabaseName("UX_Device_LogicalId");
// v3 sibling-name uniqueness within a driver (a RawPath segment).
e.HasIndex(x => new { x.DriverInstanceId, x.Name }).IsUnique()
.HasDatabaseName("UX_Device_Driver_Name");
});
}
private static void ConfigureTagGroup(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TagGroup>(e =>
{
e.ToTable("TagGroup");
e.HasKey(x => x.TagGroupRowId);
e.Property(x => x.TagGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.TagGroupId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.ParentTagGroupId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.TagGroupId).IsUnique().HasDatabaseName("UX_TagGroup_LogicalId");
e.HasIndex(x => x.DeviceId).HasDatabaseName("IX_TagGroup_Device");
// Sibling-name uniqueness. Two filtered indexes cover the nullable ParentTagGroupId:
// nested groups keyed by (DeviceId, ParentTagGroupId, Name), device-root groups by
// (DeviceId, Name). DeviceId/ParentTagGroupId are logical FKs (no navigation); non-empty
// group deletes are blocked at the service layer in later batches.
e.HasIndex(x => new { x.DeviceId, x.ParentTagGroupId, x.Name }).IsUnique()
.HasFilter("[ParentTagGroupId] IS NOT NULL")
.HasDatabaseName("UX_TagGroup_Parent_Name");
e.HasIndex(x => new { x.DeviceId, x.Name }).IsUnique()
.HasFilter("[ParentTagGroupId] IS NULL")
.HasDatabaseName("UX_TagGroup_DeviceRoot_Name");
});
}
@@ -311,8 +361,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
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);
@@ -329,7 +377,6 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
e.Property(x => x.EquipmentClassRef).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Equipment_Driver");
e.HasIndex(x => x.UnsLineId).HasDatabaseName("IX_Equipment_Line");
e.HasIndex(x => x.EquipmentId).IsUnique().HasDatabaseName("UX_Equipment_LogicalId");
e.HasIndex(x => new { x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_LinePath");
@@ -351,28 +398,29 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
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);
// v3: raw-only tag. Required DeviceId FK; nullable TagGroupId. EquipmentId/FolderPath/
// DriverInstanceId are retired — identity is the RawPath (device + optional group + name).
e.Property(x => x.DeviceId).HasMaxLength(64).IsRequired();
e.Property(x => x.TagGroupId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.FolderPath).HasMaxLength(512);
e.Property(x => x.DataType).HasMaxLength(32);
e.Property(x => x.AccessLevel).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.PollGroupId).HasMaxLength(64);
e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)");
e.Property(x => x.RowVersion).IsRowVersion();
e.HasIndex(x => new { x.DriverInstanceId, x.DeviceId }).HasDatabaseName("IX_Tag_Driver_Device");
e.HasIndex(x => x.EquipmentId)
.HasFilter("[EquipmentId] IS NOT NULL")
.HasDatabaseName("IX_Tag_Equipment");
e.HasIndex(x => new { x.DeviceId, x.TagGroupId }).HasDatabaseName("IX_Tag_Device");
e.HasIndex(x => x.TagId).IsUnique().HasDatabaseName("UX_Tag_LogicalId");
e.HasIndex(x => new { x.EquipmentId, x.Name }).IsUnique()
.HasFilter("[EquipmentId] IS NOT NULL")
.HasDatabaseName("UX_Tag_EquipmentPath");
e.HasIndex(x => new { x.DriverInstanceId, x.FolderPath, x.Name }).IsUnique()
.HasFilter("[EquipmentId] IS NULL")
.HasDatabaseName("UX_Tag_FolderPath");
// Sibling-name uniqueness (a RawPath segment). Two filtered indexes cover the nullable
// TagGroupId: grouped tags keyed by (DeviceId, TagGroupId, Name), device-root tags by
// (DeviceId, Name).
e.HasIndex(x => new { x.DeviceId, x.TagGroupId, x.Name }).IsUnique()
.HasFilter("[TagGroupId] IS NOT NULL")
.HasDatabaseName("UX_Tag_Group_Name");
e.HasIndex(x => new { x.DeviceId, x.Name }).IsUnique()
.HasFilter("[TagGroupId] IS NULL")
.HasDatabaseName("UX_Tag_Device_Name");
});
}
@@ -567,51 +615,28 @@ public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbConte
});
}
private static void ConfigureEquipmentImportBatch(ModelBuilder modelBuilder)
private static void ConfigureUnsTagReference(ModelBuilder modelBuilder)
{
modelBuilder.Entity<EquipmentImportBatch>(e =>
modelBuilder.Entity<UnsTagReference>(e =>
{
e.ToTable("EquipmentImportBatch");
e.HasKey(x => x.Id);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)");
e.Property(x => x.FinalisedAtUtc).HasColumnType("datetime2(3)");
// Admin preview modal filters by user; finalise / drop both hit this index.
e.HasIndex(x => new { x.CreatedBy, x.FinalisedAtUtc })
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
});
modelBuilder.Entity<EquipmentImportRow>(e =>
{
e.ToTable("EquipmentImportRow");
e.HasKey(x => x.Id);
e.Property(x => x.ZTag).HasMaxLength(128);
e.Property(x => x.MachineCode).HasMaxLength(128);
e.Property(x => x.SAPID).HasMaxLength(128);
e.ToTable("UnsTagReference");
e.HasKey(x => x.UnsTagReferenceRowId);
e.Property(x => x.UnsTagReferenceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.UnsTagReferenceId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.EquipmentUuid).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.UnsAreaName).HasMaxLength(64);
e.Property(x => x.UnsLineName).HasMaxLength(64);
e.Property(x => x.Manufacturer).HasMaxLength(256);
e.Property(x => x.Model).HasMaxLength(256);
e.Property(x => x.SerialNumber).HasMaxLength(256);
e.Property(x => x.HardwareRevision).HasMaxLength(64);
e.Property(x => x.SoftwareRevision).HasMaxLength(64);
e.Property(x => x.YearOfConstruction).HasMaxLength(8);
e.Property(x => x.AssetLocation).HasMaxLength(512);
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
e.Property(x => x.RejectReason).HasMaxLength(512);
e.Property(x => x.TagId).HasMaxLength(64);
e.Property(x => x.DisplayNameOverride).HasMaxLength(128);
e.Property(x => x.RowVersion).IsRowVersion();
e.HasOne(x => x.Batch)
.WithMany(b => b.Rows)
.HasForeignKey(x => x.BatchId)
.OnDelete(DeleteBehavior.Cascade);
e.HasIndex(x => x.UnsTagReferenceId).IsUnique().HasDatabaseName("UX_UnsTagReference_LogicalId");
e.HasIndex(x => x.EquipmentId).HasDatabaseName("IX_UnsTagReference_Equipment");
e.HasIndex(x => x.TagId).HasDatabaseName("IX_UnsTagReference_Tag");
e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch");
// One reference per (equipment, raw tag). EquipmentId/TagId are logical FKs (no
// navigation); the "raw-tag delete blocked while referenced" rule is enforced at the
// service layer in later batches.
e.HasIndex(x => new { x.EquipmentId, x.TagId }).IsUnique()
.HasDatabaseName("UX_UnsTagReference_Equip_Tag");
});
}
@@ -25,8 +25,9 @@ public sealed class DraftSnapshot
/// </summary>
public string? Site { get; init; }
/// <summary>Gets the list of OPC UA namespaces.</summary>
public IReadOnlyList<Namespace> Namespaces { get; init; } = [];
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The old
// Namespaces list + namespace-binding validation are gone. WP4 (Wave C) owns the new v3
// rules (raw-name charset, historized-tagname length, UNS effective-leaf uniqueness).
/// <summary>Gets the list of driver instances.</summary>
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
/// <summary>Gets the list of devices.</summary>
@@ -36,7 +36,7 @@ public static class DraftSnapshotFactory
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
Namespaces = await db.Namespaces.AsNoTracking().ToListAsync(ct),
// v3: Namespace entity retired — no namespace list to materialize.
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
@@ -30,56 +30,34 @@ public static class DraftValidator
ValidateUnsSegments(draft, errors);
ValidatePathLength(draft, errors);
ValidateEquipmentUuidImmutability(draft, errors);
ValidateSameClusterNamespaceBinding(draft, errors);
ValidateReservationPreflight(draft, errors);
ValidateEquipmentIdDerivation(draft, errors);
ValidateNoEquipmentSignalNameCollision(draft, errors);
ValidateGalaxyTagFullName(draft, errors);
// v3 WP1 note: the retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
// and ValidateGalaxyTagFullName (blob-as-identity gone; Tags are raw-only, not equipment-bound)
// are deleted here. TODO(v3 WP4): add the new v3 rules — raw-name charset (no '/', no
// lead/trail whitespace) at every level, historized effective-tagname ≤ 255 chars, and UNS
// effective-leaf uniqueness across UnsTagReference/VirtualTag/ScriptedAlarm.
return errors;
}
private static void ValidateGalaxyTagFullName(DraftSnapshot draft, List<ValidationError> errors)
{
var typeByDriver = draft.DriverInstances
.ToDictionary(d => d.DriverInstanceId, d => d.DriverType, StringComparer.Ordinal);
foreach (var t in draft.Tags)
{
if (t.EquipmentId is null) continue;
if (!typeByDriver.TryGetValue(t.DriverInstanceId, out var dtype) || dtype != "GalaxyMxGateway")
continue;
// The Galaxy rule wants the EXPLICIT reference, not the raw-blob fallback — so it reads
// TagConfigIntent.ExplicitFullName (the "FullName" property only when present-and-string,
// else null), preserving the historical null-on-absent semantics by construction (R2-11).
if (string.IsNullOrWhiteSpace(TagConfigIntent.Parse(t.TagConfig).ExplicitFullName))
errors.Add(new("GalaxyTagMissingReference",
$"Galaxy tag '{t.TagId}' on equipment '{t.EquipmentId}' is missing a Galaxy reference (TagConfig.FullName)",
t.TagId));
}
}
private static void ValidateNoEquipmentSignalNameCollision(DraftSnapshot draft, List<ValidationError> errors)
{
// Materialiser NodeId key: "{EquipmentId}[/{FolderPath}]/{Name}". Tag (EquipmentId != null) and
// VirtualTag share this space with no DB cross-table uniqueness, so the same key from both collides.
static string Key(string eq, string? folder, string name) =>
string.IsNullOrWhiteSpace(folder) ? $"{eq}/{name}" : $"{eq}/{folder}/{name}";
// v3: Tags are raw-only (no EquipmentId), so the old Tag-vs-VirtualTag NodeId collision does
// not apply here. Until WP4 wires the full UNS effective-leaf rule (across UnsTagReference,
// VirtualTag, and ScriptedAlarm), enforce the still-valid invariant that a VirtualTag Name is
// unique within its owning equipment — the OPC UA NodeId key is "{EquipmentId}/{Name}".
var signals = draft.VirtualTags.Select(v => (Eq: v.EquipmentId, v.Name));
var signals = draft.Tags
.Where(t => t.EquipmentId is not null)
.Select(t => (Key: Key(t.EquipmentId!, t.FolderPath, t.Name), Eq: t.EquipmentId!, t.Name))
// VirtualTag has no FolderPath column today — null is correct here; update if it ever gains one.
.Concat(draft.VirtualTags
.Select(v => (Key: Key(v.EquipmentId, null, v.Name), Eq: v.EquipmentId, v.Name)));
foreach (var g in signals.GroupBy(s => s.Key, StringComparer.Ordinal))
foreach (var g in signals.GroupBy(s => $"{s.Eq}/{s.Name}", StringComparer.Ordinal))
{
var items = g.ToList();
if (items.Count <= 1) continue;
var f = items[0];
errors.Add(new("EquipmentSignalNameCollision",
$"{items.Count} signals collide on OPC UA NodeId '{g.Key}' (equipment '{f.Eq}', name '{f.Name}'); " +
"a Name must be unique across Tag and VirtualTag within an equipment+folder",
"a VirtualTag Name must be unique within an equipment",
f.Eq));
}
}
@@ -149,27 +127,6 @@ public static class DraftValidator
}
}
private static void ValidateSameClusterNamespaceBinding(DraftSnapshot draft, List<ValidationError> errors)
{
var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId);
foreach (var di in draft.DriverInstances)
{
if (!nsById.TryGetValue(di.NamespaceId, out var ns))
{
errors.Add(new("NamespaceUnresolved",
$"DriverInstance '{di.DriverInstanceId}' references unknown NamespaceId '{di.NamespaceId}'",
di.DriverInstanceId));
continue;
}
if (ns.ClusterId != di.ClusterId)
errors.Add(new("BadCrossClusterNamespaceBinding",
$"DriverInstance '{di.DriverInstanceId}' is in cluster '{di.ClusterId}' but references namespace in cluster '{ns.ClusterId}'",
di.DriverInstanceId));
}
}
private static void ValidateReservationPreflight(DraftSnapshot draft, List<ValidationError> errors)
{
var activeByKindValue = draft.ActiveReservations
@@ -29,30 +29,32 @@ public sealed class DraftSnapshotFactoryTests : IDisposable
/// <summary>Disposes the database context.</summary>
public void Dispose() => _db.Dispose();
/// <summary>Seeds one Equipment plus a Tag and a VirtualTag sharing (EquipmentId, Name); the
/// snapshot must carry both signal collections AND the validator must flag the collision.</summary>
/// <summary>Seeds an Equipment plus a raw Tag and a VirtualTag; the snapshot must carry both
/// signal collections. v3: Tags are raw-only, so the equipment-signal collision rule now operates
/// on VirtualTags — two VirtualTags sharing (EquipmentId, Name) must surface the collision.</summary>
[Fact]
public async Task FromConfigDb_populates_Tags_and_VirtualTags_and_surfaces_collision()
{
SeedEquipment("eq-1");
_db.Tags.Add(BuildTag(equipmentId: "eq-1", name: "speed"));
_db.VirtualTags.Add(BuildVirtualTag(equipmentId: "eq-1", name: "speed"));
_db.Tags.Add(BuildTag(name: "speed"));
_db.VirtualTags.Add(BuildVirtualTag(equipmentId: "eq-1", name: "speed", suffix: "a"));
_db.VirtualTags.Add(BuildVirtualTag(equipmentId: "eq-1", name: "speed", suffix: "b"));
await _db.SaveChangesAsync();
var snapshot = await DraftSnapshotFactory.FromConfigDbAsync(_db);
snapshot.Tags.Count.ShouldBe(1);
snapshot.VirtualTags.Count.ShouldBe(1);
snapshot.VirtualTags.Count.ShouldBe(2);
DraftValidator.Validate(snapshot).ShouldContain(e => e.Code == "EquipmentSignalNameCollision");
}
/// <summary>A Tag and a VirtualTag with distinct names under the same equipment do not collide,
/// so the snapshot validates clean of the collision code.</summary>
/// <summary>Distinct VirtualTag names under the same equipment do not collide, so the snapshot
/// validates clean of the collision code.</summary>
[Fact]
public async Task FromConfigDb_no_collision_when_names_differ()
{
SeedEquipment("eq-1");
_db.Tags.Add(BuildTag(equipmentId: "eq-1", name: "speed"));
_db.Tags.Add(BuildTag(name: "speed"));
_db.VirtualTags.Add(BuildVirtualTag(equipmentId: "eq-1", name: "temperature"));
await _db.SaveChangesAsync();
@@ -109,26 +111,25 @@ public sealed class DraftSnapshotFactoryTests : IDisposable
EquipmentUuid = uuid,
EquipmentId = equipmentId,
Name = "eq",
DriverInstanceId = "d",
UnsLineId = "line-a",
MachineCode = "m",
});
}
private static Tag BuildTag(string equipmentId, string name) => new()
// v3: Tags are raw-only (DeviceId FK, no EquipmentId/DriverInstanceId).
private static Tag BuildTag(string name) => new()
{
TagId = $"tag-{name}",
DriverInstanceId = "d",
EquipmentId = equipmentId,
DeviceId = "dev-1",
Name = name,
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
};
private static VirtualTag BuildVirtualTag(string equipmentId, string name) => new()
private static VirtualTag BuildVirtualTag(string equipmentId, string name, string suffix = "") => new()
{
VirtualTagId = $"vtag-{name}",
VirtualTagId = $"vtag-{name}{suffix}",
EquipmentId = equipmentId,
Name = name,
DataType = "Float",
@@ -1,60 +0,0 @@
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;
/// <summary>
/// Characterization net (R2-11) pinning the DraftValidator Galaxy rule's <em>explicit</em>-FullName
/// semantics across the golden TagConfig corpus BEFORE the private FullName reader is delegated to
/// <c>TagConfigIntent.Parse(...).ExplicitFullName</c>. Distinct from the walker/composer raw-blob
/// fallback: <c>GalaxyTagMissingReference</c> fires whenever the TagConfig has no usable
/// <em>explicit</em> string <c>FullName</c> — i.e. absent / non-string / whitespace / non-object /
/// malformed — and does NOT fire only when a non-blank string FullName is present. Must be green on
/// the unmodified tree and stay green through the swap.
/// </summary>
public sealed class DraftValidatorGalaxyFullNameCorpusTests
{
[Theory]
// Present, non-blank string FullName → rule does NOT fire
[InlineData("{\"FullName\":\"X.Y\"}", false)]
[InlineData("{\"FullName\":\"A.B\",\"region\":\"Coils\"}", false)]
// Absent / non-string / whitespace / non-object / malformed → rule FIRES
[InlineData("{}", true)]
[InlineData("{\"region\":\"HoldingRegisters\"}", true)]
[InlineData("{\"FullName\":123}", true)]
[InlineData("{\"FullName\":\" \"}", true)]
[InlineData("[1,2]", true)]
[InlineData("not json {", true)]
public void Galaxy_missing_reference_fires_iff_no_explicit_string_FullName(string tagConfig, bool shouldFire)
{
var draft = new DraftSnapshot
{
GenerationId = 1,
ClusterId = "c",
DriverInstances =
[
new DriverInstance
{
DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1",
Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}",
},
],
Tags =
[
new Tag
{
TagId = "tag-galaxytag", DriverInstanceId = "d-galaxy", EquipmentId = "eq-1",
Name = "galaxytag", FolderPath = null, DataType = "Float",
AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
},
],
};
var fired = DraftValidator.Validate(draft)
.Any(e => e.Code == "GalaxyTagMissingReference" && e.Context == "tag-galaxytag");
fired.ShouldBe(shouldFire);
}
}
@@ -6,6 +6,14 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>
/// v3 WP1: the namespace-binding + Galaxy-FullName rules are retired (Namespace entity gone;
/// Tags are raw-only, not equipment-bound). The retained rules (UNS segments, path length,
/// EquipmentUuid immutability, external-id reservation preflight, EquipmentId derivation,
/// cluster topology, VirtualTag equipment-signal collision) are exercised below against the v3
/// entity shapes. WP4 (Wave C) adds the new v3 rules (raw-name charset, historized-tagname length,
/// UNS effective-leaf uniqueness).
/// </summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorTests
{
@@ -32,7 +40,6 @@ public sealed class DraftValidatorTests
EquipmentUuid = uuid,
EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
Name = name,
DriverInstanceId = "d",
UnsLineId = "line-a",
MachineCode = "m",
},
@@ -44,35 +51,6 @@ public sealed class DraftValidatorTests
hasUnsError.ShouldBe(!shouldPass);
}
/// <summary>Verifies that namespace cannot be bound to a different cluster.</summary>
[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 = "Modbus", DriverConfig = "{}" }],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
}
/// <summary>Verifies that namespace in the same cluster is accepted.</summary>
[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 = "Modbus", DriverConfig = "{}" }],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "BadCrossClusterNamespaceBinding");
}
/// <summary>Verifies that equipment UUID must remain immutable across generations.</summary>
[Fact]
public void EquipmentUuid_change_across_generations_is_rejected()
@@ -84,8 +62,8 @@ public sealed class DraftValidatorTests
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" }],
Equipment = [new Equipment { EquipmentUuid = newUuid, EquipmentId = eid, Name = "eq", UnsLineId = "line-a", MachineCode = "m" }],
PriorEquipment = [new Equipment { EquipmentUuid = oldUuid, EquipmentId = eid, Name = "eq", UnsLineId = "line-a", MachineCode = "m" }],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentUuidImmutable");
@@ -101,7 +79,7 @@ public sealed class DraftValidatorTests
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" }],
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = DraftValidator.DeriveEquipmentId(uuid), Name = "eq", UnsLineId = "line-a", MachineCode = "m", ZTag = "ZT-001" }],
ActiveReservations = [new ExternalIdReservation { Kind = ReservationKind.ZTag, Value = "ZT-001", EquipmentUuid = otherUuid, ClusterId = "c", FirstPublishedBy = "t" }],
};
@@ -116,96 +94,38 @@ public sealed class DraftValidatorTests
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" }],
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-operator-typed", Name = "eq", UnsLineId = "line-a", MachineCode = "m" }],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentIdNotDerived");
}
/// <summary>Verifies that the canonical Galaxy driver type (GalaxyMxGateway, per PR 7.2 —
/// it was "Galaxy" pre-PR-7.2) is a standard Equipment-kind driver: binding it to an
/// Equipment namespace produces no kind-mismatch error.</summary>
/// <summary>Verifies that all violations are reported simultaneously (single-pass validation).</summary>
[Fact]
public void GalaxyMxGateway_driver_in_Equipment_namespace_is_allowed()
{
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 = "GalaxyMxGateway", DriverConfig = "{}" }],
};
DraftValidator.Validate(draft).ShouldBeEmpty();
}
/// <summary>Verifies that all validation errors are reported simultaneously.</summary>
[Fact]
public void Draft_with_three_violations_surfaces_all_three()
public void Draft_with_multiple_violations_surfaces_all_of_them()
{
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 = "GalaxyMxGateway", DriverConfig = "{}" }],
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-wrong", Name = "BAD NAME", DriverInstanceId = "d-1", UnsLineId = "line-a", MachineCode = "m" }],
GenerationId = 1, ClusterId = "c",
// EQ-wrong is not the canonical derivation, and "BAD NAME" fails the UNS segment regex.
Equipment = [new Equipment { EquipmentUuid = uuid, EquipmentId = "EQ-wrong", Name = "BAD NAME", UnsLineId = "line-a", MachineCode = "m" }],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "BadCrossClusterNamespaceBinding");
errors.ShouldContain(e => e.Code == "EquipmentIdNotDerived");
errors.ShouldContain(e => e.Code == "UnsSegmentInvalid");
}
// ------------------------------------------------------------------------------------
// Full-config probe — proves a realistic canonical deployed config passes ALL rules.
// This guards the deploy-path gate flip (reject-on-ANY-error): if a clean canonical
// config fired any rule, flipping the gate would block every deploy.
// ------------------------------------------------------------------------------------
/// <summary>Probe for the deploy-path gate activation: a full, realistic config modelling
/// the REAL deployed shape. Galaxy is now a standard Equipment-kind driver (the SystemPlatform
/// namespace split is retired), so the GalaxyMxGateway driver + its Galaxy-hierarchy Tags
/// (EquipmentId=null from the seed) live in the Equipment namespace alongside a Modbus driver
/// + UNS area/line + canonical Equipment rows + VirtualTags from the company overlay. This must
/// produce ZERO validation errors so the reject-on-any-error gate is safe to activate.</summary>
/// <summary>Probe for the deploy-path gate: a full, realistic v3 config (UNS area/line +
/// canonical Equipment + distinct-named VirtualTags) must produce ZERO validation errors so the
/// reject-on-any-error deploy gate is safe to activate.</summary>
[Fact]
public void Full_realistic_config_passes_all_rules()
{
// Equipment side: Galaxy + Modbus drivers in a single Equipment namespace + UNS + canonical equipment.
var eqNamespace = new Namespace
{
NamespaceId = "MAIN-OPCUA-equipment",
ClusterId = "MAIN",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:zb:main:equipment",
};
// Galaxy hierarchy (OtOpcUa seed shape): GalaxyMxGateway driver, Tags carry EquipmentId=null.
var spDriver = new DriverInstance
{
DriverInstanceId = "main-galaxy",
ClusterId = "MAIN",
NamespaceId = eqNamespace.NamespaceId,
Name = "Galaxy",
DriverType = "GalaxyMxGateway",
DriverConfig = "{}",
};
var eqDriver = new DriverInstance
{
DriverInstanceId = "main-modbus",
ClusterId = "MAIN",
NamespaceId = eqNamespace.NamespaceId,
Name = "Modbus",
DriverType = "Modbus",
DriverConfig = "{}",
};
var area = new UnsArea { UnsAreaId = "area-filling", ClusterId = "MAIN", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = area.UnsAreaId, Name = "line-1" };
// Canonical EquipmentIds — derived from the EquipmentUuid via the same rule the overlay loader uses.
var rinserUuid = Guid.NewGuid();
var fillerUuid = Guid.NewGuid();
var rinser = new Equipment
@@ -213,43 +133,27 @@ public sealed class DraftValidatorTests
EquipmentUuid = rinserUuid,
EquipmentId = DraftValidator.DeriveEquipmentId(rinserUuid),
Name = "rinser-01",
DriverInstanceId = eqDriver.DriverInstanceId,
UnsLineId = line.UnsLineId,
MachineCode = "machine_001",
ZTag = null,
SAPID = null,
};
var filler = new Equipment
{
EquipmentUuid = fillerUuid,
EquipmentId = DraftValidator.DeriveEquipmentId(fillerUuid),
Name = "filler-02",
DriverInstanceId = eqDriver.DriverInstanceId,
UnsLineId = line.UnsLineId,
MachineCode = "machine_002",
ZTag = null,
SAPID = null,
};
var draft = new DraftSnapshot
{
GenerationId = 0,
ClusterId = string.Empty, // global snapshot — matches DraftSnapshotFactory.FromConfigDbAsync
// Enterprise/Site left null — matches the deploy path's conservative fallback
Namespaces = [eqNamespace],
DriverInstances = [spDriver, eqDriver],
UnsAreas = [area],
UnsLines = [line],
Equipment = [rinser, filler],
Tags =
[
// Galaxy-hierarchy tags from the seed: EquipmentId null, Galaxy folder hierarchy.
BuildTag(equipmentId: null, name: "PV", folderPath: "Area.Tank01"),
BuildTag(equipmentId: null, name: "SP", folderPath: "Area.Tank01"),
],
VirtualTags =
[
// One per equipment, names distinct within each owning equipment.
BuildVirtualTag(equipmentId: rinser.EquipmentId, name: "oee"),
BuildVirtualTag(equipmentId: filler.EquipmentId, name: "throughput"),
],
@@ -264,133 +168,46 @@ public sealed class DraftValidatorTests
}
// ------------------------------------------------------------------------------------
// Driver-less Equipment namespace — core safety claim (feat/driverless-equipment-namespace)
// ValidateNoEquipmentSignalNameCollision — v3: VirtualTag name uniqueness within equipment
// ------------------------------------------------------------------------------------
/// <summary>Characterisation test: a draft that contains an Equipment-kind namespace with
/// ZERO <see cref="DriverInstance"/> rows, and a single <see cref="Equipment"/> whose
/// <see cref="Equipment.DriverInstanceId"/> is <see langword="null"/>, must pass
/// <see cref="DraftValidator.Validate"/> with no errors. This locks in the design's core
/// safety claim — the validator must never implicitly require a driver for Equipment-namespace
/// equipment.</summary>
/// <summary>Two VirtualTags sharing (EquipmentId, Name) collide on a single OPC UA NodeId.</summary>
[Fact]
public void Validate_accepts_driverless_equipment_in_driverless_equipment_namespace()
{
// An Equipment-kind namespace — no DriverInstance rows at all for this namespace.
var eqNamespace = new Namespace
{
NamespaceId = "MAIN-OPCUA-equipment",
ClusterId = "MAIN",
Kind = NamespaceKind.Equipment,
NamespaceUri = "urn:zb:main:equipment",
};
// UNS topology required by ValidateUnsSegments / ValidatePathLength.
var area = new UnsArea { UnsAreaId = "area-filling", ClusterId = "MAIN", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = area.UnsAreaId, Name = "line-1" };
// Canonical EquipmentId derived from UUID — satisfies ValidateEquipmentIdDerivation.
var uuid = Guid.NewGuid();
var equipment = new Equipment
{
EquipmentUuid = uuid,
EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
Name = "rinser-01",
DriverInstanceId = null, // ← driver-less: the property under test
UnsLineId = line.UnsLineId,
MachineCode = "machine_001",
ZTag = null,
SAPID = null,
};
var draft = new DraftSnapshot
{
GenerationId = 0,
ClusterId = string.Empty, // global snapshot — matches DraftSnapshotFactory.FromConfigDbAsync
Namespaces = [eqNamespace], // Equipment namespace present
DriverInstances = [], // ← zero drivers: the other half of the safety claim
UnsAreas = [area],
UnsLines = [line],
Equipment = [equipment],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldBeEmpty(
"a driver-less Equipment in an Equipment-kind namespace with no DriverInstances must pass " +
"all validator rules; firing rules: " +
string.Join("; ", errors.Select(e => $"[{e.Code}] {e.Message}")));
}
// ------------------------------------------------------------------------------------
// ValidateNoEquipmentSignalNameCollision — Tag/VirtualTag NodeId collision
// ------------------------------------------------------------------------------------
/// <summary>Verifies that an equipment-bound Tag and a VirtualTag sharing the same
/// equipment+name collide on a single OPC UA NodeId.</summary>
[Fact]
public void Tag_and_VirtualTag_same_equipment_and_name_collide()
public void Two_VirtualTags_same_equipment_and_name_collide()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(equipmentId: "eq-1", name: "speed", folderPath: null)],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
VirtualTags =
[
BuildVirtualTag(equipmentId: "eq-1", name: "speed", suffix: "a"),
BuildVirtualTag(equipmentId: "eq-1", name: "speed", suffix: "b"),
],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "EquipmentSignalNameCollision");
}
/// <summary>Verifies that a Tag in a sub-folder does not collide with a VirtualTag at the
/// equipment root even when the names match — the NodeId keys differ.</summary>
/// <summary>VirtualTags with the same name under DIFFERENT equipment do not collide.</summary>
[Fact]
public void Same_name_different_folder_does_not_collide()
public void Same_name_different_equipment_does_not_collide()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(equipmentId: "eq-1", name: "speed", folderPath: "metrics")],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
VirtualTags =
[
BuildVirtualTag(equipmentId: "eq-1", name: "speed"),
BuildVirtualTag(equipmentId: "eq-2", name: "speed"),
],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision");
}
/// <summary>Verifies that a FolderPath-scoped tag (EquipmentId == null) is excluded from the
/// equipment-signal node space and so never collides with an equipment VirtualTag.</summary>
[Fact]
public void Unbound_tag_EquipmentId_null_does_not_collide_with_equipment_vtag()
private static VirtualTag BuildVirtualTag(string equipmentId, string name, string suffix = "") => new()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
Tags = [BuildTag(equipmentId: null, name: "speed", folderPath: null)],
VirtualTags = [BuildVirtualTag(equipmentId: "eq-1", name: "speed")],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "EquipmentSignalNameCollision");
}
private static Tag BuildTag(
string? equipmentId,
string name,
string? folderPath,
string driverInstanceId = "d",
string tagConfig = "{}") => new()
{
TagId = $"tag-{name}",
DriverInstanceId = driverInstanceId,
EquipmentId = equipmentId,
Name = name,
FolderPath = folderPath,
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = tagConfig,
};
private static VirtualTag BuildVirtualTag(string equipmentId, string name) => new()
{
VirtualTagId = $"vtag-{name}",
VirtualTagId = $"vtag-{equipmentId}-{name}{suffix}",
EquipmentId = equipmentId,
Name = name,
DataType = "Float",
@@ -398,58 +215,7 @@ public sealed class DraftValidatorTests
};
// ------------------------------------------------------------------------------------
// ValidateGalaxyTagFullName — Galaxy equipment tags must carry TagConfig.FullName
// ------------------------------------------------------------------------------------
/// <summary>Verifies that an equipment-scoped Tag bound to a GalaxyMxGateway driver whose
/// TagConfig has no FullName (a Galaxy tag with no Galaxy reference) is rejected — it would
/// subscribe to nothing.</summary>
[Fact]
public void GalaxyTag_missing_FullName_is_rejected()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
DriverInstances = [new DriverInstance { DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1", Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }],
Tags = [BuildTag(equipmentId: "eq-1", name: "galaxytag", folderPath: null, driverInstanceId: "d-galaxy", tagConfig: "{}")],
};
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "GalaxyTagMissingReference" && e.Context == "tag-galaxytag");
}
/// <summary>Verifies that an equipment-scoped Galaxy tag carrying a TagConfig.FullName
/// reference is accepted.</summary>
[Fact]
public void GalaxyTag_with_FullName_is_accepted()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
DriverInstances = [new DriverInstance { DriverInstanceId = "d-galaxy", ClusterId = "c", NamespaceId = "ns-1", Name = "Galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}" }],
Tags = [BuildTag(equipmentId: "eq-1", name: "galaxytag", folderPath: null, driverInstanceId: "d-galaxy", tagConfig: "{\"FullName\":\"X.Y\"}")],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "GalaxyTagMissingReference");
}
/// <summary>Verifies that an equipment-scoped Tag bound to a NON-Galaxy driver with an empty
/// TagConfig is NOT flagged as a Galaxy tag missing its reference — only GalaxyMxGateway-bound
/// equipment tags carry the Galaxy FullName requirement.</summary>
[Fact]
public void GalaxyTag_check_skips_nonGalaxy_equipment_tag()
{
var draft = new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
DriverInstances = [new DriverInstance { DriverInstanceId = "d-modbus", ClusterId = "c", NamespaceId = "ns-1", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}" }],
Tags = [BuildTag(equipmentId: "eq-1", name: "galaxytag", folderPath: null, driverInstanceId: "d-modbus", tagConfig: "{}")],
};
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "GalaxyTagMissingReference");
}
// ------------------------------------------------------------------------------------
// Phase 6.3 task #148 part 2 — ValidateClusterTopology
// Phase 6.3 task #148 part 2 — ValidateClusterTopology (unchanged in v3)
// ------------------------------------------------------------------------------------
/// <summary>Verifies that cluster topology validation checks node count against declared redundancy mode.</summary>
@@ -479,7 +245,6 @@ public sealed class DraftValidatorTests
[Fact]
public void ValidateClusterTopology_flags_disabled_node_mismatch()
{
// Declared 2 + Hot, but one node disabled — runtime would boot InvalidTopology.
var cluster = BuildCluster(nodeCount: 2, mode: RedundancyMode.Hot);
var nodes = new[]
{
@@ -491,11 +256,6 @@ public sealed class DraftValidatorTests
errors.ShouldContain(e => e.Code == "ClusterEnabledNodeCountMismatch");
}
// v2: the "exactly one Primary per cluster" check is gone — Akka cluster's
// role-leader-of-"driver" elects the primary at runtime. The corresponding
// ValidateClusterTopology_flags_multiple_Primary test (and the
// ClusterMultiplePrimary error code it asserted) were removed alongside Task 14d.
/// <summary>Verifies that a valid standalone cluster passes validation.</summary>
[Fact]
public void ValidateClusterTopology_returns_no_errors_on_valid_standalone()
@@ -540,13 +300,6 @@ public sealed class DraftValidatorTests
[Fact]
public void PathLength_uses_actual_Enterprise_Site_when_provided()
{
// Craft a snapshot where the 32+32 approximation would flag PathTooLong but the
// actual Enterprise/Site lengths would not. Equipment name intentionally exceeds the
// UNS-segment 32-char limit (it would also trigger UnsSegmentInvalid — that is fine;
// both checks are independent and the test is filtering only on PathTooLong).
//
// approx: 32+32 + 32+32+90 +4 = 222 > 200 → would flag PathTooLong
// actual: 2 +1 + 32+32+90 +4 = 161 ≤ 200 → no PathTooLong
var areaId = "area-a";
var lineId = "line-b";
var uuid = Guid.NewGuid();
@@ -567,7 +320,6 @@ public sealed class DraftValidatorTests
EquipmentUuid = uuid,
EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
Name = eqName,
DriverInstanceId = "d",
UnsLineId = lineId,
MachineCode = "m",
},
@@ -584,11 +336,6 @@ public sealed class DraftValidatorTests
[Fact]
public void PathLength_conservative_fallback_when_Enterprise_Site_absent()
{
// Without Enterprise/Site on the snapshot the validator assumes 32+32.
// A path whose segments sum to 93 chars (area=32 + line=32 + eq=29) fits
// under 200 even with the 32+32 approximation (32+32+93+4 = 161 ≤ 200) and
// must NOT be flagged — the fallback must not over-penalise valid paths that
// would also be valid under real Enterprise/Site values.
var areaId = "area-x";
var lineId = "line-y";
var uuid = Guid.NewGuid();
@@ -597,7 +344,6 @@ public sealed class DraftValidatorTests
{
GenerationId = 1,
ClusterId = "c",
// Enterprise and Site deliberately omitted — conservative fallback path
UnsAreas = [new UnsArea { UnsAreaId = areaId, ClusterId = "c", Name = new string('a', 32) }],
UnsLines = [new UnsLine { UnsLineId = lineId, UnsAreaId = areaId, Name = new string('b', 32) }],
Equipment =
@@ -607,7 +353,6 @@ public sealed class DraftValidatorTests
EquipmentUuid = uuid,
EquipmentId = DraftValidator.DeriveEquipmentId(uuid),
Name = new string('c', 29),
DriverInstanceId = "d",
UnsLineId = lineId,
MachineCode = "m",
},
@@ -0,0 +1,191 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
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.Configuration.Tests;
/// <summary>
/// v3 WP1 — exercises the DB-enforceable integrity + sibling-name uniqueness rules of the Raw tree
/// against the real SQL schema (the filtered unique indexes over nullable parent columns and the
/// UnsTagReference (Equipment, Tag) uniqueness). Runs against the shared SchemaComplianceFixture DB;
/// every row uses a GUID-suffixed logical id so tests do not clobber one another.
/// </summary>
[Trait("Category", "SchemaCompliance")]
[Collection(nameof(SchemaComplianceCollection))]
public sealed class RawSchemaIntegrityTests
{
private readonly SchemaComplianceFixture _fixture;
/// <summary>Initializes a new instance of the <see cref="RawSchemaIntegrityTests"/> class.</summary>
/// <param name="fixture">The schema compliance fixture (real SQL DB).</param>
public RawSchemaIntegrityTests(SchemaComplianceFixture fixture) => _fixture = fixture;
private OtOpcUaConfigDbContext NewContext()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(_fixture.ConnectionString)
.Options;
return new OtOpcUaConfigDbContext(options);
}
// Logical ids are nvarchar(64); prefix + '-' + 32 hex stays well under that.
private static string Id(string prefix) => $"{prefix}-{Guid.NewGuid():N}";
private string SeedCluster()
{
var clusterId = Id("cl");
using var db = NewContext();
db.ServerClusters.Add(new ServerCluster
{
ClusterId = clusterId,
Name = Id("Cluster"),
Enterprise = "zb",
Site = "dev",
NodeCount = 1,
RedundancyMode = RedundancyMode.None,
CreatedBy = "wp1-test",
});
db.SaveChanges();
return clusterId;
}
[Fact]
public void RawFolder_root_sibling_name_must_be_unique_within_cluster()
{
var clusterId = SeedCluster();
using var db = NewContext();
db.RawFolders.Add(new RawFolder { RawFolderId = Id("rf"), ClusterId = clusterId, Name = "plc-area" });
db.RawFolders.Add(new RawFolder { RawFolderId = Id("rf"), ClusterId = clusterId, Name = "plc-area" });
Should.Throw<DbUpdateException>(() => db.SaveChanges());
}
[Fact]
public void RawFolder_same_root_name_in_different_cluster_is_allowed()
{
var clusterA = SeedCluster();
var clusterB = SeedCluster();
using var db = NewContext();
db.RawFolders.Add(new RawFolder { RawFolderId = Id("rf"), ClusterId = clusterA, Name = "shared-name" });
db.RawFolders.Add(new RawFolder { RawFolderId = Id("rf"), ClusterId = clusterB, Name = "shared-name" });
Should.NotThrow(() => db.SaveChanges());
}
[Fact]
public void RawFolder_nested_sibling_name_must_be_unique_within_parent()
{
var clusterId = SeedCluster();
var parentId = Id("rf-parent");
using var db = NewContext();
db.RawFolders.Add(new RawFolder { RawFolderId = parentId, ClusterId = clusterId, Name = "parent" });
db.RawFolders.Add(new RawFolder { RawFolderId = Id("rf"), ClusterId = clusterId, ParentRawFolderId = parentId, Name = "child" });
db.RawFolders.Add(new RawFolder { RawFolderId = Id("rf"), ClusterId = clusterId, ParentRawFolderId = parentId, Name = "child" });
Should.Throw<DbUpdateException>(() => db.SaveChanges());
}
[Fact]
public void DriverInstance_cluster_root_sibling_name_must_be_unique()
{
var clusterId = SeedCluster();
using var db = NewContext();
db.DriverInstances.Add(new DriverInstance { DriverInstanceId = Id("di"), ClusterId = clusterId, Name = "modbus", DriverType = "Modbus", DriverConfig = "{}" });
db.DriverInstances.Add(new DriverInstance { DriverInstanceId = Id("di"), ClusterId = clusterId, Name = "modbus", DriverType = "Modbus", DriverConfig = "{}" });
Should.Throw<DbUpdateException>(() => db.SaveChanges());
}
[Fact]
public void Device_sibling_name_must_be_unique_within_driver()
{
var driverId = Id("di");
using var db = NewContext();
db.Devices.Add(new Device { DeviceId = Id("dev"), DriverInstanceId = driverId, Name = "plc-1", DeviceConfig = "{}" });
db.Devices.Add(new Device { DeviceId = Id("dev"), DriverInstanceId = driverId, Name = "plc-1", DeviceConfig = "{}" });
Should.Throw<DbUpdateException>(() => db.SaveChanges());
}
[Fact]
public void TagGroup_device_root_sibling_name_must_be_unique()
{
var deviceId = Id("dev");
using var db = NewContext();
db.TagGroups.Add(new TagGroup { TagGroupId = Id("tg"), DeviceId = deviceId, Name = "motors" });
db.TagGroups.Add(new TagGroup { TagGroupId = Id("tg"), DeviceId = deviceId, Name = "motors" });
Should.Throw<DbUpdateException>(() => db.SaveChanges());
}
[Fact]
public void Tag_device_root_sibling_name_must_be_unique()
{
var deviceId = Id("dev");
using var db = NewContext();
db.Tags.Add(NewTag(deviceId, tagGroupId: null, name: "speed"));
db.Tags.Add(NewTag(deviceId, tagGroupId: null, name: "speed"));
Should.Throw<DbUpdateException>(() => db.SaveChanges());
}
[Fact]
public void Tag_grouped_sibling_name_must_be_unique_within_group()
{
var deviceId = Id("dev");
var groupId = Id("tg");
using var db = NewContext();
db.Tags.Add(NewTag(deviceId, tagGroupId: groupId, name: "speed"));
db.Tags.Add(NewTag(deviceId, tagGroupId: groupId, name: "speed"));
Should.Throw<DbUpdateException>(() => db.SaveChanges());
}
[Fact]
public void Tag_same_name_in_different_groups_is_allowed()
{
var deviceId = Id("dev");
using var db = NewContext();
db.Tags.Add(NewTag(deviceId, tagGroupId: Id("tg"), name: "speed"));
db.Tags.Add(NewTag(deviceId, tagGroupId: Id("tg"), name: "speed"));
Should.NotThrow(() => db.SaveChanges());
}
[Fact]
public void UnsTagReference_equipment_tag_pair_must_be_unique()
{
var equipmentId = Id("eq");
var tagId = Id("tag");
using var db = NewContext();
db.UnsTagReferences.Add(new UnsTagReference { UnsTagReferenceId = Id("utr"), EquipmentId = equipmentId, TagId = tagId });
db.UnsTagReferences.Add(new UnsTagReference { UnsTagReferenceId = Id("utr"), EquipmentId = equipmentId, TagId = tagId });
Should.Throw<DbUpdateException>(() => db.SaveChanges());
}
[Fact]
public void UnsTagReference_same_tag_different_equipment_is_allowed()
{
var tagId = Id("tag");
using var db = NewContext();
db.UnsTagReferences.Add(new UnsTagReference { UnsTagReferenceId = Id("utr"), EquipmentId = Id("eq"), TagId = tagId });
db.UnsTagReferences.Add(new UnsTagReference { UnsTagReferenceId = Id("utr"), EquipmentId = Id("eq"), TagId = tagId });
Should.NotThrow(() => db.SaveChanges());
}
private static Tag NewTag(string deviceId, string? tagGroupId, string name) => new()
{
TagId = Id("tag"),
DeviceId = deviceId,
TagGroupId = tagGroupId,
Name = name,
DataType = "Float",
AccessLevel = TagAccessLevel.Read,
TagConfig = "{}",
};
}
@@ -28,14 +28,15 @@ public sealed class SchemaComplianceTests
{
"ServerCluster", "ClusterNode", "ClusterNodeCredential",
"ConfigAuditLog",
"Namespace", "UnsArea", "UnsLine",
"UnsArea", "UnsLine",
// v3 Raw-tree tables (Namespace retired; RawFolder/TagGroup/UnsTagReference added)
"RawFolder", "TagGroup", "UnsTagReference",
"DriverInstance", "Device", "Equipment", "Tag", "PollGroup", "VirtualTag",
"NodeAcl", "ExternalIdReservation",
"DriverHostStatus",
"DriverInstanceResilienceStatus",
"LdapGroupRoleMapping",
"EquipmentImportBatch",
"EquipmentImportRow",
// v3 dropped the orphaned EquipmentImportBatch / EquipmentImportRow tables
"Script", "ScriptedAlarm", "ScriptedAlarmState",
// v2 deploy-model tables (Phase 1 of Akka + fused-hosting alignment)
"Deployment", "NodeDeploymentState", "ConfigEdit", "DataProtectionKeys",
@@ -60,6 +61,15 @@ SELECT name FROM sys.tables WHERE name <> '__EFMigrationsHistory' ORDER BY name;
{
("UX_ClusterNodeCredential_Value", "([Enabled]=(1))"),
("UX_ExternalIdReservation_KindValue_Active", "([ReleasedAt] IS NULL)"),
// v3 sibling-name filtered unique indexes over nullable parent columns.
("UX_RawFolder_Parent_Name", "([ParentRawFolderId] IS NOT NULL)"),
("UX_RawFolder_ClusterRoot_Name", "([ParentRawFolderId] IS NULL)"),
("UX_DriverInstance_Folder_Name", "([RawFolderId] IS NOT NULL)"),
("UX_DriverInstance_ClusterRoot_Name", "([RawFolderId] IS NULL)"),
("UX_TagGroup_Parent_Name", "([ParentTagGroupId] IS NOT NULL)"),
("UX_TagGroup_DeviceRoot_Name", "([ParentTagGroupId] IS NULL)"),
("UX_Tag_Group_Name", "([TagGroupId] IS NOT NULL)"),
("UX_Tag_Device_Name", "([TagGroupId] IS NULL)"),
};
var rows = QueryRows(@"
@@ -148,20 +158,36 @@ SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Equipment
columns.ShouldContain(col, $"Equipment missing expected column: {col}");
}
/// <summary>Verifies that Namespace has at least one unique index.</summary>
/// <summary>Verifies the retired v2 tables (Namespace, EquipmentImportBatch/Row) are absent.</summary>
[Fact]
public void Namespace_has_some_unique_index()
public void Retired_v2_tables_are_absent()
{
// v2 dropped the "per-generation" qualifier from namespace uniqueness when live-edit
// replaced draft/publish. The v2 index name is implementation-defined; assert that
// *some* unique index exists on Namespace to catch unintentional index drops.
var indexes = QueryStrings(@"
var tables = QueryStrings("SELECT name FROM sys.tables;").ToHashSet();
tables.ShouldNotContain("Namespace");
tables.ShouldNotContain("EquipmentImportBatch");
tables.ShouldNotContain("EquipmentImportRow");
}
/// <summary>Verifies the new v3 Raw-tree tables each carry their logical-id unique index.</summary>
[Fact]
public void V3_raw_tree_tables_have_logical_id_unique_indexes()
{
var expected = new[]
{
("RawFolder", "UX_RawFolder_LogicalId"),
("TagGroup", "UX_TagGroup_LogicalId"),
("UnsTagReference", "UX_UnsTagReference_LogicalId"),
};
foreach (var (table, index) in expected)
{
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.ShouldNotBeEmpty();
WHERE t.name = '{table}' AND i.is_unique = 1;").ToHashSet();
indexes.ShouldContain(index, $"{table} missing unique index {index}");
}
}
private List<string> QueryStrings(string sql)
@@ -182,13 +182,13 @@ public sealed class ScriptingEntitiesTests
ctx.ScriptedAlarmStates.ShouldNotBeNull();
}
/// <summary>Verifies that the AddPhase7ScriptingTables migration exists in the assembly.</summary>
/// <summary>Verifies that the squashed V3Initial migration exists in the assembly.</summary>
[Fact]
public void AddPhase7ScriptingTables_migration_exists_in_assembly()
public void V3Initial_migration_exists_in_assembly()
{
// The migration type carries the design-time snapshot + Up/Down methods EF uses to
// apply the schema. Missing = schema won't roll forward in deployments.
var t = typeof(Migrations.AddPhase7ScriptingTables);
// v3 WP1 squashed every prior migration into V3Initial. The migration type carries the
// design-time snapshot + Up/Down (incl. the scripting tables) EF uses to apply the schema.
var t = typeof(Migrations.V3Initial);
t.ShouldNotBeNull();
}
}