Phase 1 Streams B–E scaffold + Phase 2 Streams A–C scaffold — 8 new projects with ~70 new tests, all green alongside the 494 v1 IntegrationTests baseline (parity preserved: no v1 tests broken; legacy OtOpcUa.Host untouched). Phase 1 finish: Configuration project (16 entities + 10 enums + DbContext + DesignTimeDbContextFactory + InitialSchema/StoredProcedures/AuthorizationGrants migrations — 8 procs including sp_PublishGeneration with MERGE on ExternalIdReservation per decision #124, sp_RollbackToGeneration cloning rows into a new published generation, sp_ValidateDraft with cross-cluster-namespace + EquipmentUuid-immutability + ZTag/SAPID reservation pre-flight, sp_ComputeGenerationDiff with CHECKSUM-based row signature — plus OtOpcUaNode/OtOpcUaAdmin SQL roles with EXECUTE grants scoped to per-principal-class proc sets and DENY UPDATE/DELETE/INSERT/SELECT on dbo schema); managed DraftValidator covering UNS segment regex, path length, EquipmentUuid immutability across generations, same-cluster namespace binding (decision #122), reservation pre-flight, EquipmentId derivation (decision #125), driver↔namespace compatibility — returning every failing rule in one pass; LiteDB local cache with round-trip + ring pruning + corruption-fast-fail; GenerationApplier with per-entity Added/Removed/Modified diff and dependency-ordered callbacks (namespace → driver → device → equipment → poll-group → tag, Removed before Added); Core project with GenericDriverNodeManager (scaffold for the Phase 2 Galaxy port) and DriverHost lifecycle registry; Server project using Microsoft.Extensions.Hosting BackgroundService replacing TopShelf, with NodeBootstrap that falls back to LiteDB cache when the central DB is unreachable (decision #79); Admin project scaffolded as Blazor Server with Bootstrap 5 sidebar layout, cookie auth, three admin roles (ConfigViewer/ConfigEditor/FleetAdmin), Cluster + Generation services fronting the stored procs. Phase 2 scaffold: Driver.Galaxy.Shared (netstandard2.0) with full MessagePack IPC contract surface — Hello version negotiation, Open/CloseSession, Heartbeat, DiscoverHierarchy + GalaxyObjectInfo/GalaxyAttributeInfo, Read/WriteValues, Subscribe/Unsubscribe/OnDataChange, AlarmSubscribe/Event/Ack, HistoryRead, HostConnectivityStatus, Recycle — plus length-prefixed framing (decision #28) with a 16 MiB cap and thread-safe FrameWriter/FrameReader; Driver.Galaxy.Host (net48) implementing the Tier C cross-cutting protections from driver-stability.md — strict PipeAcl (allow configured server SID only, explicit deny on LocalSystem + Administrators), PipeServer with caller-SID verification via pipe.RunAsClient + WindowsIdentity.GetCurrent and per-process shared-secret Hello, Galaxy-specific MemoryWatchdog (warn at max(1.5×baseline, +200 MB), soft-recycle at max(2×baseline, +200 MB), hard ceiling 1.5 GB, slope ≥5 MB/min over 30-min rolling window), RecyclePolicy (1 soft recycle per hour cap + 03:00 local daily scheduled), PostMortemMmf (1000-entry ring buffer in %ProgramData%\OtOpcUa\driver-postmortem\galaxy.mmf, survives hard crash, readable cross-process), MxAccessHandle : SafeHandle (ReleaseHandle loops Marshal.ReleaseComObject until refcount=0 then calls optional unregister callback), StaPump with responsiveness probe (BlockingCollection dispatcher for Phase 1 — real Win32 GetMessage/DispatchMessage pump slots in with the same semantics when the Galaxy code lift happens), IsExternalInit shim for init setters on .NET 4.8; Driver.Galaxy.Proxy (net10) implementing IDriver + ITagDiscovery forwarding over the IPC channel with MX data-type and security-classification mapping, plus Supervisor pieces — Backoff (5s → 15s → 60s capped, reset-on-stable-run), CircuitBreaker (3 crashes per 5 min opens; 1h → 4h → manual cooldown escalation; sticky alert doesn't auto-clear), HeartbeatMonitor (2s cadence, 3 consecutive misses = host dead per driver-stability.md). Infrastructure: docker SQL Server remapped to host port 14330 to coexist with the native MSSQL14 Galaxy ZB DB instance on 1433; NuGetAuditSuppress applied per-project for two System.Security.Cryptography.Xml advisories that only reach via EF Core Design with PrivateAssets=all (fix ships in 11.0.0-preview); .slnx gains 14 project registrations. Deferred with explicit TODOs in docs/v2/implementation/phase-2-partial-exit-evidence.md: Phase 1 Stream E Admin UI pages (Generations listing + draft-diff-publish, Equipment CRUD with OPC 40010 fields, UNS Areas/Lines tabs, ACLs + permission simulator, Generic JSON config editor, SignalR real-time, Release-Reservation + Merge-Equipment workflows, LDAP login page, AppServer smoke test per decision #142), Phase 2 Stream D (Galaxy MXAccess code lift out of legacy OtOpcUa.Host, dual-service installer, appsettings → DriverConfig migration script, legacy Host deletion — blocked by parity), Phase 2 Stream E (v1 IntegrationTests against v2 topology, Client.CLI walkthrough diff, four 2026-04-13 stability findings regression tests, adversarial review — requires live MXAccess runtime).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-17 21:35:25 -04:00
parent fc0ce36308
commit 01fd90c178
128 changed files with 12352 additions and 4 deletions

View File

@@ -0,0 +1,19 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
/// <summary>
/// Host-supplied callbacks invoked as the applier walks the diff. Callbacks are idempotent on
/// retry (the applier may re-invoke with the same inputs if a later stage fails — nodes
/// register-applied to the central DB only after success). Order: namespace → driver → device →
/// equipment → poll group → tag, with Removed before Added/Modified.
/// </summary>
public sealed class ApplyCallbacks
{
public Func<EntityChange<Namespace>, CancellationToken, Task>? OnNamespace { get; init; }
public Func<EntityChange<DriverInstance>, CancellationToken, Task>? OnDriver { get; init; }
public Func<EntityChange<Device>, CancellationToken, Task>? OnDevice { get; init; }
public Func<EntityChange<Equipment>, CancellationToken, Task>? OnEquipment { get; init; }
public Func<EntityChange<PollGroup>, CancellationToken, Task>? OnPollGroup { get; init; }
public Func<EntityChange<Tag>, CancellationToken, Task>? OnTag { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
public enum ChangeKind
{
Added,
Removed,
Modified,
}

View File

@@ -0,0 +1,48 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
public sealed class GenerationApplier(ApplyCallbacks callbacks) : IGenerationApplier
{
public async Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct)
{
var diff = GenerationDiffer.Compute(from, to);
var errors = new List<string>();
// Removed first, then Added/Modified — prevents FK dangling while cascades settle.
await ApplyPass(diff.Tags, ChangeKind.Removed, callbacks.OnTag, errors, ct);
await ApplyPass(diff.PollGroups, ChangeKind.Removed, callbacks.OnPollGroup, errors, ct);
await ApplyPass(diff.Equipment, ChangeKind.Removed, callbacks.OnEquipment, errors, ct);
await ApplyPass(diff.Devices, ChangeKind.Removed, callbacks.OnDevice, errors, ct);
await ApplyPass(diff.Drivers, ChangeKind.Removed, callbacks.OnDriver, errors, ct);
await ApplyPass(diff.Namespaces, ChangeKind.Removed, callbacks.OnNamespace, errors, ct);
foreach (var kind in new[] { ChangeKind.Added, ChangeKind.Modified })
{
await ApplyPass(diff.Namespaces, kind, callbacks.OnNamespace, errors, ct);
await ApplyPass(diff.Drivers, kind, callbacks.OnDriver, errors, ct);
await ApplyPass(diff.Devices, kind, callbacks.OnDevice, errors, ct);
await ApplyPass(diff.Equipment, kind, callbacks.OnEquipment, errors, ct);
await ApplyPass(diff.PollGroups, kind, callbacks.OnPollGroup, errors, ct);
await ApplyPass(diff.Tags, kind, callbacks.OnTag, errors, ct);
}
return errors.Count == 0 ? ApplyResult.Ok(diff) : ApplyResult.Fail(diff, errors);
}
private static async Task ApplyPass<T>(
IReadOnlyList<EntityChange<T>> changes,
ChangeKind kind,
Func<EntityChange<T>, CancellationToken, Task>? callback,
List<string> errors,
CancellationToken ct)
{
if (callback is null) return;
foreach (var change in changes.Where(c => c.Kind == kind))
{
try { await callback(change, ct); }
catch (Exception ex) { errors.Add($"{typeof(T).Name} {change.Kind} '{change.LogicalId}': {ex.Message}"); }
}
}
}

View File

@@ -0,0 +1,70 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
/// <summary>
/// Per-entity diff computed locally on the node. The enumerable order matches the dependency
/// order expected by <see cref="IGenerationApplier"/>: namespace → driver → device → equipment →
/// poll group → tag → ACL, with Removed processed before Added inside each bucket so cascades
/// settle before new rows appear.
/// </summary>
public sealed record GenerationDiff(
IReadOnlyList<EntityChange<Namespace>> Namespaces,
IReadOnlyList<EntityChange<DriverInstance>> Drivers,
IReadOnlyList<EntityChange<Device>> Devices,
IReadOnlyList<EntityChange<Equipment>> Equipment,
IReadOnlyList<EntityChange<PollGroup>> PollGroups,
IReadOnlyList<EntityChange<Tag>> Tags);
public sealed record EntityChange<T>(ChangeKind Kind, string LogicalId, T? From, T? To);
public static class GenerationDiffer
{
public static GenerationDiff Compute(DraftSnapshot? from, DraftSnapshot to)
{
from ??= new DraftSnapshot { GenerationId = 0, ClusterId = to.ClusterId };
return new GenerationDiff(
Namespaces: DiffById(from.Namespaces, to.Namespaces, x => x.NamespaceId,
(a, b) => (a.ClusterId, a.NamespaceUri, a.Kind, a.Enabled, a.Notes)
== (b.ClusterId, b.NamespaceUri, b.Kind, b.Enabled, b.Notes)),
Drivers: DiffById(from.DriverInstances, to.DriverInstances, x => x.DriverInstanceId,
(a, b) => (a.ClusterId, a.NamespaceId, a.Name, a.DriverType, a.Enabled, a.DriverConfig)
== (b.ClusterId, b.NamespaceId, b.Name, b.DriverType, b.Enabled, b.DriverConfig)),
Devices: DiffById(from.Devices, to.Devices, x => x.DeviceId,
(a, b) => (a.DriverInstanceId, a.Name, a.Enabled, a.DeviceConfig)
== (b.DriverInstanceId, b.Name, b.Enabled, b.DeviceConfig)),
Equipment: DiffById(from.Equipment, to.Equipment, x => x.EquipmentId,
(a, b) => (a.EquipmentUuid, a.DriverInstanceId, a.UnsLineId, a.Name, a.MachineCode, a.ZTag, a.SAPID, a.Enabled)
== (b.EquipmentUuid, b.DriverInstanceId, b.UnsLineId, b.Name, b.MachineCode, b.ZTag, b.SAPID, b.Enabled)),
PollGroups: DiffById(from.PollGroups, to.PollGroups, x => x.PollGroupId,
(a, b) => (a.DriverInstanceId, a.Name, a.IntervalMs)
== (b.DriverInstanceId, b.Name, b.IntervalMs)),
Tags: DiffById(from.Tags, to.Tags, x => x.TagId,
(a, b) => (a.DriverInstanceId, a.DeviceId, a.EquipmentId, a.PollGroupId, a.FolderPath, a.Name, a.DataType, a.AccessLevel, a.WriteIdempotent, a.TagConfig)
== (b.DriverInstanceId, b.DeviceId, b.EquipmentId, b.PollGroupId, b.FolderPath, b.Name, b.DataType, b.AccessLevel, b.WriteIdempotent, b.TagConfig)));
}
private static List<EntityChange<T>> DiffById<T>(
IReadOnlyList<T> from, IReadOnlyList<T> to,
Func<T, string> id, Func<T, T, bool> equal)
{
var fromById = from.ToDictionary(id);
var toById = to.ToDictionary(id);
var result = new List<EntityChange<T>>();
foreach (var (logicalId, src) in fromById.Where(kv => !toById.ContainsKey(kv.Key)))
result.Add(new(ChangeKind.Removed, logicalId, src, default));
foreach (var (logicalId, dst) in toById)
{
if (!fromById.TryGetValue(logicalId, out var src))
result.Add(new(ChangeKind.Added, logicalId, default, dst));
else if (!equal(src, dst))
result.Add(new(ChangeKind.Modified, logicalId, src, dst));
}
return result;
}
}

View File

@@ -0,0 +1,23 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
/// <summary>
/// Applies a <see cref="GenerationDiff"/> to whatever backing runtime the node owns: the OPC UA
/// address space, driver subscriptions, the local cache, etc. The Core project wires concrete
/// callbacks into this via <see cref="ApplyCallbacks"/> so the Configuration project stays free
/// of a Core/Server dependency (interface independence per decision #59).
/// </summary>
public interface IGenerationApplier
{
Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct);
}
public sealed record ApplyResult(
bool Succeeded,
GenerationDiff Diff,
IReadOnlyList<string> Errors)
{
public static ApplyResult Ok(GenerationDiff diff) => new(true, diff, []);
public static ApplyResult Fail(GenerationDiff diff, IReadOnlyList<string> errors) => new(false, diff, errors);
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace ZB.MOM.WW.OtOpcUa.Configuration;
/// <summary>
/// Used by <c>dotnet ef</c> at design time (migrations, scaffolding). Reads the connection string
/// from the <c>OTOPCUA_CONFIG_CONNECTION</c> environment variable, falling back to the local dev
/// container on <c>localhost:1433</c>.
/// </summary>
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<OtOpcUaConfigDbContext>
{
// Host-port 14330 avoids collision with the native MSSQL14 instance on 1433 (Galaxy "ZB" DB).
private const string DefaultConnectionString =
"Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;";
public OtOpcUaConfigDbContext CreateDbContext(string[] args)
{
var connection = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_CONNECTION")
?? DefaultConnectionString;
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseSqlServer(connection, sql => sql.MigrationsAssembly(typeof(OtOpcUaConfigDbContext).Assembly.FullName))
.Options;
return new OtOpcUaConfigDbContext(options);
}
}

View File

@@ -0,0 +1,51 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Physical OPC UA server node within a <see cref="ServerCluster"/>.</summary>
public sealed class ClusterNode
{
/// <summary>Stable per-machine logical ID, e.g. "LINE3-OPCUA-A".</summary>
public required string NodeId { get; set; }
public required string ClusterId { get; set; }
public required RedundancyRole RedundancyRole { get; set; }
/// <summary>Machine hostname / IP.</summary>
public required string Host { get; set; }
public int OpcUaPort { get; set; } = 4840;
public int DashboardPort { get; set; } = 8081;
/// <summary>
/// OPC UA <c>ApplicationUri</c> — MUST be unique per node per OPC UA spec. Clients pin trust here.
/// Fleet-wide unique index enforces no two nodes share a value (decision #86).
/// Stored explicitly, NOT derived from <see cref="Host"/> at runtime — silent rewrite on
/// hostname change would break all client trust.
/// </summary>
public required string ApplicationUri { get; set; }
/// <summary>Primary = 200, Secondary = 150 by default.</summary>
public byte ServiceLevelBase { get; set; } = 200;
/// <summary>
/// Per-node override JSON keyed by DriverInstanceId, merged onto cluster-level DriverConfig
/// at apply time. Minimal by intent (decision #81). Nullable when no overrides exist.
/// </summary>
public string? DriverConfigOverridesJson { get; set; }
public bool Enabled { get; set; } = true;
public DateTime? LastSeenAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public required string CreatedBy { get; set; }
// Navigation
public ServerCluster? Cluster { get; set; }
public ICollection<ClusterNodeCredential> Credentials { get; set; } = [];
public ClusterNodeGenerationState? GenerationState { get; set; }
}

View File

@@ -0,0 +1,29 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Authenticates a <see cref="ClusterNode"/> to the central config DB.
/// Per decision #83 — credentials bind to NodeId, not ClusterId.
/// </summary>
public sealed class ClusterNodeCredential
{
public Guid CredentialId { get; set; }
public required string NodeId { get; set; }
public required CredentialKind Kind { get; set; }
/// <summary>Login name / cert thumbprint / SID / gMSA name.</summary>
public required string Value { get; set; }
public bool Enabled { get; set; } = true;
public DateTime? RotatedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public required string CreatedBy { get; set; }
public ClusterNode? Node { get; set; }
}

View File

@@ -0,0 +1,26 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Tracks which generation each node has applied. Per-node (not per-cluster) — both nodes of a
/// 2-node cluster track independently per decision #84.
/// </summary>
public sealed class ClusterNodeGenerationState
{
public required string NodeId { get; set; }
public long? CurrentGenerationId { get; set; }
public DateTime? LastAppliedAt { get; set; }
public NodeApplyStatus? LastAppliedStatus { get; set; }
public string? LastAppliedError { get; set; }
/// <summary>Updated on every poll for liveness detection.</summary>
public DateTime? LastSeenAt { get; set; }
public ClusterNode? Node { get; set; }
public ConfigGeneration? CurrentGeneration { get; set; }
}

View File

@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Append-only audit log for every config write + authorization-check event. Grants revoked for
/// UPDATE / DELETE on all principals (enforced by the authorization migration in B.3).
/// </summary>
public sealed class ConfigAuditLog
{
public long AuditId { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public required string Principal { get; set; }
/// <summary>DraftCreated | DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled | ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt | OpcUaAccessDenied | …</summary>
public required string EventType { get; set; }
public string? ClusterId { get; set; }
public string? NodeId { get; set; }
public long? GenerationId { get; set; }
public string? DetailsJson { get; set; }
}

View File

@@ -0,0 +1,32 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Atomic, immutable snapshot of one cluster's configuration.
/// Per decision #82 — cluster-scoped, not fleet-scoped.
/// </summary>
public sealed class ConfigGeneration
{
/// <summary>Monotonically increasing ID, generated by <c>IDENTITY(1, 1)</c>.</summary>
public long GenerationId { get; set; }
public required string ClusterId { get; set; }
public required GenerationStatus Status { get; set; }
public long? ParentGenerationId { get; set; }
public DateTime? PublishedAt { get; set; }
public string? PublishedBy { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public required string CreatedBy { get; set; }
public ServerCluster? Cluster { get; set; }
public ConfigGeneration? Parent { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Per-device row for multi-device drivers (Modbus, AB CIP). Optional for single-device drivers.</summary>
public sealed class Device
{
public Guid DeviceRowId { get; set; }
public long GenerationId { get; set; }
public required string DeviceId { get; set; }
/// <summary>Logical FK to <see cref="DriverInstance.DriverInstanceId"/>.</summary>
public required string DriverInstanceId { get; set; }
public required string Name { get; set; }
public bool Enabled { get; set; } = true;
/// <summary>Schemaless per-driver-type device config (host, port, unit ID, slot, etc.).</summary>
public required string DeviceConfig { get; set; }
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,32 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>One driver instance in a cluster's generation. JSON config is schemaless per-driver-type.</summary>
public sealed class DriverInstance
{
public Guid DriverInstanceRowId { get; set; }
public long GenerationId { get; set; }
public required string DriverInstanceId { get; set; }
public required string ClusterId { get; set; }
/// <summary>
/// Logical FK to <see cref="Namespace.NamespaceId"/>. Same-cluster binding enforced by
/// <c>sp_ValidateDraft</c> per decision #122: Namespace.ClusterId must equal DriverInstance.ClusterId.
/// </summary>
public required string NamespaceId { get; set; }
public required string Name { get; set; }
/// <summary>Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient</summary>
public required string DriverType { get; set; }
public bool Enabled { get; set; } = true;
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
public required string DriverConfig { get; set; }
public ConfigGeneration? Generation { get; set; }
public ServerCluster? Cluster { get; set; }
}

View File

@@ -0,0 +1,64 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// UNS level-5 entity. Only for drivers in Equipment-kind namespaces.
/// Per decisions #109 (first-class), #116 (5-identifier model), #125 (system-generated EquipmentId),
/// #138139 (OPC 40010 Identification fields as first-class columns).
/// </summary>
public sealed class Equipment
{
public Guid EquipmentRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>
/// System-generated stable internal logical ID. Format: <c>'EQ-' + first 12 hex chars of EquipmentUuid</c>.
/// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI (decision #125).
/// </summary>
public required string EquipmentId { get; set; }
/// <summary>UUIDv4, IMMUTABLE across all generations of the same EquipmentId. Downstream-consumer join key.</summary>
public Guid EquipmentUuid { get; set; }
/// <summary>Logical FK to the driver providing data for this equipment.</summary>
public required string DriverInstanceId { get; set; }
/// <summary>Optional logical FK to a multi-device driver's device.</summary>
public string? DeviceId { get; set; }
/// <summary>Logical FK to <see cref="UnsLine.UnsLineId"/>.</summary>
public required string UnsLineId { get; set; }
/// <summary>UNS level 5 segment, matches <c>^[a-z0-9-]{1,32}$</c>.</summary>
public required string Name { get; set; }
// Operator-facing / external-system identifiers (decision #116)
/// <summary>Operator colloquial id (e.g. "machine_001"). Unique within cluster. Required.</summary>
public required string MachineCode { get; set; }
/// <summary>ERP equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>. Primary browse identifier in Admin UI.</summary>
public string? ZTag { get; set; }
/// <summary>SAP PM equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>.</summary>
public string? SAPID { get; set; }
// OPC UA Companion Spec OPC 40010 Machinery Identification fields (decision #139).
// All nullable so equipment can be added before identity is fully captured.
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? SerialNumber { get; set; }
public string? HardwareRevision { get; set; }
public string? SoftwareRevision { get; set; }
public short? YearOfConstruction { get; set; }
public string? AssetLocation { get; set; }
public string? ManufacturerUri { get; set; }
public string? DeviceManualUri { get; set; }
/// <summary>Nullable hook for future schemas-repo template ID (decision #112).</summary>
public string? EquipmentClassRef { get; set; }
public bool Enabled { get; set; } = true;
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,36 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Fleet-wide rollback-safe reservation of ZTag and SAPID. Per decision #124 — NOT generation-versioned.
/// Exists outside generation flow specifically because old generations and disabled equipment can
/// still hold the same external IDs; per-generation uniqueness indexes fail under rollback/re-enable.
/// </summary>
public sealed class ExternalIdReservation
{
public Guid ReservationId { get; set; }
public required ReservationKind Kind { get; set; }
public required string Value { get; set; }
/// <summary>The equipment that owns this reservation. Stays bound even when equipment is disabled.</summary>
public Guid EquipmentUuid { get; set; }
/// <summary>First cluster to publish this reservation.</summary>
public required string ClusterId { get; set; }
public DateTime FirstPublishedAt { get; set; } = DateTime.UtcNow;
public required string FirstPublishedBy { get; set; }
public DateTime LastPublishedAt { get; set; } = DateTime.UtcNow;
/// <summary>Non-null when explicitly released by FleetAdmin (audit-logged, requires reason).</summary>
public DateTime? ReleasedAt { get; set; }
public string? ReleasedBy { get; set; }
public string? ReleaseReason { get; set; }
}

View File

@@ -0,0 +1,31 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// OPC UA namespace served by a cluster. Generation-versioned per decision #123 —
/// namespaces are content (affect what consumers see at the endpoint), not topology.
/// </summary>
public sealed class Namespace
{
public Guid NamespaceRowId { get; set; }
public long GenerationId { get; set; }
/// <summary>Stable logical ID across generations, e.g. "LINE3-OPCUA-equipment".</summary>
public required string NamespaceId { get; set; }
public required string ClusterId { get; set; }
public required NamespaceKind Kind { get; set; }
/// <summary>E.g. "urn:zb:warsaw-west:equipment". Unique fleet-wide per generation.</summary>
public required string NamespaceUri { get; set; }
public bool Enabled { get; set; } = true;
public string? Notes { get; set; }
public ConfigGeneration? Generation { get; set; }
public ServerCluster? Cluster { get; set; }
}

View File

@@ -0,0 +1,32 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// One ACL grant: an LDAP group gets a set of <see cref="NodePermissions"/> at a specific scope.
/// Generation-versioned per decision #130. See <c>acl-design.md</c> for evaluation algorithm.
/// </summary>
public sealed class NodeAcl
{
public Guid NodeAclRowId { get; set; }
public long GenerationId { get; set; }
public required string NodeAclId { get; set; }
public required string ClusterId { get; set; }
public required string LdapGroup { get; set; }
public required NodeAclScopeKind ScopeKind { get; set; }
/// <summary>NULL when <see cref="ScopeKind"/> = <see cref="NodeAclScopeKind.Cluster"/>; otherwise the scoped entity's logical ID.</summary>
public string? ScopeId { get; set; }
/// <summary>Bitmask of <see cref="NodePermissions"/>. Stored as int in SQL.</summary>
public required NodePermissions PermissionFlags { get; set; }
public string? Notes { get; set; }
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>Driver-scoped polling group. Tags reference it via <see cref="Tag.PollGroupId"/>.</summary>
public sealed class PollGroup
{
public Guid PollGroupRowId { get; set; }
public long GenerationId { get; set; }
public required string PollGroupId { get; set; }
public required string DriverInstanceId { get; set; }
public required string Name { get; set; }
public int IntervalMs { get; set; }
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,42 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// Top-level deployment unit. 1 or 2 <see cref="ClusterNode"/> members.
/// Per <c>config-db-schema.md</c> ServerCluster table.
/// </summary>
public sealed class ServerCluster
{
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA".</summary>
public required string ClusterId { get; set; }
public required string Name { get; set; }
/// <summary>UNS level 1. Canonical org value: "zb" per decision #140.</summary>
public required string Enterprise { get; set; }
/// <summary>UNS level 2, e.g. "warsaw-west".</summary>
public required string Site { get; set; }
public byte NodeCount { get; set; }
public required RedundancyMode RedundancyMode { get; set; }
public bool Enabled { get; set; } = true;
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public required string CreatedBy { get; set; }
public DateTime? ModifiedAt { get; set; }
public string? ModifiedBy { get; set; }
// Navigation
public ICollection<ClusterNode> Nodes { get; set; } = [];
public ICollection<Namespace> Namespaces { get; set; } = [];
public ICollection<ConfigGeneration> Generations { get; set; } = [];
}

View File

@@ -0,0 +1,47 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>
/// One canonical tag (signal) in a cluster's generation. Per decision #110:
/// <see cref="EquipmentId"/> is REQUIRED when the driver is in an Equipment-kind namespace
/// and NULL when in SystemPlatform-kind namespace (Galaxy hierarchy preserved).
/// </summary>
public sealed class Tag
{
public Guid TagRowId { get; set; }
public long GenerationId { get; set; }
public required string TagId { get; set; }
public required string DriverInstanceId { get; set; }
public string? DeviceId { get; set; }
/// <summary>
/// Required when driver is in Equipment-kind namespace; NULL when in SystemPlatform-kind.
/// Cross-table invariant enforced by sp_ValidateDraft (decision #110).
/// </summary>
public string? EquipmentId { get; set; }
public required string Name { get; set; }
/// <summary>Only used when <see cref="EquipmentId"/> is NULL (SystemPlatform namespace).</summary>
public string? FolderPath { get; set; }
/// <summary>OPC UA built-in type name (Boolean / Int32 / Float / etc.).</summary>
public required string DataType { get; set; }
public required TagAccessLevel AccessLevel { get; set; }
/// <summary>Per decisions #4445 — opt-in for write retry eligibility.</summary>
public bool WriteIdempotent { get; set; }
public string? PollGroupId { get; set; }
/// <summary>Register address / scaling / poll group / byte-order / etc. — schemaless per driver type.</summary>
public required string TagConfig { get; set; }
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>UNS level-3 segment. Generation-versioned per decision #115.</summary>
public sealed class UnsArea
{
public Guid UnsAreaRowId { get; set; }
public long GenerationId { get; set; }
public required string UnsAreaId { get; set; }
public required string ClusterId { get; set; }
/// <summary>UNS level 3 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
public required string Name { get; set; }
public string? Notes { get; set; }
public ConfigGeneration? Generation { get; set; }
public ServerCluster? Cluster { get; set; }
}

View File

@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
/// <summary>UNS level-4 segment. Generation-versioned per decision #115.</summary>
public sealed class UnsLine
{
public Guid UnsLineRowId { get; set; }
public long GenerationId { get; set; }
public required string UnsLineId { get; set; }
/// <summary>Logical FK to <see cref="UnsArea.UnsAreaId"/>; resolved within the same generation.</summary>
public required string UnsAreaId { get; set; }
/// <summary>UNS level 4 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
public required string Name { get; set; }
public string? Notes { get; set; }
public ConfigGeneration? Generation { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>Credential kind for <see cref="Entities.ClusterNodeCredential"/>. Per decision #83.</summary>
public enum CredentialKind
{
SqlLogin,
ClientCertThumbprint,
ADPrincipal,
gMSA,
}

View File

@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>Generation lifecycle state. Draft → Published → Superseded | RolledBack.</summary>
public enum GenerationStatus
{
Draft,
Published,
Superseded,
RolledBack,
}

View File

@@ -0,0 +1,25 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>OPC UA namespace kind per decision #107. One of each kind per cluster per generation.</summary>
public enum NamespaceKind
{
/// <summary>
/// Equipment namespace — raw signals from native-protocol drivers (Modbus, AB CIP, AB Legacy,
/// S7, TwinCAT, FOCAS, and OpcUaClient when gatewaying raw equipment). UNS 5-level hierarchy
/// applies.
/// </summary>
Equipment,
/// <summary>
/// System Platform namespace — Galaxy / MXAccess processed data (v1 LmxOpcUa folded in).
/// UNS rules do NOT apply; Galaxy hierarchy preserved as v1 expressed it.
/// </summary>
SystemPlatform,
/// <summary>
/// Reserved for future replay driver per handoff §"Digital Twin Touchpoints" — not populated
/// in v2.0 but enum value reserved so the schema does not need to change when the replay
/// driver lands.
/// </summary>
Simulated,
}

View File

@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>ACL scope level. Per <c>acl-design.md</c> §"Scope Hierarchy".</summary>
public enum NodeAclScopeKind
{
Cluster,
Namespace,
UnsArea,
UnsLine,
Equipment,
Tag,
}

View File

@@ -0,0 +1,10 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>Status tracked per node in <see cref="Entities.ClusterNodeGenerationState"/>.</summary>
public enum NodeApplyStatus
{
Applied,
RolledBack,
Failed,
InProgress,
}

View File

@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>
/// OPC UA client data-path permissions per <c>acl-design.md</c>.
/// Stored as <c>int</c> bitmask in <see cref="Entities.NodeAcl.PermissionFlags"/>.
/// </summary>
[Flags]
public enum NodePermissions : uint
{
None = 0,
// Read-side
Browse = 1 << 0,
Read = 1 << 1,
Subscribe = 1 << 2,
HistoryRead = 1 << 3,
// Write-side (mirrors v1 SecurityClassification model)
WriteOperate = 1 << 4,
WriteTune = 1 << 5,
WriteConfigure = 1 << 6,
// Alarm-side
AlarmRead = 1 << 7,
AlarmAcknowledge = 1 << 8,
AlarmConfirm = 1 << 9,
AlarmShelve = 1 << 10,
// OPC UA Part 4 §5.11
MethodCall = 1 << 11,
// Bundles (one-click grants in Admin UI)
ReadOnly = Browse | Read | Subscribe | HistoryRead | AlarmRead,
Operator = ReadOnly | WriteOperate | AlarmAcknowledge | AlarmConfirm,
Engineer = Operator | WriteTune | AlarmShelve,
Admin = Engineer | WriteConfigure | MethodCall,
}

View File

@@ -0,0 +1,17 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>
/// Cluster redundancy mode per OPC UA Part 5 §6.5. Persisted as string in
/// <c>ServerCluster.RedundancyMode</c> with a CHECK constraint coupling to <c>NodeCount</c>.
/// </summary>
public enum RedundancyMode
{
/// <summary>Single-node cluster. Required when <c>NodeCount = 1</c>.</summary>
None,
/// <summary>Warm redundancy (non-transparent). Two-node cluster.</summary>
Warm,
/// <summary>Hot redundancy (non-transparent). Two-node cluster.</summary>
Hot,
}

View File

@@ -0,0 +1,9 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>Per-node redundancy role within a cluster. Per decision #84.</summary>
public enum RedundancyRole
{
Primary,
Secondary,
Standalone,
}

View File

@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>External-ID reservation kind. Per decision #124.</summary>
public enum ReservationKind
{
ZTag,
SAPID,
}

View File

@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
/// <summary>Tag-level OPC UA access level baseline. Further narrowed per-user by NodeAcl grants.</summary>
public enum TagAccessLevel
{
Read,
ReadWrite,
}

View File

@@ -0,0 +1,15 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
/// <summary>
/// A self-contained snapshot of one generation — enough to rebuild the address space on a node
/// that has lost DB connectivity. The payload is the JSON-serialized <c>sp_GetGenerationContent</c>
/// result; the local cache doesn't inspect the shape, it just round-trips bytes.
/// </summary>
public sealed class GenerationSnapshot
{
public int Id { get; set; } // LiteDB auto-ID
public required string ClusterId { get; set; }
public required long GenerationId { get; set; }
public required DateTime CachedAt { get; set; }
public required string PayloadJson { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
/// <summary>
/// Per-node local cache of the most-recently-applied generation(s). Used to bootstrap the
/// address space when the central DB is unreachable (decision #79 — degraded-but-running).
/// </summary>
public interface ILocalConfigCache
{
Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default);
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default);
}

View File

@@ -0,0 +1,89 @@
using LiteDB;
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
/// <summary>
/// LiteDB-backed <see cref="ILocalConfigCache"/>. One file per node (default
/// <c>config_cache.db</c>), one collection per snapshot. Corruption surfaces as
/// <see cref="LocalConfigCacheCorruptException"/> on construction or read — callers should
/// delete and re-fetch from the central DB (decision #80).
/// </summary>
public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
{
private const string CollectionName = "generations";
private readonly LiteDatabase _db;
private readonly ILiteCollection<GenerationSnapshot> _col;
public LiteDbConfigCache(string dbPath)
{
// LiteDB can be tolerant of header-only corruption at construction time (it may overwrite
// the header and "recover"), so we force a write + read probe to fail fast on real corruption.
try
{
_db = new LiteDatabase(new ConnectionString { Filename = dbPath, Upgrade = true });
_col = _db.GetCollection<GenerationSnapshot>(CollectionName);
_col.EnsureIndex(s => s.ClusterId);
_col.EnsureIndex(s => s.GenerationId);
_ = _col.Count();
}
catch (Exception ex) when (ex is LiteException or InvalidDataException or IOException
or NotSupportedException or UnauthorizedAccessException
or ArgumentOutOfRangeException or FormatException)
{
_db?.Dispose();
throw new LocalConfigCacheCorruptException(
$"LiteDB cache at '{dbPath}' is corrupt or unreadable — delete the file and refetch from the central DB.",
ex);
}
}
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
var snapshot = _col
.Find(s => s.ClusterId == clusterId)
.OrderByDescending(s => s.GenerationId)
.FirstOrDefault();
return Task.FromResult<GenerationSnapshot?>(snapshot);
}
public Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
// upsert by (ClusterId, GenerationId) — replace in place if already cached
var existing = _col
.Find(s => s.ClusterId == snapshot.ClusterId && s.GenerationId == snapshot.GenerationId)
.FirstOrDefault();
if (existing is null)
_col.Insert(snapshot);
else
{
snapshot.Id = existing.Id;
_col.Update(snapshot);
}
return Task.CompletedTask;
}
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
var doomed = _col
.Find(s => s.ClusterId == clusterId)
.OrderByDescending(s => s.GenerationId)
.Skip(keepLatest)
.Select(s => s.Id)
.ToList();
foreach (var id in doomed)
_col.Delete(id);
return Task.CompletedTask;
}
public void Dispose() => _db.Dispose();
}
public sealed class LocalConfigCacheCorruptException(string message, Exception inner)
: Exception(message, inner);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,811 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ConfigAuditLog",
columns: table => new
{
AuditId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Timestamp = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
Principal = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
EventType = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
GenerationId = table.Column<long>(type: "bigint", nullable: true),
DetailsJson = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ConfigAuditLog", x => x.AuditId);
table.CheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
});
migrationBuilder.CreateTable(
name: "ExternalIdReservation",
columns: table => new
{
ReservationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
Kind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
Value = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
FirstPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
FirstPublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
LastPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
ReleasedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
ReleasedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
ReleaseReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ExternalIdReservation", x => x.ReservationId);
});
migrationBuilder.CreateTable(
name: "ServerCluster",
columns: table => new
{
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Enterprise = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Site = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
NodeCount = table.Column<byte>(type: "tinyint", nullable: false),
RedundancyMode = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
ModifiedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
ModifiedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ServerCluster", x => x.ClusterId);
table.CheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
});
migrationBuilder.CreateTable(
name: "ClusterNode",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
RedundancyRole = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
Host = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
OpcUaPort = table.Column<int>(type: "int", nullable: false),
DashboardPort = table.Column<int>(type: "int", nullable: false),
ApplicationUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ServiceLevelBase = table.Column<byte>(type: "tinyint", nullable: false),
DriverConfigOverridesJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
Enabled = table.Column<bool>(type: "bit", nullable: false),
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ClusterNode", x => x.NodeId);
table.ForeignKey(
name: "FK_ClusterNode_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ConfigGeneration",
columns: table => new
{
GenerationId = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ParentGenerationId = table.Column<long>(type: "bigint", nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
PublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ConfigGeneration", x => x.GenerationId);
table.ForeignKey(
name: "FK_ConfigGeneration_ConfigGeneration_ParentGenerationId",
column: x => x.ParentGenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ConfigGeneration_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ClusterNodeCredential",
columns: table => new
{
CredentialId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Value = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
RotatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ClusterNodeCredential", x => x.CredentialId);
table.ForeignKey(
name: "FK_ClusterNodeCredential_ClusterNode_NodeId",
column: x => x.NodeId,
principalTable: "ClusterNode",
principalColumn: "NodeId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ClusterNodeGenerationState",
columns: table => new
{
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CurrentGenerationId = table.Column<long>(type: "bigint", nullable: true),
LastAppliedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
LastAppliedStatus = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
LastAppliedError = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ClusterNodeGenerationState", x => x.NodeId);
table.ForeignKey(
name: "FK_ClusterNodeGenerationState_ClusterNode_NodeId",
column: x => x.NodeId,
principalTable: "ClusterNode",
principalColumn: "NodeId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ClusterNodeGenerationState_ConfigGeneration_CurrentGenerationId",
column: x => x.CurrentGenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Device",
columns: table => new
{
DeviceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
DeviceConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Device", x => x.DeviceRowId);
table.CheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
table.ForeignKey(
name: "FK_Device_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "DriverInstance",
columns: table => new
{
DriverInstanceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
DriverType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
DriverConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DriverInstance", x => x.DriverInstanceRowId);
table.CheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
table.ForeignKey(
name: "FK_DriverInstance_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_DriverInstance_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Equipment",
columns: table => new
{
EquipmentRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
MachineCode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ZTag = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
SAPID = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Manufacturer = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Model = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
SerialNumber = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
HardwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
SoftwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
YearOfConstruction = table.Column<short>(type: "smallint", nullable: true),
AssetLocation = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
EquipmentClassRef = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
Enabled = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Equipment", x => x.EquipmentRowId);
table.ForeignKey(
name: "FK_Equipment_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Namespace",
columns: table => new
{
NamespaceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
NamespaceUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Namespace", x => x.NamespaceRowId);
table.ForeignKey(
name: "FK_Namespace_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Namespace_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NodeAcl",
columns: table => new
{
NodeAclRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
NodeAclId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
LdapGroup = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ScopeKind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
ScopeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
PermissionFlags = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NodeAcl", x => x.NodeAclRowId);
table.ForeignKey(
name: "FK_NodeAcl_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "PollGroup",
columns: table => new
{
PollGroupRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
IntervalMs = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PollGroup", x => x.PollGroupRowId);
table.CheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
table.ForeignKey(
name: "FK_PollGroup_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Tag",
columns: table => new
{
TagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
TagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
FolderPath = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
AccessLevel = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
WriteIdempotent = table.Column<bool>(type: "bit", nullable: false),
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
TagConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tag", x => x.TagRowId);
table.CheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
table.ForeignKey(
name: "FK_Tag_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "UnsArea",
columns: table => new
{
UnsAreaRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UnsArea", x => x.UnsAreaRowId);
table.ForeignKey(
name: "FK_UnsArea_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_UnsArea_ServerCluster_ClusterId",
column: x => x.ClusterId,
principalTable: "ServerCluster",
principalColumn: "ClusterId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "UnsLine",
columns: table => new
{
UnsLineRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
GenerationId = table.Column<long>(type: "bigint", nullable: false),
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UnsLine", x => x.UnsLineRowId);
table.ForeignKey(
name: "FK_UnsLine_ConfigGeneration_GenerationId",
column: x => x.GenerationId,
principalTable: "ConfigGeneration",
principalColumn: "GenerationId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "UX_ClusterNode_ApplicationUri",
table: "ClusterNode",
column: "ApplicationUri",
unique: true);
migrationBuilder.CreateIndex(
name: "UX_ClusterNode_Primary_Per_Cluster",
table: "ClusterNode",
column: "ClusterId",
unique: true,
filter: "[RedundancyRole] = 'Primary'");
migrationBuilder.CreateIndex(
name: "IX_ClusterNodeCredential_NodeId",
table: "ClusterNodeCredential",
columns: new[] { "NodeId", "Enabled" });
migrationBuilder.CreateIndex(
name: "UX_ClusterNodeCredential_Value",
table: "ClusterNodeCredential",
columns: new[] { "Kind", "Value" },
unique: true,
filter: "[Enabled] = 1");
migrationBuilder.CreateIndex(
name: "IX_ClusterNodeGenerationState_Generation",
table: "ClusterNodeGenerationState",
column: "CurrentGenerationId");
migrationBuilder.CreateIndex(
name: "IX_ConfigAuditLog_Cluster_Time",
table: "ConfigAuditLog",
columns: new[] { "ClusterId", "Timestamp" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_ConfigAuditLog_Generation",
table: "ConfigAuditLog",
column: "GenerationId",
filter: "[GenerationId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ConfigGeneration_Cluster_Published",
table: "ConfigGeneration",
columns: new[] { "ClusterId", "Status", "GenerationId" },
descending: new[] { false, false, true })
.Annotation("SqlServer:Include", new[] { "PublishedAt" });
migrationBuilder.CreateIndex(
name: "IX_ConfigGeneration_ParentGenerationId",
table: "ConfigGeneration",
column: "ParentGenerationId");
migrationBuilder.CreateIndex(
name: "UX_ConfigGeneration_Draft_Per_Cluster",
table: "ConfigGeneration",
column: "ClusterId",
unique: true,
filter: "[Status] = 'Draft'");
migrationBuilder.CreateIndex(
name: "IX_Device_Generation_Driver",
table: "Device",
columns: new[] { "GenerationId", "DriverInstanceId" });
migrationBuilder.CreateIndex(
name: "UX_Device_Generation_LogicalId",
table: "Device",
columns: new[] { "GenerationId", "DeviceId" },
unique: true,
filter: "[DeviceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_DriverInstance_ClusterId",
table: "DriverInstance",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_DriverInstance_Generation_Cluster",
table: "DriverInstance",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "IX_DriverInstance_Generation_Namespace",
table: "DriverInstance",
columns: new[] { "GenerationId", "NamespaceId" });
migrationBuilder.CreateIndex(
name: "UX_DriverInstance_Generation_LogicalId",
table: "DriverInstance",
columns: new[] { "GenerationId", "DriverInstanceId" },
unique: true,
filter: "[DriverInstanceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_Driver",
table: "Equipment",
columns: new[] { "GenerationId", "DriverInstanceId" });
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_Line",
table: "Equipment",
columns: new[] { "GenerationId", "UnsLineId" });
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_MachineCode",
table: "Equipment",
columns: new[] { "GenerationId", "MachineCode" });
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_SAPID",
table: "Equipment",
columns: new[] { "GenerationId", "SAPID" },
filter: "[SAPID] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Equipment_Generation_ZTag",
table: "Equipment",
columns: new[] { "GenerationId", "ZTag" },
filter: "[ZTag] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Equipment_Generation_LinePath",
table: "Equipment",
columns: new[] { "GenerationId", "UnsLineId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_Equipment_Generation_LogicalId",
table: "Equipment",
columns: new[] { "GenerationId", "EquipmentId" },
unique: true,
filter: "[EquipmentId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Equipment_Generation_Uuid",
table: "Equipment",
columns: new[] { "GenerationId", "EquipmentUuid" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ExternalIdReservation_Equipment",
table: "ExternalIdReservation",
column: "EquipmentUuid");
migrationBuilder.CreateIndex(
name: "UX_ExternalIdReservation_KindValue_Active",
table: "ExternalIdReservation",
columns: new[] { "Kind", "Value" },
unique: true,
filter: "[ReleasedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Namespace_ClusterId",
table: "Namespace",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_Namespace_Generation_Cluster",
table: "Namespace",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_Cluster_Kind",
table: "Namespace",
columns: new[] { "GenerationId", "ClusterId", "Kind" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_LogicalId",
table: "Namespace",
columns: new[] { "GenerationId", "NamespaceId" },
unique: true,
filter: "[NamespaceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_LogicalId_Cluster",
table: "Namespace",
columns: new[] { "GenerationId", "NamespaceId", "ClusterId" },
unique: true,
filter: "[NamespaceId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Namespace_Generation_NamespaceUri",
table: "Namespace",
columns: new[] { "GenerationId", "NamespaceUri" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NodeAcl_Generation_Cluster",
table: "NodeAcl",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "IX_NodeAcl_Generation_Group",
table: "NodeAcl",
columns: new[] { "GenerationId", "LdapGroup" });
migrationBuilder.CreateIndex(
name: "IX_NodeAcl_Generation_Scope",
table: "NodeAcl",
columns: new[] { "GenerationId", "ScopeKind", "ScopeId" },
filter: "[ScopeId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_NodeAcl_Generation_GroupScope",
table: "NodeAcl",
columns: new[] { "GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId" },
unique: true,
filter: "[ScopeId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_NodeAcl_Generation_LogicalId",
table: "NodeAcl",
columns: new[] { "GenerationId", "NodeAclId" },
unique: true,
filter: "[NodeAclId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_PollGroup_Generation_Driver",
table: "PollGroup",
columns: new[] { "GenerationId", "DriverInstanceId" });
migrationBuilder.CreateIndex(
name: "UX_PollGroup_Generation_LogicalId",
table: "PollGroup",
columns: new[] { "GenerationId", "PollGroupId" },
unique: true,
filter: "[PollGroupId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_ServerCluster_Site",
table: "ServerCluster",
column: "Site");
migrationBuilder.CreateIndex(
name: "UX_ServerCluster_Name",
table: "ServerCluster",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Tag_Generation_Driver_Device",
table: "Tag",
columns: new[] { "GenerationId", "DriverInstanceId", "DeviceId" });
migrationBuilder.CreateIndex(
name: "IX_Tag_Generation_Equipment",
table: "Tag",
columns: new[] { "GenerationId", "EquipmentId" },
filter: "[EquipmentId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Tag_Generation_EquipmentPath",
table: "Tag",
columns: new[] { "GenerationId", "EquipmentId", "Name" },
unique: true,
filter: "[EquipmentId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "UX_Tag_Generation_FolderPath",
table: "Tag",
columns: new[] { "GenerationId", "DriverInstanceId", "FolderPath", "Name" },
unique: true,
filter: "[EquipmentId] IS NULL");
migrationBuilder.CreateIndex(
name: "UX_Tag_Generation_LogicalId",
table: "Tag",
columns: new[] { "GenerationId", "TagId" },
unique: true,
filter: "[TagId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_UnsArea_ClusterId",
table: "UnsArea",
column: "ClusterId");
migrationBuilder.CreateIndex(
name: "IX_UnsArea_Generation_Cluster",
table: "UnsArea",
columns: new[] { "GenerationId", "ClusterId" });
migrationBuilder.CreateIndex(
name: "UX_UnsArea_Generation_ClusterName",
table: "UnsArea",
columns: new[] { "GenerationId", "ClusterId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_UnsArea_Generation_LogicalId",
table: "UnsArea",
columns: new[] { "GenerationId", "UnsAreaId" },
unique: true,
filter: "[UnsAreaId] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_UnsLine_Generation_Area",
table: "UnsLine",
columns: new[] { "GenerationId", "UnsAreaId" });
migrationBuilder.CreateIndex(
name: "UX_UnsLine_Generation_AreaName",
table: "UnsLine",
columns: new[] { "GenerationId", "UnsAreaId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "UX_UnsLine_Generation_LogicalId",
table: "UnsLine",
columns: new[] { "GenerationId", "UnsLineId" },
unique: true,
filter: "[UnsLineId] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ClusterNodeCredential");
migrationBuilder.DropTable(
name: "ClusterNodeGenerationState");
migrationBuilder.DropTable(
name: "ConfigAuditLog");
migrationBuilder.DropTable(
name: "Device");
migrationBuilder.DropTable(
name: "DriverInstance");
migrationBuilder.DropTable(
name: "Equipment");
migrationBuilder.DropTable(
name: "ExternalIdReservation");
migrationBuilder.DropTable(
name: "Namespace");
migrationBuilder.DropTable(
name: "NodeAcl");
migrationBuilder.DropTable(
name: "PollGroup");
migrationBuilder.DropTable(
name: "Tag");
migrationBuilder.DropTable(
name: "UnsArea");
migrationBuilder.DropTable(
name: "UnsLine");
migrationBuilder.DropTable(
name: "ClusterNode");
migrationBuilder.DropTable(
name: "ConfigGeneration");
migrationBuilder.DropTable(
name: "ServerCluster");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,473 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
/// <summary>
/// Stored procedures per <c>config-db-schema.md §"Stored Procedures"</c>. All node + admin DB
/// access funnels through these — direct table writes are revoked in the AuthorizationGrants
/// migration that follows. CREATE OR ALTER style so procs version with the schema.
/// </summary>
public partial class StoredProcedures : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(Procs.GetCurrentGenerationForCluster);
migrationBuilder.Sql(Procs.GetGenerationContent);
migrationBuilder.Sql(Procs.RegisterNodeGenerationApplied);
migrationBuilder.Sql(Procs.ValidateDraft);
migrationBuilder.Sql(Procs.PublishGeneration);
migrationBuilder.Sql(Procs.RollbackToGeneration);
migrationBuilder.Sql(Procs.ComputeGenerationDiff);
migrationBuilder.Sql(Procs.ReleaseExternalIdReservation);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
foreach (var name in new[]
{
"sp_ReleaseExternalIdReservation", "sp_ComputeGenerationDiff", "sp_RollbackToGeneration",
"sp_PublishGeneration", "sp_ValidateDraft", "sp_RegisterNodeGenerationApplied",
"sp_GetGenerationContent", "sp_GetCurrentGenerationForCluster",
})
{
migrationBuilder.Sql($"IF OBJECT_ID(N'dbo.{name}', N'P') IS NOT NULL DROP PROCEDURE dbo.{name};");
}
}
private static class Procs
{
public const string GetCurrentGenerationForCluster = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster
@NodeId nvarchar(64),
@ClusterId nvarchar(64)
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNodeCredential
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
BEGIN
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
RETURN;
END
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNode
WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1)
BEGIN
RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId);
RETURN;
END
SELECT TOP 1 GenerationId, ClusterId, Status, PublishedAt, PublishedBy, Notes
FROM dbo.ConfigGeneration
WHERE ClusterId = @ClusterId AND Status = 'Published'
ORDER BY GenerationId DESC;
END
";
public const string GetGenerationContent = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent
@NodeId nvarchar(64),
@GenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
DECLARE @ClusterId nvarchar(64);
SELECT @ClusterId = ClusterId FROM dbo.ConfigGeneration WHERE GenerationId = @GenerationId;
IF @ClusterId IS NULL
BEGIN
RAISERROR('GenerationId %I64d not found', 16, 1, @GenerationId);
RETURN;
END
IF NOT EXISTS (
SELECT 1
FROM dbo.ClusterNodeCredential c
JOIN dbo.ClusterNode n ON n.NodeId = c.NodeId
WHERE c.NodeId = @NodeId AND c.Value = @Caller AND c.Enabled = 1
AND n.ClusterId = @ClusterId AND n.Enabled = 1)
BEGIN
RAISERROR('Forbidden: caller %s not bound to a node in ClusterId %s', 16, 1, @Caller, @ClusterId);
RETURN;
END
SELECT * FROM dbo.Namespace WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.UnsArea WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.UnsLine WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.DriverInstance WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Device WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Equipment WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.PollGroup WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.Tag WHERE GenerationId = @GenerationId;
SELECT * FROM dbo.NodeAcl WHERE GenerationId = @GenerationId;
END
";
public const string RegisterNodeGenerationApplied = @"
CREATE OR ALTER PROCEDURE dbo.sp_RegisterNodeGenerationApplied
@NodeId nvarchar(64),
@GenerationId bigint,
@Status nvarchar(16),
@Error nvarchar(max) = NULL
AS
BEGIN
SET NOCOUNT ON;
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
IF NOT EXISTS (
SELECT 1 FROM dbo.ClusterNodeCredential
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
BEGIN
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
RETURN;
END
MERGE dbo.ClusterNodeGenerationState AS tgt
USING (SELECT @NodeId AS NodeId) AS src ON tgt.NodeId = src.NodeId
WHEN MATCHED THEN UPDATE SET
CurrentGenerationId = @GenerationId,
LastAppliedAt = SYSUTCDATETIME(),
LastAppliedStatus = @Status,
LastAppliedError = @Error,
LastSeenAt = SYSUTCDATETIME()
WHEN NOT MATCHED THEN INSERT
(NodeId, CurrentGenerationId, LastAppliedAt, LastAppliedStatus, LastAppliedError, LastSeenAt)
VALUES (@NodeId, @GenerationId, SYSUTCDATETIME(), @Status, @Error, SYSUTCDATETIME());
INSERT dbo.ConfigAuditLog (Principal, EventType, NodeId, GenerationId, DetailsJson)
VALUES (@Caller, 'NodeApplied', @NodeId, @GenerationId,
CONCAT('{""status"":""', @Status, '""}'));
END
";
public const string ValidateDraft = @"
CREATE OR ALTER PROCEDURE dbo.sp_ValidateDraft
@DraftGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
DECLARE @ClusterId nvarchar(64);
DECLARE @Status nvarchar(16);
SELECT @ClusterId = ClusterId, @Status = Status
FROM dbo.ConfigGeneration WHERE GenerationId = @DraftGenerationId;
IF @ClusterId IS NULL
BEGIN
RAISERROR('GenerationId %I64d not found', 16, 1, @DraftGenerationId);
RETURN;
END
IF @Status <> 'Draft'
BEGIN
RAISERROR('GenerationId %I64d is not in Draft status (current=%s)', 16, 1, @DraftGenerationId, @Status);
RETURN;
END
IF EXISTS (
SELECT 1 FROM dbo.Tag t
LEFT JOIN dbo.DriverInstance d ON d.GenerationId = t.GenerationId AND d.DriverInstanceId = t.DriverInstanceId
WHERE t.GenerationId = @DraftGenerationId AND d.DriverInstanceId IS NULL)
BEGIN
RAISERROR('Draft has tags with unresolved DriverInstanceId', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1 FROM dbo.Tag t
LEFT JOIN dbo.Device dv ON dv.GenerationId = t.GenerationId AND dv.DeviceId = t.DeviceId
WHERE t.GenerationId = @DraftGenerationId AND t.DeviceId IS NOT NULL AND dv.DeviceId IS NULL)
BEGIN
RAISERROR('Draft has tags with unresolved DeviceId', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1 FROM dbo.Tag t
LEFT JOIN dbo.PollGroup pg ON pg.GenerationId = t.GenerationId AND pg.PollGroupId = t.PollGroupId
WHERE t.GenerationId = @DraftGenerationId AND t.PollGroupId IS NOT NULL AND pg.PollGroupId IS NULL)
BEGIN
RAISERROR('Draft has tags with unresolved PollGroupId', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.DriverInstance di
JOIN dbo.Namespace ns ON ns.GenerationId = di.GenerationId AND ns.NamespaceId = di.NamespaceId
WHERE di.GenerationId = @DraftGenerationId
AND ns.ClusterId <> di.ClusterId)
BEGIN
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
VALUES (SUSER_SNAME(), 'CrossClusterNamespaceAttempt', @ClusterId, @DraftGenerationId);
RAISERROR('BadCrossClusterNamespaceBinding: namespace and driver must belong to the same cluster', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.Equipment draft
JOIN dbo.Equipment prior
ON prior.EquipmentId = draft.EquipmentId
AND prior.EquipmentUuid <> draft.EquipmentUuid
AND prior.GenerationId <> draft.GenerationId
JOIN dbo.ConfigGeneration pg ON pg.GenerationId = prior.GenerationId
WHERE draft.GenerationId = @DraftGenerationId
AND pg.ClusterId = @ClusterId)
BEGIN
RAISERROR('EquipmentUuid immutability violated for an EquipmentId that existed in a prior generation', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.Equipment draft
JOIN dbo.ExternalIdReservation r
ON r.Kind = 'ZTag' AND r.Value = draft.ZTag AND r.ReleasedAt IS NULL
AND r.EquipmentUuid <> draft.EquipmentUuid
WHERE draft.GenerationId = @DraftGenerationId AND draft.ZTag IS NOT NULL)
BEGIN
RAISERROR('BadDuplicateExternalIdentifier: a ZTag in the draft is reserved by a different EquipmentUuid', 16, 1);
RETURN;
END
IF EXISTS (
SELECT 1
FROM dbo.Equipment draft
JOIN dbo.ExternalIdReservation r
ON r.Kind = 'SAPID' AND r.Value = draft.SAPID AND r.ReleasedAt IS NULL
AND r.EquipmentUuid <> draft.EquipmentUuid
WHERE draft.GenerationId = @DraftGenerationId AND draft.SAPID IS NOT NULL)
BEGIN
RAISERROR('BadDuplicateExternalIdentifier: a SAPID in the draft is reserved by a different EquipmentUuid', 16, 1);
RETURN;
END
END
";
public const string PublishGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_PublishGeneration
@ClusterId nvarchar(64),
@DraftGenerationId bigint,
@Notes nvarchar(1024) = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
DECLARE @Lock nvarchar(255) = N'OtOpcUa_Publish_' + @ClusterId;
DECLARE @LockResult int;
EXEC @LockResult = sp_getapplock @Resource = @Lock, @LockMode = 'Exclusive', @LockTimeout = 0;
IF @LockResult < 0
BEGIN
RAISERROR('PublishConflict: another publish is in progress for cluster %s', 16, 1, @ClusterId);
ROLLBACK;
RETURN;
END
EXEC dbo.sp_ValidateDraft @DraftGenerationId = @DraftGenerationId;
MERGE dbo.ExternalIdReservation AS tgt
USING (
SELECT 'ZTag' AS Kind, ZTag AS Value, EquipmentUuid
FROM dbo.Equipment
WHERE GenerationId = @DraftGenerationId AND ZTag IS NOT NULL
UNION ALL
SELECT 'SAPID', SAPID, EquipmentUuid
FROM dbo.Equipment
WHERE GenerationId = @DraftGenerationId AND SAPID IS NOT NULL
) AS src
ON tgt.Kind = src.Kind AND tgt.Value = src.Value AND tgt.EquipmentUuid = src.EquipmentUuid
WHEN MATCHED THEN UPDATE SET LastPublishedAt = SYSUTCDATETIME()
WHEN NOT MATCHED BY TARGET THEN
INSERT (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt)
VALUES (src.Kind, src.Value, src.EquipmentUuid, @ClusterId, SUSER_SNAME(), SYSUTCDATETIME());
UPDATE dbo.ConfigGeneration
SET Status = 'Superseded'
WHERE ClusterId = @ClusterId AND Status = 'Published';
UPDATE dbo.ConfigGeneration
SET Status = 'Published',
PublishedAt = SYSUTCDATETIME(),
PublishedBy = SUSER_SNAME(),
Notes = ISNULL(@Notes, Notes)
WHERE GenerationId = @DraftGenerationId AND ClusterId = @ClusterId AND Status = 'Draft';
IF @@ROWCOUNT = 0
BEGIN
RAISERROR('Draft %I64d for cluster %s not found (was it already published?)', 16, 1, @DraftGenerationId, @ClusterId);
ROLLBACK;
RETURN;
END
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);
COMMIT;
END
";
public const string RollbackToGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_RollbackToGeneration
@ClusterId nvarchar(64),
@TargetGenerationId bigint,
@Notes nvarchar(1024) = NULL
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
BEGIN TRANSACTION;
IF NOT EXISTS (
SELECT 1 FROM dbo.ConfigGeneration
WHERE GenerationId = @TargetGenerationId AND ClusterId = @ClusterId
AND Status IN ('Published', 'Superseded'))
BEGIN
RAISERROR('Target generation %I64d not found or not rollback-eligible', 16, 1, @TargetGenerationId);
ROLLBACK; RETURN;
END
DECLARE @NewGenId bigint;
INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy, PublishedAt, PublishedBy, Notes)
VALUES (@ClusterId, 'Draft', SYSUTCDATETIME(), SUSER_SNAME(), NULL, NULL,
ISNULL(@Notes, CONCAT('Rollback clone of generation ', @TargetGenerationId)));
SET @NewGenId = SCOPE_IDENTITY();
INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes)
SELECT @NewGenId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes FROM dbo.Namespace WHERE GenerationId = @TargetGenerationId;
INSERT dbo.UnsArea (GenerationId, UnsAreaId, ClusterId, Name, Notes)
SELECT @NewGenId, UnsAreaId, ClusterId, Name, Notes FROM dbo.UnsArea WHERE GenerationId = @TargetGenerationId;
INSERT dbo.UnsLine (GenerationId, UnsLineId, UnsAreaId, Name, Notes)
SELECT @NewGenId, UnsLineId, UnsAreaId, Name, Notes FROM dbo.UnsLine WHERE GenerationId = @TargetGenerationId;
INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
SELECT @NewGenId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig FROM dbo.DriverInstance WHERE GenerationId = @TargetGenerationId;
INSERT dbo.Device (GenerationId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
SELECT @NewGenId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig FROM dbo.Device WHERE GenerationId = @TargetGenerationId;
INSERT dbo.Equipment (GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled)
SELECT @NewGenId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled FROM dbo.Equipment WHERE GenerationId = @TargetGenerationId;
INSERT dbo.PollGroup (GenerationId, PollGroupId, DriverInstanceId, Name, IntervalMs)
SELECT @NewGenId, PollGroupId, DriverInstanceId, Name, IntervalMs FROM dbo.PollGroup WHERE GenerationId = @TargetGenerationId;
INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
SELECT @NewGenId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig FROM dbo.Tag WHERE GenerationId = @TargetGenerationId;
INSERT dbo.NodeAcl (GenerationId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes)
SELECT @NewGenId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes FROM dbo.NodeAcl WHERE GenerationId = @TargetGenerationId;
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @NewGenId, @Notes = @Notes;
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId, DetailsJson)
VALUES (SUSER_SNAME(), 'RolledBack', @ClusterId, @NewGenId,
CONCAT('{""rolledBackTo"":', @TargetGenerationId, '}'));
COMMIT;
END
";
public const string ComputeGenerationDiff = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
@FromGenerationId bigint,
@ToGenerationId bigint
AS
BEGIN
SET NOCOUNT ON;
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
INSERT #diff
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
CASE WHEN f.LogicalId IS NULL THEN 'Added'
WHEN t.LogicalId IS NULL THEN 'Removed'
WHEN f.Sig <> t.Sig THEN 'Modified'
ELSE 'Unchanged' END
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
SELECT TableName, LogicalId, ChangeKind FROM #diff;
DROP TABLE #diff;
END
";
public const string ReleaseExternalIdReservation = @"
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
@Kind nvarchar(16),
@Value nvarchar(64),
@ReleaseReason nvarchar(512)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0
BEGIN
RAISERROR('ReleaseReason is required', 16, 1);
RETURN;
END
UPDATE dbo.ExternalIdReservation
SET ReleasedAt = SYSUTCDATETIME(),
ReleasedBy = SUSER_SNAME(),
ReleaseReason = @ReleaseReason
WHERE Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL;
IF @@ROWCOUNT = 0
BEGIN
RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value);
RETURN;
END
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
CONCAT('{""kind"":""', @Kind, '"",""value"":""', @Value, '""}'));
END
";
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
/// <summary>
/// Creates the two DB roles per <c>config-db-schema.md §"Authorization Model"</c> and grants
/// EXECUTE on the appropriate stored procedures. Deliberately grants no direct table DML — all
/// writes funnel through the procs, which authenticate via <c>SUSER_SNAME()</c>.
/// Principals (SQL logins, gMSA users, cert-mapped users) are provisioned by the DBA outside
/// this migration and then added to one of the two roles.
/// </summary>
public partial class AuthorizationGrants : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NULL
CREATE ROLE OtOpcUaNode;
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NULL
CREATE ROLE OtOpcUaAdmin;
");
migrationBuilder.Sql(@"
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_RegisterNodeGenerationApplied TO OtOpcUaNode;
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ValidateDraft TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_PublishGeneration TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_RollbackToGeneration TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ComputeGenerationDiff TO OtOpcUaAdmin;
GRANT EXECUTE ON OBJECT::dbo.sp_ReleaseExternalIdReservation TO OtOpcUaAdmin;
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaNode;
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaAdmin;
DENY SELECT ON SCHEMA::dbo TO OtOpcUaNode;
-- Admins may SELECT for reporting views in the future — grant views explicitly, not the schema.
DENY SELECT ON SCHEMA::dbo TO OtOpcUaAdmin;
");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NOT NULL
DROP ROLE OtOpcUaNode;
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NOT NULL
DROP ROLE OtOpcUaAdmin;
");
}
}

View File

@@ -0,0 +1,487 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration;
/// <summary>
/// Central config DB context. Schema matches <c>docs/v2/config-db-schema.md</c> exactly —
/// any divergence is a defect caught by the SchemaComplianceTests introspection check.
/// </summary>
public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbContext> options)
: DbContext(options)
{
public DbSet<ServerCluster> ServerClusters => Set<ServerCluster>();
public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
public DbSet<ClusterNodeCredential> ClusterNodeCredentials => Set<ClusterNodeCredential>();
public DbSet<ConfigGeneration> ConfigGenerations => Set<ConfigGeneration>();
public DbSet<Namespace> Namespaces => Set<Namespace>();
public DbSet<UnsArea> UnsAreas => Set<UnsArea>();
public DbSet<UnsLine> UnsLines => Set<UnsLine>();
public DbSet<DriverInstance> DriverInstances => Set<DriverInstance>();
public DbSet<Device> Devices => Set<Device>();
public DbSet<Equipment> Equipment => Set<Equipment>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<PollGroup> PollGroups => Set<PollGroup>();
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
ConfigureServerCluster(modelBuilder);
ConfigureClusterNode(modelBuilder);
ConfigureClusterNodeCredential(modelBuilder);
ConfigureConfigGeneration(modelBuilder);
ConfigureNamespace(modelBuilder);
ConfigureUnsArea(modelBuilder);
ConfigureUnsLine(modelBuilder);
ConfigureDriverInstance(modelBuilder);
ConfigureDevice(modelBuilder);
ConfigureEquipment(modelBuilder);
ConfigureTag(modelBuilder);
ConfigurePollGroup(modelBuilder);
ConfigureNodeAcl(modelBuilder);
ConfigureClusterNodeGenerationState(modelBuilder);
ConfigureConfigAuditLog(modelBuilder);
ConfigureExternalIdReservation(modelBuilder);
}
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ServerCluster>(e =>
{
e.ToTable("ServerCluster", t =>
{
t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount",
"((NodeCount = 1 AND RedundancyMode = 'None') " +
"OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
});
e.HasKey(x => x.ClusterId);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.Enterprise).HasMaxLength(32);
e.Property(x => x.Site).HasMaxLength(32);
e.Property(x => x.RedundancyMode).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Notes).HasMaxLength(1024);
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.Property(x => x.ModifiedAt).HasColumnType("datetime2(3)");
e.Property(x => x.ModifiedBy).HasMaxLength(128);
e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_ServerCluster_Name");
e.HasIndex(x => x.Site).HasDatabaseName("IX_ServerCluster_Site");
});
}
private static void ConfigureClusterNode(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ClusterNode>(e =>
{
e.ToTable("ClusterNode");
e.HasKey(x => x.NodeId);
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.RedundancyRole).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Host).HasMaxLength(255);
e.Property(x => x.ApplicationUri).HasMaxLength(256);
e.Property(x => x.DriverConfigOverridesJson).HasColumnType("nvarchar(max)");
e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)");
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.HasOne(x => x.Cluster).WithMany(c => c.Nodes)
.HasForeignKey(x => x.ClusterId)
.OnDelete(DeleteBehavior.Restrict);
// Fleet-wide unique per decision #86
e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri");
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId");
// At most one Primary per cluster
e.HasIndex(x => x.ClusterId).IsUnique()
.HasFilter("[RedundancyRole] = 'Primary'")
.HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster");
});
}
private static void ConfigureClusterNodeCredential(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ClusterNodeCredential>(e =>
{
e.ToTable("ClusterNodeCredential");
e.HasKey(x => x.CredentialId);
e.Property(x => x.CredentialId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Value).HasMaxLength(512);
e.Property(x => x.RotatedAt).HasColumnType("datetime2(3)");
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.HasOne(x => x.Node).WithMany(n => n.Credentials)
.HasForeignKey(x => x.NodeId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.NodeId, x.Enabled }).HasDatabaseName("IX_ClusterNodeCredential_NodeId");
e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
.HasFilter("[Enabled] = 1")
.HasDatabaseName("UX_ClusterNodeCredential_Value");
});
}
private static void ConfigureConfigGeneration(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ConfigGeneration>(e =>
{
e.ToTable("ConfigGeneration");
e.HasKey(x => x.GenerationId);
e.Property(x => x.GenerationId).UseIdentityColumn(seed: 1, increment: 1);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.PublishedAt).HasColumnType("datetime2(3)");
e.Property(x => x.PublishedBy).HasMaxLength(128);
e.Property(x => x.Notes).HasMaxLength(1024);
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.CreatedBy).HasMaxLength(128);
e.HasOne(x => x.Cluster).WithMany(c => c.Generations)
.HasForeignKey(x => x.ClusterId)
.OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Parent).WithMany()
.HasForeignKey(x => x.ParentGenerationId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.ClusterId, x.Status, x.GenerationId })
.IsDescending(false, false, true)
.IncludeProperties(x => x.PublishedAt)
.HasDatabaseName("IX_ConfigGeneration_Cluster_Published");
// One Draft per cluster at a time
e.HasIndex(x => x.ClusterId).IsUnique()
.HasFilter("[Status] = 'Draft'")
.HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster");
});
}
private static void ConfigureNamespace(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Namespace>(e =>
{
e.ToTable("Namespace");
e.HasKey(x => x.NamespaceRowId);
e.Property(x => x.NamespaceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.NamespaceId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.NamespaceUri).HasMaxLength(256);
e.Property(x => x.Notes).HasMaxLength(1024);
e.HasOne(x => x.Generation).WithMany()
.HasForeignKey(x => x.GenerationId)
.OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Cluster).WithMany(c => c.Namespaces)
.HasForeignKey(x => x.ClusterId)
.OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.Kind }).IsUnique()
.HasDatabaseName("UX_Namespace_Generation_Cluster_Kind");
e.HasIndex(x => new { x.GenerationId, x.NamespaceUri }).IsUnique()
.HasDatabaseName("UX_Namespace_Generation_NamespaceUri");
e.HasIndex(x => new { x.GenerationId, x.NamespaceId }).IsUnique()
.HasDatabaseName("UX_Namespace_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.NamespaceId, x.ClusterId }).IsUnique()
.HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster");
e.HasIndex(x => new { x.GenerationId, x.ClusterId })
.HasDatabaseName("IX_Namespace_Generation_Cluster");
});
}
private static void ConfigureUnsArea(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UnsArea>(e =>
{
e.ToTable("UnsArea");
e.HasKey(x => x.UnsAreaRowId);
e.Property(x => x.UnsAreaRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.UnsAreaId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(32);
e.Property(x => x.Notes).HasMaxLength(512);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_UnsArea_Generation_Cluster");
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId }).IsUnique().HasDatabaseName("UX_UnsArea_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.Name }).IsUnique().HasDatabaseName("UX_UnsArea_Generation_ClusterName");
});
}
private static void ConfigureUnsLine(ModelBuilder modelBuilder)
{
modelBuilder.Entity<UnsLine>(e =>
{
e.ToTable("UnsLine");
e.HasKey(x => x.UnsLineRowId);
e.Property(x => x.UnsLineRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.UnsLineId).HasMaxLength(64);
e.Property(x => x.UnsAreaId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(32);
e.Property(x => x.Notes).HasMaxLength(512);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId }).HasDatabaseName("IX_UnsLine_Generation_Area");
e.HasIndex(x => new { x.GenerationId, x.UnsLineId }).IsUnique().HasDatabaseName("UX_UnsLine_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId, x.Name }).IsUnique().HasDatabaseName("UX_UnsLine_Generation_AreaName");
});
}
private static void ConfigureDriverInstance(ModelBuilder modelBuilder)
{
modelBuilder.Entity<DriverInstance>(e =>
{
e.ToTable("DriverInstance", t =>
{
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson",
"ISJSON(DriverConfig) = 1");
});
e.HasKey(x => x.DriverInstanceRowId);
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.NamespaceId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DriverType).HasMaxLength(32);
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_DriverInstance_Generation_Cluster");
e.HasIndex(x => new { x.GenerationId, x.NamespaceId }).HasDatabaseName("IX_DriverInstance_Generation_Namespace");
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).IsUnique().HasDatabaseName("UX_DriverInstance_Generation_LogicalId");
});
}
private static void ConfigureDevice(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Device>(e =>
{
e.ToTable("Device", t =>
{
t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
});
e.HasKey(x => x.DeviceRowId);
e.Property(x => x.DeviceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.DeviceConfig).HasColumnType("nvarchar(max)");
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_Device_Generation_Driver");
e.HasIndex(x => new { x.GenerationId, x.DeviceId }).IsUnique().HasDatabaseName("UX_Device_Generation_LogicalId");
});
}
private static void ConfigureEquipment(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Equipment>(e =>
{
e.ToTable("Equipment");
e.HasKey(x => x.EquipmentRowId);
e.Property(x => x.EquipmentRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.UnsLineId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(32);
e.Property(x => x.MachineCode).HasMaxLength(64);
e.Property(x => x.ZTag).HasMaxLength(64);
e.Property(x => x.SAPID).HasMaxLength(64);
e.Property(x => x.Manufacturer).HasMaxLength(64);
e.Property(x => x.Model).HasMaxLength(64);
e.Property(x => x.SerialNumber).HasMaxLength(64);
e.Property(x => x.HardwareRevision).HasMaxLength(32);
e.Property(x => x.SoftwareRevision).HasMaxLength(32);
e.Property(x => x.AssetLocation).HasMaxLength(256);
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
e.Property(x => x.EquipmentClassRef).HasMaxLength(128);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_Equipment_Generation_Driver");
e.HasIndex(x => new { x.GenerationId, x.UnsLineId }).HasDatabaseName("IX_Equipment_Generation_Line");
e.HasIndex(x => new { x.GenerationId, x.EquipmentId }).IsUnique().HasDatabaseName("UX_Equipment_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_Generation_LinePath");
e.HasIndex(x => new { x.GenerationId, x.EquipmentUuid }).IsUnique().HasDatabaseName("UX_Equipment_Generation_Uuid");
e.HasIndex(x => new { x.GenerationId, x.ZTag }).HasFilter("[ZTag] IS NOT NULL").HasDatabaseName("IX_Equipment_Generation_ZTag");
e.HasIndex(x => new { x.GenerationId, x.SAPID }).HasFilter("[SAPID] IS NOT NULL").HasDatabaseName("IX_Equipment_Generation_SAPID");
e.HasIndex(x => new { x.GenerationId, x.MachineCode }).HasDatabaseName("IX_Equipment_Generation_MachineCode");
});
}
private static void ConfigureTag(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Tag>(e =>
{
e.ToTable("Tag", t =>
{
t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
});
e.HasKey(x => x.TagRowId);
e.Property(x => x.TagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.TagId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.DeviceId).HasMaxLength(64);
e.Property(x => x.EquipmentId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.Property(x => x.FolderPath).HasMaxLength(512);
e.Property(x => x.DataType).HasMaxLength(32);
e.Property(x => x.AccessLevel).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.PollGroupId).HasMaxLength(64);
e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)");
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId, x.DeviceId }).HasDatabaseName("IX_Tag_Generation_Driver_Device");
e.HasIndex(x => new { x.GenerationId, x.EquipmentId })
.HasFilter("[EquipmentId] IS NOT NULL")
.HasDatabaseName("IX_Tag_Generation_Equipment");
e.HasIndex(x => new { x.GenerationId, x.TagId }).IsUnique().HasDatabaseName("UX_Tag_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique()
.HasFilter("[EquipmentId] IS NOT NULL")
.HasDatabaseName("UX_Tag_Generation_EquipmentPath");
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId, x.FolderPath, x.Name }).IsUnique()
.HasFilter("[EquipmentId] IS NULL")
.HasDatabaseName("UX_Tag_Generation_FolderPath");
});
}
private static void ConfigurePollGroup(ModelBuilder modelBuilder)
{
modelBuilder.Entity<PollGroup>(e =>
{
e.ToTable("PollGroup", t =>
{
t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
});
e.HasKey(x => x.PollGroupRowId);
e.Property(x => x.PollGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.PollGroupId).HasMaxLength(64);
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
e.Property(x => x.Name).HasMaxLength(128);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_PollGroup_Generation_Driver");
e.HasIndex(x => new { x.GenerationId, x.PollGroupId }).IsUnique().HasDatabaseName("UX_PollGroup_Generation_LogicalId");
});
}
private static void ConfigureNodeAcl(ModelBuilder modelBuilder)
{
modelBuilder.Entity<NodeAcl>(e =>
{
e.ToTable("NodeAcl");
e.HasKey(x => x.NodeAclRowId);
e.Property(x => x.NodeAclRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.NodeAclId).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.LdapGroup).HasMaxLength(256);
e.Property(x => x.ScopeKind).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.ScopeId).HasMaxLength(64);
e.Property(x => x.PermissionFlags).HasConversion<int>();
e.Property(x => x.Notes).HasMaxLength(512);
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_NodeAcl_Generation_Cluster");
e.HasIndex(x => new { x.GenerationId, x.LdapGroup }).HasDatabaseName("IX_NodeAcl_Generation_Group");
e.HasIndex(x => new { x.GenerationId, x.ScopeKind, x.ScopeId })
.HasFilter("[ScopeId] IS NOT NULL")
.HasDatabaseName("IX_NodeAcl_Generation_Scope");
e.HasIndex(x => new { x.GenerationId, x.NodeAclId }).IsUnique().HasDatabaseName("UX_NodeAcl_Generation_LogicalId");
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique()
.HasDatabaseName("UX_NodeAcl_Generation_GroupScope");
});
}
private static void ConfigureClusterNodeGenerationState(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ClusterNodeGenerationState>(e =>
{
e.ToTable("ClusterNodeGenerationState");
e.HasKey(x => x.NodeId);
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.LastAppliedAt).HasColumnType("datetime2(3)");
e.Property(x => x.LastAppliedStatus).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.LastAppliedError).HasMaxLength(2048);
e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)");
e.HasOne(x => x.Node).WithOne(n => n.GenerationState).HasForeignKey<ClusterNodeGenerationState>(x => x.NodeId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(x => x.CurrentGeneration).WithMany().HasForeignKey(x => x.CurrentGenerationId).OnDelete(DeleteBehavior.Restrict);
e.HasIndex(x => x.CurrentGenerationId).HasDatabaseName("IX_ClusterNodeGenerationState_Generation");
});
}
private static void ConfigureConfigAuditLog(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ConfigAuditLog>(e =>
{
e.ToTable("ConfigAuditLog", t =>
{
t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson",
"DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
});
e.HasKey(x => x.AuditId);
e.Property(x => x.AuditId).UseIdentityColumn(seed: 1, increment: 1);
e.Property(x => x.Timestamp).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.Principal).HasMaxLength(128);
e.Property(x => x.EventType).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.NodeId).HasMaxLength(64);
e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
e.HasIndex(x => new { x.ClusterId, x.Timestamp })
.IsDescending(false, true)
.HasDatabaseName("IX_ConfigAuditLog_Cluster_Time");
e.HasIndex(x => x.GenerationId)
.HasFilter("[GenerationId] IS NOT NULL")
.HasDatabaseName("IX_ConfigAuditLog_Generation");
});
}
private static void ConfigureExternalIdReservation(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ExternalIdReservation>(e =>
{
e.ToTable("ExternalIdReservation");
e.HasKey(x => x.ReservationId);
e.Property(x => x.ReservationId).HasDefaultValueSql("NEWSEQUENTIALID()");
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(16);
e.Property(x => x.Value).HasMaxLength(64);
e.Property(x => x.ClusterId).HasMaxLength(64);
e.Property(x => x.FirstPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.FirstPublishedBy).HasMaxLength(128);
e.Property(x => x.LastPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
e.Property(x => x.ReleasedAt).HasColumnType("datetime2(3)");
e.Property(x => x.ReleasedBy).HasMaxLength(128);
e.Property(x => x.ReleaseReason).HasMaxLength(512);
// Active reservations unique per (Kind, Value) — filtered index lets released rows coexist with a new reservation of the same value.
// The UX_ filtered index covers active-reservation lookups; history queries over released rows
// fall back to the table scan (released rows are rare + small). No separate non-unique (Kind, Value)
// index is declared because EF Core merges duplicate column sets into a single index, which would
// clobber the filtered-unique name.
e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
.HasFilter("[ReleasedAt] IS NULL")
.HasDatabaseName("UX_ExternalIdReservation_KindValue_Active");
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
});
}
}

View File

@@ -0,0 +1,28 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// Inputs for draft validation. Contains the draft's rows plus the minimum prior-generation
/// rows needed for cross-generation invariants (EquipmentUuid stability, UnsArea identity).
/// </summary>
public sealed class DraftSnapshot
{
public required long GenerationId { get; init; }
public required string ClusterId { get; init; }
public IReadOnlyList<Namespace> Namespaces { get; init; } = [];
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
public IReadOnlyList<Device> Devices { get; init; } = [];
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
public IReadOnlyList<Tag> Tags { get; init; } = [];
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
/// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary>
public IReadOnlyList<Equipment> PriorEquipment { get; init; } = [];
/// <summary>Active reservations (<c>ReleasedAt IS NULL</c>) for pre-flight.</summary>
public IReadOnlyList<ExternalIdReservation> ActiveReservations { get; init; } = [];
}

View File

@@ -0,0 +1,176 @@
using System.Text.RegularExpressions;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// Managed-code pre-publish validator per decision #91. Complements the structural checks in
/// <c>sp_ValidateDraft</c> — this layer owns schema validation for JSON columns, UNS segment
/// regex, EquipmentId derivation, cross-cluster checks, and anything else that's uncomfortable
/// to express in T-SQL. Returns every failing rule in one pass (decision: surface all errors,
/// not just the first, so operators fix in bulk).
/// </summary>
public static class DraftValidator
{
private static readonly Regex UnsSegment = new(@"^[a-z0-9-]{1,32}$", RegexOptions.Compiled);
private const string UnsDefaultSegment = "_default";
private const int MaxPathLength = 200;
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
{
var errors = new List<ValidationError>();
ValidateUnsSegments(draft, errors);
ValidatePathLength(draft, errors);
ValidateEquipmentUuidImmutability(draft, errors);
ValidateSameClusterNamespaceBinding(draft, errors);
ValidateReservationPreflight(draft, errors);
ValidateEquipmentIdDerivation(draft, errors);
ValidateDriverNamespaceCompatibility(draft, errors);
return errors;
}
private static bool IsValidSegment(string? s) =>
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
private static void ValidateUnsSegments(DraftSnapshot draft, List<ValidationError> errors)
{
foreach (var a in draft.UnsAreas)
if (!IsValidSegment(a.Name))
errors.Add(new("UnsSegmentInvalid",
$"UnsArea.Name '{a.Name}' does not match [a-z0-9-]{{1,32}} or '_default'",
a.UnsAreaId));
foreach (var l in draft.UnsLines)
if (!IsValidSegment(l.Name))
errors.Add(new("UnsSegmentInvalid",
$"UnsLine.Name '{l.Name}' does not match [a-z0-9-]{{1,32}} or '_default'",
l.UnsLineId));
foreach (var e in draft.Equipment)
if (!IsValidSegment(e.Name))
errors.Add(new("UnsSegmentInvalid",
$"Equipment.Name '{e.Name}' does not match [a-z0-9-]{{1,32}} or '_default'",
e.EquipmentId));
}
/// <summary>Cluster.Enterprise + Site + area + line + equipment + 4 slashes ≤ 200 chars.</summary>
private static void ValidatePathLength(DraftSnapshot draft, List<ValidationError> errors)
{
// The cluster row isn't in the snapshot — we assume caller pre-validated Enterprise+Site
// length and bound them as constants <= 64 chars each. Here we validate the dynamic portion.
var areaById = draft.UnsAreas.ToDictionary(a => a.UnsAreaId);
var lineById = draft.UnsLines.ToDictionary(l => l.UnsLineId);
foreach (var eq in draft.Equipment.Where(e => e.UnsLineId is not null))
{
if (!lineById.TryGetValue(eq.UnsLineId!, out var line)) continue;
if (!areaById.TryGetValue(line.UnsAreaId, out var area)) continue;
// rough upper bound: Enterprise+Site at most 32+32; add dynamic segments + 4 slashes
var len = 32 + 32 + area.Name.Length + line.Name.Length + eq.Name.Length + 4;
if (len > MaxPathLength)
errors.Add(new("PathTooLong",
$"Equipment path exceeds {MaxPathLength} chars (approx {len})",
eq.EquipmentId));
}
}
private static void ValidateEquipmentUuidImmutability(DraftSnapshot draft, List<ValidationError> errors)
{
var priorById = draft.PriorEquipment
.GroupBy(e => e.EquipmentId)
.ToDictionary(g => g.Key, g => g.First().EquipmentUuid);
foreach (var eq in draft.Equipment)
{
if (priorById.TryGetValue(eq.EquipmentId, out var priorUuid) && priorUuid != eq.EquipmentUuid)
errors.Add(new("EquipmentUuidImmutable",
$"EquipmentId '{eq.EquipmentId}' had UUID '{priorUuid}' in a prior generation; cannot change to '{eq.EquipmentUuid}'",
eq.EquipmentId));
}
}
private static void ValidateSameClusterNamespaceBinding(DraftSnapshot draft, List<ValidationError> errors)
{
var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId);
foreach (var di in draft.DriverInstances)
{
if (!nsById.TryGetValue(di.NamespaceId, out var ns))
{
errors.Add(new("NamespaceUnresolved",
$"DriverInstance '{di.DriverInstanceId}' references unknown NamespaceId '{di.NamespaceId}'",
di.DriverInstanceId));
continue;
}
if (ns.ClusterId != di.ClusterId)
errors.Add(new("BadCrossClusterNamespaceBinding",
$"DriverInstance '{di.DriverInstanceId}' is in cluster '{di.ClusterId}' but references namespace in cluster '{ns.ClusterId}'",
di.DriverInstanceId));
}
}
private static void ValidateReservationPreflight(DraftSnapshot draft, List<ValidationError> errors)
{
var activeByKindValue = draft.ActiveReservations
.ToDictionary(r => (r.Kind, r.Value), r => r.EquipmentUuid);
foreach (var eq in draft.Equipment)
{
if (eq.ZTag is not null &&
activeByKindValue.TryGetValue((ReservationKind.ZTag, eq.ZTag), out var ztagOwner) &&
ztagOwner != eq.EquipmentUuid)
errors.Add(new("BadDuplicateExternalIdentifier",
$"ZTag '{eq.ZTag}' is already reserved by EquipmentUuid '{ztagOwner}'",
eq.EquipmentId));
if (eq.SAPID is not null &&
activeByKindValue.TryGetValue((ReservationKind.SAPID, eq.SAPID), out var sapOwner) &&
sapOwner != eq.EquipmentUuid)
errors.Add(new("BadDuplicateExternalIdentifier",
$"SAPID '{eq.SAPID}' is already reserved by EquipmentUuid '{sapOwner}'",
eq.EquipmentId));
}
}
/// <summary>Decision #125: EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID.</summary>
public static string DeriveEquipmentId(Guid uuid) =>
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
private static void ValidateEquipmentIdDerivation(DraftSnapshot draft, List<ValidationError> errors)
{
foreach (var eq in draft.Equipment)
{
var expected = DeriveEquipmentId(eq.EquipmentUuid);
if (!string.Equals(eq.EquipmentId, expected, StringComparison.Ordinal))
errors.Add(new("EquipmentIdNotDerived",
$"Equipment.EquipmentId '{eq.EquipmentId}' does not match the canonical derivation '{expected}'",
eq.EquipmentId));
}
}
private static void ValidateDriverNamespaceCompatibility(DraftSnapshot draft, List<ValidationError> errors)
{
var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId);
foreach (var di in draft.DriverInstances)
{
if (!nsById.TryGetValue(di.NamespaceId, out var ns)) continue;
var compat = ns.Kind switch
{
NamespaceKind.SystemPlatform => di.DriverType == "Galaxy",
NamespaceKind.Equipment => di.DriverType != "Galaxy",
_ => true,
};
if (!compat)
errors.Add(new("DriverNamespaceKindMismatch",
$"DriverInstance '{di.DriverInstanceId}' ({di.DriverType}) is not allowed in {ns.Kind} namespace",
di.DriverInstanceId));
}
}
}

View File

@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
/// <summary>
/// One validation failure. <see cref="Code"/> is a stable machine-readable symbol
/// (<c>BadCrossClusterNamespaceBinding</c>, <c>UnsSegmentInvalid</c>, …). <see cref="Context"/>
/// carries the offending logical ID so the Admin UI can link straight to the row.
/// </summary>
public sealed record ValidationError(string Code, string Message, string? Context = null);

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0"/>
<PackageReference Include="LiteDB" Version="5.0.21"/>
</ItemGroup>
<ItemGroup>
<!--
System.Security.Cryptography.Xml reaches this project transitively from
Microsoft.EntityFrameworkCore.Design → Microsoft.Build.Tasks.Core. EF Core Design is
marked PrivateAssets=all (design-time only, never shipped at runtime), and we do not
use XML digital signatures. Fix is only available in 11.0.0-preview. Suppress the two
advisories until a stable 11.x is released or the transitive chain is updated.
-->
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>