Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftSnapshot.cs
T
Joseph Doherty 73e74e270d fix(controlplane): validate and compose a deployment from ONE snapshot read
StartDeployment read the whole config DB twice with no transaction — DraftSnapshotFactory for
the validator, then ConfigComposer for the artifact, plus clusters/nodes and Script.SourceCode
again in between. An AdminUI save landing between the two shipped an artifact the DraftValidator
never saw.

- DraftSnapshot carries ServerClusters, ClusterNodes and ApiKeys; DraftSnapshotFactory reads
  every table inside one SNAPSHOT transaction on a relational provider (no-op on InMemory or
  under an ambient transaction).
- ConfigComposer.Compose(DraftSnapshot) is pure over the snapshot (ordinal-sorted keys);
  SnapshotAndFlattenAsync(db) delegates to it, byte-identical.
- AdminOperationsActor validates topology, estimates compile cost and composes the artifact
  from the one snapshot it holds.
- Migration EnableSnapshotIsolation: ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON
  (suppressTransaction; no model change).

Tests: the SQL Server snapshot read ignores a row committed mid-read (positive control: a later
read sees it); an edit injected between the Equipment and Tags reads is absent from the shipped
artifact and rejected by the next deploy; Compose(snapshot) == SnapshotAndFlattenAsync(db).

Refs #733

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 06:36:12 -04:00

92 lines
5.2 KiB
C#

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
{
/// <summary>Gets the draft generation identifier.</summary>
public required long GenerationId { get; init; }
/// <summary>Gets the cluster identifier.</summary>
public required string ClusterId { get; init; }
/// <summary>
/// Cluster's Enterprise segment (UNS level 1). When set, <see cref="DraftValidator"/> uses
/// the actual length for path-length checks instead of a conservative 32-char upper bound.
/// </summary>
public string? Enterprise { get; init; }
/// <summary>
/// Cluster's Site segment (UNS level 2). When set, <see cref="DraftValidator"/> uses the
/// actual length for path-length checks instead of a conservative 32-char upper bound.
/// </summary>
public string? Site { get; init; }
// v3: the Namespace entity is retired (the two OPC UA namespaces are implicit). The Raw tree
// (RawFolders → DriverInstances → Devices → TagGroups → Tags) + the UNS projection
// (UnsTagReferences) feed the v3 validator rules (raw-name charset, historized-tagname length, UNS
// effective-leaf uniqueness).
/// <summary>Gets the list of Raw-tree folders (drive the raw-name charset rule + RawPath computation).</summary>
public IReadOnlyList<RawFolder> RawFolders { get; init; } = [];
/// <summary>Gets the list of driver instances.</summary>
public IReadOnlyList<DriverInstance> DriverInstances { get; init; } = [];
/// <summary>Gets the list of devices.</summary>
public IReadOnlyList<Device> Devices { get; init; } = [];
/// <summary>Gets the list of tag-groups (drive the raw-name charset rule + RawPath computation).</summary>
public IReadOnlyList<TagGroup> TagGroups { get; init; } = [];
/// <summary>Gets the list of UNS areas.</summary>
public IReadOnlyList<UnsArea> UnsAreas { get; init; } = [];
/// <summary>Gets the list of UNS lines.</summary>
public IReadOnlyList<UnsLine> UnsLines { get; init; } = [];
/// <summary>Gets the list of equipment.</summary>
public IReadOnlyList<Equipment> Equipment { get; init; } = [];
/// <summary>Gets the list of raw tags.</summary>
public IReadOnlyList<Tag> Tags { get; init; } = [];
/// <summary>Gets the UNS tag references (raw tag → equipment projection). Drives the UNS effective-leaf
/// uniqueness rule (effective name = DisplayNameOverride else the backing raw tag's Name).</summary>
public IReadOnlyList<UnsTagReference> UnsTagReferences { get; init; } = [];
/// <summary>Equipment-bound VirtualTags (script-derived signals). Part of the UNS effective-leaf
/// uniqueness set within an equipment (references + VirtualTags + ScriptedAlarms).</summary>
public IReadOnlyList<VirtualTag> VirtualTags { get; init; } = [];
/// <summary>Equipment-bound scripted alarms. Part of the UNS effective-leaf uniqueness set.</summary>
public IReadOnlyList<ScriptedAlarm> ScriptedAlarms { get; init; } = [];
/// <summary>User-authored scripts (shared by VirtualTags, ScriptedAlarms, and Calculation tags). Drives
/// the Calculation gates: <c>scriptId</c> existence + the calc→calc dependency-cycle check.</summary>
public IReadOnlyList<Script> Scripts { get; init; } = [];
/// <summary>Gets the list of poll groups.</summary>
public IReadOnlyList<PollGroup> PollGroups { get; init; } = [];
/// <summary>ACL grants. Drive the scope-resolution rule: a scope that names nothing deployed is
/// silently dropped when the permission trie is built, so the grant has to be caught here instead.</summary>
public IReadOnlyList<NodeAcl> NodeAcls { get; init; } = [];
/// <summary>
/// Cluster rows. Not read by the row-level rules; carried so the deploy path validates the cluster
/// topology (<see cref="DraftValidator.ValidateClusterTopology"/>) and composes the artifact from
/// the SAME read as every other table — a second read between validate and compose is exactly how
/// an edit ships unvalidated.
/// </summary>
public IReadOnlyList<ServerCluster> ServerClusters { get; init; } = [];
/// <summary>Cluster-node rows; see <see cref="ServerClusters"/> for why they ride the snapshot.</summary>
public IReadOnlyList<ClusterNode> ClusterNodes { get; init; } = [];
/// <summary>
/// Data-plane API keys (peppered HMAC only, never a secret). Not validated; carried so the artifact
/// composer ships the keys a driver-only node authenticates against from the one consistent read.
/// </summary>
public IReadOnlyList<ApiKey> ApiKeys { 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; } = [];
}