chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Device.cs
Normal file
23
src/Core/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,61 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per-host connectivity snapshot the Server publishes for each driver's
|
||||
/// <c>IHostConnectivityProbe.GetHostStatuses</c> entry. One row per
|
||||
/// (<see cref="NodeId"/>, <see cref="DriverInstanceId"/>, <see cref="HostName"/>) triple —
|
||||
/// a redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces 6
|
||||
/// rows, not 3, because each server node owns its own runtime view.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Closes the data-layer piece of LMX follow-up #7 (per-AppEngine Admin dashboard
|
||||
/// drill-down). The publisher hosted service on the Server side subscribes to every
|
||||
/// registered driver's <c>OnHostStatusChanged</c> and upserts rows on transitions +
|
||||
/// periodic liveness heartbeats. <see cref="LastSeenUtc"/> advances on every
|
||||
/// heartbeat so the Admin UI can flag stale rows from a crashed Server.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No foreign-key to <see cref="ClusterNode"/> — a Server may start reporting host
|
||||
/// status before its ClusterNode row exists (e.g. first-boot bootstrap), and we'd
|
||||
/// rather keep the status row than drop it. The Admin-side service left-joins on
|
||||
/// NodeId when presenting rows.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DriverHostStatus
|
||||
{
|
||||
/// <summary>Server node that's running the driver.</summary>
|
||||
public required string NodeId { get; set; }
|
||||
|
||||
/// <summary>Driver instance's stable id (matches <c>IDriver.DriverInstanceId</c>).</summary>
|
||||
public required string DriverInstanceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Driver-side host identifier — Galaxy Platform / AppEngine name, Modbus
|
||||
/// <c>host:port</c>, whatever the probe returns. Opaque to the Admin UI except as
|
||||
/// a display string.
|
||||
/// </summary>
|
||||
public required string HostName { get; set; }
|
||||
|
||||
public DriverHostState State { get; set; } = DriverHostState.Unknown;
|
||||
|
||||
/// <summary>Timestamp of the last state transition (not of the most recent heartbeat).</summary>
|
||||
public DateTime StateChangedUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Advances on every publisher heartbeat — the Admin UI uses
|
||||
/// <c>now - LastSeenUtc > threshold</c> to flag rows whose owning Server has
|
||||
/// stopped reporting (crashed, network-partitioned, etc.), independent of
|
||||
/// <see cref="State"/>.
|
||||
/// </summary>
|
||||
public DateTime LastSeenUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional human-readable detail populated when <see cref="State"/> is
|
||||
/// <see cref="DriverHostState.Faulted"/> — e.g. the exception message from the
|
||||
/// driver's probe. Null for Running / Stopped / Unknown transitions.
|
||||
/// </summary>
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-instance overrides for the Phase 6.1 shared Polly resilience pipeline.
|
||||
/// Null = use the driver's tier defaults (decision #143). When populated, expected shape:
|
||||
/// <code>
|
||||
/// {
|
||||
/// "bulkheadMaxConcurrent": 16,
|
||||
/// "bulkheadMaxQueue": 64,
|
||||
/// "capabilityPolicies": {
|
||||
/// "Read": { "timeoutSeconds": 5, "retryCount": 5, "breakerFailureThreshold": 3 },
|
||||
/// "Write": { "timeoutSeconds": 5, "retryCount": 0, "breakerFailureThreshold": 5 }
|
||||
/// }
|
||||
/// }
|
||||
/// </code>
|
||||
/// Parsed at startup by <c>DriverResilienceOptionsParser</c>; every key is optional +
|
||||
/// unrecognised keys are ignored so future shapes land without a migration.
|
||||
/// </summary>
|
||||
public string? ResilienceConfig { get; set; }
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime resilience counters the CapabilityInvoker + MemoryTracking + MemoryRecycle
|
||||
/// surfaces for each <c>(DriverInstanceId, HostName)</c> pair. Separate from
|
||||
/// <see cref="DriverHostStatus"/> (which owns per-host <i>connectivity</i> state) so a
|
||||
/// host that's Running but has tripped its breaker or is approaching its memory ceiling
|
||||
/// shows up distinctly on Admin <c>/hosts</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/implementation/phase-6-1-resilience-and-observability.md</c> §Stream E.1.
|
||||
/// The Admin UI left-joins this table on DriverHostStatus for display; rows are written
|
||||
/// by the runtime via a HostedService that samples the tracker at a configurable
|
||||
/// interval (default 5 s) — writes are non-critical, a missed sample is tolerated.
|
||||
/// </remarks>
|
||||
public sealed class DriverInstanceResilienceStatus
|
||||
{
|
||||
public required string DriverInstanceId { get; set; }
|
||||
public required string HostName { get; set; }
|
||||
|
||||
/// <summary>Most recent time the circuit breaker for this (instance, host) opened; null if never.</summary>
|
||||
public DateTime? LastCircuitBreakerOpenUtc { get; set; }
|
||||
|
||||
/// <summary>Rolling count of consecutive Polly pipeline failures for this (instance, host).</summary>
|
||||
public int ConsecutiveFailures { get; set; }
|
||||
|
||||
/// <summary>Current Polly bulkhead depth (in-flight calls) for this (instance, host).</summary>
|
||||
public int CurrentBulkheadDepth { get; set; }
|
||||
|
||||
/// <summary>Most recent process recycle time (Tier C only; null for in-process tiers).</summary>
|
||||
public DateTime? LastRecycleUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Post-init memory baseline captured by <c>MemoryTracking</c> (median of first
|
||||
/// BaselineWindow samples). Zero while still warming up.
|
||||
/// </summary>
|
||||
public long BaselineFootprintBytes { get; set; }
|
||||
|
||||
/// <summary>Most recent footprint sample the tracker saw (steady-state read).</summary>
|
||||
public long CurrentFootprintBytes { get; set; }
|
||||
|
||||
/// <summary>Row last-write timestamp — advances on every sampling tick.</summary>
|
||||
public DateTime LastSampledUtc { get; set; }
|
||||
}
|
||||
@@ -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,68 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Staged equipment-import batch per Phase 6.4 Stream B.2. Rows land in the child
|
||||
/// <see cref="EquipmentImportRow"/> table under a batch header; operator reviews + either
|
||||
/// drops (via <c>DropImportBatch</c>) or finalises (via <c>FinaliseImportBatch</c>) in one
|
||||
/// bounded transaction. The live <c>Equipment</c> table never sees partial state.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>User-scoped visibility: the preview modal only shows batches where
|
||||
/// <see cref="CreatedBy"/> equals the current operator. Prevents accidental
|
||||
/// cross-operator finalise during concurrent imports. An admin finalise / drop surface
|
||||
/// can override this — tracked alongside the UI follow-up.</para>
|
||||
///
|
||||
/// <para><see cref="FinalisedAtUtc"/> stamps the moment the batch promoted from staging
|
||||
/// into <c>Equipment</c>. Null = still in staging; non-null = archived / finalised.</para>
|
||||
/// </remarks>
|
||||
public sealed class EquipmentImportBatch
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public required string ClusterId { get; set; }
|
||||
public required string CreatedBy { get; set; }
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
public int RowsStaged { get; set; }
|
||||
public int RowsAccepted { get; set; }
|
||||
public int RowsRejected { get; set; }
|
||||
public DateTime? FinalisedAtUtc { get; set; }
|
||||
|
||||
public ICollection<EquipmentImportRow> Rows { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One staged row under an <see cref="EquipmentImportBatch"/>. Mirrors the decision #117
|
||||
/// + decision #139 columns from the CSV importer's output + an
|
||||
/// <see cref="IsAccepted"/> flag + a <see cref="RejectReason"/> string the preview modal
|
||||
/// renders.
|
||||
/// </summary>
|
||||
public sealed class EquipmentImportRow
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid BatchId { get; set; }
|
||||
public int LineNumberInFile { get; set; }
|
||||
public bool IsAccepted { get; set; }
|
||||
public string? RejectReason { get; set; }
|
||||
|
||||
// Required (decision #117)
|
||||
public required string ZTag { get; set; }
|
||||
public required string MachineCode { get; set; }
|
||||
public required string SAPID { get; set; }
|
||||
public required string EquipmentId { get; set; }
|
||||
public required string EquipmentUuid { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string UnsAreaName { get; set; }
|
||||
public required string UnsLineName { get; set; }
|
||||
|
||||
// Optional (decision #139 — OPC 40010 Identification)
|
||||
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 string? YearOfConstruction { get; set; }
|
||||
public string? AssetLocation { get; set; }
|
||||
public string? ManufacturerUri { get; set; }
|
||||
public string? DeviceManualUri { get; set; }
|
||||
|
||||
public EquipmentImportBatch? Batch { 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; }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Maps an LDAP group to an <see cref="AdminRole"/> for Admin UI access. Optionally scoped
|
||||
/// to one <see cref="ClusterId"/>; when <see cref="IsSystemWide"/> is true, the grant
|
||||
/// applies fleet-wide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Per <c>docs/v2/plan.md</c> decisions #105 and #150 — this entity is <b>control-plane
|
||||
/// only</b>. The OPC UA data-path evaluator does not read these rows; it reads
|
||||
/// <see cref="NodeAcl"/> joined directly against the session's resolved LDAP group
|
||||
/// memberships. Collapsing the two would let a user inherit tag permissions via an
|
||||
/// admin-role claim path never intended as a data-path grant.</para>
|
||||
///
|
||||
/// <para>Uniqueness: <c>(LdapGroup, ClusterId)</c> — the same LDAP group may hold
|
||||
/// different roles on different clusters, but only one row per cluster. A system-wide row
|
||||
/// (<c>IsSystemWide = true</c>, <c>ClusterId = null</c>) stacks additively with any
|
||||
/// cluster-scoped rows for the same group.</para>
|
||||
/// </remarks>
|
||||
public sealed class LdapGroupRoleMapping
|
||||
{
|
||||
/// <summary>Surrogate primary key.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// LDAP group DN the membership query returns (e.g. <c>cn=fleet-admin,ou=groups,dc=corp,dc=example</c>).
|
||||
/// Comparison is case-insensitive per LDAP conventions.
|
||||
/// </summary>
|
||||
public required string LdapGroup { get; set; }
|
||||
|
||||
/// <summary>Admin role this group grants.</summary>
|
||||
public required AdminRole Role { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cluster the grant applies to; <c>null</c> when <see cref="IsSystemWide"/> is true.
|
||||
/// Foreign key to <see cref="ServerCluster.ClusterId"/>.
|
||||
/// </summary>
|
||||
public string? ClusterId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// <c>true</c> = grant applies across every cluster in the fleet; <c>ClusterId</c> must be null.
|
||||
/// <c>false</c> = grant is cluster-scoped; <c>ClusterId</c> must be populated.
|
||||
/// </summary>
|
||||
public required bool IsSystemWide { get; set; }
|
||||
|
||||
/// <summary>Row creation timestamp (UTC).</summary>
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Optional human-readable note (e.g. "added 2026-04-19 for Warsaw fleet admin handoff").</summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>Navigation for EF core when the row is cluster-scoped.</summary>
|
||||
public ServerCluster? Cluster { get; set; }
|
||||
}
|
||||
@@ -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/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/NodeAcl.cs
Normal file
32
src/Core/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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
38
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs
Normal file
38
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Script.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #8 — user-authored C# script source, referenced by
|
||||
/// <see cref="VirtualTag"/> and <see cref="ScriptedAlarm"/>. One row per script,
|
||||
/// per generation. <c>SourceHash</c> is the compile-cache key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Scripts are generation-scoped: a draft's edit creates a new row in the draft
|
||||
/// generation, the old row stays frozen in the published generation. Shape mirrors
|
||||
/// the other generation-scoped entities (Equipment, Tag, etc.) — <c>ScriptId</c> is
|
||||
/// the stable logical id that carries across generations; <c>ScriptRowId</c> is the
|
||||
/// row identity.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Script
|
||||
{
|
||||
public Guid ScriptRowId { get; set; }
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>Stable logical id. Carries across generations.</summary>
|
||||
public required string ScriptId { get; set; }
|
||||
|
||||
/// <summary>Operator-friendly name for log filtering + Admin UI list view.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Raw C# source. Size bounded by the DB column (nvarchar(max)).</summary>
|
||||
public required string SourceCode { get; set; }
|
||||
|
||||
/// <summary>SHA-256 of <see cref="SourceCode"/> — compile-cache key for Phase 7 Stream A's <c>CompiledScriptCache</c>.</summary>
|
||||
public required string SourceHash { get; set; }
|
||||
|
||||
/// <summary>Language — always "CSharp" today; placeholder for future engines (Python/Lua).</summary>
|
||||
public string Language { get; set; } = "CSharp";
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decisions #5, #13, #15 — a scripted OPC UA Part 9 alarm whose
|
||||
/// condition is the predicate <see cref="Script"/> referenced by
|
||||
/// <see cref="PredicateScriptId"/>. Materialized by <c>Core.ScriptedAlarms</c> as a
|
||||
/// concrete <c>AlarmConditionType</c> subtype per <see cref="AlarmType"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Message tokens (<c>{TagPath}</c>) resolved at emission time per plan decision #13.
|
||||
/// <see cref="HistorizeToAveva"/> (plan decision #15) gates whether transitions
|
||||
/// route through the Core.AlarmHistorian SQLite queue + Galaxy.Host to the Aveva
|
||||
/// Historian alarm schema.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptedAlarm
|
||||
{
|
||||
public Guid ScriptedAlarmRowId { get; set; }
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>Stable logical id — drives <c>AlarmConditionType.ConditionName</c>.</summary>
|
||||
public required string ScriptedAlarmId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this alarm.</summary>
|
||||
public required string EquipmentId { get; set; }
|
||||
|
||||
/// <summary>Operator-facing alarm name.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Concrete Part 9 type — "AlarmCondition" / "LimitAlarm" / "OffNormalAlarm" / "DiscreteAlarm".</summary>
|
||||
public required string AlarmType { get; set; }
|
||||
|
||||
/// <summary>Numeric severity 1..1000 per OPC UA Part 9 (usual bands: 1-250 Low, 251-500 Medium, 501-750 High, 751-1000 Critical).</summary>
|
||||
public int Severity { get; set; } = 500;
|
||||
|
||||
/// <summary>Template with <c>{TagPath}</c> tokens resolved at emission time.</summary>
|
||||
public required string MessageTemplate { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="Script.ScriptId"/> — predicate script returning <c>bool</c>.</summary>
|
||||
public required string PredicateScriptId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Plan decision #15 — when true, transitions route through the SQLite store-and-forward
|
||||
/// queue to the Aveva Historian. Defaults on for scripted alarms because they are the
|
||||
/// primary motivation for the historian sink; operator can disable per alarm.
|
||||
/// </summary>
|
||||
public bool HistorizeToAveva { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA Part 9 <c>Retain</c> flag — whether the alarm keeps active-state between
|
||||
/// sessions. Most plant alarms are retained; one-shot event-style alarms are not.
|
||||
/// </summary>
|
||||
public bool Retain { get; set; } = true;
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #14 — persistent runtime state for each scripted alarm.
|
||||
/// Survives process restart so operators don't re-ack and ack history survives for
|
||||
/// GxP / 21 CFR Part 11 compliance. Keyed on <c>ScriptedAlarmId</c> logically (not
|
||||
/// per-generation) because ack state follows the alarm's stable identity across
|
||||
/// generations — a Modified alarm keeps its ack history.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>ActiveState</c> is deliberately NOT persisted — it rederives from the current
|
||||
/// predicate evaluation on startup. Only operator-supplied state (<see cref="AckedState"/>,
|
||||
/// <see cref="ConfirmedState"/>, <see cref="ShelvingState"/>) + audit trail persist.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="CommentsJson"/> is an append-only JSON array of <c>{user, utc, text}</c>
|
||||
/// tuples — one per operator comment. Core.ScriptedAlarms' <c>AlarmConditionState.Comments</c>
|
||||
/// serializes directly into this column.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ScriptedAlarmState
|
||||
{
|
||||
/// <summary>Logical FK — matches <see cref="ScriptedAlarm.ScriptedAlarmId"/>. One row per alarm identity.</summary>
|
||||
public required string ScriptedAlarmId { get; set; }
|
||||
|
||||
/// <summary>Enabled/Disabled. Persists across restart per plan decision #14.</summary>
|
||||
public required string EnabledState { get; set; } = "Enabled";
|
||||
|
||||
/// <summary>Unacknowledged / Acknowledged.</summary>
|
||||
public required string AckedState { get; set; } = "Unacknowledged";
|
||||
|
||||
/// <summary>Unconfirmed / Confirmed.</summary>
|
||||
public required string ConfirmedState { get; set; } = "Unconfirmed";
|
||||
|
||||
/// <summary>Unshelved / OneShotShelved / TimedShelved.</summary>
|
||||
public required string ShelvingState { get; set; } = "Unshelved";
|
||||
|
||||
/// <summary>When a TimedShelve expires — null if not shelved or OneShotShelved.</summary>
|
||||
public DateTime? ShelvingExpiresUtc { get; set; }
|
||||
|
||||
/// <summary>User who last acknowledged. Null if never acked.</summary>
|
||||
public string? LastAckUser { get; set; }
|
||||
|
||||
/// <summary>Operator-supplied ack comment. Null if no comment or never acked.</summary>
|
||||
public string? LastAckComment { get; set; }
|
||||
|
||||
public DateTime? LastAckUtc { get; set; }
|
||||
|
||||
/// <summary>User who last confirmed.</summary>
|
||||
public string? LastConfirmUser { get; set; }
|
||||
|
||||
public string? LastConfirmComment { get; set; }
|
||||
|
||||
public DateTime? LastConfirmUtc { get; set; }
|
||||
|
||||
/// <summary>JSON array of operator comments, append-only (GxP audit).</summary>
|
||||
public string CommentsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>Row write timestamp — tracks last state change.</summary>
|
||||
public DateTime UpdatedAtUtc { 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/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/Tag.cs
Normal file
47
src/Core/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/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsArea.cs
Normal file
21
src/Core/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/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/UnsLine.cs
Normal file
21
src/Core/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; }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Per Phase 7 plan decision #2 — a virtual (calculated) tag that lives in the
|
||||
/// Equipment tree alongside driver tags. Value is produced by the
|
||||
/// <see cref="Script"/> referenced by <see cref="ScriptId"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="EquipmentId"/> is mandatory — virtual tags are always scoped to an
|
||||
/// Equipment node per plan decision #2 (unified Equipment tree, not a separate
|
||||
/// /Virtual namespace). <see cref="DataType"/> matches the shape used by
|
||||
/// <c>Tag.DataType</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="ChangeTriggered"/> and <see cref="TimerIntervalMs"/> together realize
|
||||
/// plan decision #3 (change + timer). At least one must produce evaluations; the
|
||||
/// Core.VirtualTags engine rejects an all-disabled tag at load time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VirtualTag
|
||||
{
|
||||
public Guid VirtualTagRowId { get; set; }
|
||||
public long GenerationId { get; set; }
|
||||
|
||||
/// <summary>Stable logical id.</summary>
|
||||
public required string VirtualTagId { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="Equipment.EquipmentId"/> — owner of this virtual tag.</summary>
|
||||
public required string EquipmentId { get; set; }
|
||||
|
||||
/// <summary>Browse name — unique within owning Equipment.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>DataType string — same vocabulary as <see cref="Tag.DataType"/>.</summary>
|
||||
public required string DataType { get; set; }
|
||||
|
||||
/// <summary>Logical FK to <see cref="Script.ScriptId"/> — the script that computes this tag's value.</summary>
|
||||
public required string ScriptId { get; set; }
|
||||
|
||||
/// <summary>Re-evaluate when any referenced input tag changes. Default on.</summary>
|
||||
public bool ChangeTriggered { get; set; } = true;
|
||||
|
||||
/// <summary>Timer re-evaluation cadence in milliseconds. <c>null</c> = no timer.</summary>
|
||||
public int? TimerIntervalMs { get; set; }
|
||||
|
||||
/// <summary>Per plan decision #10 — checkbox to route this tag's values through <c>IHistoryWriter</c>.</summary>
|
||||
public bool Historize { get; set; }
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public ConfigGeneration? Generation { get; set; }
|
||||
}
|
||||
26
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs
Normal file
26
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Admin UI roles per <c>admin-ui.md</c> §"Admin Roles" and Phase 6.2 Stream A.
|
||||
/// These govern Admin UI capabilities (cluster CRUD, draft → publish, fleet-wide admin
|
||||
/// actions) — they do NOT govern OPC UA data-path authorization, which reads
|
||||
/// <see cref="Entities.NodeAcl"/> joined against LDAP group memberships directly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #150 the two concerns share zero runtime code path:
|
||||
/// the control plane (Admin UI) consumes <see cref="Entities.LdapGroupRoleMapping"/>; the
|
||||
/// data plane consumes <see cref="Entities.NodeAcl"/> rows directly. Having them in one
|
||||
/// table would collapse the distinction + let a user inherit tag permissions via their
|
||||
/// admin-role claim path.
|
||||
/// </remarks>
|
||||
public enum AdminRole
|
||||
{
|
||||
/// <summary>Read-only Admin UI access — can view cluster state, drafts, publish history.</summary>
|
||||
ConfigViewer,
|
||||
|
||||
/// <summary>Can author drafts + submit for publish.</summary>
|
||||
ConfigEditor,
|
||||
|
||||
/// <summary>Full Admin UI privileges including publish + fleet-admin actions.</summary>
|
||||
FleetAdmin,
|
||||
}
|
||||
@@ -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,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Persisted mirror of <c>Core.Abstractions.HostState</c> — the lifecycle state each
|
||||
/// <c>IHostConnectivityProbe</c>-capable driver reports for its per-host topology
|
||||
/// (Galaxy Platforms / AppEngines, Modbus PLC endpoints, future OPC UA gateway upstreams).
|
||||
/// Defined here instead of re-using <c>Core.Abstractions.HostState</c> so the
|
||||
/// Configuration project stays free of driver-runtime dependencies.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The server-side publisher (follow-up PR) translates
|
||||
/// <c>HostStatusChangedEventArgs.NewState</c> to this enum on every transition and
|
||||
/// upserts into <see cref="Entities.DriverHostStatus"/>. Admin UI reads from the DB.
|
||||
/// </remarks>
|
||||
public enum DriverHostState
|
||||
{
|
||||
Unknown,
|
||||
Running,
|
||||
Stopped,
|
||||
Faulted,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,170 @@
|
||||
using LiteDB;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Generation-sealed LiteDB cache per <c>docs/v2/plan.md</c> decision #148 and Phase 6.1
|
||||
/// Stream D.1. Each published generation writes one <b>read-only</b> LiteDB file under
|
||||
/// <c><cache-root>/<clusterId>/<generationId>.db</c>. A per-cluster
|
||||
/// <c>CURRENT</c> text file holds the currently-active generation id; it is updated
|
||||
/// atomically (temp file + <see cref="File.Replace(string, string, string?)"/>) only after
|
||||
/// the sealed file is fully written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Mixed-generation reads are impossible: any read opens the single file pointed to
|
||||
/// by <c>CURRENT</c>, which is a coherent snapshot. Corruption of the CURRENT file or the
|
||||
/// sealed file surfaces as <see cref="GenerationCacheUnavailableException"/> — the reader
|
||||
/// fails closed rather than silently falling back to an older generation. Recovery path
|
||||
/// is to re-fetch from the central DB (and the Phase 6.1 Stream C <c>UsingStaleConfig</c>
|
||||
/// flag goes true until that succeeds).</para>
|
||||
///
|
||||
/// <para>This cache is the read-path fallback when the central DB is unreachable. The
|
||||
/// write path (draft edits, publish) bypasses the cache and fails hard on DB outage per
|
||||
/// Stream D.2 — inconsistent writes are worse than a temporary inability to edit.</para>
|
||||
/// </remarks>
|
||||
public sealed class GenerationSealedCache
|
||||
{
|
||||
private const string CollectionName = "generation";
|
||||
private const string CurrentPointerFileName = "CURRENT";
|
||||
private readonly string _cacheRoot;
|
||||
|
||||
/// <summary>Root directory for all clusters' sealed caches.</summary>
|
||||
public string CacheRoot => _cacheRoot;
|
||||
|
||||
public GenerationSealedCache(string cacheRoot)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(cacheRoot);
|
||||
_cacheRoot = cacheRoot;
|
||||
Directory.CreateDirectory(_cacheRoot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seal a generation: write the snapshot to <c><cluster>/<generationId>.db</c>,
|
||||
/// mark the file read-only, then atomically publish the <c>CURRENT</c> pointer. Existing
|
||||
/// sealed files for prior generations are preserved (prune separately).
|
||||
/// </summary>
|
||||
public async Task SealAsync(GenerationSnapshot snapshot, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var clusterDir = Path.Combine(_cacheRoot, snapshot.ClusterId);
|
||||
Directory.CreateDirectory(clusterDir);
|
||||
var sealedPath = Path.Combine(clusterDir, $"{snapshot.GenerationId}.db");
|
||||
|
||||
if (File.Exists(sealedPath))
|
||||
{
|
||||
// Already sealed — idempotent. Treat as no-op + update pointer in case an earlier
|
||||
// seal succeeded but the pointer update failed (crash recovery).
|
||||
WritePointerAtomically(clusterDir, snapshot.GenerationId);
|
||||
return;
|
||||
}
|
||||
|
||||
var tmpPath = sealedPath + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var db = new LiteDatabase(new ConnectionString { Filename = tmpPath, Upgrade = false }))
|
||||
{
|
||||
var col = db.GetCollection<GenerationSnapshot>(CollectionName);
|
||||
col.Insert(snapshot);
|
||||
}
|
||||
|
||||
File.Move(tmpPath, sealedPath);
|
||||
File.SetAttributes(sealedPath, File.GetAttributes(sealedPath) | FileAttributes.ReadOnly);
|
||||
WritePointerAtomically(clusterDir, snapshot.GenerationId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { /* best-effort */ }
|
||||
throw;
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the current sealed snapshot for <paramref name="clusterId"/>. Throws
|
||||
/// <see cref="GenerationCacheUnavailableException"/> when the pointer is missing
|
||||
/// (first-boot-no-snapshot case) or when the sealed file is corrupt. Never silently
|
||||
/// falls back to a prior generation.
|
||||
/// </summary>
|
||||
public Task<GenerationSnapshot> ReadCurrentAsync(string clusterId, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var clusterDir = Path.Combine(_cacheRoot, clusterId);
|
||||
var pointerPath = Path.Combine(clusterDir, CurrentPointerFileName);
|
||||
if (!File.Exists(pointerPath))
|
||||
throw new GenerationCacheUnavailableException(
|
||||
$"No sealed generation for cluster '{clusterId}' at '{clusterDir}'. First-boot case: the central DB must be reachable at least once before cache fallback is possible.");
|
||||
|
||||
long generationId;
|
||||
try
|
||||
{
|
||||
var text = File.ReadAllText(pointerPath).Trim();
|
||||
generationId = long.Parse(text, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new GenerationCacheUnavailableException(
|
||||
$"CURRENT pointer at '{pointerPath}' is corrupt or unreadable.", ex);
|
||||
}
|
||||
|
||||
var sealedPath = Path.Combine(clusterDir, $"{generationId}.db");
|
||||
if (!File.Exists(sealedPath))
|
||||
throw new GenerationCacheUnavailableException(
|
||||
$"CURRENT points at generation {generationId} but '{sealedPath}' is missing — fails closed rather than serving an older generation.");
|
||||
|
||||
try
|
||||
{
|
||||
using var db = new LiteDatabase(new ConnectionString { Filename = sealedPath, ReadOnly = true });
|
||||
var col = db.GetCollection<GenerationSnapshot>(CollectionName);
|
||||
var snapshot = col.FindAll().FirstOrDefault()
|
||||
?? throw new GenerationCacheUnavailableException(
|
||||
$"Sealed file '{sealedPath}' contains no snapshot row — file is corrupt.");
|
||||
return Task.FromResult(snapshot);
|
||||
}
|
||||
catch (GenerationCacheUnavailableException) { throw; }
|
||||
catch (Exception ex) when (ex is LiteException or InvalidDataException or IOException
|
||||
or NotSupportedException or FormatException)
|
||||
{
|
||||
throw new GenerationCacheUnavailableException(
|
||||
$"Sealed file '{sealedPath}' is corrupt or unreadable — fails closed rather than falling back to an older generation.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Return the generation id the <c>CURRENT</c> pointer points at, or null if no pointer exists.</summary>
|
||||
public long? TryGetCurrentGenerationId(string clusterId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
var pointerPath = Path.Combine(_cacheRoot, clusterId, CurrentPointerFileName);
|
||||
if (!File.Exists(pointerPath)) return null;
|
||||
try
|
||||
{
|
||||
return long.Parse(File.ReadAllText(pointerPath).Trim(), System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WritePointerAtomically(string clusterDir, long generationId)
|
||||
{
|
||||
var pointerPath = Path.Combine(clusterDir, CurrentPointerFileName);
|
||||
var tmpPath = pointerPath + ".tmp";
|
||||
File.WriteAllText(tmpPath, generationId.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
if (File.Exists(pointerPath))
|
||||
File.Replace(tmpPath, pointerPath, destinationBackupFileName: null);
|
||||
else
|
||||
File.Move(tmpPath, pointerPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sealed cache is unreachable — caller must fail closed.</summary>
|
||||
public sealed class GenerationCacheUnavailableException : Exception
|
||||
{
|
||||
public GenerationCacheUnavailableException(string message) : base(message) { }
|
||||
public GenerationCacheUnavailableException(string message, Exception inner) : base(message, inner) { }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using Polly.Timeout;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a central-DB fetch function with Phase 6.1 Stream D.2 resilience:
|
||||
/// <b>timeout 2 s → retry 3× jittered → fallback to sealed cache</b>. Maintains the
|
||||
/// <see cref="StaleConfigFlag"/> — fresh on central-DB success, stale on cache fallback.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Read-path only per plan. The write path (draft save, publish) bypasses this
|
||||
/// wrapper entirely and fails hard on DB outage so inconsistent writes never land.</para>
|
||||
///
|
||||
/// <para>Fallback is triggered by <b>any exception</b> the fetch raises (central-DB
|
||||
/// unreachable, SqlException, timeout). If the sealed cache also fails (no pointer,
|
||||
/// corrupt file, etc.), <see cref="GenerationCacheUnavailableException"/> surfaces — caller
|
||||
/// must fail the current request (InitializeAsync for a driver, etc.).</para>
|
||||
/// </remarks>
|
||||
public sealed class ResilientConfigReader
|
||||
{
|
||||
private readonly GenerationSealedCache _cache;
|
||||
private readonly StaleConfigFlag _staleFlag;
|
||||
private readonly ResiliencePipeline _pipeline;
|
||||
private readonly ILogger<ResilientConfigReader> _logger;
|
||||
|
||||
public ResilientConfigReader(
|
||||
GenerationSealedCache cache,
|
||||
StaleConfigFlag staleFlag,
|
||||
ILogger<ResilientConfigReader> logger,
|
||||
TimeSpan? timeout = null,
|
||||
int retryCount = 3)
|
||||
{
|
||||
_cache = cache;
|
||||
_staleFlag = staleFlag;
|
||||
_logger = logger;
|
||||
var builder = new ResiliencePipelineBuilder()
|
||||
.AddTimeout(new TimeoutStrategyOptions { Timeout = timeout ?? TimeSpan.FromSeconds(2) });
|
||||
|
||||
if (retryCount > 0)
|
||||
{
|
||||
builder.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = retryCount,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
Delay = TimeSpan.FromMilliseconds(100),
|
||||
MaxDelay = TimeSpan.FromSeconds(1),
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(ex => ex is not OperationCanceledException),
|
||||
});
|
||||
}
|
||||
|
||||
_pipeline = builder.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute <paramref name="centralFetch"/> through the resilience pipeline. On full failure
|
||||
/// (post-retry), reads the sealed cache for <paramref name="clusterId"/> and passes the
|
||||
/// snapshot to <paramref name="fromSnapshot"/> to extract the requested shape.
|
||||
/// </summary>
|
||||
public async ValueTask<T> ReadAsync<T>(
|
||||
string clusterId,
|
||||
Func<CancellationToken, ValueTask<T>> centralFetch,
|
||||
Func<GenerationSnapshot, T> fromSnapshot,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||
ArgumentNullException.ThrowIfNull(centralFetch);
|
||||
ArgumentNullException.ThrowIfNull(fromSnapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _pipeline.ExecuteAsync(centralFetch, cancellationToken).ConfigureAwait(false);
|
||||
_staleFlag.MarkFresh();
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Central-DB read failed after retries; falling back to sealed cache for cluster {ClusterId}", clusterId);
|
||||
// GenerationCacheUnavailableException surfaces intentionally — fails the caller's
|
||||
// operation. StaleConfigFlag stays unchanged; the flag only flips when we actually
|
||||
// served a cache snapshot.
|
||||
var snapshot = await _cache.ReadCurrentAsync(clusterId, cancellationToken).ConfigureAwait(false);
|
||||
_staleFlag.MarkStale();
|
||||
return fromSnapshot(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.LocalCache;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe <c>UsingStaleConfig</c> signal per Phase 6.1 Stream D.3. Flips true whenever
|
||||
/// a read falls back to a sealed cache snapshot; flips false on the next successful central-DB
|
||||
/// round-trip. Surfaced on <c>/healthz</c> body and on the Admin <c>/hosts</c> page.
|
||||
/// </summary>
|
||||
public sealed class StaleConfigFlag
|
||||
{
|
||||
private int _stale;
|
||||
|
||||
/// <summary>True when the last config read was served from the sealed cache, not the central DB.</summary>
|
||||
public bool IsStale => Volatile.Read(ref _stale) != 0;
|
||||
|
||||
/// <summary>Mark the current config as stale (a read fell back to the cache).</summary>
|
||||
public void MarkStale() => Volatile.Write(ref _stale, 1);
|
||||
|
||||
/// <summary>Mark the current config as fresh (a central-DB read succeeded).</summary>
|
||||
public void MarkFresh() => Volatile.Write(ref _stale, 0);
|
||||
}
|
||||
1208
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417212220_InitialSchema.Designer.cs
generated
Normal file
1208
src/Core/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/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417215224_StoredProcedures.Designer.cs
generated
Normal file
1208
src/Core/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/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260417220857_AuthorizationGrants.Designer.cs
generated
Normal file
1208
src/Core/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;
|
||||
");
|
||||
}
|
||||
}
|
||||
1248
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs
generated
Normal file
1248
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260418193608_AddDriverHostStatus.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDriverHostStatus : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DriverHostStatus",
|
||||
columns: table => new
|
||||
{
|
||||
NodeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
StateChangedUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||
LastSeenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||
Detail = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DriverHostStatus", x => new { x.NodeId, x.DriverInstanceId, x.HostName });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverHostStatus_LastSeen",
|
||||
table: "DriverHostStatus",
|
||||
column: "LastSeenUtc");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverHostStatus_Node",
|
||||
table: "DriverHostStatus",
|
||||
column: "NodeId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DriverHostStatus");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDriverInstanceResilienceStatus : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DriverInstanceResilienceStatus",
|
||||
columns: table => new
|
||||
{
|
||||
DriverInstanceId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
HostName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
LastCircuitBreakerOpenUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
ConsecutiveFailures = table.Column<int>(type: "int", nullable: false),
|
||||
CurrentBulkheadDepth = table.Column<int>(type: "int", nullable: false),
|
||||
LastRecycleUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
BaselineFootprintBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
CurrentFootprintBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
LastSampledUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_DriverInstanceResilienceStatus", x => new { x.DriverInstanceId, x.HostName });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DriverResilience_LastSampled",
|
||||
table: "DriverInstanceResilienceStatus",
|
||||
column: "LastSampledUtc");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DriverInstanceResilienceStatus");
|
||||
}
|
||||
}
|
||||
}
|
||||
1342
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419131444_AddLdapGroupRoleMapping.Designer.cs
generated
Normal file
1342
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419131444_AddLdapGroupRoleMapping.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLdapGroupRoleMapping : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LdapGroupRoleMapping",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
LdapGroup = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
|
||||
Role = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
IsSystemWide = table.Column<bool>(type: "bit", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LdapGroupRoleMapping", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_LdapGroupRoleMapping_ServerCluster_ClusterId",
|
||||
column: x => x.ClusterId,
|
||||
principalTable: "ServerCluster",
|
||||
principalColumn: "ClusterId",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LdapGroupRoleMapping_ClusterId",
|
||||
table: "LdapGroupRoleMapping",
|
||||
column: "ClusterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LdapGroupRoleMapping_Group",
|
||||
table: "LdapGroupRoleMapping",
|
||||
column: "LdapGroup");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_LdapGroupRoleMapping_Group_Cluster",
|
||||
table: "LdapGroupRoleMapping",
|
||||
columns: new[] { "LdapGroup", "ClusterId" },
|
||||
unique: true,
|
||||
filter: "[ClusterId] IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "LdapGroupRoleMapping");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDriverInstanceResilienceConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ResilienceConfig",
|
||||
table: "DriverInstance",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddCheckConstraint(
|
||||
name: "CK_DriverInstance_ResilienceConfig_IsJson",
|
||||
table: "DriverInstance",
|
||||
sql: "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropCheckConstraint(
|
||||
name: "CK_DriverInstance_ResilienceConfig_IsJson",
|
||||
table: "DriverInstance");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ResilienceConfig",
|
||||
table: "DriverInstance");
|
||||
}
|
||||
}
|
||||
}
|
||||
1505
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.Designer.cs
generated
Normal file
1505
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419185124_AddEquipmentImportBatch.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEquipmentImportBatch : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EquipmentImportBatch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
ClusterId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false),
|
||||
RowsStaged = table.Column<int>(type: "int", nullable: false),
|
||||
RowsAccepted = table.Column<int>(type: "int", nullable: false),
|
||||
RowsRejected = table.Column<int>(type: "int", nullable: false),
|
||||
FinalisedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EquipmentImportBatch", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EquipmentImportRow",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
BatchId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
LineNumberInFile = table.Column<int>(type: "int", nullable: false),
|
||||
IsAccepted = table.Column<bool>(type: "bit", nullable: false),
|
||||
RejectReason = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
ZTag = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
MachineCode = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
SAPID = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
EquipmentUuid = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
UnsAreaName = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
UnsLineName = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Manufacturer = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
Model = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
SerialNumber = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
|
||||
HardwareRevision = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
SoftwareRevision = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
YearOfConstruction = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: true),
|
||||
AssetLocation = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
ManufacturerUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true),
|
||||
DeviceManualUri = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EquipmentImportRow", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EquipmentImportRow_EquipmentImportBatch_BatchId",
|
||||
column: x => x.BatchId,
|
||||
principalTable: "EquipmentImportBatch",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EquipmentImportBatch_Creator_Finalised",
|
||||
table: "EquipmentImportBatch",
|
||||
columns: new[] { "CreatedBy", "FinalisedAtUtc" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EquipmentImportRow_Batch",
|
||||
table: "EquipmentImportRow",
|
||||
column: "BatchId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EquipmentImportRow");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "EquipmentImportBatch");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Extends <c>dbo.sp_ComputeGenerationDiff</c> to emit <c>NodeAcl</c> rows alongside the
|
||||
/// existing Namespace/DriverInstance/Equipment/Tag output — closes the final slice of
|
||||
/// task #196 (DiffViewer ACL section). Logical id for NodeAcl is a composite
|
||||
/// <c>LdapGroup|ScopeKind|ScopeId</c> triple so a Change row surfaces whether the grant
|
||||
/// shifted permissions, moved scope, or was added/removed outright.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public partial class ExtendComputeGenerationDiffWithNodeAcl : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiffV1);
|
||||
}
|
||||
|
||||
private static class Procs
|
||||
{
|
||||
/// <summary>V2 — adds the NodeAcl section to the diff output.</summary>
|
||||
public const string ComputeGenerationDiffV2 = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- NodeAcl section. Logical id is the (LdapGroup, ScopeKind, ScopeId) triple so the diff
|
||||
-- distinguishes same row with new permissions (Modified via CHECKSUM on PermissionFlags + Notes)
|
||||
-- from a scope move (which surfaces as Added + Removed of different logical ids).
|
||||
WITH f AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||
t AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
|
||||
/// <summary>V1 — exact proc shipped in migration 20260417215224_StoredProcedures. Restored on Down().</summary>
|
||||
public const string ComputeGenerationDiffV1 = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
}
|
||||
}
|
||||
}
|
||||
1793
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.Designer.cs
generated
Normal file
1793
src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260420231641_AddPhase7ScriptingTables.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPhase7ScriptingTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Script",
|
||||
columns: table => new
|
||||
{
|
||||
ScriptRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
SourceCode = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
SourceHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Language = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Script", x => x.ScriptRowId);
|
||||
table.ForeignKey(
|
||||
name: "FK_Script_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ScriptedAlarm",
|
||||
columns: table => new
|
||||
{
|
||||
ScriptedAlarmRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
AlarmType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Severity = table.Column<int>(type: "int", nullable: false),
|
||||
MessageTemplate = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
|
||||
PredicateScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
HistorizeToAveva = table.Column<bool>(type: "bit", nullable: false),
|
||||
Retain = table.Column<bool>(type: "bit", nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ScriptedAlarm", x => x.ScriptedAlarmRowId);
|
||||
table.CheckConstraint("CK_ScriptedAlarm_AlarmType", "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
|
||||
table.CheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
|
||||
table.ForeignKey(
|
||||
name: "FK_ScriptedAlarm_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ScriptedAlarmState",
|
||||
columns: table => new
|
||||
{
|
||||
ScriptedAlarmId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
EnabledState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
AckedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
ConfirmedState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
ShelvingState = table.Column<string>(type: "nvarchar(16)", maxLength: 16, nullable: false),
|
||||
ShelvingExpiresUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
LastAckUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
LastAckComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
LastAckUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
LastConfirmUser = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
|
||||
LastConfirmComment = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
|
||||
LastConfirmUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: true),
|
||||
CommentsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTime>(type: "datetime2(3)", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ScriptedAlarmState", x => x.ScriptedAlarmId);
|
||||
table.CheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VirtualTag",
|
||||
columns: table => new
|
||||
{
|
||||
VirtualTagRowId = table.Column<Guid>(type: "uniqueidentifier", nullable: false, defaultValueSql: "NEWSEQUENTIALID()"),
|
||||
GenerationId = table.Column<long>(type: "bigint", nullable: false),
|
||||
VirtualTagId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
|
||||
EquipmentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
DataType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
ScriptId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ChangeTriggered = table.Column<bool>(type: "bit", nullable: false),
|
||||
TimerIntervalMs = table.Column<int>(type: "int", nullable: true),
|
||||
Historize = table.Column<bool>(type: "bit", nullable: false),
|
||||
Enabled = table.Column<bool>(type: "bit", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VirtualTag", x => x.VirtualTagRowId);
|
||||
table.CheckConstraint("CK_VirtualTag_TimerInterval_Min", "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
|
||||
table.CheckConstraint("CK_VirtualTag_Trigger_AtLeastOne", "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
|
||||
table.ForeignKey(
|
||||
name: "FK_VirtualTag_ConfigGeneration_GenerationId",
|
||||
column: x => x.GenerationId,
|
||||
principalTable: "ConfigGeneration",
|
||||
principalColumn: "GenerationId",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Script_Generation_SourceHash",
|
||||
table: "Script",
|
||||
columns: new[] { "GenerationId", "SourceHash" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_Script_Generation_LogicalId",
|
||||
table: "Script",
|
||||
columns: new[] { "GenerationId", "ScriptId" },
|
||||
unique: true,
|
||||
filter: "[ScriptId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ScriptedAlarm_Generation_Script",
|
||||
table: "ScriptedAlarm",
|
||||
columns: new[] { "GenerationId", "PredicateScriptId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ScriptedAlarm_Generation_EquipmentPath",
|
||||
table: "ScriptedAlarm",
|
||||
columns: new[] { "GenerationId", "EquipmentId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_ScriptedAlarm_Generation_LogicalId",
|
||||
table: "ScriptedAlarm",
|
||||
columns: new[] { "GenerationId", "ScriptedAlarmId" },
|
||||
unique: true,
|
||||
filter: "[ScriptedAlarmId] IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VirtualTag_Generation_Script",
|
||||
table: "VirtualTag",
|
||||
columns: new[] { "GenerationId", "ScriptId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_VirtualTag_Generation_EquipmentPath",
|
||||
table: "VirtualTag",
|
||||
columns: new[] { "GenerationId", "EquipmentId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UX_VirtualTag_Generation_LogicalId",
|
||||
table: "VirtualTag",
|
||||
columns: new[] { "GenerationId", "VirtualTagId" },
|
||||
unique: true,
|
||||
filter: "[VirtualTagId] IS NOT NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Script");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ScriptedAlarm");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ScriptedAlarmState");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VirtualTag");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 7 follow-up (task #241) — extends <c>dbo.sp_ComputeGenerationDiff</c> to emit
|
||||
/// Script / VirtualTag / ScriptedAlarm rows alongside the existing Namespace /
|
||||
/// DriverInstance / Equipment / Tag / NodeAcl output. Admin DiffViewer now shows
|
||||
/// Phase 7 changes between generations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Logical ids: ScriptId, VirtualTagId, ScriptedAlarmId — stable across generations
|
||||
/// so a Script whose source changes surfaces as Modified (CHECKSUM picks up the
|
||||
/// SourceHash delta) while a renamed script surfaces as Modified on Name alone.
|
||||
/// ScriptedAlarmState is deliberately excluded — it's not generation-scoped, so
|
||||
/// diffing it between generations is meaningless.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public partial class ExtendComputeGenerationDiffWithPhase7 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiffV3);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(Procs.ComputeGenerationDiffV2);
|
||||
}
|
||||
|
||||
private static class Procs
|
||||
{
|
||||
/// <summary>V3 — adds Script / VirtualTag / ScriptedAlarm sections.</summary>
|
||||
public const string ComputeGenerationDiffV3 = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||
t AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- Phase 7 — Script section. CHECKSUM picks up source changes via SourceHash + rename
|
||||
-- via Name; Language future-proofs for non-C# engines. Same Name + same Source =
|
||||
-- Unchanged (identical hash).
|
||||
WITH f AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT ScriptId AS LogicalId, CHECKSUM(Name, SourceHash, Language) AS Sig FROM dbo.Script WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Script', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- Phase 7 — VirtualTag section.
|
||||
WITH f AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT VirtualTagId AS LogicalId, CHECKSUM(EquipmentId, Name, DataType, ScriptId, ChangeTriggered, TimerIntervalMs, Historize, Enabled) AS Sig FROM dbo.VirtualTag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'VirtualTag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
-- Phase 7 — ScriptedAlarm section. ScriptedAlarmState (operator ack trail) is
|
||||
-- logical-id keyed outside the generation scope + intentionally excluded here —
|
||||
-- diffing ack state between generations is semantically meaningless.
|
||||
WITH f AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT ScriptedAlarmId AS LogicalId, CHECKSUM(EquipmentId, Name, AlarmType, Severity, MessageTemplate, PredicateScriptId, HistorizeToAveva, Retain, Enabled) AS Sig FROM dbo.ScriptedAlarm WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'ScriptedAlarm', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
|
||||
/// <summary>V2 — restores the pre-Phase-7 proc on Down().</summary>
|
||||
public const string ComputeGenerationDiffV2 = @"
|
||||
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
|
||||
@FromGenerationId bigint,
|
||||
@ToGenerationId bigint
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(128), ChangeKind nvarchar(16));
|
||||
|
||||
WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Namespace', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'DriverInstance', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Equipment', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
|
||||
t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'Tag', CONVERT(nvarchar(128), COALESCE(f.LogicalId, t.LogicalId)),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
WITH f AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @FromGenerationId),
|
||||
t AS (
|
||||
SELECT CONVERT(nvarchar(128), LdapGroup + '|' + CONVERT(nvarchar(16), ScopeKind) + '|' + ISNULL(ScopeId, '(cluster)')) AS LogicalId,
|
||||
CHECKSUM(ClusterId, PermissionFlags, Notes) AS Sig
|
||||
FROM dbo.NodeAcl WHERE GenerationId = @ToGenerationId)
|
||||
INSERT #diff
|
||||
SELECT 'NodeAcl', COALESCE(f.LogicalId, t.LogicalId),
|
||||
CASE WHEN f.LogicalId IS NULL THEN 'Added'
|
||||
WHEN t.LogicalId IS NULL THEN 'Removed'
|
||||
WHEN f.Sig <> t.Sig THEN 'Modified'
|
||||
ELSE 'Unchanged' END
|
||||
FROM f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
|
||||
WHERE f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;
|
||||
|
||||
SELECT TableName, LogicalId, ChangeKind FROM #diff;
|
||||
DROP TABLE #diff;
|
||||
END
|
||||
";
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,732 @@
|
||||
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>();
|
||||
public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
|
||||
public DbSet<DriverInstanceResilienceStatus> DriverInstanceResilienceStatuses => Set<DriverInstanceResilienceStatus>();
|
||||
public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
|
||||
public DbSet<EquipmentImportBatch> EquipmentImportBatches => Set<EquipmentImportBatch>();
|
||||
public DbSet<EquipmentImportRow> EquipmentImportRows => Set<EquipmentImportRow>();
|
||||
public DbSet<Script> Scripts => Set<Script>();
|
||||
public DbSet<VirtualTag> VirtualTags => Set<VirtualTag>();
|
||||
public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>();
|
||||
public DbSet<ScriptedAlarmState> ScriptedAlarmStates => Set<ScriptedAlarmState>();
|
||||
|
||||
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);
|
||||
ConfigureDriverHostStatus(modelBuilder);
|
||||
ConfigureDriverInstanceResilienceStatus(modelBuilder);
|
||||
ConfigureLdapGroupRoleMapping(modelBuilder);
|
||||
ConfigureEquipmentImportBatch(modelBuilder);
|
||||
ConfigureScript(modelBuilder);
|
||||
ConfigureVirtualTag(modelBuilder);
|
||||
ConfigureScriptedAlarm(modelBuilder);
|
||||
ConfigureScriptedAlarmState(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");
|
||||
t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson",
|
||||
"ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 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.Property(x => x.ResilienceConfig).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");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<DriverHostStatus>(e =>
|
||||
{
|
||||
e.ToTable("DriverHostStatus");
|
||||
// Composite key — one row per (server node, driver instance, probe-reported host).
|
||||
// A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces
|
||||
// 6 rows because each server node owns its own runtime view; the composite key is
|
||||
// what lets both views coexist without shadowing each other.
|
||||
e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName });
|
||||
e.Property(x => x.NodeId).HasMaxLength(64);
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.HostName).HasMaxLength(256);
|
||||
e.Property(x => x.State).HasConversion<string>().HasMaxLength(16);
|
||||
e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.Detail).HasMaxLength(1024);
|
||||
|
||||
// NodeId-only index drives the Admin UI's per-cluster drill-down (select all host
|
||||
// statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId).
|
||||
e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node");
|
||||
// LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N).
|
||||
e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureDriverInstanceResilienceStatus(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<DriverInstanceResilienceStatus>(e =>
|
||||
{
|
||||
e.ToTable("DriverInstanceResilienceStatus");
|
||||
e.HasKey(x => new { x.DriverInstanceId, x.HostName });
|
||||
e.Property(x => x.DriverInstanceId).HasMaxLength(64);
|
||||
e.Property(x => x.HostName).HasMaxLength(256);
|
||||
e.Property(x => x.LastCircuitBreakerOpenUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastRecycleUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastSampledUtc).HasColumnType("datetime2(3)");
|
||||
// LastSampledUtc drives the Admin UI's stale-sample filter same way DriverHostStatus's
|
||||
// LastSeenUtc index does for connectivity rows.
|
||||
e.HasIndex(x => x.LastSampledUtc).HasDatabaseName("IX_DriverResilience_LastSampled");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureLdapGroupRoleMapping(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<LdapGroupRoleMapping>(e =>
|
||||
{
|
||||
e.ToTable("LdapGroupRoleMapping");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.LdapGroup).HasMaxLength(512).IsRequired();
|
||||
e.Property(x => x.Role).HasConversion<string>().HasMaxLength(32);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.Notes).HasMaxLength(512);
|
||||
|
||||
// FK to ServerCluster when cluster-scoped; null for system-wide grants.
|
||||
e.HasOne(x => x.Cluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.ClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Uniqueness: one row per (LdapGroup, ClusterId). Null ClusterId is treated as its own
|
||||
// "bucket" so a system-wide row coexists with cluster-scoped rows for the same group.
|
||||
// SQL Server treats NULL as a distinct value in unique-index comparisons by default
|
||||
// since 2008 SP1 onwards under the session setting we use — tested in SchemaCompliance.
|
||||
e.HasIndex(x => new { x.LdapGroup, x.ClusterId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster");
|
||||
|
||||
// Hot-path lookup during cookie auth: "what grants does this user's set of LDAP
|
||||
// groups carry?". Fires on every sign-in so the index earns its keep.
|
||||
e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_LdapGroupRoleMapping_Group");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureEquipmentImportBatch(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<EquipmentImportBatch>(e =>
|
||||
{
|
||||
e.ToTable("EquipmentImportBatch");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.ClusterId).HasMaxLength(64);
|
||||
e.Property(x => x.CreatedBy).HasMaxLength(128);
|
||||
e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.FinalisedAtUtc).HasColumnType("datetime2(3)");
|
||||
|
||||
// Admin preview modal filters by user; finalise / drop both hit this index.
|
||||
e.HasIndex(x => new { x.CreatedBy, x.FinalisedAtUtc })
|
||||
.HasDatabaseName("IX_EquipmentImportBatch_Creator_Finalised");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<EquipmentImportRow>(e =>
|
||||
{
|
||||
e.ToTable("EquipmentImportRow");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.ZTag).HasMaxLength(128);
|
||||
e.Property(x => x.MachineCode).HasMaxLength(128);
|
||||
e.Property(x => x.SAPID).HasMaxLength(128);
|
||||
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||
e.Property(x => x.EquipmentUuid).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.UnsAreaName).HasMaxLength(64);
|
||||
e.Property(x => x.UnsLineName).HasMaxLength(64);
|
||||
e.Property(x => x.Manufacturer).HasMaxLength(256);
|
||||
e.Property(x => x.Model).HasMaxLength(256);
|
||||
e.Property(x => x.SerialNumber).HasMaxLength(256);
|
||||
e.Property(x => x.HardwareRevision).HasMaxLength(64);
|
||||
e.Property(x => x.SoftwareRevision).HasMaxLength(64);
|
||||
e.Property(x => x.YearOfConstruction).HasMaxLength(8);
|
||||
e.Property(x => x.AssetLocation).HasMaxLength(512);
|
||||
e.Property(x => x.ManufacturerUri).HasMaxLength(512);
|
||||
e.Property(x => x.DeviceManualUri).HasMaxLength(512);
|
||||
e.Property(x => x.RejectReason).HasMaxLength(512);
|
||||
|
||||
e.HasOne(x => x.Batch)
|
||||
.WithMany(b => b.Rows)
|
||||
.HasForeignKey(x => x.BatchId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasIndex(x => x.BatchId).HasDatabaseName("IX_EquipmentImportRow_Batch");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureScript(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Script>(e =>
|
||||
{
|
||||
e.ToTable("Script");
|
||||
e.HasKey(x => x.ScriptRowId);
|
||||
e.Property(x => x.ScriptRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.ScriptId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.SourceCode).HasColumnType("nvarchar(max)");
|
||||
e.Property(x => x.SourceHash).HasMaxLength(64);
|
||||
e.Property(x => x.Language).HasMaxLength(16);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ScriptId }).IsUnique().HasDatabaseName("UX_Script_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.SourceHash }).HasDatabaseName("IX_Script_Generation_SourceHash");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureVirtualTag(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<VirtualTag>(e =>
|
||||
{
|
||||
e.ToTable("VirtualTag", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne",
|
||||
"ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
|
||||
t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min",
|
||||
"TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
|
||||
});
|
||||
e.HasKey(x => x.VirtualTagRowId);
|
||||
e.Property(x => x.VirtualTagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.VirtualTagId).HasMaxLength(64);
|
||||
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.DataType).HasMaxLength(32);
|
||||
e.Property(x => x.ScriptId).HasMaxLength(64);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.VirtualTagId }).IsUnique().HasDatabaseName("UX_VirtualTag_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_VirtualTag_Generation_EquipmentPath");
|
||||
e.HasIndex(x => new { x.GenerationId, x.ScriptId }).HasDatabaseName("IX_VirtualTag_Generation_Script");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureScriptedAlarm(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ScriptedAlarm>(e =>
|
||||
{
|
||||
e.ToTable("ScriptedAlarm", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
|
||||
t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType",
|
||||
"AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
|
||||
});
|
||||
e.HasKey(x => x.ScriptedAlarmRowId);
|
||||
e.Property(x => x.ScriptedAlarmRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
|
||||
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
|
||||
e.Property(x => x.EquipmentId).HasMaxLength(64);
|
||||
e.Property(x => x.Name).HasMaxLength(128);
|
||||
e.Property(x => x.AlarmType).HasMaxLength(32);
|
||||
e.Property(x => x.MessageTemplate).HasMaxLength(1024);
|
||||
e.Property(x => x.PredicateScriptId).HasMaxLength(64);
|
||||
|
||||
e.HasOne(x => x.Generation).WithMany().HasForeignKey(x => x.GenerationId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasIndex(x => new { x.GenerationId, x.ScriptedAlarmId }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_Generation_LogicalId");
|
||||
e.HasIndex(x => new { x.GenerationId, x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_Generation_EquipmentPath");
|
||||
e.HasIndex(x => new { x.GenerationId, x.PredicateScriptId }).HasDatabaseName("IX_ScriptedAlarm_Generation_Script");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureScriptedAlarmState(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ScriptedAlarmState>(e =>
|
||||
{
|
||||
// Logical-id keyed (not generation-scoped) because ack state follows the alarm's
|
||||
// stable identity across generations — Modified alarms keep their ack audit trail.
|
||||
e.ToTable("ScriptedAlarmState", t =>
|
||||
{
|
||||
t.HasCheckConstraint("CK_ScriptedAlarmState_CommentsJson_IsJson", "ISJSON(CommentsJson) = 1");
|
||||
});
|
||||
e.HasKey(x => x.ScriptedAlarmId);
|
||||
e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
|
||||
e.Property(x => x.EnabledState).HasMaxLength(16);
|
||||
e.Property(x => x.AckedState).HasMaxLength(16);
|
||||
e.Property(x => x.ConfirmedState).HasMaxLength(16);
|
||||
e.Property(x => x.ShelvingState).HasMaxLength(16);
|
||||
e.Property(x => x.ShelvingExpiresUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastAckUser).HasMaxLength(128);
|
||||
e.Property(x => x.LastAckComment).HasMaxLength(1024);
|
||||
e.Property(x => x.LastAckUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.LastConfirmUser).HasMaxLength(128);
|
||||
e.Property(x => x.LastConfirmComment).HasMaxLength(1024);
|
||||
e.Property(x => x.LastConfirmUtc).HasColumnType("datetime2(3)");
|
||||
e.Property(x => x.CommentsJson).HasColumnType("nvarchar(max)");
|
||||
e.Property(x => x.UpdatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
/// <summary>
|
||||
/// CRUD surface for <see cref="LdapGroupRoleMapping"/> — the control-plane mapping from
|
||||
/// LDAP groups to Admin UI roles. Consumed only by Admin UI code paths; the OPC UA
|
||||
/// data-path evaluator MUST NOT depend on this interface (see decision #150 and the
|
||||
/// Phase 6.2 compliance check on control/data-plane separation).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per Phase 6.2 Stream A.2 this service is expected to run behind the Phase 6.1
|
||||
/// <c>ResilientConfigReader</c> pipeline (timeout → retry → fallback-to-cache) so a
|
||||
/// transient DB outage during sign-in falls back to the sealed snapshot rather than
|
||||
/// denying every login.
|
||||
/// </remarks>
|
||||
public interface ILdapGroupRoleMappingService
|
||||
{
|
||||
/// <summary>List every mapping whose LDAP group matches one of <paramref name="ldapGroups"/>.</summary>
|
||||
/// <remarks>
|
||||
/// Hot path — fires on every sign-in. The default EF implementation relies on the
|
||||
/// <c>IX_LdapGroupRoleMapping_Group</c> index. Case-insensitive per LDAP conventions.
|
||||
/// </remarks>
|
||||
Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Enumerate every mapping; Admin UI listing only.</summary>
|
||||
Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Create a new grant.</summary>
|
||||
/// <exception cref="InvalidLdapGroupRoleMappingException">
|
||||
/// Thrown when the proposed row violates an invariant (IsSystemWide inconsistent with
|
||||
/// ClusterId, duplicate (group, cluster) pair, etc.) — ValidatedLdapGroupRoleMappingService
|
||||
/// is the write surface that enforces these; the raw service here surfaces DB-level violations.
|
||||
/// </exception>
|
||||
Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Delete a mapping by its surrogate key.</summary>
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Thrown when <see cref="LdapGroupRoleMapping"/> authoring violates an invariant.</summary>
|
||||
public sealed class InvalidLdapGroupRoleMappingException : Exception
|
||||
{
|
||||
public InvalidLdapGroupRoleMappingException(string message) : base(message) { }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core implementation of <see cref="ILdapGroupRoleMappingService"/>. Enforces the
|
||||
/// "exactly one of (ClusterId, IsSystemWide)" invariant at the write surface so a
|
||||
/// malformed row can't land in the DB.
|
||||
/// </summary>
|
||||
public sealed class LdapGroupRoleMappingService(OtOpcUaConfigDbContext db) : ILdapGroupRoleMappingService
|
||||
{
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ldapGroups);
|
||||
var groupSet = ldapGroups.ToList();
|
||||
if (groupSet.Count == 0) return [];
|
||||
|
||||
return await db.LdapGroupRoleMappings
|
||||
.AsNoTracking()
|
||||
.Where(m => groupSet.Contains(m.LdapGroup))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||
=> await db.LdapGroupRoleMappings
|
||||
.AsNoTracking()
|
||||
.OrderBy(m => m.LdapGroup)
|
||||
.ThenBy(m => m.ClusterId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
public async Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
ValidateInvariants(row);
|
||||
|
||||
if (row.Id == Guid.Empty) row.Id = Guid.NewGuid();
|
||||
if (row.CreatedAtUtc == default) row.CreatedAtUtc = DateTime.UtcNow;
|
||||
|
||||
db.LdapGroupRoleMappings.Add(row);
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return row;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await db.LdapGroupRoleMappings.FindAsync([id], cancellationToken).ConfigureAwait(false);
|
||||
if (existing is null) return;
|
||||
db.LdapGroupRoleMappings.Remove(existing);
|
||||
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void ValidateInvariants(LdapGroupRoleMapping row)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.LdapGroup))
|
||||
throw new InvalidLdapGroupRoleMappingException("LdapGroup must not be empty.");
|
||||
|
||||
if (row.IsSystemWide && !string.IsNullOrEmpty(row.ClusterId))
|
||||
throw new InvalidLdapGroupRoleMappingException(
|
||||
"IsSystemWide=true requires ClusterId to be null. A fleet-wide grant cannot also be cluster-scoped.");
|
||||
|
||||
if (!row.IsSystemWide && string.IsNullOrEmpty(row.ClusterId))
|
||||
throw new InvalidLdapGroupRoleMappingException(
|
||||
"IsSystemWide=false requires a populated ClusterId. A cluster-scoped grant needs its target cluster.");
|
||||
}
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Phase 6.3 Stream A.2 + task #148 part 2 — managed pre-publish guard for cluster
|
||||
/// topology vs. <see cref="ServerCluster.RedundancyMode"/>. The SQL
|
||||
/// <c>CK_ServerCluster_RedundancyMode_NodeCount</c> CHECK already enforces the
|
||||
/// (NodeCount, RedundancyMode) pair on the row itself, but it cannot see the
|
||||
/// <see cref="ClusterNode.Enabled"/> flag on child nodes — an operator can toggle
|
||||
/// nodes off (effective count = 1) while leaving RedundancyMode at Hot and the
|
||||
/// constraint stays green. This check catches that drift before publish so the
|
||||
/// runtime doesn't boot into a topology the <see cref="Enums.RedundancyMode"/> claims
|
||||
/// is invalid.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called from the publish pipeline separately from <see cref="Validate"/> because the
|
||||
/// cluster/nodes rows aren't generation-versioned — they don't belong on
|
||||
/// <see cref="DraftSnapshot"/>. Returns every failing rule in one pass, same shape as
|
||||
/// <see cref="Validate"/>.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<ValidationError> ValidateClusterTopology(
|
||||
ServerCluster cluster,
|
||||
IReadOnlyList<ClusterNode> clusterNodes)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cluster);
|
||||
ArgumentNullException.ThrowIfNull(clusterNodes);
|
||||
|
||||
var errors = new List<ValidationError>();
|
||||
var enabledNodes = clusterNodes.Count(n => n.Enabled);
|
||||
|
||||
// Declared count must match declared mode (belt around the SQL CHECK).
|
||||
var declaredOk = (cluster.NodeCount, cluster.RedundancyMode) switch
|
||||
{
|
||||
(1, RedundancyMode.None) => true,
|
||||
(2, RedundancyMode.Warm) => true,
|
||||
(2, RedundancyMode.Hot) => true,
|
||||
_ => false,
|
||||
};
|
||||
if (!declaredOk)
|
||||
errors.Add(new("ClusterRedundancyModeInvalid",
|
||||
$"Cluster '{cluster.ClusterId}' declares NodeCount={cluster.NodeCount} + RedundancyMode={cluster.RedundancyMode}. " +
|
||||
$"Supported combinations: (1, None), (2, Warm), (2, Hot).",
|
||||
cluster.ClusterId));
|
||||
|
||||
// Enabled-node count must match declared count. Disabling a node to 1 while leaving
|
||||
// mode at Hot/Warm would boot the runtime into InvalidTopology band.
|
||||
if (enabledNodes != cluster.NodeCount)
|
||||
errors.Add(new("ClusterEnabledNodeCountMismatch",
|
||||
$"Cluster '{cluster.ClusterId}' declares NodeCount={cluster.NodeCount} but has {enabledNodes} Enabled nodes. " +
|
||||
$"Toggle the missing node(s) back on or change RedundancyMode/NodeCount to match.",
|
||||
cluster.ClusterId));
|
||||
|
||||
// Primary uniqueness — decision #84. Two Primary nodes is always an invariant violation
|
||||
// regardless of mode; catch it here so publish fails loud rather than the runtime
|
||||
// demoting both to ServiceLevelBand.InvalidTopology at boot.
|
||||
var primaryCount = clusterNodes.Count(n => n.Enabled && n.RedundancyRole == RedundancyRole.Primary);
|
||||
if (primaryCount > 1)
|
||||
errors.Add(new("ClusterMultiplePrimary",
|
||||
$"Cluster '{cluster.ClusterId}' has {primaryCount} Enabled Primary nodes. At most one Primary per cluster.",
|
||||
cluster.ClusterId));
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -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,43 @@
|
||||
<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="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0"/>
|
||||
<PackageReference Include="LiteDB" Version="5.0.21"/>
|
||||
<PackageReference Include="Polly.Core" Version="8.6.6"/>
|
||||
</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>
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-agnostic value snapshot returned by <see cref="IReadable"/> and pushed
|
||||
/// by <see cref="ISubscribable.OnDataChange"/>. Mirrors the OPC UA <c>DataValue</c>
|
||||
/// shape so the node-manager can pass through quality, source timestamp, and
|
||||
/// server timestamp without translation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #13 — every driver maps to the same
|
||||
/// OPC UA StatusCode space; this DTO is the universal carrier.
|
||||
/// </remarks>
|
||||
/// <param name="Value">The raw value; null when <see cref="StatusCode"/> indicates Bad.</param>
|
||||
/// <param name="StatusCode">OPC UA status code (numeric value matches the OPC UA spec).</param>
|
||||
/// <param name="SourceTimestampUtc">Driver-side timestamp when the value was sampled at the source. Null if unavailable.</param>
|
||||
/// <param name="ServerTimestampUtc">Driver-side timestamp when the driver received / processed the value.</param>
|
||||
public sealed record DataValueSnapshot(
|
||||
object? Value,
|
||||
uint StatusCode,
|
||||
DateTime? SourceTimestampUtc,
|
||||
DateTime ServerTimestampUtc);
|
||||
@@ -0,0 +1,73 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-agnostic per-attribute (tag) descriptor used by the generic node-manager
|
||||
/// to build OPC UA address-space variables. Every driver maps its native attribute
|
||||
/// metadata into this DTO during discovery.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> §5a (LmxNodeManager reusability) — <c>DriverAttributeInfo</c>
|
||||
/// replaces the v1 Galaxy-specific <c>GalaxyAttributeInfo</c> in the generic node-manager
|
||||
/// so the same node-manager class works against every driver.
|
||||
/// </remarks>
|
||||
/// <param name="FullName">
|
||||
/// Driver-side full reference for read/write addressing
|
||||
/// (e.g. for Galaxy: <c>"DelmiaReceiver_001.DownloadPath"</c>).
|
||||
/// </param>
|
||||
/// <param name="DriverDataType">Driver-agnostic data type; maps to OPC UA built-in type at build time.</param>
|
||||
/// <param name="IsArray">True when this attribute is a 1-D array.</param>
|
||||
/// <param name="ArrayDim">Declared array length when <see cref="IsArray"/> is true; null otherwise.</param>
|
||||
/// <param name="SecurityClass">Write-authorization tier for this attribute.</param>
|
||||
/// <param name="IsHistorized">True when this attribute is expected to feed historian / HistoryRead.</param>
|
||||
/// <param name="IsAlarm">
|
||||
/// True when this attribute represents an alarm condition (Galaxy: has an
|
||||
/// <c>AlarmExtension</c> primitive). The generic node-manager enriches the variable with an
|
||||
/// OPC UA <c>AlarmConditionState</c> when true. Defaults to false so existing non-Galaxy
|
||||
/// drivers aren't forced to flow a flag they don't produce.
|
||||
/// </param>
|
||||
/// <param name="WriteIdempotent">
|
||||
/// True when a timed-out or failed write to this attribute is safe to replay. Per
|
||||
/// <c>docs/v2/plan.md</c> decisions #44, #45, #143 — writes are NOT auto-retried by default
|
||||
/// because replaying a pulse / alarm-ack / counter-increment / recipe-step advance can
|
||||
/// duplicate field actions. Drivers flag only tags whose semantics make retry safe
|
||||
/// (holding registers with level-set values, set-point writes to analog tags) — the
|
||||
/// capability invoker respects this flag when deciding whether to apply Polly retry.
|
||||
/// </param>
|
||||
/// <param name="Source">
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's dispatch.
|
||||
/// Defaults to <see cref="NodeSourceKind.Driver"/> so existing callers are unchanged.
|
||||
/// </param>
|
||||
/// <param name="VirtualTagId">
|
||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.Virtual"/> — stable
|
||||
/// logical id the VirtualTagEngine addresses by. Null otherwise.
|
||||
/// </param>
|
||||
/// <param name="ScriptedAlarmId">
|
||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
||||
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
||||
/// </param>
|
||||
public sealed record DriverAttributeInfo(
|
||||
string FullName,
|
||||
DriverDataType DriverDataType,
|
||||
bool IsArray,
|
||||
uint? ArrayDim,
|
||||
SecurityClassification SecurityClass,
|
||||
bool IsHistorized,
|
||||
bool IsAlarm = false,
|
||||
bool WriteIdempotent = false,
|
||||
NodeSourceKind Source = NodeSourceKind.Driver,
|
||||
string? VirtualTagId = null,
|
||||
string? ScriptedAlarmId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
||||
/// Subscribe dispatch. <c>Driver</c> = a real IDriver capability surface;
|
||||
/// <c>Virtual</c> = a Phase 7 <see cref="DriverAttributeInfo"/>.VirtualTagId'd tag
|
||||
/// computed by the VirtualTagEngine; <c>ScriptedAlarm</c> = a scripted Part 9 alarm
|
||||
/// materialized by the ScriptedAlarmEngine.
|
||||
/// </summary>
|
||||
public enum NodeSourceKind
|
||||
{
|
||||
Driver = 0,
|
||||
Virtual = 1,
|
||||
ScriptedAlarm = 2,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the driver-capability surface points guarded by Phase 6.1 resilience pipelines.
|
||||
/// Each value corresponds to one method (or tightly-related method group) on the
|
||||
/// <c>Core.Abstractions</c> capability interfaces (<see cref="IReadable"/>, <see cref="IWritable"/>,
|
||||
/// <see cref="ITagDiscovery"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>,
|
||||
/// <see cref="IAlarmSource"/>, <see cref="IHistoryProvider"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #143 (per-capability retry policy): Read / HistoryRead /
|
||||
/// Discover / Probe / AlarmSubscribe auto-retry; <see cref="Write"/> does NOT retry unless the
|
||||
/// tag-definition carries <see cref="WriteIdempotentAttribute"/>. Alarm-acknowledge is treated
|
||||
/// as a write for retry semantics (an alarm-ack is not idempotent at the plant-floor acknowledgement
|
||||
/// level even if the OPC UA spec permits re-issue).
|
||||
/// </remarks>
|
||||
public enum DriverCapability
|
||||
{
|
||||
/// <summary>Batch <see cref="IReadable.ReadAsync"/>. Retries by default.</summary>
|
||||
Read,
|
||||
|
||||
/// <summary>Batch <see cref="IWritable.WriteAsync"/>. Does not retry unless tag is <see cref="WriteIdempotentAttribute">idempotent</see>.</summary>
|
||||
Write,
|
||||
|
||||
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
|
||||
Discover,
|
||||
|
||||
/// <summary><see cref="ISubscribable.SubscribeAsync"/> and unsubscribe. Retries by default.</summary>
|
||||
Subscribe,
|
||||
|
||||
/// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary>
|
||||
Probe,
|
||||
|
||||
/// <summary><see cref="IAlarmSource.SubscribeAlarmsAsync"/>. Retries by default.</summary>
|
||||
AlarmSubscribe,
|
||||
|
||||
/// <summary><see cref="IAlarmSource.AcknowledgeAsync"/>. Does NOT retry — ack is a write-shaped operation (decision #143).</summary>
|
||||
AlarmAcknowledge,
|
||||
|
||||
/// <summary><see cref="IHistoryProvider"/> reads (Raw/Processed/AtTime/Events). Retries by default.</summary>
|
||||
HistoryRead,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver-agnostic data type for an attribute or signal.
|
||||
/// Maps to OPC UA built-in types at the address-space build layer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/driver-specs.md</c> driver DataType columns, every driver maps its
|
||||
/// native types into this enumeration. Mirrors the OPC UA built-in type set commonly
|
||||
/// seen across Modbus / S7 / AB CIP / TwinCAT / FANUC / Galaxy.
|
||||
/// </remarks>
|
||||
public enum DriverDataType
|
||||
{
|
||||
Boolean,
|
||||
Int16,
|
||||
Int32,
|
||||
Int64,
|
||||
UInt16,
|
||||
UInt32,
|
||||
UInt64,
|
||||
Float32,
|
||||
Float64,
|
||||
String,
|
||||
DateTime,
|
||||
|
||||
/// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary>
|
||||
Reference,
|
||||
}
|
||||
38
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs
Normal file
38
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverHealth.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Health snapshot a driver returns to the Core. Drives the status dashboard,
|
||||
/// ServiceLevel computation, and Bad-quality fan-out decisions.
|
||||
/// </summary>
|
||||
/// <param name="State">Current driver-instance state.</param>
|
||||
/// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param>
|
||||
/// <param name="LastError">Most recent error message; null when state is Healthy.</param>
|
||||
public sealed record DriverHealth(
|
||||
DriverState State,
|
||||
DateTime? LastSuccessfulRead,
|
||||
string? LastError);
|
||||
|
||||
/// <summary>Driver-instance lifecycle state.</summary>
|
||||
public enum DriverState
|
||||
{
|
||||
/// <summary>Driver has not been initialized yet.</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>Driver is in the middle of <see cref="IDriver.InitializeAsync"/> or <see cref="IDriver.ReinitializeAsync"/>.</summary>
|
||||
Initializing,
|
||||
|
||||
/// <summary>Driver is connected and serving data.</summary>
|
||||
Healthy,
|
||||
|
||||
/// <summary>Driver is connected but reporting degraded data (e.g. some equipment unreachable, some tags Bad).</summary>
|
||||
Degraded,
|
||||
|
||||
/// <summary>Driver lost connection to its data source; reconnecting in the background.</summary>
|
||||
Reconnecting,
|
||||
|
||||
/// <summary>
|
||||
/// Driver hit an unrecoverable error and stopped trying.
|
||||
/// Operator must reinitialize via Admin UI; nodes report Bad quality.
|
||||
/// </summary>
|
||||
Faulted,
|
||||
}
|
||||
34
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTier.cs
Normal file
34
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTier.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Stability tier of a driver type. Determines which cross-cutting runtime protections
|
||||
/// apply — per-tier retry defaults, memory-tracking thresholds, and whether out-of-process
|
||||
/// supervision with process-level recycle is in play.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/driver-stability.md</c> §2-4 and <c>docs/v2/plan.md</c> decisions #63-74.
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>A</b> — managed, known-good SDK; low blast radius. In-process. Fast retries.
|
||||
/// Examples: OPC UA Client (OPCFoundation stack), S7 (S7NetPlus).</item>
|
||||
/// <item><b>B</b> — native or semi-trusted SDK with an in-process footprint. Examples: Modbus.</item>
|
||||
/// <item><b>C</b> — unmanaged SDK with COM/STA constraints, leak risk, or other out-of-process
|
||||
/// requirements. Must run as a separate Host process behind a Proxy with a supervisor that
|
||||
/// can recycle the process on hard-breach. Example: Galaxy (MXAccess COM).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Process-kill protections (<c>MemoryRecycle</c>, <c>ScheduledRecycleScheduler</c>) are
|
||||
/// Tier C only per decisions #73-74 and #145 — killing an in-process Tier A/B driver also kills
|
||||
/// every OPC UA session and every co-hosted driver, blast-radius worse than the leak.</para>
|
||||
/// </remarks>
|
||||
public enum DriverTier
|
||||
{
|
||||
/// <summary>Managed SDK, in-process, low blast radius.</summary>
|
||||
A,
|
||||
|
||||
/// <summary>Native or semi-trusted SDK, in-process.</summary>
|
||||
B,
|
||||
|
||||
/// <summary>Unmanaged SDK, out-of-process required with Proxy+Host+Supervisor.</summary>
|
||||
C,
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Process-singleton registry of driver types known to this OtOpcUa instance.
|
||||
/// Per-driver assemblies register their type metadata at startup; the Core uses
|
||||
/// the registry to validate <c>DriverInstance.DriverType</c> values from the central config DB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decisions #91 (JSON content validation in Admin app, not SQL CLR)
|
||||
/// and #111 (driver type → namespace kind mapping enforced by sp_ValidateDraft).
|
||||
/// The registry is the source of truth for both checks.
|
||||
///
|
||||
/// Thread-safety: registration happens at startup (single thread); lookups happen on every
|
||||
/// config-apply (multi-threaded). The internal dictionary is replaced atomically via
|
||||
/// <see cref="System.Threading.Interlocked"/> on register; readers see a stable snapshot.
|
||||
/// </remarks>
|
||||
public sealed class DriverTypeRegistry
|
||||
{
|
||||
private IReadOnlyDictionary<string, DriverTypeMetadata> _types =
|
||||
new Dictionary<string, DriverTypeMetadata>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Register a driver type. Throws if the type name is already registered.</summary>
|
||||
public void Register(DriverTypeMetadata metadata)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(metadata);
|
||||
|
||||
var snapshot = _types;
|
||||
if (snapshot.ContainsKey(metadata.TypeName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Driver type '{metadata.TypeName}' is already registered. " +
|
||||
$"Each driver type may be registered only once per process.");
|
||||
}
|
||||
|
||||
var next = new Dictionary<string, DriverTypeMetadata>(snapshot, StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[metadata.TypeName] = metadata,
|
||||
};
|
||||
Interlocked.Exchange(ref _types, next);
|
||||
}
|
||||
|
||||
/// <summary>Look up a driver type by name. Throws if unknown.</summary>
|
||||
public DriverTypeMetadata Get(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
|
||||
if (_types.TryGetValue(driverType, out var metadata))
|
||||
return metadata;
|
||||
|
||||
throw new KeyNotFoundException(
|
||||
$"Driver type '{driverType}' is not registered. " +
|
||||
$"Known types: {string.Join(", ", _types.Keys)}.");
|
||||
}
|
||||
|
||||
/// <summary>Try to look up a driver type by name. Returns null if unknown (no exception).</summary>
|
||||
public DriverTypeMetadata? TryGet(string driverType)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(driverType);
|
||||
return _types.GetValueOrDefault(driverType);
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of all registered driver types.</summary>
|
||||
public IReadOnlyCollection<DriverTypeMetadata> All() => _types.Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Per-driver-type metadata used by the Core, validator, and Admin UI.</summary>
|
||||
/// <param name="TypeName">Driver type name (matches <c>DriverInstance.DriverType</c> column values).</param>
|
||||
/// <param name="AllowedNamespaceKinds">Which namespace kinds this driver type may be bound to.</param>
|
||||
/// <param name="DriverConfigJsonSchema">JSON Schema (Draft 2020-12) the driver's <c>DriverConfig</c> column must validate against.</param>
|
||||
/// <param name="DeviceConfigJsonSchema">JSON Schema for <c>DeviceConfig</c> (multi-device drivers); null if the driver has no device layer.</param>
|
||||
/// <param name="TagConfigJsonSchema">JSON Schema for <c>TagConfig</c>; required for every driver since every driver has tags.</param>
|
||||
/// <param name="Tier">
|
||||
/// Stability tier per <c>docs/v2/driver-stability.md</c> §2-4 and <c>docs/v2/plan.md</c>
|
||||
/// decisions #63-74. Drives the shared resilience pipeline defaults
|
||||
/// (<see cref="Tier"/> × capability → <c>CapabilityPolicy</c>), the <c>MemoryTracking</c>
|
||||
/// hybrid-formula constants, and whether process-level <c>MemoryRecycle</c> / scheduled-
|
||||
/// recycle protections apply (Tier C only). Every registered driver type must declare one.
|
||||
/// </param>
|
||||
public sealed record DriverTypeMetadata(
|
||||
string TypeName,
|
||||
NamespaceKindCompatibility AllowedNamespaceKinds,
|
||||
string DriverConfigJsonSchema,
|
||||
string? DeviceConfigJsonSchema,
|
||||
string TagConfigJsonSchema,
|
||||
DriverTier Tier);
|
||||
|
||||
/// <summary>Bitmask of namespace kinds a driver type may populate. Per decision #111.</summary>
|
||||
[Flags]
|
||||
public enum NamespaceKindCompatibility
|
||||
{
|
||||
/// <summary>Driver does not populate any namespace (invalid; should never appear in registry).</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Driver may populate Equipment-kind namespaces (UNS path, Equipment rows).</summary>
|
||||
Equipment = 1,
|
||||
|
||||
/// <summary>Driver may populate SystemPlatform-kind namespaces (Galaxy hierarchy, FolderPath).</summary>
|
||||
SystemPlatform = 2,
|
||||
|
||||
/// <summary>Driver may populate the future Simulated namespace (replay driver — not in v2.0).</summary>
|
||||
Simulated = 4,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time state of a single historian cluster node, included inside
|
||||
/// <see cref="HistorianHealthSnapshot.Nodes"/> when the backend is clustered.
|
||||
/// </summary>
|
||||
/// <param name="Name">Node identifier — backend-specific (typically a hostname).</param>
|
||||
/// <param name="IsHealthy">True when the node is currently considered usable for reads.</param>
|
||||
/// <param name="CooldownUntil">When the next retry against an unhealthy node is allowed; null when no cooldown is active.</param>
|
||||
/// <param name="FailureCount">Consecutive failures observed against this node since the last success.</param>
|
||||
/// <param name="LastError">Diagnostic text from the last failure against this node; null when no failures.</param>
|
||||
/// <param name="LastFailureTime">UTC of the last failure against this node; null when no failures.</param>
|
||||
public sealed record HistorianClusterNodeState(
|
||||
string Name,
|
||||
bool IsHealthy,
|
||||
DateTime? CooldownUntil,
|
||||
int FailureCount,
|
||||
string? LastError,
|
||||
DateTime? LastFailureTime);
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time runtime health of a historian data source. Returned by
|
||||
/// <see cref="IHistorianDataSource.GetHealthSnapshot"/> and projected onto the
|
||||
/// server status dashboard.
|
||||
/// </summary>
|
||||
/// <param name="TotalQueries">Lifetime count of read calls received.</param>
|
||||
/// <param name="TotalSuccesses">Subset of <paramref name="TotalQueries"/> that completed without error.</param>
|
||||
/// <param name="TotalFailures">Subset of <paramref name="TotalQueries"/> that ended in error.</param>
|
||||
/// <param name="ConsecutiveFailures">Failures since the last success — non-zero means the source is currently degraded.</param>
|
||||
/// <param name="LastSuccessTime">UTC of the most recent successful read; null if none yet.</param>
|
||||
/// <param name="LastFailureTime">UTC of the most recent failed read; null if none yet.</param>
|
||||
/// <param name="LastError">Diagnostic text from the most recent failure; null when no failures recorded.</param>
|
||||
/// <param name="ProcessConnectionOpen">True when the source's process-data connection is currently established.</param>
|
||||
/// <param name="EventConnectionOpen">True when the source's event-data connection is currently established. Some backends share one connection — implementations may report the same value here as <paramref name="ProcessConnectionOpen"/>.</param>
|
||||
/// <param name="ActiveProcessNode">Cluster node currently serving process reads; null when no node is active or the backend is non-clustered.</param>
|
||||
/// <param name="ActiveEventNode">Cluster node currently serving event reads; null when no node is active or the backend is non-clustered.</param>
|
||||
/// <param name="Nodes">Per-cluster-node state. Empty when the backend is non-clustered.</param>
|
||||
public sealed record HistorianHealthSnapshot(
|
||||
long TotalQueries,
|
||||
long TotalSuccesses,
|
||||
long TotalFailures,
|
||||
int ConsecutiveFailures,
|
||||
DateTime? LastSuccessTime,
|
||||
DateTime? LastFailureTime,
|
||||
string? LastError,
|
||||
bool ProcessConnectionOpen,
|
||||
bool EventConnectionOpen,
|
||||
string? ActiveProcessNode,
|
||||
string? ActiveEventNode,
|
||||
IReadOnlyList<HistorianClusterNodeState> Nodes);
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side historian data source. Registered with the server's history router
|
||||
/// and resolved per OPC UA namespace, independent of any driver's lifecycle.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Distinct from <see cref="IHistoryProvider"/>:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="IHistoryProvider"/> is a *driver capability* — the server
|
||||
/// dispatches to it via the driver instance.</item>
|
||||
/// <item><see cref="IHistorianDataSource"/> is a *server registration* — the
|
||||
/// server resolves it via namespace and calls it directly, so a single
|
||||
/// historian (e.g. Wonderware) can serve many drivers' nodes, and drivers can
|
||||
/// restart without dropping history availability.</item>
|
||||
/// </list>
|
||||
/// All values returned use the shared <see cref="DataValueSnapshot"/> /
|
||||
/// <see cref="HistoricalEvent"/> shapes; backend-specific quality / type encodings
|
||||
/// are translated to OPC UA <c>StatusCode</c> uints inside the data source.
|
||||
/// </remarks>
|
||||
public interface IHistorianDataSource : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Read raw historical samples for a single tag over a time range.
|
||||
/// </summary>
|
||||
Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read processed (interval-bucketed) samples — average / min / max / count / etc.
|
||||
/// A bucket with no source data returns a sample whose
|
||||
/// <see cref="DataValueSnapshot.StatusCode"/> indicates BadNoData.
|
||||
/// </summary>
|
||||
Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
TimeSpan interval,
|
||||
HistoryAggregateType aggregate,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service.
|
||||
/// Implementations interpolate or return prior-boundary samples per their
|
||||
/// backend's policy. The returned list MUST be the same length and order as
|
||||
/// <paramref name="timestampsUtc"/>; gaps are returned as Bad-quality snapshots.
|
||||
/// </summary>
|
||||
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read historical alarm / event records — OPC UA HistoryReadEvents service.
|
||||
/// Distinct from any live event stream; sources here come from the historian's
|
||||
/// event log. <paramref name="sourceName"/> is null to return all sources.
|
||||
/// </summary>
|
||||
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Point-in-time health snapshot for diagnostics and dashboards. Pure
|
||||
/// observation; never blocks on backend I/O.
|
||||
/// </summary>
|
||||
HistorianHealthSnapshot GetHealthSnapshot();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Streaming builder API a driver uses to register OPC UA nodes during discovery.
|
||||
/// Core owns the tree; driver streams <c>AddFolder</c> / <c>AddVariable</c> calls
|
||||
/// as it discovers nodes — no buffering of the whole tree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #52 — drivers register nodes via this builder
|
||||
/// rather than returning a tree object. Supports incremental / large address spaces
|
||||
/// without forcing the driver to buffer the whole tree.
|
||||
/// </remarks>
|
||||
public interface IAddressSpaceBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Add a folder node. Returns a child builder scoped to inside this folder, so subsequent
|
||||
/// calls on the child place nodes under it.
|
||||
/// </summary>
|
||||
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent).</param>
|
||||
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
|
||||
IAddressSpaceBuilder Folder(string browseName, string displayName);
|
||||
|
||||
/// <summary>
|
||||
/// Add a variable node corresponding to a tag. Driver-side full reference + data-type
|
||||
/// metadata come from the <see cref="DriverAttributeInfo"/> DTO.
|
||||
/// </summary>
|
||||
/// <param name="browseName">OPC UA browse name (the segment of the path under the parent folder).</param>
|
||||
/// <param name="displayName">Human-readable display name. May equal <paramref name="browseName"/>.</param>
|
||||
/// <param name="attributeInfo">Driver-side metadata for the variable.</param>
|
||||
IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo);
|
||||
|
||||
/// <summary>
|
||||
/// Add a property to the current node (folder or variable). Properties are static metadata
|
||||
/// read once at build time (e.g. OPC 40010 Identification fields per the schemas-repo
|
||||
/// <c>_base</c> equipment-class template).
|
||||
/// </summary>
|
||||
void AddProperty(string browseName, DriverDataType dataType, object? value);
|
||||
}
|
||||
|
||||
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
|
||||
public interface IVariableHandle
|
||||
{
|
||||
/// <summary>Driver-side full reference for read/write addressing.</summary>
|
||||
string FullReference { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotate this variable with an OPC UA <c>AlarmConditionState</c>. Drivers with
|
||||
/// <see cref="DriverAttributeInfo.IsAlarm"/> = true call this during discovery so the
|
||||
/// concrete address-space builder can materialize a sibling condition node. The returned
|
||||
/// sink receives lifecycle transitions raised through <see cref="IAlarmSource.OnAlarmEvent"/>
|
||||
/// — the generic node manager wires the subscription; the concrete builder decides how
|
||||
/// to surface the state (e.g. OPC UA <c>AlarmConditionState.Activate</c>,
|
||||
/// <c>Acknowledge</c>, <c>Deactivate</c>).
|
||||
/// </summary>
|
||||
IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata used to materialize an OPC UA <c>AlarmConditionState</c> sibling for a variable.
|
||||
/// Populated by the driver's discovery step; concrete builders decide how to surface it.
|
||||
/// </summary>
|
||||
/// <param name="SourceName">Human-readable alarm name used for the <c>SourceName</c> event field.</param>
|
||||
/// <param name="InitialSeverity">Severity at address-space build time; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
|
||||
/// <param name="InitialDescription">Initial description; updates arrive via <see cref="IAlarmConditionSink"/>.</param>
|
||||
/// <param name="InAlarmRef">
|
||||
/// Driver-side full reference for the boolean attribute that toggles when the
|
||||
/// alarm condition becomes active. Consumed by the server-level alarm-condition
|
||||
/// service to subscribe to active/inactive transitions. Null when the driver
|
||||
/// reports alarm transitions through some other channel.
|
||||
/// </param>
|
||||
/// <param name="PriorityRef">
|
||||
/// Driver-side full reference for the integer attribute carrying the alarm's
|
||||
/// current priority / severity. Live updates flow through the same subscription
|
||||
/// pipeline as <paramref name="InAlarmRef"/>. Null when the driver does not
|
||||
/// expose live priority changes.
|
||||
/// </param>
|
||||
/// <param name="DescAttrNameRef">
|
||||
/// Driver-side full reference for the string attribute carrying the human-readable
|
||||
/// description / message. Null when the driver does not expose a live description.
|
||||
/// </param>
|
||||
/// <param name="AckedRef">
|
||||
/// Driver-side full reference for the boolean attribute that toggles when the
|
||||
/// alarm is acknowledged. Null when acknowledgement is not observable on the
|
||||
/// driver side.
|
||||
/// </param>
|
||||
/// <param name="AckMsgWriteRef">
|
||||
/// Driver-side full reference the server writes to acknowledge the condition,
|
||||
/// typically the alarm's <c>.AckMsg</c> attribute. Null when the driver does not
|
||||
/// accept acknowledgement writes (or routes them through a separate API).
|
||||
/// </param>
|
||||
public sealed record AlarmConditionInfo(
|
||||
string SourceName,
|
||||
AlarmSeverity InitialSeverity,
|
||||
string? InitialDescription,
|
||||
string? InAlarmRef = null,
|
||||
string? PriorityRef = null,
|
||||
string? DescAttrNameRef = null,
|
||||
string? AckedRef = null,
|
||||
string? AckMsgWriteRef = null);
|
||||
|
||||
/// <summary>
|
||||
/// Sink a concrete address-space builder returns from <see cref="IVariableHandle.MarkAsAlarmCondition"/>.
|
||||
/// The generic node manager routes per-alarm <see cref="IAlarmSource.OnAlarmEvent"/> payloads here —
|
||||
/// the sink translates the transition into an OPC UA condition state change or whatever the
|
||||
/// concrete builder's backing address space supports.
|
||||
/// </summary>
|
||||
public interface IAlarmConditionSink
|
||||
{
|
||||
/// <summary>Push an alarm transition (Active / Acknowledged / Inactive) for this condition.</summary>
|
||||
void OnTransition(AlarmEventArgs args);
|
||||
}
|
||||
81
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
Normal file
81
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IAlarmSource.cs
Normal file
@@ -0,0 +1,81 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver capability for alarm events. Optional — only drivers whose backends expose
|
||||
/// alarm conditions implement this. Currently: Galaxy (MxAccess alarms), FOCAS
|
||||
/// (CNC alarms), OPC UA Client (A&C events from upstream server).
|
||||
/// </summary>
|
||||
public interface IAlarmSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribe to alarm events for a node-set (typically: a folder or equipment subtree).
|
||||
/// The driver fires <see cref="OnAlarmEvent"/> for every alarm transition.
|
||||
/// </summary>
|
||||
Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Cancel an alarm subscription returned by <see cref="SubscribeAlarmsAsync"/>.</summary>
|
||||
Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Acknowledge one or more active alarms by source node ID + condition ID.</summary>
|
||||
Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Server-pushed alarm transition (raise / clear / change).</summary>
|
||||
event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
}
|
||||
|
||||
/// <summary>Opaque alarm-subscription identity returned by <see cref="IAlarmSource.SubscribeAlarmsAsync"/>.</summary>
|
||||
public interface IAlarmSubscriptionHandle
|
||||
{
|
||||
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
|
||||
string DiagnosticId { get; }
|
||||
}
|
||||
|
||||
/// <summary>One alarm acknowledgement in a batch.</summary>
|
||||
public sealed record AlarmAcknowledgeRequest(
|
||||
string SourceNodeId,
|
||||
string ConditionId,
|
||||
string? Comment);
|
||||
|
||||
/// <summary>Event payload for <see cref="IAlarmSource.OnAlarmEvent"/>.</summary>
|
||||
/// <param name="SubscriptionHandle">Subscription this event belongs to.</param>
|
||||
/// <param name="SourceNodeId">Driver-side identifier for the alarm source.</param>
|
||||
/// <param name="ConditionId">Stable id correlating raise / ack / clear of the same condition.</param>
|
||||
/// <param name="AlarmType">Driver-defined alarm type name (e.g. AnalogLimitAlarm.HiHi).</param>
|
||||
/// <param name="Message">Human-readable alarm description.</param>
|
||||
/// <param name="Severity">Four-bucket severity ladder.</param>
|
||||
/// <param name="SourceTimestampUtc">When this transition occurred.</param>
|
||||
/// <param name="OperatorComment">
|
||||
/// Operator-supplied comment recorded by the upstream alarm system on Acknowledge
|
||||
/// transitions. Null on raise / clear, or when the upstream path can't surface
|
||||
/// the comment (the Galaxy sub-attribute fallback path collapses comments into a
|
||||
/// single string write — null on that path; the driver-native gateway path
|
||||
/// populates this).
|
||||
/// </param>
|
||||
/// <param name="OriginalRaiseTimestampUtc">
|
||||
/// When the alarm originally entered the active state. Preserved across
|
||||
/// Acknowledge transitions so OPC UA Part 9 conditions keep the original raise
|
||||
/// time in <c>Time</c>. Null when the upstream path doesn't surface it.
|
||||
/// </param>
|
||||
/// <param name="AlarmCategory">
|
||||
/// Upstream alarm taxonomy bucket (e.g. <c>Process</c> / <c>Safety</c> /
|
||||
/// <c>Diagnostics</c>). Maps to OPC UA <c>ConditionClassName</c> downstream when
|
||||
/// a class mapping is configured. Null when the upstream path doesn't carry it.
|
||||
/// </param>
|
||||
public sealed record AlarmEventArgs(
|
||||
IAlarmSubscriptionHandle SubscriptionHandle,
|
||||
string SourceNodeId,
|
||||
string ConditionId,
|
||||
string AlarmType,
|
||||
string Message,
|
||||
AlarmSeverity Severity,
|
||||
DateTime SourceTimestampUtc,
|
||||
string? OperatorComment = null,
|
||||
DateTime? OriginalRaiseTimestampUtc = null,
|
||||
string? AlarmCategory = null);
|
||||
|
||||
/// <summary>Mirrors the <c>NodePermissions</c> alarm-severity enum in <c>docs/v2/acl-design.md</c>.</summary>
|
||||
public enum AlarmSeverity { Low, Medium, High, Critical }
|
||||
60
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs
Normal file
60
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriver.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Required capability for every driver instance. Owns lifecycle, metadata, health.
|
||||
/// Other capabilities (<see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
|
||||
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IAlarmSource"/>,
|
||||
/// <see cref="IHistoryProvider"/>, <see cref="IRediscoverable"/>,
|
||||
/// <see cref="IHostConnectivityProbe"/>) are composable — a driver implements only what its
|
||||
/// backend actually supports.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decisions #4 (composable capability interfaces) and #53
|
||||
/// (capability discovery via <c>is</c> checks — no redundant flag enum).
|
||||
/// </remarks>
|
||||
public interface IDriver
|
||||
{
|
||||
/// <summary>Stable logical ID of this driver instance, sourced from the central config DB.</summary>
|
||||
string DriverInstanceId { get; }
|
||||
|
||||
/// <summary>Driver type name (e.g. "Galaxy", "ModbusTcp", "AbCip"). Matches <c>DriverInstance.DriverType</c>.</summary>
|
||||
string DriverType { get; }
|
||||
|
||||
/// <summary>Initialize the driver from its <c>DriverConfig</c> JSON; open connections; prepare for first use.</summary>
|
||||
Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Apply a config change in place without tearing down the driver process.
|
||||
/// Used by <c>IGenerationApplier</c> when only this driver's config changed in the new generation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/driver-stability.md</c> §"In-process only (Tier A/B)" — Reinitialize is the
|
||||
/// only Core-initiated recovery path for in-process drivers; if it fails, the driver instance
|
||||
/// is marked Faulted and its nodes go Bad quality, but the server process keeps running.
|
||||
/// </remarks>
|
||||
Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Stop the driver, close connections, release resources. Called on shutdown or driver removal.</summary>
|
||||
Task ShutdownAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Current health snapshot, polled by Core for the status dashboard and ServiceLevel.</summary>
|
||||
DriverHealth GetHealth();
|
||||
|
||||
/// <summary>
|
||||
/// Approximate driver-attributable footprint in bytes (caches, queues, symbol tables).
|
||||
/// Polled every 30s by Core; on cache-budget breach, Core asks the driver to flush via
|
||||
/// <see cref="FlushOptionalCachesAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/driver-stability.md</c> §"In-process only (Tier A/B) — driver-instance
|
||||
/// allocation tracking". Tier C drivers (process-isolated) report through the same
|
||||
/// interface but the cache-flush is internal to their host.
|
||||
/// </remarks>
|
||||
long GetMemoryFootprint();
|
||||
|
||||
/// <summary>
|
||||
/// Drop optional caches (symbol cache, browse cache, etc.) to bring footprint back below budget.
|
||||
/// Required-for-correctness state must NOT be flushed.
|
||||
/// </summary>
|
||||
Task FlushOptionalCachesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional plug-point a driver implements to provide a custom Admin UI editor for its
|
||||
/// <c>DriverConfig</c> JSON. Drivers that don't implement this fall back to the generic
|
||||
/// JSON editor with schema-driven validation against the registered JSON schema.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #27 — driver-specific config editors are deferred
|
||||
/// to each driver's implementation phase; v2.0 ships with the generic JSON editor as the
|
||||
/// default. This interface is the future plug-point so phase-specific editors can land
|
||||
/// incrementally.
|
||||
///
|
||||
/// The actual UI rendering happens in the Admin Blazor Server app (see
|
||||
/// <c>docs/v2/admin-ui.md</c>). This interface in <c>Core.Abstractions</c> is the
|
||||
/// contract between the driver and the Admin app — the Admin app discovers
|
||||
/// implementations and slots them into the Driver Detail screen.
|
||||
/// </remarks>
|
||||
public interface IDriverConfigEditor
|
||||
{
|
||||
/// <summary>Driver type name this editor handles (e.g. "Galaxy", "ModbusTcp").</summary>
|
||||
string DriverType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of the Razor component (must derive from <c>ComponentBase</c> in the Admin app's
|
||||
/// `Components/Shared/` folder) that renders the editor. Returned as <c>Type</c> so the
|
||||
/// <c>Core.Abstractions</c> project doesn't need a Blazor reference.
|
||||
/// </summary>
|
||||
Type EditorComponentType { get; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Process-level supervisor contract a Tier C driver's out-of-process topology provides
|
||||
/// (e.g. <c>Driver.Galaxy.Proxy/Supervisor/</c>). Concerns: restart the Host process when a
|
||||
/// hard fault is detected (memory breach, wedge, scheduled recycle window).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decisions #68, #73-74, and #145. Tier A/B drivers do NOT have
|
||||
/// a supervisor because they run in-process — recycling would kill every OPC UA session and
|
||||
/// every co-hosted driver. The Core.Stability layer only invokes this interface for Tier C
|
||||
/// instances after asserting the tier via <see cref="DriverTypeMetadata.Tier"/>.
|
||||
/// </remarks>
|
||||
public interface IDriverSupervisor
|
||||
{
|
||||
/// <summary>Driver instance this supervisor governs.</summary>
|
||||
string DriverInstanceId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Request the supervisor to recycle (terminate + restart) the Host process. Implementations
|
||||
/// are expected to be idempotent under repeat calls during an in-flight recycle.
|
||||
/// </summary>
|
||||
/// <param name="reason">Human-readable reason — flows into the supervisor's logs.</param>
|
||||
/// <param name="cancellationToken">Cancels the recycle request; an in-flight restart is not interrupted.</param>
|
||||
Task RecycleAsync(string reason, CancellationToken cancellationToken);
|
||||
}
|
||||
122
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs
Normal file
122
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IHistoryProvider.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver capability for historical-data reads (OPC UA HistoryRead). Optional —
|
||||
/// only drivers whose backends carry historian data implement this. Currently:
|
||||
/// Galaxy (Wonderware Historian via the optional plugin), OPC UA Client (forward
|
||||
/// to upstream server).
|
||||
/// </summary>
|
||||
public interface IHistoryProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Read raw historical samples for a single attribute over a time range.
|
||||
/// The Core wraps this with continuation-point handling.
|
||||
/// </summary>
|
||||
Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read processed (aggregated) samples — interval-bucketed average / min / max / etc.
|
||||
/// Optional — drivers that only support raw history can throw <see cref="NotSupportedException"/>.
|
||||
/// </summary>
|
||||
Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
TimeSpan interval,
|
||||
HistoryAggregateType aggregate,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read one sample per requested timestamp — OPC UA HistoryReadAtTime service. The
|
||||
/// driver interpolates (or returns the prior-boundary sample) when no exact match
|
||||
/// exists. Optional; drivers that can't interpolate throw <see cref="NotSupportedException"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Default implementation throws. Drivers opt in by overriding; keeps existing
|
||||
/// <c>IHistoryProvider</c> implementations compiling without forcing a ReadAtTime path
|
||||
/// they may not have a backend for.
|
||||
/// </remarks>
|
||||
Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException(
|
||||
$"{GetType().Name} does not implement ReadAtTimeAsync. " +
|
||||
"Drivers whose backends support at-time reads override this method.");
|
||||
|
||||
/// <summary>
|
||||
/// Read historical alarm/event records — OPC UA HistoryReadEvents service. Distinct
|
||||
/// from the live event stream — historical rows come from an event historian (Galaxy's
|
||||
/// Alarm Provider history log, etc.) rather than the driver's active subscription.
|
||||
/// </summary>
|
||||
/// <param name="sourceName">
|
||||
/// Optional filter: null means "all sources", otherwise restrict to events from that
|
||||
/// source-object name. Drivers may ignore the filter if the backend doesn't support it.
|
||||
/// </param>
|
||||
/// <param name="startUtc">Inclusive lower bound on <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="endUtc">Exclusive upper bound on <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="maxEvents">Upper cap on returned events — the driver's backend enforces this.</param>
|
||||
/// <param name="cancellationToken">Request cancellation.</param>
|
||||
/// <remarks>
|
||||
/// Default implementation throws. Only drivers with an event historian (Galaxy via the
|
||||
/// Wonderware Alarm & Events log) override. Modbus / the OPC UA Client driver stay
|
||||
/// with the default and let callers see <c>BadHistoryOperationUnsupported</c>.
|
||||
/// </remarks>
|
||||
Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException(
|
||||
$"{GetType().Name} does not implement ReadEventsAsync. " +
|
||||
"Drivers whose backends have an event historian override this method.");
|
||||
}
|
||||
|
||||
/// <summary>Result of a HistoryRead call.</summary>
|
||||
/// <param name="Samples">Returned samples in chronological order.</param>
|
||||
/// <param name="ContinuationPoint">Opaque token for the next call when more samples are available; null when complete.</param>
|
||||
public sealed record HistoryReadResult(
|
||||
IReadOnlyList<DataValueSnapshot> Samples,
|
||||
byte[]? ContinuationPoint);
|
||||
|
||||
/// <summary>Aggregate function for processed history reads. Mirrors OPC UA Part 13 standard aggregates.</summary>
|
||||
public enum HistoryAggregateType
|
||||
{
|
||||
Average,
|
||||
Minimum,
|
||||
Maximum,
|
||||
Total,
|
||||
Count,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row returned by <see cref="IHistoryProvider.ReadEventsAsync"/> — a historical
|
||||
/// alarm/event record, not the OPC UA live-event stream. Fields match the minimum set the
|
||||
/// Server needs to populate a <c>HistoryEventFieldList</c> for HistoryReadEvents responses.
|
||||
/// </summary>
|
||||
/// <param name="EventId">Stable unique id for the event — driver-specific format.</param>
|
||||
/// <param name="SourceName">Source object that emitted the event. May differ from the <c>sourceName</c> filter the caller passed (fuzzy matches).</param>
|
||||
/// <param name="EventTimeUtc">Process-side timestamp — when the event actually occurred.</param>
|
||||
/// <param name="ReceivedTimeUtc">Historian-side timestamp — when the historian persisted the row; may lag <paramref name="EventTimeUtc"/> by the historian's buffer flush cadence.</param>
|
||||
/// <param name="Message">Human-readable message text.</param>
|
||||
/// <param name="Severity">OPC UA severity (1-1000). Drivers map their native priority scale onto this range.</param>
|
||||
public sealed record HistoricalEvent(
|
||||
string EventId,
|
||||
string? SourceName,
|
||||
DateTime EventTimeUtc,
|
||||
DateTime ReceivedTimeUtc,
|
||||
string? Message,
|
||||
ushort Severity);
|
||||
|
||||
/// <summary>Result of a <see cref="IHistoryProvider.ReadEventsAsync"/> call.</summary>
|
||||
/// <param name="Events">Events in chronological order by <c>EventTimeUtc</c>.</param>
|
||||
/// <param name="ContinuationPoint">Opaque token for the next call when more events are available; null when complete.</param>
|
||||
public sealed record HistoricalEventsResult(
|
||||
IReadOnlyList<HistoricalEvent> Events,
|
||||
byte[]? ContinuationPoint);
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional driver capability for per-host connectivity reporting. Currently used by
|
||||
/// the Galaxy driver (Platform / AppEngine ScanState) but generalized so future drivers
|
||||
/// with multi-host topology (e.g. an OPC UA Client gateway proxying multiple upstream
|
||||
/// servers) can opt in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> §5a — the Galaxy driver's <c>GalaxyRuntimeProbeManager</c>
|
||||
/// becomes <c>IHostConnectivityProbe</c> after the v2 refactor.
|
||||
/// </remarks>
|
||||
public interface IHostConnectivityProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Snapshot of host-level connectivity. The Core uses this to drive Bad-quality
|
||||
/// fan-out scoped to the affected host's subtree (not the whole driver namespace).
|
||||
/// </summary>
|
||||
IReadOnlyList<HostConnectivityStatus> GetHostStatuses();
|
||||
|
||||
/// <summary>Fired when a host transitions Running ↔ Stopped (or similar lifecycle change).</summary>
|
||||
event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
}
|
||||
|
||||
/// <summary>Per-host connectivity snapshot.</summary>
|
||||
/// <param name="HostName">Driver-side host identifier (e.g. for Galaxy: Platform or AppEngine name).</param>
|
||||
/// <param name="State">Current state.</param>
|
||||
/// <param name="LastChangedUtc">Timestamp of the last state transition.</param>
|
||||
public sealed record HostConnectivityStatus(
|
||||
string HostName,
|
||||
HostState State,
|
||||
DateTime LastChangedUtc);
|
||||
|
||||
/// <summary>Event payload for <see cref="IHostConnectivityProbe.OnHostStatusChanged"/>.</summary>
|
||||
public sealed record HostStatusChangedEventArgs(
|
||||
string HostName,
|
||||
HostState OldState,
|
||||
HostState NewState);
|
||||
|
||||
/// <summary>Host lifecycle state. Generalization of Galaxy's Platform/Engine ScanState.</summary>
|
||||
public enum HostState { Unknown, Running, Stopped, Faulted }
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional driver capability that maps a per-tag full reference to the underlying host
|
||||
/// name responsible for serving it. Drivers with a one-host topology (Galaxy on one
|
||||
/// MXAccess endpoint, OpcUaClient against one remote server, S7 against one PLC) do NOT
|
||||
/// need to implement this — the dispatch layer falls back to
|
||||
/// <see cref="IDriver.DriverInstanceId"/> as a single-host key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Multi-host drivers (Modbus with N PLCs, hypothetical AB CIP across a rack, etc.)
|
||||
/// implement this so the Phase 6.1 resilience pipeline can be keyed on
|
||||
/// <c>(DriverInstanceId, ResolvedHostName, DriverCapability)</c> per decision #144. One
|
||||
/// dead PLC behind a multi-device Modbus driver then trips only its own breaker; healthy
|
||||
/// siblings keep serving.</para>
|
||||
///
|
||||
/// <para>Implementations must be fast + allocation-free on the hot path — <c>ReadAsync</c>
|
||||
/// / <c>WriteAsync</c> call this once per tag. A simple <c>Dictionary<string, string></c>
|
||||
/// lookup is typical.</para>
|
||||
///
|
||||
/// <para>When the fullRef doesn't map to a known host (caller passes an unregistered
|
||||
/// reference, or the tag was removed mid-flight), implementations should return the
|
||||
/// driver's default-host string rather than throwing — the invoker falls back to a
|
||||
/// single-host pipeline for that call, which is safer than tearing down the request.</para>
|
||||
/// </remarks>
|
||||
public interface IPerCallHostResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve the host name for the given driver-side full reference. Returned value is
|
||||
/// used as the <c>hostName</c> argument to the Phase 6.1 <c>CapabilityInvoker</c> so
|
||||
/// per-host breaker isolation + per-host bulkhead accounting both kick in.
|
||||
/// </summary>
|
||||
string ResolveHost(string fullReference);
|
||||
}
|
||||
25
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs
Normal file
25
src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IReadable.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver capability for on-demand reads. Required for any driver whose nodes are
|
||||
/// readable from OPC UA clients (essentially all of them — every committed v2 driver
|
||||
/// implements this).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reads are idempotent — Polly retry pipelines can safely retry on transient failures
|
||||
/// (per <c>docs/v2/plan.md</c> decisions #34 and #44).
|
||||
/// </remarks>
|
||||
public interface IReadable
|
||||
{
|
||||
/// <summary>
|
||||
/// Read a batch of attributes by their full driver-side reference.
|
||||
/// Returns one snapshot per requested reference, in the same order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per-reference failures should be reported via the snapshot's <see cref="DataValueSnapshot.StatusCode"/>
|
||||
/// (Bad-coded), not as exceptions. The whole call should throw only if the driver itself is unreachable.
|
||||
/// </remarks>
|
||||
Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional driver capability — drivers whose backend has a native change signal
|
||||
/// (Galaxy <c>time_of_last_deploy</c>, OPC UA server change notifications, TwinCAT
|
||||
/// symbol-version-changed) implement this to tell Core when to re-run discovery.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #54 — static drivers (Modbus, S7, etc. whose tags
|
||||
/// only change via a published config generation) don't implement <c>IRediscoverable</c>.
|
||||
/// The Core just sees absence of the interface and skips change-detection wiring for that driver.
|
||||
/// </remarks>
|
||||
public interface IRediscoverable
|
||||
{
|
||||
/// <summary>
|
||||
/// Fired when the driver's backend signals that the address space may have changed.
|
||||
/// The Core's response is to re-run <see cref="ITagDiscovery.DiscoverAsync"/> and
|
||||
/// diff the result against the current address space.
|
||||
/// </summary>
|
||||
event EventHandler<RediscoveryEventArgs>? OnRediscoveryNeeded;
|
||||
}
|
||||
|
||||
/// <summary>Event payload for <see cref="IRediscoverable.OnRediscoveryNeeded"/>.</summary>
|
||||
/// <param name="Reason">Driver-supplied reason string for the diagnostic log (e.g. "Galaxy time_of_last_deploy advanced", "TwinCAT symbol-version-changed 0x0702").</param>
|
||||
/// <param name="ScopeHint">
|
||||
/// Optional hint about which subtree changed. Null means "the whole address space may have changed".
|
||||
/// A non-null value (e.g. a folder path) lets the Core scope the rebuild surgically.
|
||||
/// </param>
|
||||
public sealed record RediscoveryEventArgs(string Reason, string? ScopeHint);
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver capability for data-change subscriptions — covers both native subscriptions
|
||||
/// (Galaxy MXAccess advisory, OPC UA monitored items, TwinCAT ADS notifications) and
|
||||
/// driver-internal polled subscriptions (Modbus, AB CIP, S7, FOCAS). The driver owns
|
||||
/// its polling loop where applicable; the Core just sees <see cref="OnDataChange"/>
|
||||
/// callbacks regardless of mechanism.
|
||||
/// </summary>
|
||||
public interface ISubscribable
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribe to data changes for a batch of attributes.
|
||||
/// The driver MAY fire <see cref="OnDataChange"/> immediately with the current value
|
||||
/// (initial-data callback per OPC UA convention) and again on every change.
|
||||
/// </summary>
|
||||
/// <returns>An opaque subscription handle the caller passes to <see cref="UnsubscribeAsync"/>.</returns>
|
||||
Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences,
|
||||
TimeSpan publishingInterval,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Cancel a subscription returned by <see cref="SubscribeAsync"/>.</summary>
|
||||
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Server-pushed data-change notification. Fires whenever a subscribed attribute changes,
|
||||
/// and (per OPC UA convention) on subscription establishment for current values.
|
||||
/// </summary>
|
||||
event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
}
|
||||
|
||||
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync"/>.</summary>
|
||||
public interface ISubscriptionHandle
|
||||
{
|
||||
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
|
||||
string DiagnosticId { get; }
|
||||
}
|
||||
|
||||
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
|
||||
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param>
|
||||
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
|
||||
/// <param name="Snapshot">New value + quality + timestamps.</param>
|
||||
public sealed record DataChangeEventArgs(
|
||||
ISubscriptionHandle SubscriptionHandle,
|
||||
string FullReference,
|
||||
DataValueSnapshot Snapshot);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver capability for discovering tags and hierarchy from the backend.
|
||||
/// Streams discovered nodes into <see cref="IAddressSpaceBuilder"/> rather than
|
||||
/// buffering the entire tree (decision #52 — supports incremental / large address spaces).
|
||||
/// </summary>
|
||||
public interface ITagDiscovery
|
||||
{
|
||||
/// <summary>
|
||||
/// Discover the driver's tag set and stream nodes to the builder.
|
||||
/// The driver decides ordering (root → leaf typically) and may yield as many calls as needed.
|
||||
/// </summary>
|
||||
Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user