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:
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs
Normal file
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ApplyCallbacks.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
/// <summary>
|
||||
/// Host-supplied callbacks invoked as the applier walks the diff. Callbacks are idempotent on
|
||||
/// retry (the applier may re-invoke with the same inputs if a later stage fails — nodes
|
||||
/// register-applied to the central DB only after success). Order: namespace → driver → device →
|
||||
/// equipment → poll group → tag, with Removed before Added/Modified.
|
||||
/// </summary>
|
||||
public sealed class ApplyCallbacks
|
||||
{
|
||||
public Func<EntityChange<Namespace>, CancellationToken, Task>? OnNamespace { get; init; }
|
||||
public Func<EntityChange<DriverInstance>, CancellationToken, Task>? OnDriver { get; init; }
|
||||
public Func<EntityChange<Device>, CancellationToken, Task>? OnDevice { get; init; }
|
||||
public Func<EntityChange<Equipment>, CancellationToken, Task>? OnEquipment { get; init; }
|
||||
public Func<EntityChange<PollGroup>, CancellationToken, Task>? OnPollGroup { get; init; }
|
||||
public Func<EntityChange<Tag>, CancellationToken, Task>? OnTag { get; init; }
|
||||
}
|
||||
8
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs
Normal file
8
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/ChangeKind.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
public enum ChangeKind
|
||||
{
|
||||
Added,
|
||||
Removed,
|
||||
Modified,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
public sealed class GenerationApplier(ApplyCallbacks callbacks) : IGenerationApplier
|
||||
{
|
||||
public async Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct)
|
||||
{
|
||||
var diff = GenerationDiffer.Compute(from, to);
|
||||
var errors = new List<string>();
|
||||
|
||||
// Removed first, then Added/Modified — prevents FK dangling while cascades settle.
|
||||
await ApplyPass(diff.Tags, ChangeKind.Removed, callbacks.OnTag, errors, ct);
|
||||
await ApplyPass(diff.PollGroups, ChangeKind.Removed, callbacks.OnPollGroup, errors, ct);
|
||||
await ApplyPass(diff.Equipment, ChangeKind.Removed, callbacks.OnEquipment, errors, ct);
|
||||
await ApplyPass(diff.Devices, ChangeKind.Removed, callbacks.OnDevice, errors, ct);
|
||||
await ApplyPass(diff.Drivers, ChangeKind.Removed, callbacks.OnDriver, errors, ct);
|
||||
await ApplyPass(diff.Namespaces, ChangeKind.Removed, callbacks.OnNamespace, errors, ct);
|
||||
|
||||
foreach (var kind in new[] { ChangeKind.Added, ChangeKind.Modified })
|
||||
{
|
||||
await ApplyPass(diff.Namespaces, kind, callbacks.OnNamespace, errors, ct);
|
||||
await ApplyPass(diff.Drivers, kind, callbacks.OnDriver, errors, ct);
|
||||
await ApplyPass(diff.Devices, kind, callbacks.OnDevice, errors, ct);
|
||||
await ApplyPass(diff.Equipment, kind, callbacks.OnEquipment, errors, ct);
|
||||
await ApplyPass(diff.PollGroups, kind, callbacks.OnPollGroup, errors, ct);
|
||||
await ApplyPass(diff.Tags, kind, callbacks.OnTag, errors, ct);
|
||||
}
|
||||
|
||||
return errors.Count == 0 ? ApplyResult.Ok(diff) : ApplyResult.Fail(diff, errors);
|
||||
}
|
||||
|
||||
private static async Task ApplyPass<T>(
|
||||
IReadOnlyList<EntityChange<T>> changes,
|
||||
ChangeKind kind,
|
||||
Func<EntityChange<T>, CancellationToken, Task>? callback,
|
||||
List<string> errors,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (callback is null) return;
|
||||
|
||||
foreach (var change in changes.Where(c => c.Kind == kind))
|
||||
{
|
||||
try { await callback(change, ct); }
|
||||
catch (Exception ex) { errors.Add($"{typeof(T).Name} {change.Kind} '{change.LogicalId}': {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs
Normal file
70
src/ZB.MOM.WW.OtOpcUa.Configuration/Apply/GenerationDiff.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
/// <summary>
|
||||
/// Per-entity diff computed locally on the node. The enumerable order matches the dependency
|
||||
/// order expected by <see cref="IGenerationApplier"/>: namespace → driver → device → equipment →
|
||||
/// poll group → tag → ACL, with Removed processed before Added inside each bucket so cascades
|
||||
/// settle before new rows appear.
|
||||
/// </summary>
|
||||
public sealed record GenerationDiff(
|
||||
IReadOnlyList<EntityChange<Namespace>> Namespaces,
|
||||
IReadOnlyList<EntityChange<DriverInstance>> Drivers,
|
||||
IReadOnlyList<EntityChange<Device>> Devices,
|
||||
IReadOnlyList<EntityChange<Equipment>> Equipment,
|
||||
IReadOnlyList<EntityChange<PollGroup>> PollGroups,
|
||||
IReadOnlyList<EntityChange<Tag>> Tags);
|
||||
|
||||
public sealed record EntityChange<T>(ChangeKind Kind, string LogicalId, T? From, T? To);
|
||||
|
||||
public static class GenerationDiffer
|
||||
{
|
||||
public static GenerationDiff Compute(DraftSnapshot? from, DraftSnapshot to)
|
||||
{
|
||||
from ??= new DraftSnapshot { GenerationId = 0, ClusterId = to.ClusterId };
|
||||
|
||||
return new GenerationDiff(
|
||||
Namespaces: DiffById(from.Namespaces, to.Namespaces, x => x.NamespaceId,
|
||||
(a, b) => (a.ClusterId, a.NamespaceUri, a.Kind, a.Enabled, a.Notes)
|
||||
== (b.ClusterId, b.NamespaceUri, b.Kind, b.Enabled, b.Notes)),
|
||||
Drivers: DiffById(from.DriverInstances, to.DriverInstances, x => x.DriverInstanceId,
|
||||
(a, b) => (a.ClusterId, a.NamespaceId, a.Name, a.DriverType, a.Enabled, a.DriverConfig)
|
||||
== (b.ClusterId, b.NamespaceId, b.Name, b.DriverType, b.Enabled, b.DriverConfig)),
|
||||
Devices: DiffById(from.Devices, to.Devices, x => x.DeviceId,
|
||||
(a, b) => (a.DriverInstanceId, a.Name, a.Enabled, a.DeviceConfig)
|
||||
== (b.DriverInstanceId, b.Name, b.Enabled, b.DeviceConfig)),
|
||||
Equipment: DiffById(from.Equipment, to.Equipment, x => x.EquipmentId,
|
||||
(a, b) => (a.EquipmentUuid, a.DriverInstanceId, a.UnsLineId, a.Name, a.MachineCode, a.ZTag, a.SAPID, a.Enabled)
|
||||
== (b.EquipmentUuid, b.DriverInstanceId, b.UnsLineId, b.Name, b.MachineCode, b.ZTag, b.SAPID, b.Enabled)),
|
||||
PollGroups: DiffById(from.PollGroups, to.PollGroups, x => x.PollGroupId,
|
||||
(a, b) => (a.DriverInstanceId, a.Name, a.IntervalMs)
|
||||
== (b.DriverInstanceId, b.Name, b.IntervalMs)),
|
||||
Tags: DiffById(from.Tags, to.Tags, x => x.TagId,
|
||||
(a, b) => (a.DriverInstanceId, a.DeviceId, a.EquipmentId, a.PollGroupId, a.FolderPath, a.Name, a.DataType, a.AccessLevel, a.WriteIdempotent, a.TagConfig)
|
||||
== (b.DriverInstanceId, b.DeviceId, b.EquipmentId, b.PollGroupId, b.FolderPath, b.Name, b.DataType, b.AccessLevel, b.WriteIdempotent, b.TagConfig)));
|
||||
}
|
||||
|
||||
private static List<EntityChange<T>> DiffById<T>(
|
||||
IReadOnlyList<T> from, IReadOnlyList<T> to,
|
||||
Func<T, string> id, Func<T, T, bool> equal)
|
||||
{
|
||||
var fromById = from.ToDictionary(id);
|
||||
var toById = to.ToDictionary(id);
|
||||
var result = new List<EntityChange<T>>();
|
||||
|
||||
foreach (var (logicalId, src) in fromById.Where(kv => !toById.ContainsKey(kv.Key)))
|
||||
result.Add(new(ChangeKind.Removed, logicalId, src, default));
|
||||
|
||||
foreach (var (logicalId, dst) in toById)
|
||||
{
|
||||
if (!fromById.TryGetValue(logicalId, out var src))
|
||||
result.Add(new(ChangeKind.Added, logicalId, default, dst));
|
||||
else if (!equal(src, dst))
|
||||
result.Add(new(ChangeKind.Modified, logicalId, src, dst));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Apply;
|
||||
|
||||
/// <summary>
|
||||
/// Applies a <see cref="GenerationDiff"/> to whatever backing runtime the node owns: the OPC UA
|
||||
/// address space, driver subscriptions, the local cache, etc. The Core project wires concrete
|
||||
/// callbacks into this via <see cref="ApplyCallbacks"/> so the Configuration project stays free
|
||||
/// of a Core/Server dependency (interface independence per decision #59).
|
||||
/// </summary>
|
||||
public interface IGenerationApplier
|
||||
{
|
||||
Task<ApplyResult> ApplyAsync(DraftSnapshot? from, DraftSnapshot to, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record ApplyResult(
|
||||
bool Succeeded,
|
||||
GenerationDiff Diff,
|
||||
IReadOnlyList<string> Errors)
|
||||
{
|
||||
public static ApplyResult Ok(GenerationDiff diff) => new(true, diff, []);
|
||||
public static ApplyResult Fail(GenerationDiff diff, IReadOnlyList<string> errors) => new(false, diff, errors);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Used by <c>dotnet ef</c> at design time (migrations, scaffolding). Reads the connection string
|
||||
/// from the <c>OTOPCUA_CONFIG_CONNECTION</c> environment variable, falling back to the local dev
|
||||
/// container on <c>localhost:1433</c>.
|
||||
/// </summary>
|
||||
public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<OtOpcUaConfigDbContext>
|
||||
{
|
||||
// Host-port 14330 avoids collision with the native MSSQL14 instance on 1433 (Galaxy "ZB" DB).
|
||||
private const string DefaultConnectionString =
|
||||
"Server=localhost,14330;Database=OtOpcUaConfig;User Id=sa;Password=OtOpcUaDev_2026!;TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
public OtOpcUaConfigDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connection = Environment.GetEnvironmentVariable("OTOPCUA_CONFIG_CONNECTION")
|
||||
?? DefaultConnectionString;
|
||||
|
||||
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
|
||||
.UseSqlServer(connection, sql => sql.MigrationsAssembly(typeof(OtOpcUaConfigDbContext).Assembly.FullName))
|
||||
.Options;
|
||||
|
||||
return new OtOpcUaConfigDbContext(options);
|
||||
}
|
||||
}
|
||||
51
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs
Normal file
51
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/ClusterNode.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>Physical OPC UA server node within a <see cref="ServerCluster"/>.</summary>
|
||||
public sealed class ClusterNode
|
||||
{
|
||||
/// <summary>Stable per-machine logical ID, e.g. "LINE3-OPCUA-A".</summary>
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required RedundancyRole RedundancyRole { get; set; }
|
||||
|
||||
/// <summary>Machine hostname / IP.</summary>
|
||||
public required string Host { get; set; }
|
||||
|
||||
public int OpcUaPort { get; set; } = 4840;
|
||||
|
||||
public int DashboardPort { get; set; } = 8081;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>ApplicationUri</c> — MUST be unique per node per OPC UA spec. Clients pin trust here.
|
||||
/// Fleet-wide unique index enforces no two nodes share a value (decision #86).
|
||||
/// Stored explicitly, NOT derived from <see cref="Host"/> at runtime — silent rewrite on
|
||||
/// hostname change would break all client trust.
|
||||
/// </summary>
|
||||
public required string ApplicationUri { get; set; }
|
||||
|
||||
/// <summary>Primary = 200, Secondary = 150 by default.</summary>
|
||||
public byte ServiceLevelBase { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node override JSON keyed by DriverInstanceId, merged onto cluster-level DriverConfig
|
||||
/// at apply time. Minimal by intent (decision #81). Nullable when no overrides exist.
|
||||
/// </summary>
|
||||
public string? DriverConfigOverridesJson { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
public ICollection<ClusterNodeCredential> Credentials { get; set; } = [];
|
||||
public ClusterNodeGenerationState? GenerationState { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates a <see cref="ClusterNode"/> to the central config DB.
|
||||
/// Per decision #83 — credentials bind to NodeId, not ClusterId.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeCredential
|
||||
{
|
||||
public Guid CredentialId { get; set; }
|
||||
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
public required CredentialKind Kind { get; set; }
|
||||
|
||||
/// <summary>Login name / cert thumbprint / SID / gMSA name.</summary>
|
||||
public required string Value { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public DateTime? RotatedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
public ClusterNode? Node { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which generation each node has applied. Per-node (not per-cluster) — both nodes of a
|
||||
/// 2-node cluster track independently per decision #84.
|
||||
/// </summary>
|
||||
public sealed class ClusterNodeGenerationState
|
||||
{
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
public long? CurrentGenerationId { get; set; }
|
||||
|
||||
public DateTime? LastAppliedAt { get; set; }
|
||||
|
||||
public NodeApplyStatus? LastAppliedStatus { get; set; }
|
||||
|
||||
public string? LastAppliedError { get; set; }
|
||||
|
||||
/// <summary>Updated on every poll for liveness detection.</summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
|
||||
public ClusterNode? Node { get; set; }
|
||||
public ConfigGeneration? CurrentGeneration { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only audit log for every config write + authorization-check event. Grants revoked for
|
||||
/// UPDATE / DELETE on all principals (enforced by the authorization migration in B.3).
|
||||
/// </summary>
|
||||
public sealed class ConfigAuditLog
|
||||
{
|
||||
public long AuditId { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string Principal { get; set; }
|
||||
|
||||
/// <summary>DraftCreated | DraftEdited | Published | RolledBack | NodeApplied | CredentialAdded | CredentialDisabled | ClusterCreated | NodeAdded | ExternalIdReleased | CrossClusterNamespaceAttempt | OpcUaAccessDenied | …</summary>
|
||||
public required string EventType { get; set; }
|
||||
|
||||
public string? ClusterId { get; set; }
|
||||
|
||||
public string? NodeId { get; set; }
|
||||
|
||||
public long? GenerationId { get; set; }
|
||||
|
||||
public string? DetailsJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Atomic, immutable snapshot of one cluster's configuration.
|
||||
/// Per decision #82 — cluster-scoped, not fleet-scoped.
|
||||
/// </summary>
|
||||
public sealed class ConfigGeneration
|
||||
{
|
||||
/// <summary>Monotonically increasing ID, generated by <c>IDENTITY(1, 1)</c>.</summary>
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required GenerationStatus Status { get; set; }
|
||||
|
||||
public long? ParentGenerationId { get; set; }
|
||||
|
||||
public DateTime? PublishedAt { get; set; }
|
||||
|
||||
public string? PublishedBy { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
public ConfigGeneration? Parent { get; set; }
|
||||
}
|
||||
23
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs
Normal file
23
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>Per-device row for multi-device drivers (Modbus, AB CIP). Optional for single-device drivers.</summary>
|
||||
public sealed class Device
|
||||
{
|
||||
public Guid DeviceRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string DeviceId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="DriverInstance.DriverInstanceId"/>.</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Schemaless per-driver-type device config (host, port, unit ID, slot, etc.).</summary>
|
||||
public required string DeviceConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>One driver instance in a cluster's generation. JSON config is schemaless per-driver-type.</summary>
|
||||
public sealed class DriverInstance
|
||||
{
|
||||
public Guid DriverInstanceRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Logical FK to <see cref="Namespace.NamespaceId"/>. Same-cluster binding enforced by
|
||||
/// <c>sp_ValidateDraft</c> per decision #122: Namespace.ClusterId must equal DriverInstance.ClusterId.
|
||||
/// </summary>
|
||||
public required string NamespaceId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Galaxy | ModbusTcp | AbCip | AbLegacy | S7 | TwinCat | Focas | OpcUaClient</summary>
|
||||
public required string DriverType { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>Schemaless per-driver-type JSON config. Validated against registered JSON schema at draft-publish time (decision #91).</summary>
|
||||
public required string DriverConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
64
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs
Normal file
64
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Equipment.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// UNS level-5 entity. Only for drivers in Equipment-kind namespaces.
|
||||
/// Per decisions #109 (first-class), #116 (5-identifier model), #125 (system-generated EquipmentId),
|
||||
/// #138–139 (OPC 40010 Identification fields as first-class columns).
|
||||
/// </summary>
|
||||
public sealed class Equipment
|
||||
{
|
||||
public Guid EquipmentRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// System-generated stable internal logical ID. Format: <c>'EQ-' + first 12 hex chars of EquipmentUuid</c>.
|
||||
/// NEVER operator-supplied, NEVER in CSV imports, NEVER editable in Admin UI (decision #125).
|
||||
/// </summary>
|
||||
public required string EquipmentId { get; set; }
|
||||
|
||||
/// <summary>UUIDv4, IMMUTABLE across all generations of the same EquipmentId. Downstream-consumer join key.</summary>
|
||||
public Guid EquipmentUuid { get; set; }
|
||||
|
||||
/// <summary>Logical FK to the driver providing data for this equipment.</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
/// <summary>Optional logical FK to a multi-device driver's device.</summary>
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="UnsLine.UnsLineId"/>.</summary>
|
||||
public required string UnsLineId { get; set; }
|
||||
|
||||
/// <summary>UNS level 5 segment, matches <c>^[a-z0-9-]{1,32}$</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
// Operator-facing / external-system identifiers (decision #116)
|
||||
|
||||
/// <summary>Operator colloquial id (e.g. "machine_001"). Unique within cluster. Required.</summary>
|
||||
public required string MachineCode { get; set; }
|
||||
|
||||
/// <summary>ERP equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>. Primary browse identifier in Admin UI.</summary>
|
||||
public string? ZTag { get; set; }
|
||||
|
||||
/// <summary>SAP PM equipment id. Unique fleet-wide via <see cref="ExternalIdReservation"/>.</summary>
|
||||
public string? SAPID { get; set; }
|
||||
|
||||
// OPC UA Companion Spec OPC 40010 Machinery Identification fields (decision #139).
|
||||
// All nullable so equipment can be added before identity is fully captured.
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public string? SerialNumber { get; set; }
|
||||
public string? HardwareRevision { get; set; }
|
||||
public string? SoftwareRevision { get; set; }
|
||||
public short? YearOfConstruction { get; set; }
|
||||
public string? AssetLocation { get; set; }
|
||||
public string? ManufacturerUri { get; set; }
|
||||
public string? DeviceManualUri { get; set; }
|
||||
|
||||
/// <summary>Nullable hook for future schemas-repo template ID (decision #112).</summary>
|
||||
public string? EquipmentClassRef { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Fleet-wide rollback-safe reservation of ZTag and SAPID. Per decision #124 — NOT generation-versioned.
|
||||
/// Exists outside generation flow specifically because old generations and disabled equipment can
|
||||
/// still hold the same external IDs; per-generation uniqueness indexes fail under rollback/re-enable.
|
||||
/// </summary>
|
||||
public sealed class ExternalIdReservation
|
||||
{
|
||||
public Guid ReservationId { get; set; }
|
||||
|
||||
public required ReservationKind Kind { get; set; }
|
||||
|
||||
public required string Value { get; set; }
|
||||
|
||||
/// <summary>The equipment that owns this reservation. Stays bound even when equipment is disabled.</summary>
|
||||
public Guid EquipmentUuid { get; set; }
|
||||
|
||||
/// <summary>First cluster to publish this reservation.</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public DateTime FirstPublishedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string FirstPublishedBy { get; set; }
|
||||
|
||||
public DateTime LastPublishedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Non-null when explicitly released by FleetAdmin (audit-logged, requires reason).</summary>
|
||||
public DateTime? ReleasedAt { get; set; }
|
||||
|
||||
public string? ReleasedBy { get; set; }
|
||||
|
||||
public string? ReleaseReason { get; set; }
|
||||
}
|
||||
31
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs
Normal file
31
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Namespace.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA namespace served by a cluster. Generation-versioned per decision #123 —
|
||||
/// namespaces are content (affect what consumers see at the endpoint), not topology.
|
||||
/// </summary>
|
||||
public sealed class Namespace
|
||||
{
|
||||
public Guid NamespaceRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>Stable logical ID across generations, e.g. "LINE3-OPCUA-equipment".</summary>
|
||||
public required string NamespaceId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required NamespaceKind Kind { get; set; }
|
||||
|
||||
/// <summary>E.g. "urn:zb:warsaw-west:equipment". Unique fleet-wide per generation.</summary>
|
||||
public required string NamespaceUri { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
32
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs
Normal file
32
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One ACL grant: an LDAP group gets a set of <see cref="NodePermissions"/> at a specific scope.
|
||||
/// Generation-versioned per decision #130. See <c>acl-design.md</c> for evaluation algorithm.
|
||||
/// </summary>
|
||||
public sealed class NodeAcl
|
||||
{
|
||||
public Guid NodeAclRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string NodeAclId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required string LdapGroup { get; set; }
|
||||
|
||||
public required NodeAclScopeKind ScopeKind { get; set; }
|
||||
|
||||
/// <summary>NULL when <see cref="ScopeKind"/> = <see cref="NodeAclScopeKind.Cluster"/>; otherwise the scoped entity's logical ID.</summary>
|
||||
public string? ScopeId { get; set; }
|
||||
|
||||
/// <summary>Bitmask of <see cref="NodePermissions"/>. Stored as int in SQL.</summary>
|
||||
public required NodePermissions PermissionFlags { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs
Normal file
19
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/PollGroup.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>Driver-scoped polling group. Tags reference it via <see cref="Tag.PollGroupId"/>.</summary>
|
||||
public sealed class PollGroup
|
||||
{
|
||||
public Guid PollGroupRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string PollGroupId { get; set; }
|
||||
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
public int IntervalMs { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level deployment unit. 1 or 2 <see cref="ClusterNode"/> members.
|
||||
/// Per <c>config-db-schema.md</c> ServerCluster table.
|
||||
/// </summary>
|
||||
public sealed class ServerCluster
|
||||
{
|
||||
/// <summary>Stable logical ID, e.g. "LINE3-OPCUA".</summary>
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>UNS level 1. Canonical org value: "zb" per decision #140.</summary>
|
||||
public required string Enterprise { get; set; }
|
||||
|
||||
/// <summary>UNS level 2, e.g. "warsaw-west".</summary>
|
||||
public required string Site { get; set; }
|
||||
|
||||
public byte NodeCount { get; set; }
|
||||
|
||||
public required RedundancyMode RedundancyMode { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public required string CreatedBy { get; set; }
|
||||
|
||||
public DateTime? ModifiedAt { get; set; }
|
||||
|
||||
public string? ModifiedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
public ICollection<ClusterNode> Nodes { get; set; } = [];
|
||||
public ICollection<Namespace> Namespaces { get; set; } = [];
|
||||
public ICollection<ConfigGeneration> Generations { get; set; } = [];
|
||||
}
|
||||
47
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
Normal file
47
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One canonical tag (signal) in a cluster's generation. Per decision #110:
|
||||
/// <see cref="EquipmentId"/> is REQUIRED when the driver is in an Equipment-kind namespace
|
||||
/// and NULL when in SystemPlatform-kind namespace (Galaxy hierarchy preserved).
|
||||
/// </summary>
|
||||
public sealed class Tag
|
||||
{
|
||||
public Guid TagRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string TagId { get; set; }
|
||||
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Required when driver is in Equipment-kind namespace; NULL when in SystemPlatform-kind.
|
||||
/// Cross-table invariant enforced by sp_ValidateDraft (decision #110).
|
||||
/// </summary>
|
||||
public string? EquipmentId { get; set; }
|
||||
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Only used when <see cref="EquipmentId"/> is NULL (SystemPlatform namespace).</summary>
|
||||
public string? FolderPath { get; set; }
|
||||
|
||||
/// <summary>OPC UA built-in type name (Boolean / Int32 / Float / etc.).</summary>
|
||||
public required string DataType { get; set; }
|
||||
|
||||
public required TagAccessLevel AccessLevel { get; set; }
|
||||
|
||||
/// <summary>Per decisions #44–45 — opt-in for write retry eligibility.</summary>
|
||||
public bool WriteIdempotent { get; set; }
|
||||
|
||||
public string? PollGroupId { get; set; }
|
||||
|
||||
/// <summary>Register address / scaling / poll group / byte-order / etc. — schemaless per driver type.</summary>
|
||||
public required string TagConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>UNS level-3 segment. Generation-versioned per decision #115.</summary>
|
||||
public sealed class UnsArea
|
||||
{
|
||||
public Guid UnsAreaRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string UnsAreaId { get; set; }
|
||||
|
||||
public required string ClusterId { get; set; }
|
||||
|
||||
/// <summary>UNS level 3 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs
Normal file
21
src/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>UNS level-4 segment. Generation-versioned per decision #115.</summary>
|
||||
public sealed class UnsLine
|
||||
{
|
||||
public Guid UnsLineRowId { get; set; }
|
||||
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
public required string UnsLineId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="UnsArea.UnsAreaId"/>; resolved within the same generation.</summary>
|
||||
public required string UnsAreaId { get; set; }
|
||||
|
||||
/// <summary>UNS level 4 segment: matches <c>^[a-z0-9-]{1,32}$</c> OR equals literal <c>_default</c>.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/CredentialKind.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Credential kind for <see cref="Entities.ClusterNodeCredential"/>. Per decision #83.</summary>
|
||||
public enum CredentialKind
|
||||
{
|
||||
SqlLogin,
|
||||
ClientCertThumbprint,
|
||||
ADPrincipal,
|
||||
gMSA,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Generation lifecycle state. Draft → Published → Superseded | RolledBack.</summary>
|
||||
public enum GenerationStatus
|
||||
{
|
||||
Draft,
|
||||
Published,
|
||||
Superseded,
|
||||
RolledBack,
|
||||
}
|
||||
25
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NamespaceKind.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>OPC UA namespace kind per decision #107. One of each kind per cluster per generation.</summary>
|
||||
public enum NamespaceKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Equipment namespace — raw signals from native-protocol drivers (Modbus, AB CIP, AB Legacy,
|
||||
/// S7, TwinCAT, FOCAS, and OpcUaClient when gatewaying raw equipment). UNS 5-level hierarchy
|
||||
/// applies.
|
||||
/// </summary>
|
||||
Equipment,
|
||||
|
||||
/// <summary>
|
||||
/// System Platform namespace — Galaxy / MXAccess processed data (v1 LmxOpcUa folded in).
|
||||
/// UNS rules do NOT apply; Galaxy hierarchy preserved as v1 expressed it.
|
||||
/// </summary>
|
||||
SystemPlatform,
|
||||
|
||||
/// <summary>
|
||||
/// Reserved for future replay driver per handoff §"Digital Twin Touchpoints" — not populated
|
||||
/// in v2.0 but enum value reserved so the schema does not need to change when the replay
|
||||
/// driver lands.
|
||||
/// </summary>
|
||||
Simulated,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>ACL scope level. Per <c>acl-design.md</c> §"Scope Hierarchy".</summary>
|
||||
public enum NodeAclScopeKind
|
||||
{
|
||||
Cluster,
|
||||
Namespace,
|
||||
UnsArea,
|
||||
UnsLine,
|
||||
Equipment,
|
||||
Tag,
|
||||
}
|
||||
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs
Normal file
10
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodeApplyStatus.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Status tracked per node in <see cref="Entities.ClusterNodeGenerationState"/>.</summary>
|
||||
public enum NodeApplyStatus
|
||||
{
|
||||
Applied,
|
||||
RolledBack,
|
||||
Failed,
|
||||
InProgress,
|
||||
}
|
||||
37
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs
Normal file
37
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/NodePermissions.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA client data-path permissions per <c>acl-design.md</c>.
|
||||
/// Stored as <c>int</c> bitmask in <see cref="Entities.NodeAcl.PermissionFlags"/>.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum NodePermissions : uint
|
||||
{
|
||||
None = 0,
|
||||
|
||||
// Read-side
|
||||
Browse = 1 << 0,
|
||||
Read = 1 << 1,
|
||||
Subscribe = 1 << 2,
|
||||
HistoryRead = 1 << 3,
|
||||
|
||||
// Write-side (mirrors v1 SecurityClassification model)
|
||||
WriteOperate = 1 << 4,
|
||||
WriteTune = 1 << 5,
|
||||
WriteConfigure = 1 << 6,
|
||||
|
||||
// Alarm-side
|
||||
AlarmRead = 1 << 7,
|
||||
AlarmAcknowledge = 1 << 8,
|
||||
AlarmConfirm = 1 << 9,
|
||||
AlarmShelve = 1 << 10,
|
||||
|
||||
// OPC UA Part 4 §5.11
|
||||
MethodCall = 1 << 11,
|
||||
|
||||
// Bundles (one-click grants in Admin UI)
|
||||
ReadOnly = Browse | Read | Subscribe | HistoryRead | AlarmRead,
|
||||
Operator = ReadOnly | WriteOperate | AlarmAcknowledge | AlarmConfirm,
|
||||
Engineer = Operator | WriteTune | AlarmShelve,
|
||||
Admin = Engineer | WriteConfigure | MethodCall,
|
||||
}
|
||||
17
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyMode.cs
Normal file
17
src/ZB.MOM.WW.OtOpcUa.Configuration/Enums/RedundancyMode.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Cluster redundancy mode per OPC UA Part 5 §6.5. Persisted as string in
|
||||
/// <c>ServerCluster.RedundancyMode</c> with a CHECK constraint coupling to <c>NodeCount</c>.
|
||||
/// </summary>
|
||||
public enum RedundancyMode
|
||||
{
|
||||
/// <summary>Single-node cluster. Required when <c>NodeCount = 1</c>.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Warm redundancy (non-transparent). Two-node cluster.</summary>
|
||||
Warm,
|
||||
|
||||
/// <summary>Hot redundancy (non-transparent). Two-node cluster.</summary>
|
||||
Hot,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Per-node redundancy role within a cluster. Per decision #84.</summary>
|
||||
public enum RedundancyRole
|
||||
{
|
||||
Primary,
|
||||
Secondary,
|
||||
Standalone,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>External-ID reservation kind. Per decision #124.</summary>
|
||||
public enum ReservationKind
|
||||
{
|
||||
ZTag,
|
||||
SAPID,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>Tag-level OPC UA access level baseline. Further narrowed per-user by NodeAcl grants.</summary>
|
||||
public enum TagAccessLevel
|
||||
{
|
||||
Read,
|
||||
ReadWrite,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// A self-contained snapshot of one generation — enough to rebuild the address space on a node
|
||||
/// that has lost DB connectivity. The payload is the JSON-serialized <c>sp_GetGenerationContent</c>
|
||||
/// result; the local cache doesn't inspect the shape, it just round-trips bytes.
|
||||
/// </summary>
|
||||
public sealed class GenerationSnapshot
|
||||
{
|
||||
public int Id { get; set; } // LiteDB auto-ID
|
||||
public required string ClusterId { get; set; }
|
||||
public required long GenerationId { get; set; }
|
||||
public required DateTime CachedAt { get; set; }
|
||||
public required string PayloadJson { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node local cache of the most-recently-applied generation(s). Used to bootstrap the
|
||||
/// address space when the central DB is unreachable (decision #79 — degraded-but-running).
|
||||
/// </summary>
|
||||
public interface ILocalConfigCache
|
||||
{
|
||||
Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default);
|
||||
Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default);
|
||||
Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// LiteDB-backed <see cref="ILocalConfigCache"/>. One file per node (default
|
||||
/// <c>config_cache.db</c>), one collection per snapshot. Corruption surfaces as
|
||||
/// <see cref="LocalConfigCacheCorruptException"/> on construction or read — callers should
|
||||
/// delete and re-fetch from the central DB (decision #80).
|
||||
/// </summary>
|
||||
public sealed class LiteDbConfigCache : ILocalConfigCache, IDisposable
|
||||
{
|
||||
private const string CollectionName = "generations";
|
||||
private readonly LiteDatabase _db;
|
||||
private readonly ILiteCollection<GenerationSnapshot> _col;
|
||||
|
||||
public LiteDbConfigCache(string dbPath)
|
||||
{
|
||||
// LiteDB can be tolerant of header-only corruption at construction time (it may overwrite
|
||||
// the header and "recover"), so we force a write + read probe to fail fast on real corruption.
|
||||
try
|
||||
{
|
||||
_db = new LiteDatabase(new ConnectionString { Filename = dbPath, Upgrade = true });
|
||||
_col = _db.GetCollection<GenerationSnapshot>(CollectionName);
|
||||
_col.EnsureIndex(s => s.ClusterId);
|
||||
_col.EnsureIndex(s => s.GenerationId);
|
||||
_ = _col.Count();
|
||||
}
|
||||
catch (Exception ex) when (ex is LiteException or InvalidDataException or IOException
|
||||
or NotSupportedException or UnauthorizedAccessException
|
||||
or ArgumentOutOfRangeException or FormatException)
|
||||
{
|
||||
_db?.Dispose();
|
||||
throw new LocalConfigCacheCorruptException(
|
||||
$"LiteDB cache at '{dbPath}' is corrupt or unreadable — delete the file and refetch from the central DB.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<GenerationSnapshot?> GetMostRecentAsync(string clusterId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var snapshot = _col
|
||||
.Find(s => s.ClusterId == clusterId)
|
||||
.OrderByDescending(s => s.GenerationId)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult<GenerationSnapshot?>(snapshot);
|
||||
}
|
||||
|
||||
public Task PutAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
// upsert by (ClusterId, GenerationId) — replace in place if already cached
|
||||
var existing = _col
|
||||
.Find(s => s.ClusterId == snapshot.ClusterId && s.GenerationId == snapshot.GenerationId)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (existing is null)
|
||||
_col.Insert(snapshot);
|
||||
else
|
||||
{
|
||||
snapshot.Id = existing.Id;
|
||||
_col.Update(snapshot);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PruneOldGenerationsAsync(string clusterId, int keepLatest = 10, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var doomed = _col
|
||||
.Find(s => s.ClusterId == clusterId)
|
||||
.OrderByDescending(s => s.GenerationId)
|
||||
.Skip(keepLatest)
|
||||
.Select(s => s.Id)
|
||||
.ToList();
|
||||
|
||||
foreach (var id in doomed)
|
||||
_col.Delete(id);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
|
||||
public sealed class LocalConfigCacheCorruptException(string message, Exception inner)
|
||||
: Exception(message, inner);
|
||||
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.Designer.cs
generated
Normal file
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConfigAuditLog",
|
||||
columns: table => new
|
||||
{
|
||||
AuditId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Timestamp = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
Principal = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
EventType = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: true),
|
||||
DetailsJson = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConfigAuditLog", x => x.AuditId);
|
||||
table.CheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson", "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ExternalIdReservation",
|
||||
columns: table => new
|
||||
{
|
||||
ReservationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
Kind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
FirstPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
FirstPublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
LastPublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
ReleasedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
ReleasedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
ReleaseReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ExternalIdReservation", x => x.ReservationId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ServerCluster",
|
||||
columns: table => new
|
||||
{
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Enterprise = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Site = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
NodeCount = table.Column<byte>(type: "tinyint", nullable: false),
|
||||
RedundancyMode = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
ModifiedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
ModifiedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ServerCluster", x => x.ClusterId);
|
||||
table.CheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount", "((NodeCount = 1 AND RedundancyMode = 'None') OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ClusterNode",
|
||||
columns: table => new
|
||||
{
|
||||
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
RedundancyRole = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
Host = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
|
||||
OpcUaPort = table.Column<int>(type: "int", nullable: false),
|
||||
DashboardPort = table.Column<int>(type: "int", nullable: false),
|
||||
ApplicationUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
ServiceLevelBase = table.Column<byte>(type: "tinyint", nullable: false),
|
||||
DriverConfigOverridesJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ClusterNode", x => x.NodeId);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClusterNode_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ConfigGeneration",
|
||||
columns: table => new
|
||||
{
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
ParentGenerationId = table.Column<long>(type: "bigint", nullable: true),
|
||||
PublishedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
PublishedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ConfigGeneration", x => x.GenerationId);
|
||||
table.ForeignKey(
|
||||
name: "FK_ConfigGeneration_ConfigGeneration_ParentGenerationId",
|
||||
column: x => x.ParentGenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ConfigGeneration_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ClusterNodeCredential",
|
||||
columns: table => new
|
||||
{
|
||||
CredentialId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
RotatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ClusterNodeCredential", x => x.CredentialId);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClusterNodeCredential_ClusterNode_NodeId",
|
||||
column: x => x.NodeId,
|
||||
principalTable: "ClusterNode",
|
||||
principalColumn: "NodeId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ClusterNodeGenerationState",
|
||||
columns: table => new
|
||||
{
|
||||
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CurrentGenerationId = table.Column<long>(type: "bigint", nullable: true),
|
||||
LastAppliedAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
LastAppliedStatus = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: true),
|
||||
LastAppliedError = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
|
||||
LastSeenAt = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ClusterNodeGenerationState", x => x.NodeId);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClusterNodeGenerationState_ClusterNode_NodeId",
|
||||
column: x => x.NodeId,
|
||||
principalTable: "ClusterNode",
|
||||
principalColumn: "NodeId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_ClusterNodeGenerationState_ConfigGeneration_CurrentGenerationId",
|
||||
column: x => x.CurrentGenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Device",
|
||||
columns: table => new
|
||||
{
|
||||
DeviceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeviceConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Device", x => x.DeviceRowId);
|
||||
table.CheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_Device_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DriverInstance",
|
||||
columns: table => new
|
||||
{
|
||||
DriverInstanceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
DriverType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
DriverConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DriverInstance", x => x.DriverInstanceRowId);
|
||||
table.CheckConstraint("CK_DriverInstance_DriverConfig_IsJson", "ISJSON(DriverConfig) = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_DriverInstance_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_DriverInstance_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Equipment",
|
||||
columns: table => new
|
||||
{
|
||||
EquipmentRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
EquipmentUuid = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
MachineCode = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ZTag = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
SAPID = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Manufacturer = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Model = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
SerialNumber = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
HardwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
|
||||
SoftwareRevision = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: true),
|
||||
YearOfConstruction = table.Column<short>(type: "smallint", nullable: true),
|
||||
AssetLocation = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
EquipmentClassRef = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Equipment", x => x.EquipmentRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Equipment_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Namespace",
|
||||
columns: table => new
|
||||
{
|
||||
NamespaceRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
NamespaceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Kind = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
NamespaceUri = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Namespace", x => x.NamespaceRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Namespace_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_Namespace_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "NodeAcl",
|
||||
columns: table => new
|
||||
{
|
||||
NodeAclRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
NodeAclId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
LdapGroup = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
ScopeKind = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
ScopeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
PermissionFlags = table.Column<int>(type: "int", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_NodeAcl", x => x.NodeAclRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_NodeAcl_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PollGroup",
|
||||
columns: table => new
|
||||
{
|
||||
PollGroupRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
IntervalMs = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PollGroup", x => x.PollGroupRowId);
|
||||
table.CheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
|
||||
table.ForeignKey(
|
||||
name: "FK_PollGroup_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Tag",
|
||||
columns: table => new
|
||||
{
|
||||
TagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
TagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
DeviceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
FolderPath = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
AccessLevel = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
WriteIdempotent = table.Column<bool>(type: "bit", nullable: false),
|
||||
PollGroupId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
TagConfig = table.Column<string>(type: "nvarchar(max)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Tag", x => x.TagRowId);
|
||||
table.CheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
|
||||
table.ForeignKey(
|
||||
name: "FK_Tag_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UnsArea",
|
||||
columns: table => new
|
||||
{
|
||||
UnsAreaRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UnsArea", x => x.UnsAreaRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_UnsArea_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_UnsArea_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UnsLine",
|
||||
columns: table => new
|
||||
{
|
||||
UnsLineRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
UnsLineId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
UnsAreaId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UnsLine", x => x.UnsLineRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_UnsLine_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ClusterNode_ApplicationUri",
|
||||
table: "ClusterNode",
|
||||
column: "ApplicationUri",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ClusterNode_Primary_Per_Cluster",
|
||||
table: "ClusterNode",
|
||||
column: "ClusterId",
|
||||
unique: true,
|
||||
filter: "[RedundancyRole] = 'Primary'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClusterNodeCredential_NodeId",
|
||||
table: "ClusterNodeCredential",
|
||||
columns: new[] { "NodeId", "Enabled" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ClusterNodeCredential_Value",
|
||||
table: "ClusterNodeCredential",
|
||||
columns: new[] { "Kind", "Value" },
|
||||
unique: true,
|
||||
filter: "[Enabled] = 1");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ClusterNodeGenerationState_Generation",
|
||||
table: "ClusterNodeGenerationState",
|
||||
column: "CurrentGenerationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConfigAuditLog_Cluster_Time",
|
||||
table: "ConfigAuditLog",
|
||||
columns: new[] { "ClusterId", "Timestamp" },
|
||||
descending: new[] { false, true });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConfigAuditLog_Generation",
|
||||
table: "ConfigAuditLog",
|
||||
column: "GenerationId",
|
||||
filter: "[GenerationId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConfigGeneration_Cluster_Published",
|
||||
table: "ConfigGeneration",
|
||||
columns: new[] { "ClusterId", "Status", "GenerationId" },
|
||||
descending: new[] { false, false, true })
|
||||
.Annotation("SqlServer:Include", new[] { "PublishedAt" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ConfigGeneration_ParentGenerationId",
|
||||
table: "ConfigGeneration",
|
||||
column: "ParentGenerationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ConfigGeneration_Draft_Per_Cluster",
|
||||
table: "ConfigGeneration",
|
||||
column: "ClusterId",
|
||||
unique: true,
|
||||
filter: "[Status] = 'Draft'");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Device_Generation_Driver",
|
||||
table: "Device",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Device_Generation_LogicalId",
|
||||
table: "Device",
|
||||
columns: new[] { "GenerationId", "DeviceId" },
|
||||
unique: true,
|
||||
filter: "[DeviceId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverInstance_ClusterId",
|
||||
table: "DriverInstance",
|
||||
column: "ClusterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverInstance_Generation_Cluster",
|
||||
table: "DriverInstance",
|
||||
columns: new[] { "GenerationId", "ClusterId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverInstance_Generation_Namespace",
|
||||
table: "DriverInstance",
|
||||
columns: new[] { "GenerationId", "NamespaceId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_DriverInstance_Generation_LogicalId",
|
||||
table: "DriverInstance",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId" },
|
||||
unique: true,
|
||||
filter: "[DriverInstanceId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Equipment_Generation_Driver",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Equipment_Generation_Line",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "UnsLineId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Equipment_Generation_MachineCode",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "MachineCode" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Equipment_Generation_SAPID",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "SAPID" },
|
||||
filter: "[SAPID] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Equipment_Generation_ZTag",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "ZTag" },
|
||||
filter: "[ZTag] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Equipment_Generation_LinePath",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "UnsLineId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Equipment_Generation_LogicalId",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "EquipmentId" },
|
||||
unique: true,
|
||||
filter: "[EquipmentId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Equipment_Generation_Uuid",
|
||||
table: "Equipment",
|
||||
columns: new[] { "GenerationId", "EquipmentUuid" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ExternalIdReservation_Equipment",
|
||||
table: "ExternalIdReservation",
|
||||
column: "EquipmentUuid");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ExternalIdReservation_KindValue_Active",
|
||||
table: "ExternalIdReservation",
|
||||
columns: new[] { "Kind", "Value" },
|
||||
unique: true,
|
||||
filter: "[ReleasedAt] IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Namespace_ClusterId",
|
||||
table: "Namespace",
|
||||
column: "ClusterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Namespace_Generation_Cluster",
|
||||
table: "Namespace",
|
||||
columns: new[] { "GenerationId", "ClusterId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Namespace_Generation_Cluster_Kind",
|
||||
table: "Namespace",
|
||||
columns: new[] { "GenerationId", "ClusterId", "Kind" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Namespace_Generation_LogicalId",
|
||||
table: "Namespace",
|
||||
columns: new[] { "GenerationId", "NamespaceId" },
|
||||
unique: true,
|
||||
filter: "[NamespaceId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Namespace_Generation_LogicalId_Cluster",
|
||||
table: "Namespace",
|
||||
columns: new[] { "GenerationId", "NamespaceId", "ClusterId" },
|
||||
unique: true,
|
||||
filter: "[NamespaceId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Namespace_Generation_NamespaceUri",
|
||||
table: "Namespace",
|
||||
columns: new[] { "GenerationId", "NamespaceUri" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NodeAcl_Generation_Cluster",
|
||||
table: "NodeAcl",
|
||||
columns: new[] { "GenerationId", "ClusterId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NodeAcl_Generation_Group",
|
||||
table: "NodeAcl",
|
||||
columns: new[] { "GenerationId", "LdapGroup" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_NodeAcl_Generation_Scope",
|
||||
table: "NodeAcl",
|
||||
columns: new[] { "GenerationId", "ScopeKind", "ScopeId" },
|
||||
filter: "[ScopeId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_NodeAcl_Generation_GroupScope",
|
||||
table: "NodeAcl",
|
||||
columns: new[] { "GenerationId", "ClusterId", "LdapGroup", "ScopeKind", "ScopeId" },
|
||||
unique: true,
|
||||
filter: "[ScopeId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_NodeAcl_Generation_LogicalId",
|
||||
table: "NodeAcl",
|
||||
columns: new[] { "GenerationId", "NodeAclId" },
|
||||
unique: true,
|
||||
filter: "[NodeAclId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PollGroup_Generation_Driver",
|
||||
table: "PollGroup",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_PollGroup_Generation_LogicalId",
|
||||
table: "PollGroup",
|
||||
columns: new[] { "GenerationId", "PollGroupId" },
|
||||
unique: true,
|
||||
filter: "[PollGroupId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ServerCluster_Site",
|
||||
table: "ServerCluster",
|
||||
column: "Site");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ServerCluster_Name",
|
||||
table: "ServerCluster",
|
||||
column: "Name",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tag_Generation_Driver_Device",
|
||||
table: "Tag",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId", "DeviceId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tag_Generation_Equipment",
|
||||
table: "Tag",
|
||||
columns: new[] { "GenerationId", "EquipmentId" },
|
||||
filter: "[EquipmentId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Tag_Generation_EquipmentPath",
|
||||
table: "Tag",
|
||||
columns: new[] { "GenerationId", "EquipmentId", "Name" },
|
||||
unique: true,
|
||||
filter: "[EquipmentId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Tag_Generation_FolderPath",
|
||||
table: "Tag",
|
||||
columns: new[] { "GenerationId", "DriverInstanceId", "FolderPath", "Name" },
|
||||
unique: true,
|
||||
filter: "[EquipmentId] IS NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Tag_Generation_LogicalId",
|
||||
table: "Tag",
|
||||
columns: new[] { "GenerationId", "TagId" },
|
||||
unique: true,
|
||||
filter: "[TagId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UnsArea_ClusterId",
|
||||
table: "UnsArea",
|
||||
column: "ClusterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UnsArea_Generation_Cluster",
|
||||
table: "UnsArea",
|
||||
columns: new[] { "GenerationId", "ClusterId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_UnsArea_Generation_ClusterName",
|
||||
table: "UnsArea",
|
||||
columns: new[] { "GenerationId", "ClusterId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_UnsArea_Generation_LogicalId",
|
||||
table: "UnsArea",
|
||||
columns: new[] { "GenerationId", "UnsAreaId" },
|
||||
unique: true,
|
||||
filter: "[UnsAreaId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UnsLine_Generation_Area",
|
||||
table: "UnsLine",
|
||||
columns: new[] { "GenerationId", "UnsAreaId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_UnsLine_Generation_AreaName",
|
||||
table: "UnsLine",
|
||||
columns: new[] { "GenerationId", "UnsAreaId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_UnsLine_Generation_LogicalId",
|
||||
table: "UnsLine",
|
||||
columns: new[] { "GenerationId", "UnsLineId" },
|
||||
unique: true,
|
||||
filter: "[UnsLineId] IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ClusterNodeCredential");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ClusterNodeGenerationState");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ConfigAuditLog");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Device");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "DriverInstance");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Equipment");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ExternalIdReservation");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Namespace");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "NodeAcl");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PollGroup");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Tag");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UnsArea");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UnsLine");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ClusterNode");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ConfigGeneration");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ServerCluster");
|
||||
}
|
||||
}
|
||||
}
|
||||
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.Designer.cs
generated
Normal file
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,473 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Stored procedures per <c>config-db-schema.md §"Stored Procedures"</c>. All node + admin DB
|
||||
/// access funnels through these — direct table writes are revoked in the AuthorizationGrants
|
||||
/// migration that follows. CREATE OR ALTER style so procs version with the schema.
|
||||
/// </summary>
|
||||
public partial class StoredProcedures : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.GetCurrentGenerationForCluster);
|
||||
migrationBuilder.Sql(Procs.GetGenerationContent);
|
||||
migrationBuilder.Sql(Procs.RegisterNodeGenerationApplied);
|
||||
migrationBuilder.Sql(Procs.ValidateDraft);
|
||||
migrationBuilder.Sql(Procs.PublishGeneration);
|
||||
migrationBuilder.Sql(Procs.RollbackToGeneration);
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiff);
|
||||
migrationBuilder.Sql(Procs.ReleaseExternalIdReservation);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
foreach (var name in new[]
|
||||
{
|
||||
"sp_ReleaseExternalIdReservation", "sp_ComputeGenerationDiff", "sp_RollbackToGeneration",
|
||||
"sp_PublishGeneration", "sp_ValidateDraft", "sp_RegisterNodeGenerationApplied",
|
||||
"sp_GetGenerationContent", "sp_GetCurrentGenerationForCluster",
|
||||
})
|
||||
{
|
||||
migrationBuilder.Sql($"IF OBJECT_ID(N'dbo.{name}', N'P') IS NOT NULL DROP PROCEDURE dbo.{name};");
|
||||
}
|
||||
}
|
||||
|
||||
private static class Procs
|
||||
{
|
||||
public const string GetCurrentGenerationForCluster = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster
|
||||
@NodeId nvarchar(64),
|
||||
@ClusterId nvarchar(64)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM dbo.ClusterNodeCredential
|
||||
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
|
||||
BEGIN
|
||||
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM dbo.ClusterNode
|
||||
WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1)
|
||||
BEGIN
|
||||
RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SELECT TOP 1 GenerationId, ClusterId, Status, PublishedAt, PublishedBy, Notes
|
||||
FROM dbo.ConfigGeneration
|
||||
WHERE ClusterId = @ClusterId AND Status = 'Published'
|
||||
ORDER BY GenerationId DESC;
|
||||
END
|
||||
";
|
||||
|
||||
public const string GetGenerationContent = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent
|
||||
@NodeId nvarchar(64),
|
||||
@GenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
|
||||
DECLARE @ClusterId nvarchar(64);
|
||||
|
||||
SELECT @ClusterId = ClusterId FROM dbo.ConfigGeneration WHERE GenerationId = @GenerationId;
|
||||
|
||||
IF @ClusterId IS NULL
|
||||
BEGIN
|
||||
RAISERROR('GenerationId %I64d not found', 16, 1, @GenerationId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.ClusterNodeCredential c
|
||||
JOIN dbo.ClusterNode n ON n.NodeId = c.NodeId
|
||||
WHERE c.NodeId = @NodeId AND c.Value = @Caller AND c.Enabled = 1
|
||||
AND n.ClusterId = @ClusterId AND n.Enabled = 1)
|
||||
BEGIN
|
||||
RAISERROR('Forbidden: caller %s not bound to a node in ClusterId %s', 16, 1, @Caller, @ClusterId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
SELECT * FROM dbo.Namespace WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.UnsArea WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.UnsLine WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.DriverInstance WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.Device WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.Equipment WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.PollGroup WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.Tag WHERE GenerationId = @GenerationId;
|
||||
SELECT * FROM dbo.NodeAcl WHERE GenerationId = @GenerationId;
|
||||
END
|
||||
";
|
||||
|
||||
public const string RegisterNodeGenerationApplied = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_RegisterNodeGenerationApplied
|
||||
@NodeId nvarchar(64),
|
||||
@GenerationId bigint,
|
||||
@Status nvarchar(16),
|
||||
@Error nvarchar(max) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @Caller nvarchar(128) = SUSER_SNAME();
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM dbo.ClusterNodeCredential
|
||||
WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
|
||||
BEGIN
|
||||
RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
MERGE dbo.ClusterNodeGenerationState AS tgt
|
||||
USING (SELECT @NodeId AS NodeId) AS src ON tgt.NodeId = src.NodeId
|
||||
WHEN MATCHED THEN UPDATE SET
|
||||
CurrentGenerationId = @GenerationId,
|
||||
LastAppliedAt = SYSUTCDATETIME(),
|
||||
LastAppliedStatus = @Status,
|
||||
LastAppliedError = @Error,
|
||||
LastSeenAt = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED THEN INSERT
|
||||
(NodeId, CurrentGenerationId, LastAppliedAt, LastAppliedStatus, LastAppliedError, LastSeenAt)
|
||||
VALUES (@NodeId, @GenerationId, SYSUTCDATETIME(), @Status, @Error, SYSUTCDATETIME());
|
||||
|
||||
INSERT dbo.ConfigAuditLog (Principal, EventType, NodeId, GenerationId, DetailsJson)
|
||||
VALUES (@Caller, 'NodeApplied', @NodeId, @GenerationId,
|
||||
CONCAT('{""status"":""', @Status, '""}'));
|
||||
END
|
||||
";
|
||||
|
||||
public const string ValidateDraft = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ValidateDraft
|
||||
@DraftGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
DECLARE @ClusterId nvarchar(64);
|
||||
DECLARE @Status nvarchar(16);
|
||||
|
||||
SELECT @ClusterId = ClusterId, @Status = Status
|
||||
FROM dbo.ConfigGeneration WHERE GenerationId = @DraftGenerationId;
|
||||
|
||||
IF @ClusterId IS NULL
|
||||
BEGIN
|
||||
RAISERROR('GenerationId %I64d not found', 16, 1, @DraftGenerationId);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF @Status <> 'Draft'
|
||||
BEGIN
|
||||
RAISERROR('GenerationId %I64d is not in Draft status (current=%s)', 16, 1, @DraftGenerationId, @Status);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM dbo.Tag t
|
||||
LEFT JOIN dbo.DriverInstance d ON d.GenerationId = t.GenerationId AND d.DriverInstanceId = t.DriverInstanceId
|
||||
WHERE t.GenerationId = @DraftGenerationId AND d.DriverInstanceId IS NULL)
|
||||
BEGIN
|
||||
RAISERROR('Draft has tags with unresolved DriverInstanceId', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM dbo.Tag t
|
||||
LEFT JOIN dbo.Device dv ON dv.GenerationId = t.GenerationId AND dv.DeviceId = t.DeviceId
|
||||
WHERE t.GenerationId = @DraftGenerationId AND t.DeviceId IS NOT NULL AND dv.DeviceId IS NULL)
|
||||
BEGIN
|
||||
RAISERROR('Draft has tags with unresolved DeviceId', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM dbo.Tag t
|
||||
LEFT JOIN dbo.PollGroup pg ON pg.GenerationId = t.GenerationId AND pg.PollGroupId = t.PollGroupId
|
||||
WHERE t.GenerationId = @DraftGenerationId AND t.PollGroupId IS NOT NULL AND pg.PollGroupId IS NULL)
|
||||
BEGIN
|
||||
RAISERROR('Draft has tags with unresolved PollGroupId', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.DriverInstance di
|
||||
JOIN dbo.Namespace ns ON ns.GenerationId = di.GenerationId AND ns.NamespaceId = di.NamespaceId
|
||||
WHERE di.GenerationId = @DraftGenerationId
|
||||
AND ns.ClusterId <> di.ClusterId)
|
||||
BEGIN
|
||||
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
|
||||
VALUES (SUSER_SNAME(), 'CrossClusterNamespaceAttempt', @ClusterId, @DraftGenerationId);
|
||||
RAISERROR('BadCrossClusterNamespaceBinding: namespace and driver must belong to the same cluster', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.Equipment draft
|
||||
JOIN dbo.Equipment prior
|
||||
ON prior.EquipmentId = draft.EquipmentId
|
||||
AND prior.EquipmentUuid <> draft.EquipmentUuid
|
||||
AND prior.GenerationId <> draft.GenerationId
|
||||
JOIN dbo.ConfigGeneration pg ON pg.GenerationId = prior.GenerationId
|
||||
WHERE draft.GenerationId = @DraftGenerationId
|
||||
AND pg.ClusterId = @ClusterId)
|
||||
BEGIN
|
||||
RAISERROR('EquipmentUuid immutability violated for an EquipmentId that existed in a prior generation', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.Equipment draft
|
||||
JOIN dbo.ExternalIdReservation r
|
||||
ON r.Kind = 'ZTag' AND r.Value = draft.ZTag AND r.ReleasedAt IS NULL
|
||||
AND r.EquipmentUuid <> draft.EquipmentUuid
|
||||
WHERE draft.GenerationId = @DraftGenerationId AND draft.ZTag IS NOT NULL)
|
||||
BEGIN
|
||||
RAISERROR('BadDuplicateExternalIdentifier: a ZTag in the draft is reserved by a different EquipmentUuid', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM dbo.Equipment draft
|
||||
JOIN dbo.ExternalIdReservation r
|
||||
ON r.Kind = 'SAPID' AND r.Value = draft.SAPID AND r.ReleasedAt IS NULL
|
||||
AND r.EquipmentUuid <> draft.EquipmentUuid
|
||||
WHERE draft.GenerationId = @DraftGenerationId AND draft.SAPID IS NOT NULL)
|
||||
BEGIN
|
||||
RAISERROR('BadDuplicateExternalIdentifier: a SAPID in the draft is reserved by a different EquipmentUuid', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
END
|
||||
";
|
||||
|
||||
public const string PublishGeneration = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_PublishGeneration
|
||||
@ClusterId nvarchar(64),
|
||||
@DraftGenerationId bigint,
|
||||
@Notes nvarchar(1024) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DECLARE @Lock nvarchar(255) = N'OtOpcUa_Publish_' + @ClusterId;
|
||||
DECLARE @LockResult int;
|
||||
EXEC @LockResult = sp_getapplock @Resource = @Lock, @LockMode = 'Exclusive', @LockTimeout = 0;
|
||||
IF @LockResult < 0
|
||||
BEGIN
|
||||
RAISERROR('PublishConflict: another publish is in progress for cluster %s', 16, 1, @ClusterId);
|
||||
ROLLBACK;
|
||||
RETURN;
|
||||
END
|
||||
|
||||
EXEC dbo.sp_ValidateDraft @DraftGenerationId = @DraftGenerationId;
|
||||
|
||||
MERGE dbo.ExternalIdReservation AS tgt
|
||||
USING (
|
||||
SELECT 'ZTag' AS Kind, ZTag AS Value, EquipmentUuid
|
||||
FROM dbo.Equipment
|
||||
WHERE GenerationId = @DraftGenerationId AND ZTag IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT 'SAPID', SAPID, EquipmentUuid
|
||||
FROM dbo.Equipment
|
||||
WHERE GenerationId = @DraftGenerationId AND SAPID IS NOT NULL
|
||||
) AS src
|
||||
ON tgt.Kind = src.Kind AND tgt.Value = src.Value AND tgt.EquipmentUuid = src.EquipmentUuid
|
||||
WHEN MATCHED THEN UPDATE SET LastPublishedAt = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED BY TARGET THEN
|
||||
INSERT (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt)
|
||||
VALUES (src.Kind, src.Value, src.EquipmentUuid, @ClusterId, SUSER_SNAME(), SYSUTCDATETIME());
|
||||
|
||||
UPDATE dbo.ConfigGeneration
|
||||
SET Status = 'Superseded'
|
||||
WHERE ClusterId = @ClusterId AND Status = 'Published';
|
||||
|
||||
UPDATE dbo.ConfigGeneration
|
||||
SET Status = 'Published',
|
||||
PublishedAt = SYSUTCDATETIME(),
|
||||
PublishedBy = SUSER_SNAME(),
|
||||
Notes = ISNULL(@Notes, Notes)
|
||||
WHERE GenerationId = @DraftGenerationId AND ClusterId = @ClusterId AND Status = 'Draft';
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
RAISERROR('Draft %I64d for cluster %s not found (was it already published?)', 16, 1, @DraftGenerationId, @ClusterId);
|
||||
ROLLBACK;
|
||||
RETURN;
|
||||
END
|
||||
|
||||
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
|
||||
VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);
|
||||
|
||||
COMMIT;
|
||||
END
|
||||
";
|
||||
|
||||
public const string RollbackToGeneration = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_RollbackToGeneration
|
||||
@ClusterId nvarchar(64),
|
||||
@TargetGenerationId bigint,
|
||||
@Notes nvarchar(1024) = NULL
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM dbo.ConfigGeneration
|
||||
WHERE GenerationId = @TargetGenerationId AND ClusterId = @ClusterId
|
||||
AND Status IN ('Published', 'Superseded'))
|
||||
BEGIN
|
||||
RAISERROR('Target generation %I64d not found or not rollback-eligible', 16, 1, @TargetGenerationId);
|
||||
ROLLBACK; RETURN;
|
||||
END
|
||||
|
||||
DECLARE @NewGenId bigint;
|
||||
INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy, PublishedAt, PublishedBy, Notes)
|
||||
VALUES (@ClusterId, 'Draft', SYSUTCDATETIME(), SUSER_SNAME(), NULL, NULL,
|
||||
ISNULL(@Notes, CONCAT('Rollback clone of generation ', @TargetGenerationId)));
|
||||
SET @NewGenId = SCOPE_IDENTITY();
|
||||
|
||||
INSERT dbo.Namespace (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes)
|
||||
SELECT @NewGenId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes FROM dbo.Namespace WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.UnsArea (GenerationId, UnsAreaId, ClusterId, Name, Notes)
|
||||
SELECT @NewGenId, UnsAreaId, ClusterId, Name, Notes FROM dbo.UnsArea WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.UnsLine (GenerationId, UnsLineId, UnsAreaId, Name, Notes)
|
||||
SELECT @NewGenId, UnsLineId, UnsAreaId, Name, Notes FROM dbo.UnsLine WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
|
||||
SELECT @NewGenId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig FROM dbo.DriverInstance WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.Device (GenerationId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
|
||||
SELECT @NewGenId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig FROM dbo.Device WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.Equipment (GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled)
|
||||
SELECT @NewGenId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled FROM dbo.Equipment WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.PollGroup (GenerationId, PollGroupId, DriverInstanceId, Name, IntervalMs)
|
||||
SELECT @NewGenId, PollGroupId, DriverInstanceId, Name, IntervalMs FROM dbo.PollGroup WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.Tag (GenerationId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
|
||||
SELECT @NewGenId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig FROM dbo.Tag WHERE GenerationId = @TargetGenerationId;
|
||||
INSERT dbo.NodeAcl (GenerationId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes)
|
||||
SELECT @NewGenId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes FROM dbo.NodeAcl WHERE GenerationId = @TargetGenerationId;
|
||||
|
||||
EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @NewGenId, @Notes = @Notes;
|
||||
|
||||
INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId, DetailsJson)
|
||||
VALUES (SUSER_SNAME(), 'RolledBack', @ClusterId, @NewGenId,
|
||||
CONCAT('{""rolledBackTo"":', @TargetGenerationId, '}'));
|
||||
|
||||
COMMIT;
|
||||
END
|
||||
";
|
||||
|
||||
public const string ComputeGenerationDiff = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
|
||||
public const string ReleaseExternalIdReservation = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
|
||||
@Kind nvarchar(16),
|
||||
@Value nvarchar(64),
|
||||
@ReleaseReason nvarchar(512)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0
|
||||
BEGIN
|
||||
RAISERROR('ReleaseReason is required', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
UPDATE dbo.ExternalIdReservation
|
||||
SET ReleasedAt = SYSUTCDATETIME(),
|
||||
ReleasedBy = SUSER_SNAME(),
|
||||
ReleaseReason = @ReleaseReason
|
||||
WHERE Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL;
|
||||
|
||||
IF @@ROWCOUNT = 0
|
||||
BEGIN
|
||||
RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
|
||||
VALUES (SUSER_SNAME(), 'ExternalIdReleased',
|
||||
CONCAT('{""kind"":""', @Kind, '"",""value"":""', @Value, '""}'));
|
||||
END
|
||||
";
|
||||
}
|
||||
}
|
||||
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.Designer.cs
generated
Normal file
1208
src/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the two DB roles per <c>config-db-schema.md §"Authorization Model"</c> and grants
|
||||
/// EXECUTE on the appropriate stored procedures. Deliberately grants no direct table DML — all
|
||||
/// writes funnel through the procs, which authenticate via <c>SUSER_SNAME()</c>.
|
||||
/// Principals (SQL logins, gMSA users, cert-mapped users) are provisioned by the DBA outside
|
||||
/// this migration and then added to one of the two roles.
|
||||
/// </summary>
|
||||
public partial class AuthorizationGrants : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NULL
|
||||
CREATE ROLE OtOpcUaNode;
|
||||
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NULL
|
||||
CREATE ROLE OtOpcUaAdmin;
|
||||
");
|
||||
|
||||
migrationBuilder.Sql(@"
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaNode;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaNode;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_RegisterNodeGenerationApplied TO OtOpcUaNode;
|
||||
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_GetCurrentGenerationForCluster TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_GetGenerationContent TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_ValidateDraft TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_PublishGeneration TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_RollbackToGeneration TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_ComputeGenerationDiff TO OtOpcUaAdmin;
|
||||
GRANT EXECUTE ON OBJECT::dbo.sp_ReleaseExternalIdReservation TO OtOpcUaAdmin;
|
||||
|
||||
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaNode;
|
||||
DENY UPDATE, DELETE, INSERT ON SCHEMA::dbo TO OtOpcUaAdmin;
|
||||
DENY SELECT ON SCHEMA::dbo TO OtOpcUaNode;
|
||||
-- Admins may SELECT for reporting views in the future — grant views explicitly, not the schema.
|
||||
DENY SELECT ON SCHEMA::dbo TO OtOpcUaAdmin;
|
||||
");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"
|
||||
IF DATABASE_PRINCIPAL_ID('OtOpcUaNode') IS NOT NULL
|
||||
DROP ROLE OtOpcUaNode;
|
||||
IF DATABASE_PRINCIPAL_ID('OtOpcUaAdmin') IS NOT NULL
|
||||
DROP ROLE OtOpcUaAdmin;
|
||||
");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
487
src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
Normal file
487
src/ZB.MOM.WW.OtOpcUa.Configuration/OtOpcUaConfigDbContext.cs
Normal file
@@ -0,0 +1,487 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Central config DB context. Schema matches <c>docs/v2/config-db-schema.md</c> exactly —
|
||||
/// any divergence is a defect caught by the SchemaComplianceTests introspection check.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbContext> options)
|
||||
: DbContext(options)
|
||||
{
|
||||
public DbSet<ServerCluster> ServerClusters => Set<ServerCluster>();
|
||||
public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
|
||||
public DbSet<ClusterNodeCredential> ClusterNodeCredentials => Set<ClusterNodeCredential>();
|
||||
public DbSet<ConfigGeneration> ConfigGenerations => Set<ConfigGeneration>();
|
||||
public DbSet<Namespace> Namespaces => Set<Namespace>();
|
||||
public DbSet<UnsArea> UnsAreas => Set<UnsArea>();
|
||||
public DbSet<UnsLine> UnsLines => Set<UnsLine>();
|
||||
public DbSet<DriverInstance> DriverInstances => Set<DriverInstance>();
|
||||
public DbSet<Device> Devices => Set<Device>();
|
||||
public DbSet<Equipment> Equipment => Set<Equipment>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<PollGroup> PollGroups => Set<PollGroup>();
|
||||
public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
|
||||
public DbSet<ClusterNodeGenerationState> ClusterNodeGenerationStates => Set<ClusterNodeGenerationState>();
|
||||
public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
|
||||
public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ConfigureServerCluster(modelBuilder);
|
||||
ConfigureClusterNode(modelBuilder);
|
||||
ConfigureClusterNodeCredential(modelBuilder);
|
||||
ConfigureConfigGeneration(modelBuilder);
|
||||
ConfigureNamespace(modelBuilder);
|
||||
ConfigureUnsArea(modelBuilder);
|
||||
ConfigureUnsLine(modelBuilder);
|
||||
ConfigureDriverInstance(modelBuilder);
|
||||
ConfigureDevice(modelBuilder);
|
||||
ConfigureEquipment(modelBuilder);
|
||||
ConfigureTag(modelBuilder);
|
||||
ConfigurePollGroup(modelBuilder);
|
||||
ConfigureNodeAcl(modelBuilder);
|
||||
ConfigureClusterNodeGenerationState(modelBuilder);
|
||||
ConfigureConfigAuditLog(modelBuilder);
|
||||
ConfigureExternalIdReservation(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureServerCluster(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ServerCluster>(e =>
|
||||
{
|
||||
e.ToTable("ServerCluster", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount",
|
||||
"((NodeCount = 1 AND RedundancyMode = 'None') " +
|
||||
"OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
|
||||
});
|
||||
e.HasKey(x => x.ClusterId);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.Enterprise).HasMaxLength(32);
|
||||
e.Property(x => x.Site).HasMaxLength(32);
|
||||
e.Property(x => x.RedundancyMode).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.Notes).HasMaxLength(1024);
|
||||
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
e.Property(x => x.ModifiedAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.ModifiedBy).HasMaxLength(128);
|
||||
e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_ServerCluster_Name");
|
||||
e.HasIndex(x => x.Site).HasDatabaseName("IX_ServerCluster_Site");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureClusterNode(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ClusterNode>(e =>
|
||||
{
|
||||
e.ToTable("ClusterNode");
|
||||
e.HasKey(x => x.NodeId);
|
||||
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.RedundancyRole).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.Host).HasMaxLength(255);
|
||||
e.Property(x => x.ApplicationUri).HasMaxLength(256);
|
||||
e.Property(x => x.DriverConfigOverridesJson).HasColumnType("nvarchar(max)");
|
||||
e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
|
||||
e.HasOne(x => x.Cluster).WithMany(c => c.Nodes)
|
||||
.HasForeignKey(x => x.ClusterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Fleet-wide unique per decision #86
|
||||
e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri");
|
||||
e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId");
|
||||
// At most one Primary per cluster
|
||||
e.HasIndex(x => x.ClusterId).IsUnique()
|
||||
.HasFilter("[RedundancyRole] = 'Primary'")
|
||||
.HasDatabaseName("UX_ClusterNode_Primary_Per_Cluster");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureClusterNodeCredential(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ClusterNodeCredential>(e =>
|
||||
{
|
||||
e.ToTable("ClusterNodeCredential");
|
||||
e.HasKey(x => x.CredentialId);
|
||||
e.Property(x => x.CredentialId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
|
||||
e.Property(x => x.Value).HasMaxLength(512);
|
||||
e.Property(x => x.RotatedAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
|
||||
e.HasOne(x => x.Node).WithMany(n => n.Credentials)
|
||||
.HasForeignKey(x => x.NodeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.NodeId, x.Enabled }).HasDatabaseName("IX_ClusterNodeCredential_NodeId");
|
||||
e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
|
||||
.HasFilter("[Enabled] = 1")
|
||||
.HasDatabaseName("UX_ClusterNodeCredential_Value");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureConfigGeneration(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ConfigGeneration>(e =>
|
||||
{
|
||||
e.ToTable("ConfigGeneration");
|
||||
e.HasKey(x => x.GenerationId);
|
||||
e.Property(x => x.GenerationId).UseIdentityColumn(seed: 1, increment: 1);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.PublishedAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.PublishedBy).HasMaxLength(128);
|
||||
e.Property(x => x.Notes).HasMaxLength(1024);
|
||||
e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
|
||||
e.HasOne(x => x.Cluster).WithMany(c => c.Generations)
|
||||
.HasForeignKey(x => x.ClusterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Parent).WithMany()
|
||||
.HasForeignKey(x => x.ParentGenerationId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.ClusterId, x.Status, x.GenerationId })
|
||||
.IsDescending(false, false, true)
|
||||
.IncludeProperties(x => x.PublishedAt)
|
||||
.HasDatabaseName("IX_ConfigGeneration_Cluster_Published");
|
||||
// One Draft per cluster at a time
|
||||
e.HasIndex(x => x.ClusterId).IsUnique()
|
||||
.HasFilter("[Status] = 'Draft'")
|
||||
.HasDatabaseName("UX_ConfigGeneration_Draft_Per_Cluster");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureNamespace(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Namespace>(e =>
|
||||
{
|
||||
e.ToTable("Namespace");
|
||||
e.HasKey(x => x.NamespaceRowId);
|
||||
e.Property(x => x.NamespaceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.NamespaceId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
|
||||
e.Property(x => x.NamespaceUri).HasMaxLength(256);
|
||||
e.Property(x => x.Notes).HasMaxLength(1024);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany()
|
||||
.HasForeignKey(x => x.GenerationId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Cluster).WithMany(c => c.Namespaces)
|
||||
.HasForeignKey(x => x.ClusterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.Kind }).IsUnique()
|
||||
.HasDatabaseName("UX_Namespace_Generation_Cluster_Kind");
|
||||
e.HasIndex(x => new { x.GenerationId, x.NamespaceUri }).IsUnique()
|
||||
.HasDatabaseName("UX_Namespace_Generation_NamespaceUri");
|
||||
e.HasIndex(x => new { x.GenerationId, x.NamespaceId }).IsUnique()
|
||||
.HasDatabaseName("UX_Namespace_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.NamespaceId, x.ClusterId }).IsUnique()
|
||||
.HasDatabaseName("UX_Namespace_Generation_LogicalId_Cluster");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId })
|
||||
.HasDatabaseName("IX_Namespace_Generation_Cluster");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureUnsArea(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<UnsArea>(e =>
|
||||
{
|
||||
e.ToTable("UnsArea");
|
||||
e.HasKey(x => x.UnsAreaRowId);
|
||||
e.Property(x => x.UnsAreaRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.UnsAreaId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(32);
|
||||
e.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_UnsArea_Generation_Cluster");
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId }).IsUnique().HasDatabaseName("UX_UnsArea_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.Name }).IsUnique().HasDatabaseName("UX_UnsArea_Generation_ClusterName");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureUnsLine(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<UnsLine>(e =>
|
||||
{
|
||||
e.ToTable("UnsLine");
|
||||
e.HasKey(x => x.UnsLineRowId);
|
||||
e.Property(x => x.UnsLineRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.UnsLineId).HasMaxLength(64);
|
||||
e.Property(x => x.UnsAreaId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(32);
|
||||
e.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId }).HasDatabaseName("IX_UnsLine_Generation_Area");
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsLineId }).IsUnique().HasDatabaseName("UX_UnsLine_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsAreaId, x.Name }).IsUnique().HasDatabaseName("UX_UnsLine_Generation_AreaName");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureDriverInstance(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<DriverInstance>(e =>
|
||||
{
|
||||
e.ToTable("DriverInstance", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson",
|
||||
"ISJSON(DriverConfig) = 1");
|
||||
});
|
||||
e.HasKey(x => x.DriverInstanceRowId);
|
||||
e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.NamespaceId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.DriverType).HasMaxLength(32);
|
||||
e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_DriverInstance_Generation_Cluster");
|
||||
e.HasIndex(x => new { x.GenerationId, x.NamespaceId }).HasDatabaseName("IX_DriverInstance_Generation_Namespace");
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).IsUnique().HasDatabaseName("UX_DriverInstance_Generation_LogicalId");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureDevice(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Device>(e =>
|
||||
{
|
||||
e.ToTable("Device", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
|
||||
});
|
||||
e.HasKey(x => x.DeviceRowId);
|
||||
e.Property(x => x.DeviceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.DeviceConfig).HasColumnType("nvarchar(max)");
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_Device_Generation_Driver");
|
||||
e.HasIndex(x => new { x.GenerationId, x.DeviceId }).IsUnique().HasDatabaseName("UX_Device_Generation_LogicalId");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureEquipment(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Equipment>(e =>
|
||||
{
|
||||
e.ToTable("Equipment");
|
||||
e.HasKey(x => x.EquipmentRowId);
|
||||
e.Property(x => x.EquipmentRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||
e.Property(x => x.UnsLineId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(32);
|
||||
e.Property(x => x.MachineCode).HasMaxLength(64);
|
||||
e.Property(x => x.ZTag).HasMaxLength(64);
|
||||
e.Property(x => x.SAPID).HasMaxLength(64);
|
||||
e.Property(x => x.Manufacturer).HasMaxLength(64);
|
||||
e.Property(x => x.Model).HasMaxLength(64);
|
||||
e.Property(x => x.SerialNumber).HasMaxLength(64);
|
||||
e.Property(x => x.HardwareRevision).HasMaxLength(32);
|
||||
e.Property(x => x.SoftwareRevision).HasMaxLength(32);
|
||||
e.Property(x => x.AssetLocation).HasMaxLength(256);
|
||||
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
|
||||
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
|
||||
e.Property(x => x.EquipmentClassRef).HasMaxLength(128);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_Equipment_Generation_Driver");
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsLineId }).HasDatabaseName("IX_Equipment_Generation_Line");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentId }).IsUnique().HasDatabaseName("UX_Equipment_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_Generation_LinePath");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentUuid }).IsUnique().HasDatabaseName("UX_Equipment_Generation_Uuid");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ZTag }).HasFilter("[ZTag] IS NOT NULL").HasDatabaseName("IX_Equipment_Generation_ZTag");
|
||||
e.HasIndex(x => new { x.GenerationId, x.SAPID }).HasFilter("[SAPID] IS NOT NULL").HasDatabaseName("IX_Equipment_Generation_SAPID");
|
||||
e.HasIndex(x => new { x.GenerationId, x.MachineCode }).HasDatabaseName("IX_Equipment_Generation_MachineCode");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureTag(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Tag>(e =>
|
||||
{
|
||||
e.ToTable("Tag", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
|
||||
});
|
||||
e.HasKey(x => x.TagRowId);
|
||||
e.Property(x => x.TagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.TagId).HasMaxLength(64);
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.DeviceId).HasMaxLength(64);
|
||||
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.FolderPath).HasMaxLength(512);
|
||||
e.Property(x => x.DataType).HasMaxLength(32);
|
||||
e.Property(x => x.AccessLevel).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.PollGroupId).HasMaxLength(64);
|
||||
e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)");
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId, x.DeviceId }).HasDatabaseName("IX_Tag_Generation_Driver_Device");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentId })
|
||||
.HasFilter("[EquipmentId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_Tag_Generation_Equipment");
|
||||
e.HasIndex(x => new { x.GenerationId, x.TagId }).IsUnique().HasDatabaseName("UX_Tag_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique()
|
||||
.HasFilter("[EquipmentId] IS NOT NULL")
|
||||
.HasDatabaseName("UX_Tag_Generation_EquipmentPath");
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId, x.FolderPath, x.Name }).IsUnique()
|
||||
.HasFilter("[EquipmentId] IS NULL")
|
||||
.HasDatabaseName("UX_Tag_Generation_FolderPath");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigurePollGroup(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<PollGroup>(e =>
|
||||
{
|
||||
e.ToTable("PollGroup", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
|
||||
});
|
||||
e.HasKey(x => x.PollGroupRowId);
|
||||
e.Property(x => x.PollGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.PollGroupId).HasMaxLength(64);
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.DriverInstanceId }).HasDatabaseName("IX_PollGroup_Generation_Driver");
|
||||
e.HasIndex(x => new { x.GenerationId, x.PollGroupId }).IsUnique().HasDatabaseName("UX_PollGroup_Generation_LogicalId");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureNodeAcl(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<NodeAcl>(e =>
|
||||
{
|
||||
e.ToTable("NodeAcl");
|
||||
e.HasKey(x => x.NodeAclRowId);
|
||||
e.Property(x => x.NodeAclRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.NodeAclId).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.LdapGroup).HasMaxLength(256);
|
||||
e.Property(x => x.ScopeKind).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.ScopeId).HasMaxLength(64);
|
||||
e.Property(x => x.PermissionFlags).HasConversion<int>();
|
||||
e.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId }).HasDatabaseName("IX_NodeAcl_Generation_Cluster");
|
||||
e.HasIndex(x => new { x.GenerationId, x.LdapGroup }).HasDatabaseName("IX_NodeAcl_Generation_Group");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ScopeKind, x.ScopeId })
|
||||
.HasFilter("[ScopeId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_NodeAcl_Generation_Scope");
|
||||
e.HasIndex(x => new { x.GenerationId, x.NodeAclId }).IsUnique().HasDatabaseName("UX_NodeAcl_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique()
|
||||
.HasDatabaseName("UX_NodeAcl_Generation_GroupScope");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureClusterNodeGenerationState(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ClusterNodeGenerationState>(e =>
|
||||
{
|
||||
e.ToTable("ClusterNodeGenerationState");
|
||||
e.HasKey(x => x.NodeId);
|
||||
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||
e.Property(x => x.LastAppliedAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastAppliedStatus).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.LastAppliedError).HasMaxLength(2048);
|
||||
e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)");
|
||||
|
||||
e.HasOne(x => x.Node).WithOne(n => n.GenerationState).HasForeignKey<ClusterNodeGenerationState>(x => x.NodeId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.CurrentGeneration).WithMany().HasForeignKey(x => x.CurrentGenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => x.CurrentGenerationId).HasDatabaseName("IX_ClusterNodeGenerationState_Generation");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureConfigAuditLog(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ConfigAuditLog>(e =>
|
||||
{
|
||||
e.ToTable("ConfigAuditLog", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson",
|
||||
"DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
|
||||
});
|
||||
e.HasKey(x => x.AuditId);
|
||||
e.Property(x => x.AuditId).UseIdentityColumn(seed: 1, increment: 1);
|
||||
e.Property(x => x.Timestamp).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.Principal).HasMaxLength(128);
|
||||
e.Property(x => x.EventType).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||
e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
|
||||
|
||||
e.HasIndex(x => new { x.ClusterId, x.Timestamp })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_ConfigAuditLog_Cluster_Time");
|
||||
e.HasIndex(x => x.GenerationId)
|
||||
.HasFilter("[GenerationId] IS NOT NULL")
|
||||
.HasDatabaseName("IX_ConfigAuditLog_Generation");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureExternalIdReservation(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ExternalIdReservation>(e =>
|
||||
{
|
||||
e.ToTable("ExternalIdReservation");
|
||||
e.HasKey(x => x.ReservationId);
|
||||
e.Property(x => x.ReservationId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.Value).HasMaxLength(64);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.FirstPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.FirstPublishedBy).HasMaxLength(128);
|
||||
e.Property(x => x.LastPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
e.Property(x => x.ReleasedAt).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.ReleasedBy).HasMaxLength(128);
|
||||
e.Property(x => x.ReleaseReason).HasMaxLength(512);
|
||||
|
||||
// Active reservations unique per (Kind, Value) — filtered index lets released rows coexist with a new reservation of the same value.
|
||||
// The UX_ filtered index covers active-reservation lookups; history queries over released rows
|
||||
// fall back to the table scan (released rows are rare + small). No separate non-unique (Kind, Value)
|
||||
// index is declared because EF Core merges duplicate column sets into a single index, which would
|
||||
// clobber the filtered-unique name.
|
||||
e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
|
||||
.HasFilter("[ReleasedAt] IS NULL")
|
||||
.HasDatabaseName("UX_ExternalIdReservation_KindValue_Active");
|
||||
e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Inputs for draft validation. Contains the draft's rows plus the minimum prior-generation
|
||||
/// rows needed for cross-generation invariants (EquipmentUuid stability, UnsArea identity).
|
||||
/// </summary>
|
||||
public sealed class DraftSnapshot
|
||||
{
|
||||
public required long GenerationId { get; init; }
|
||||
public required string ClusterId { get; init; }
|
||||
|
||||
public IReadOnlyList<Namespace> Namespaces { get; init; } = [];
|
||||
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
|
||||
public IReadOnlyList<Device> Devices { get; init; } = [];
|
||||
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
|
||||
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
|
||||
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
|
||||
public IReadOnlyList<Tag> Tags { get; init; } = [];
|
||||
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
|
||||
|
||||
/// <summary>Prior Equipment rows (any generation, same cluster) for stability checks.</summary>
|
||||
public IReadOnlyList<Equipment> PriorEquipment { get; init; } = [];
|
||||
|
||||
/// <summary>Active reservations (<c>ReleasedAt IS NULL</c>) for pre-flight.</summary>
|
||||
public IReadOnlyList<ExternalIdReservation> ActiveReservations { get; init; } = [];
|
||||
}
|
||||
176
src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
Normal file
176
src/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Managed-code pre-publish validator per decision #91. Complements the structural checks in
|
||||
/// <c>sp_ValidateDraft</c> — this layer owns schema validation for JSON columns, UNS segment
|
||||
/// regex, EquipmentId derivation, cross-cluster checks, and anything else that's uncomfortable
|
||||
/// to express in T-SQL. Returns every failing rule in one pass (decision: surface all errors,
|
||||
/// not just the first, so operators fix in bulk).
|
||||
/// </summary>
|
||||
public static class DraftValidator
|
||||
{
|
||||
private static readonly Regex UnsSegment = new(@"^[a-z0-9-]{1,32}$", RegexOptions.Compiled);
|
||||
private const string UnsDefaultSegment = "_default";
|
||||
private const int MaxPathLength = 200;
|
||||
|
||||
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
|
||||
ValidateUnsSegments(draft, errors);
|
||||
ValidatePathLength(draft, errors);
|
||||
ValidateEquipmentUuidImmutability(draft, errors);
|
||||
ValidateSameClusterNamespaceBinding(draft, errors);
|
||||
ValidateReservationPreflight(draft, errors);
|
||||
ValidateEquipmentIdDerivation(draft, errors);
|
||||
ValidateDriverNamespaceCompatibility(draft, errors);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static bool IsValidSegment(string? s) =>
|
||||
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
|
||||
|
||||
private static void ValidateUnsSegments(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
foreach (var a in draft.UnsAreas)
|
||||
if (!IsValidSegment(a.Name))
|
||||
errors.Add(new("UnsSegmentInvalid",
|
||||
$"UnsArea.Name '{a.Name}' does not match [a-z0-9-]{{1,32}} or '_default'",
|
||||
a.UnsAreaId));
|
||||
|
||||
foreach (var l in draft.UnsLines)
|
||||
if (!IsValidSegment(l.Name))
|
||||
errors.Add(new("UnsSegmentInvalid",
|
||||
$"UnsLine.Name '{l.Name}' does not match [a-z0-9-]{{1,32}} or '_default'",
|
||||
l.UnsLineId));
|
||||
|
||||
foreach (var e in draft.Equipment)
|
||||
if (!IsValidSegment(e.Name))
|
||||
errors.Add(new("UnsSegmentInvalid",
|
||||
$"Equipment.Name '{e.Name}' does not match [a-z0-9-]{{1,32}} or '_default'",
|
||||
e.EquipmentId));
|
||||
}
|
||||
|
||||
/// <summary>Cluster.Enterprise + Site + area + line + equipment + 4 slashes ≤ 200 chars.</summary>
|
||||
private static void ValidatePathLength(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
// The cluster row isn't in the snapshot — we assume caller pre-validated Enterprise+Site
|
||||
// length and bound them as constants <= 64 chars each. Here we validate the dynamic portion.
|
||||
var areaById = draft.UnsAreas.ToDictionary(a => a.UnsAreaId);
|
||||
var lineById = draft.UnsLines.ToDictionary(l => l.UnsLineId);
|
||||
|
||||
foreach (var eq in draft.Equipment.Where(e => e.UnsLineId is not null))
|
||||
{
|
||||
if (!lineById.TryGetValue(eq.UnsLineId!, out var line)) continue;
|
||||
if (!areaById.TryGetValue(line.UnsAreaId, out var area)) continue;
|
||||
|
||||
// rough upper bound: Enterprise+Site at most 32+32; add dynamic segments + 4 slashes
|
||||
var len = 32 + 32 + area.Name.Length + line.Name.Length + eq.Name.Length + 4;
|
||||
if (len > MaxPathLength)
|
||||
errors.Add(new("PathTooLong",
|
||||
$"Equipment path exceeds {MaxPathLength} chars (approx {len})",
|
||||
eq.EquipmentId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEquipmentUuidImmutability(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var priorById = draft.PriorEquipment
|
||||
.GroupBy(e => e.EquipmentId)
|
||||
.ToDictionary(g => g.Key, g => g.First().EquipmentUuid);
|
||||
|
||||
foreach (var eq in draft.Equipment)
|
||||
{
|
||||
if (priorById.TryGetValue(eq.EquipmentId, out var priorUuid) && priorUuid != eq.EquipmentUuid)
|
||||
errors.Add(new("EquipmentUuidImmutable",
|
||||
$"EquipmentId '{eq.EquipmentId}' had UUID '{priorUuid}' in a prior generation; cannot change to '{eq.EquipmentUuid}'",
|
||||
eq.EquipmentId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSameClusterNamespaceBinding(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId);
|
||||
|
||||
foreach (var di in draft.DriverInstances)
|
||||
{
|
||||
if (!nsById.TryGetValue(di.NamespaceId, out var ns))
|
||||
{
|
||||
errors.Add(new("NamespaceUnresolved",
|
||||
$"DriverInstance '{di.DriverInstanceId}' references unknown NamespaceId '{di.NamespaceId}'",
|
||||
di.DriverInstanceId));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ns.ClusterId != di.ClusterId)
|
||||
errors.Add(new("BadCrossClusterNamespaceBinding",
|
||||
$"DriverInstance '{di.DriverInstanceId}' is in cluster '{di.ClusterId}' but references namespace in cluster '{ns.ClusterId}'",
|
||||
di.DriverInstanceId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateReservationPreflight(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var activeByKindValue = draft.ActiveReservations
|
||||
.ToDictionary(r => (r.Kind, r.Value), r => r.EquipmentUuid);
|
||||
|
||||
foreach (var eq in draft.Equipment)
|
||||
{
|
||||
if (eq.ZTag is not null &&
|
||||
activeByKindValue.TryGetValue((ReservationKind.ZTag, eq.ZTag), out var ztagOwner) &&
|
||||
ztagOwner != eq.EquipmentUuid)
|
||||
errors.Add(new("BadDuplicateExternalIdentifier",
|
||||
$"ZTag '{eq.ZTag}' is already reserved by EquipmentUuid '{ztagOwner}'",
|
||||
eq.EquipmentId));
|
||||
|
||||
if (eq.SAPID is not null &&
|
||||
activeByKindValue.TryGetValue((ReservationKind.SAPID, eq.SAPID), out var sapOwner) &&
|
||||
sapOwner != eq.EquipmentUuid)
|
||||
errors.Add(new("BadDuplicateExternalIdentifier",
|
||||
$"SAPID '{eq.SAPID}' is already reserved by EquipmentUuid '{sapOwner}'",
|
||||
eq.EquipmentId));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Decision #125: EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID.</summary>
|
||||
public static string DeriveEquipmentId(Guid uuid) =>
|
||||
"EQ-" + uuid.ToString("N")[..12].ToLowerInvariant();
|
||||
|
||||
private static void ValidateEquipmentIdDerivation(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
foreach (var eq in draft.Equipment)
|
||||
{
|
||||
var expected = DeriveEquipmentId(eq.EquipmentUuid);
|
||||
if (!string.Equals(eq.EquipmentId, expected, StringComparison.Ordinal))
|
||||
errors.Add(new("EquipmentIdNotDerived",
|
||||
$"Equipment.EquipmentId '{eq.EquipmentId}' does not match the canonical derivation '{expected}'",
|
||||
eq.EquipmentId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateDriverNamespaceCompatibility(DraftSnapshot draft, List<ValidationError> errors)
|
||||
{
|
||||
var nsById = draft.Namespaces.ToDictionary(n => n.NamespaceId);
|
||||
|
||||
foreach (var di in draft.DriverInstances)
|
||||
{
|
||||
if (!nsById.TryGetValue(di.NamespaceId, out var ns)) continue;
|
||||
|
||||
var compat = ns.Kind switch
|
||||
{
|
||||
NamespaceKind.SystemPlatform => di.DriverType == "Galaxy",
|
||||
NamespaceKind.Equipment => di.DriverType != "Galaxy",
|
||||
_ => true,
|
||||
};
|
||||
|
||||
if (!compat)
|
||||
errors.Add(new("DriverNamespaceKindMismatch",
|
||||
$"DriverInstance '{di.DriverInstanceId}' ({di.DriverType}) is not allowed in {ns.Kind} namespace",
|
||||
di.DriverInstanceId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// One validation failure. <see cref="Code"/> is a stable machine-readable symbol
|
||||
/// (<c>BadCrossClusterNamespaceBinding</c>, <c>UnsSegmentInvalid</c>, …). <see cref="Context"/>
|
||||
/// carries the offending logical ID so the Admin UI can link straight to the row.
|
||||
/// </summary>
|
||||
public sealed record ValidationError(string Code, string Message, string? Context = null);
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Configuration</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0"/>
|
||||
<PackageReference Include="LiteDB" Version="5.0.21"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!--
|
||||
System.Security.Cryptography.Xml reaches this project transitively from
|
||||
Microsoft.EntityFrameworkCore.Design → Microsoft.Build.Tasks.Core. EF Core Design is
|
||||
marked PrivateAssets=all (design-time only, never shipped at runtime), and we do not
|
||||
use XML digital signatures. Fix is only available in 11.0.0-preview. Suppress the two
|
||||
advisories until a stable 11.x is released or the transitive chain is updated.
|
||||
-->
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user