ef2eebe6b6
- ScriptLogRateLimiter.LimitPerWindow. The ScriptLogTopicSink and VirtualTagActor summaries now print the configured limit, not DefaultLimitPerWindow. Tests assert "limit N/window" for a non-default N. - CalculationDriver.Dispose cancels, then takes _evalGate (bounded 2 s) and disposes the gate and _evalCts. The pump and timer ticks enter the gate through TryEnterEvalGateAsync, which stops cleanly once the driver is disposed. Test: Dispose with a 6 s runaway run in flight returns promptly, disposes both, and a late trigger is harmless. - ScriptedAlarmEngine captures _shutdownToken once and disposes _shutdownCts in Dispose when every drained task has completed. A task stuck past the grace leaves the CTS to the GC. Lifecycle tests assert the disposal. - DraftValidator: CalculationNoEffectiveTrigger is skipped for a change-triggered tag whose scriptId does not resolve. That case is already CalculationScriptMissing or CalculationScriptNotFound, and there are no reads to count. Both existing tests now assert the absence (red before the change). - docs/VirtualTags.md: System.Threading is denied as a whole namespace, with an allow-list; the type list is now accurate (Unsafe, RuntimeHelpers, and the ForbiddenMembers on System.Type); the resource limits section covers the run guard and the allocation budget. docs/ScriptEditor.md: accurate deny-list and a new "Runtime safety" section covering timeout, refusal, quarantine after 3 timeouts, allocation budget, lifting, and rate-limit summaries that appear only on the source's next entry after a burst then quiet. Refs #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
848 lines
49 KiB
C#
848 lines
49 KiB
C#
using System.Text.RegularExpressions;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
|
|
|
/// <summary>
|
|
/// Managed-code pre-publish validator. 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 const int MaxPathLength = 200;
|
|
|
|
/// <summary>
|
|
/// Validates a draft snapshot and returns all validation errors found in a single pass.
|
|
/// </summary>
|
|
/// <param name="draft">The draft snapshot to validate.</param>
|
|
/// <returns>Every validation error found; empty when the draft is valid.</returns>
|
|
public static IReadOnlyList<ValidationError> Validate(DraftSnapshot draft)
|
|
{
|
|
var errors = new List<ValidationError>();
|
|
|
|
ValidateUnsSegments(draft, errors);
|
|
ValidatePathLength(draft, errors);
|
|
ValidateEquipmentUuidImmutability(draft, errors);
|
|
ValidateReservationPreflight(draft, errors);
|
|
ValidateEquipmentIdDerivation(draft, errors);
|
|
|
|
// v3 rules. The retired rules ValidateSameClusterNamespaceBinding (Namespace entity gone)
|
|
// and ValidateGalaxyTagFullName (blob-as-identity gone) are replaced by:
|
|
ValidateRawNameCharset(draft, errors);
|
|
ValidateHistorizedTagnameLength(draft, errors);
|
|
ValidateUnsEffectiveLeafUniqueness(draft, errors);
|
|
ValidateEquipReferenceResolution(draft, errors);
|
|
ValidateCalculationTags(draft, errors);
|
|
ValidateSqlConnectionStringNotPersisted(draft, errors);
|
|
ValidateMTConnectSingleDevice(draft, errors);
|
|
ValidateNodeAclScopes(draft, errors);
|
|
return errors;
|
|
}
|
|
|
|
/// <summary>
|
|
/// every <see cref="NodeAcl"/> row's scope must name something the deployed address
|
|
/// space actually contains.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ACL rows have had <b>no</b> validation anywhere until now, and the failure mode is the worst
|
|
/// shape a security control can have: <c>AclScopePathResolver</c> returns no path for a scope it
|
|
/// cannot resolve, the trie builder then drops the grant with a build-time diagnostic, and the
|
|
/// deploy still seals green. An operator who mistypes a <c>ScopeId</c>, or scopes a grant at an
|
|
/// entity someone later deleted, gets a row that reads as a live grant in the AdminUI and
|
|
/// authorizes nothing at runtime. Silently dropping a grant is fail-closed and therefore safe,
|
|
/// but it is not <em>visible</em> — which is why the check belongs at the deploy gate, where the
|
|
/// author is still standing in front of the mistake.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The realm tokens are duplicated here, deliberately.</b> <c>NodeAclRealms</c> in
|
|
/// <c>Core.Authorization</c> is the authority for their spelling, but <c>Core</c> references
|
|
/// <c>Configuration</c> and not the reverse, so this project cannot reach it. The two namespace
|
|
/// <em>URIs</em> come from <see cref="V3NodeIds"/> in <c>Commons</c>, which both sides do share;
|
|
/// only the two short tokens are literals. If a third realm is ever added, it must be added in
|
|
/// both places — the accepted cost of not inverting the project graph for one gate.
|
|
/// </para>
|
|
/// <para>
|
|
/// Existence is checked with the same <b>case-insensitive, untrimmed</b> comparison
|
|
/// <c>AclScopePathResolver</c>'s dictionary lookups use, so a row this rule passes is a row that
|
|
/// resolver resolves. The realm check mirrors <c>NodeAclRealms.TryCanonicalize</c> instead
|
|
/// (trimmed, case-insensitive) for the same reason. Both this rule and the resolver were
|
|
/// ordinal until that was fixed; the cross-cluster comparison below was ordinal too, which was the
|
|
/// worse half — it could reject a deploy as cross-cluster over a <c>ClusterId</c> the runtime
|
|
/// would have matched, and the two spellings genuinely occur (see
|
|
/// <c>RoleParser.ClusterIdFromRole</c>). The rule now is: identifiers compare
|
|
/// <c>OrdinalIgnoreCase</c>, path segments compare <c>Ordinal</c>; it is stated in full on
|
|
/// <c>AclScopeRef</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Containment is checked as well as existence.</b> A grant in cluster A naming an entity that
|
|
/// belongs to cluster B is not the "names nothing" failure — it resolves to a real trie path, and
|
|
/// so it silently confers authority across a cluster boundary. It gets its own code because the
|
|
/// remedy is different: an unresolved id is a typo or a deletion, a cross-cluster id is a grant
|
|
/// authored against the wrong cluster. The lookups are deliberately <b>fleet-wide, not
|
|
/// cluster-filtered</b> — a per-cluster lookup would make a cross-cluster id present as merely
|
|
/// unresolved, which is the wrong diagnosis and would leave this check unable to ever fire.
|
|
/// </para>
|
|
/// <para>
|
|
/// Ownership is not a uniform property. <c>RawFolder</c>, <c>DriverInstance</c> and <c>UnsArea</c>
|
|
/// carry <c>ClusterId</c>; <c>Device</c>, <c>TagGroup</c>, <c>Tag</c>, <c>UnsLine</c> and
|
|
/// <c>Equipment</c> do not, and inherit it through their parent. Both chains are at most three
|
|
/// hops and neither can loop (<c>TagGroup</c> and <c>RawFolder</c> nest, but both reach the
|
|
/// cluster through a non-nesting link — <c>DeviceId</c> and <c>ClusterId</c> respectively), so
|
|
/// unlike the resolver's ancestry walks these need no depth guard. A broken chain means the
|
|
/// owning cluster is unknowable, which is the same state as unresolvable: it reports
|
|
/// <c>NodeAclScopeUnresolved</c> and never both codes.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static void ValidateNodeAclScopes(DraftSnapshot draft, List<ValidationError> errors)
|
|
{
|
|
if (draft.NodeAcls.Count == 0) return;
|
|
|
|
// One map per ancestry link, built once for the whole pass. Their key sets double as the existence
|
|
// sets, so "does this id name a row of the kind's type" and "which cluster owns it" cannot disagree.
|
|
// GroupBy/First rather than ToDictionary: a duplicated id is another rule's business, and throwing
|
|
// here would take down every remaining check in the same pass.
|
|
var folderCluster = Link(draft.RawFolders, f => f.RawFolderId, f => f.ClusterId);
|
|
var driverCluster = Link(draft.DriverInstances, d => d.DriverInstanceId, d => d.ClusterId);
|
|
var deviceDriver = Link(draft.Devices, d => d.DeviceId, d => d.DriverInstanceId);
|
|
var groupDevice = Link(draft.TagGroups, g => g.TagGroupId, g => g.DeviceId);
|
|
var tagDevice = Link(draft.Tags, t => t.TagId, t => t.DeviceId);
|
|
var areaCluster = Link(draft.UnsAreas, a => a.UnsAreaId, a => a.ClusterId);
|
|
var lineArea = Link(draft.UnsLines, l => l.UnsLineId, l => l.UnsAreaId);
|
|
var equipLine = Link(draft.Equipment, e => e.EquipmentId, e => e.UnsLineId);
|
|
|
|
static Dictionary<string, string> Link<T>(
|
|
IReadOnlyList<T> rows, Func<T, string> key, Func<T, string> value) =>
|
|
rows.GroupBy(key, StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(g => g.Key, g => value(g.First()), StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Owning cluster, or null when a link in the chain is dangling. Direct-ClusterId kinds bottom out
|
|
// immediately; the rest hop through their parent.
|
|
string? ClusterOfDevice(string deviceId) =>
|
|
deviceDriver.TryGetValue(deviceId, out var driverId) ? driverCluster.GetValueOrDefault(driverId) : null;
|
|
string? ClusterOfLine(string lineId) =>
|
|
lineArea.TryGetValue(lineId, out var areaId) ? areaCluster.GetValueOrDefault(areaId) : null;
|
|
|
|
foreach (var acl in draft.NodeAcls)
|
|
{
|
|
var id = acl.NodeAclId;
|
|
|
|
// An ordinal outside the enum comes from a hand-edited row or a schema the code no longer
|
|
// matches. Every later check switches on the kind, so nothing below it would be meaningful.
|
|
if (!Enum.IsDefined(acl.ScopeKind))
|
|
{
|
|
errors.Add(new("NodeAclScopeKindUndefined",
|
|
$"NodeAcl '{id}' has ScopeKind ordinal {(int)acl.ScopeKind}, which is not a defined " +
|
|
"NodeAclScopeKind; the row cannot be evaluated. Re-author it with a valid scope kind.",
|
|
id));
|
|
continue;
|
|
}
|
|
|
|
if (acl.ScopeKind == NodeAclScopeKind.Cluster)
|
|
{
|
|
// Cluster is the one kind with nothing to point at, so a ScopeId here means the author
|
|
// intended a narrower scope and picked the wrong kind — the grant is cluster-wide either
|
|
// way, i.e. broader than intended, which is the direction that must never pass silently.
|
|
if (!string.IsNullOrWhiteSpace(acl.ScopeId))
|
|
errors.Add(new("NodeAclScopeIdUnexpected",
|
|
$"NodeAcl '{id}' is Cluster-scoped but carries ScopeId '{acl.ScopeId}'. A Cluster grant " +
|
|
"covers the whole cluster and ignores ScopeId; clear it, or change ScopeKind to the level " +
|
|
"the id names.",
|
|
id));
|
|
continue;
|
|
}
|
|
|
|
// Ahead of the missing-ScopeId check on purpose: the retirement is the actionable fact, and
|
|
// telling someone to supply a ScopeId for a kind that can never resolve sends them the wrong way.
|
|
if (acl.ScopeKind == NodeAclScopeKind.FolderSegment)
|
|
{
|
|
errors.Add(new("NodeAclScopeKindRetired",
|
|
$"NodeAcl '{id}' uses the retired v2 scope kind FolderSegment, which has no v3 entity behind " +
|
|
"it and can never resolve, whatever its ScopeId. Re-scope the grant to RawFolder (the v3 " +
|
|
"replacement) and point ScopeId at the RawFolderId that replaced the old folder segment.",
|
|
id));
|
|
continue;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(acl.ScopeId))
|
|
{
|
|
errors.Add(new("NodeAclScopeIdMissing",
|
|
$"NodeAcl '{id}' has ScopeKind {acl.ScopeKind} but no ScopeId. Only Cluster grants may omit " +
|
|
"it; every other kind resolves no scope path without one and the grant is dropped at build " +
|
|
"time. Set the ScopeId, or change ScopeKind to Cluster.",
|
|
id));
|
|
continue;
|
|
}
|
|
|
|
var scopeId = acl.ScopeId!;
|
|
|
|
if (acl.ScopeKind == NodeAclScopeKind.Namespace)
|
|
{
|
|
if (CanonicalRealm(scopeId) is null)
|
|
errors.Add(new("NodeAclNamespaceInvalid",
|
|
$"NodeAcl '{id}' is Namespace-scoped but ScopeId '{scopeId}' names no OPC UA namespace. " +
|
|
$"Use '{RealmRaw}' or '{RealmUns}' (or the full URI '{V3NodeIds.RawNamespaceUri}' / " +
|
|
$"'{V3NodeIds.UnsNamespaceUri}').",
|
|
id));
|
|
continue;
|
|
}
|
|
|
|
// Each arm names the entity type the kind's ScopeId is drawn from, whether a row with that id
|
|
// exists at all, and which cluster owns it. Switch arms evaluate lazily, so only the matched
|
|
// kind's chain is walked.
|
|
var (entity, exists, owner) = acl.ScopeKind switch
|
|
{
|
|
NodeAclScopeKind.UnsArea =>
|
|
("UnsArea", areaCluster.ContainsKey(scopeId), areaCluster.GetValueOrDefault(scopeId)),
|
|
NodeAclScopeKind.UnsLine =>
|
|
("UnsLine", lineArea.ContainsKey(scopeId), ClusterOfLine(scopeId)),
|
|
NodeAclScopeKind.Equipment =>
|
|
("Equipment", equipLine.ContainsKey(scopeId),
|
|
equipLine.TryGetValue(scopeId, out var eqLine) ? ClusterOfLine(eqLine) : null),
|
|
NodeAclScopeKind.Tag =>
|
|
("Tag", tagDevice.ContainsKey(scopeId),
|
|
tagDevice.TryGetValue(scopeId, out var tagDev) ? ClusterOfDevice(tagDev) : null),
|
|
NodeAclScopeKind.RawFolder =>
|
|
("RawFolder", folderCluster.ContainsKey(scopeId), folderCluster.GetValueOrDefault(scopeId)),
|
|
NodeAclScopeKind.RawDriver =>
|
|
("DriverInstance", driverCluster.ContainsKey(scopeId), driverCluster.GetValueOrDefault(scopeId)),
|
|
NodeAclScopeKind.RawDevice =>
|
|
("Device", deviceDriver.ContainsKey(scopeId), ClusterOfDevice(scopeId)),
|
|
NodeAclScopeKind.RawTagGroup =>
|
|
("TagGroup", groupDevice.ContainsKey(scopeId),
|
|
groupDevice.TryGetValue(scopeId, out var grpDev) ? ClusterOfDevice(grpDev) : null),
|
|
|
|
// Cluster / Namespace / FolderSegment all returned above; the undefined case did too.
|
|
_ => (string.Empty, true, acl.ClusterId),
|
|
};
|
|
|
|
if (!exists)
|
|
{
|
|
errors.Add(new("NodeAclScopeUnresolved",
|
|
$"NodeAcl '{id}' is scoped at {acl.ScopeKind} '{scopeId}', which is not a {entity} in this " +
|
|
"configuration. The grant would be dropped at build time and authorize nothing. Correct the " +
|
|
"ScopeId, or delete the grant if the entity was removed.",
|
|
id));
|
|
continue;
|
|
}
|
|
|
|
if (owner is null)
|
|
{
|
|
// The row exists but its parent chain is dangling, so it has no place in any cluster's tree
|
|
// and resolves to no path — the same outcome as a missing row, reported under the same code.
|
|
errors.Add(new("NodeAclScopeUnresolved",
|
|
$"NodeAcl '{id}' is scoped at {acl.ScopeKind} '{scopeId}', which exists but whose parent " +
|
|
$"chain is broken, so no cluster owns it and it resolves to no scope path. Repair the {entity}'s " +
|
|
"parent reference, or delete the grant.",
|
|
id));
|
|
continue;
|
|
}
|
|
|
|
if (!string.Equals(owner, acl.ClusterId, StringComparison.OrdinalIgnoreCase))
|
|
errors.Add(new("NodeAclScopeCrossCluster",
|
|
$"NodeAcl '{id}' is a grant in cluster '{acl.ClusterId}' but is scoped at {acl.ScopeKind} " +
|
|
$"'{scopeId}', which belongs to cluster '{owner}'. The id is real, so the grant would resolve " +
|
|
"and confer authority across a cluster boundary. Re-author the grant under cluster " +
|
|
$"'{owner}', or point ScopeId at the matching {entity} in '{acl.ClusterId}'.",
|
|
id));
|
|
}
|
|
}
|
|
|
|
// Realm tokens — see ValidateNodeAclScopes for why these are duplicated rather than referenced.
|
|
// Core.Authorization.NodeAclRealms is the authority; keep the two in step.
|
|
private const string RealmRaw = "raw";
|
|
private const string RealmUns = "uns";
|
|
|
|
/// <summary>Mirror of <c>NodeAclRealms.TryCanonicalize</c>: accepts either short realm token or either
|
|
/// full namespace URI, trimmed and case-insensitive. Null when the id names no realm.</summary>
|
|
private static string? CanonicalRealm(string scopeId)
|
|
{
|
|
var trimmed = scopeId.Trim();
|
|
|
|
if (string.Equals(trimmed, RealmRaw, StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(trimmed, V3NodeIds.RawNamespaceUri, StringComparison.OrdinalIgnoreCase))
|
|
return RealmRaw;
|
|
|
|
if (string.Equals(trimmed, RealmUns, StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(trimmed, V3NodeIds.UnsNamespaceUri, StringComparison.OrdinalIgnoreCase))
|
|
return RealmUns;
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
|
|
/// at the deploy gate.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>A Sql driver names its credentials indirectly — <c>connectionStringRef</c> resolves from the
|
|
/// environment / secret store at Initialize — so a deployed artifact carries no database password.
|
|
/// Until now that guarantee rested entirely on the <b>read</b> path: <c>SqlDriverConfigDto</c> has no
|
|
/// <c>connectionString</c> property and <c>UnmappedMemberHandling.Skip</c> drops the key on
|
|
/// deserialization. Nothing stopped the key being <b>written</b>. Config blobs are schemaless JSON
|
|
/// columns, and the fallback authoring pattern for a driver without a typed form is a raw-JSON
|
|
/// textarea, so an operator pasting <c>{"connectionString":"Server=…;Password=…"}</c> would put a live
|
|
/// credential in the ConfigDb — persisted, replicated to every node's artifact cache, and readable by
|
|
/// anyone with config access — while the runtime silently ignored it and the driver failed to connect.
|
|
/// Discarding a secret on read is not the same as refusing to store it.</para>
|
|
/// <para><b>Two of the three surfaces are checked here.</b> The third — a node's
|
|
/// <see cref="ClusterNode.DriverConfigOverridesJson"/> — is not reachable from
|
|
/// <see cref="DraftSnapshot"/> and is gated at its save instead; see
|
|
/// <see cref="SqlCredentialGuard"/> for why a deploy gate is the wrong instrument for a value that
|
|
/// never enters the artifact.</para>
|
|
/// <para>Checked on <b>both</b> config surfaces a Sql driver reads: the instance's
|
|
/// <see cref="DriverInstance.DriverConfig"/> and the <see cref="Device.DeviceConfig"/> of every device
|
|
/// beneath it, because the two are merged before the DTO sees them — a credential pasted into the
|
|
/// device blob lands in exactly the same place.</para>
|
|
/// <para>The key is matched <b>case-insensitively</b>: <c>System.Text.Json</c> binds
|
|
/// <c>ConnectionString</c> to a <c>connectionString</c> property by default, so a case variant is the
|
|
/// same key, not a different one. Only the top level is scanned — the DTO is flat, so a nested
|
|
/// occurrence cannot bind and is not the credential-shaped mistake this rule exists to catch.</para>
|
|
/// <para><b>The message never echoes the value.</b> Validation errors reach the AdminUI, the deploy
|
|
/// log and the audit trail; repeating the offending string there would leak the very credential the
|
|
/// rule is refusing to store.</para>
|
|
/// </remarks>
|
|
private static void ValidateSqlConnectionStringNotPersisted(DraftSnapshot draft, List<ValidationError> errors)
|
|
{
|
|
const string ForbiddenKey = SqlCredentialGuard.ForbiddenKey;
|
|
|
|
var sqlInstanceIds = draft.DriverInstances
|
|
.Where(d => string.Equals(d.DriverType, Core.Abstractions.DriverTypeNames.Sql, StringComparison.Ordinal))
|
|
.Select(d => d.DriverInstanceId)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
if (sqlInstanceIds.Count == 0) return;
|
|
|
|
foreach (var d in draft.DriverInstances)
|
|
{
|
|
if (!sqlInstanceIds.Contains(d.DriverInstanceId)) continue;
|
|
if (!SqlCredentialGuard.CarriesLiteralConnectionString(d.DriverConfig)) continue;
|
|
errors.Add(new("SqlConnectionStringPersisted",
|
|
$"Sql driver instance '{d.DriverInstanceId}' has a '{ForbiddenKey}' key in its DriverConfig. " +
|
|
"A Sql driver must name its credentials indirectly via 'connectionStringRef', which resolves " +
|
|
"from the environment / secret store at Initialize; a literal connection string here would be " +
|
|
"stored in the config database and replicated to every node, and the runtime ignores it anyway. " +
|
|
"Remove the key and set 'connectionStringRef'.",
|
|
d.DriverInstanceId));
|
|
}
|
|
|
|
foreach (var dev in draft.Devices)
|
|
{
|
|
if (!sqlInstanceIds.Contains(dev.DriverInstanceId)) continue;
|
|
if (!SqlCredentialGuard.CarriesLiteralConnectionString(dev.DeviceConfig)) continue;
|
|
errors.Add(new("SqlConnectionStringPersisted",
|
|
$"Device '{dev.DeviceId}' on Sql driver instance '{dev.DriverInstanceId}' has a " +
|
|
$"'{ForbiddenKey}' key in its DeviceConfig. DeviceConfig is merged onto DriverConfig before " +
|
|
"the driver reads it, so this is the same leak: use 'connectionStringRef' on the driver instead.",
|
|
dev.DeviceId));
|
|
}
|
|
}
|
|
|
|
/// <summary>Error code for an MTConnect driver instance that has more than one <c>/raw</c> Device.</summary>
|
|
public const string MTConnectMultipleDevicesCode = "MTConnectMultipleDevices";
|
|
|
|
/// <summary>
|
|
/// The one message both the deploy gate and the authoring-time guard (<c>RawTreeService.CreateDeviceAsync</c>)
|
|
/// use, so the two can never describe different rules.
|
|
/// </summary>
|
|
/// <param name="driverLabel">The driver instance id or name to cite.</param>
|
|
/// <returns>The validation message text.</returns>
|
|
public static string MTConnectMultipleDevicesMessage(string driverLabel) =>
|
|
$"MTConnect driver '{driverLabel}' has more than one Device. MTConnect binds all tags through one agent " +
|
|
"client per driver instance and does not route by /raw Device, and with several Devices the Device's " +
|
|
"DeviceConfig (agent URI / device name) is no longer merged into the driver config, so which Device would " +
|
|
"supply the endpoint is ambiguous. Author one MTConnect driver per agent device, each with a single Device.";
|
|
|
|
/// <summary>
|
|
/// An MTConnect driver instance owns exactly one agent client, so a second <c>/raw</c>
|
|
/// Device under it is a genuine ambiguity — the driver ignores <c>RawTagEntry.DeviceName</c>, and
|
|
/// <c>DriverDeviceConfigMerger</c> only merges a Device's <c>DeviceConfig</c> up to the driver config when
|
|
/// there is exactly ONE Device. The normal single-Device layout (every driver gets a default
|
|
/// <c>Device1</c>) stays valid.
|
|
/// </summary>
|
|
private static void ValidateMTConnectSingleDevice(DraftSnapshot draft, List<ValidationError> errors)
|
|
{
|
|
foreach (var driver in draft.DriverInstances)
|
|
{
|
|
if (!string.Equals(driver.DriverType, Core.Abstractions.DriverTypeNames.MTConnect, StringComparison.Ordinal))
|
|
continue;
|
|
var count = draft.Devices.Count(d =>
|
|
string.Equals(d.DriverInstanceId, driver.DriverInstanceId, StringComparison.Ordinal));
|
|
if (count <= 1) continue;
|
|
errors.Add(new(MTConnectMultipleDevicesCode,
|
|
MTConnectMultipleDevicesMessage(driver.Name) + $" ({count} Devices found.)",
|
|
driver.DriverInstanceId));
|
|
}
|
|
}
|
|
|
|
/// <summary>Calculation-driver deploy gates. For every tag bound to a <c>Calculation</c> driver:
|
|
/// <list type="number">
|
|
/// <item><b>scriptId existence</b> — the tag's <c>TagConfig.scriptId</c> must be present and resolve to
|
|
/// a <see cref="Script"/> row in the draft.</item>
|
|
/// <item><b>Cycle gate (hard error)</b> — build the calc→calc edge set (a calc tag → its
|
|
/// <c>ctx.GetTag</c> deps that are <b>themselves</b> Calculation tags; cross-driver refs are terminal,
|
|
/// not edges) and run <see cref="DependencyGraph.DetectCycles"/> (Tarjan SCC). Any SCC / self-loop is a
|
|
/// deploy error naming the cycle members — an undetected calc cycle is a live oscillation loop.</item>
|
|
/// </list>
|
|
/// Compile is deliberately NOT hard-gated (VirtualTag parity — the editor's live diagnostics mirror it and
|
|
/// a runtime compile failure lands as Bad quality + a script-log).</summary>
|
|
private static void ValidateCalculationTags(DraftSnapshot draft, List<ValidationError> errors)
|
|
{
|
|
var driverTypeByInstance = draft.DriverInstances
|
|
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First().DriverType, StringComparer.Ordinal);
|
|
var driverTypeByDevice = draft.Devices
|
|
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
|
.ToDictionary(
|
|
g => g.Key,
|
|
g => driverTypeByInstance.TryGetValue(g.First().DriverInstanceId, out var dt) ? dt : null,
|
|
StringComparer.Ordinal);
|
|
|
|
var resolver = BuildRawPathResolver(draft);
|
|
var scriptExists = new HashSet<string>(draft.Scripts.Select(s => s.ScriptId), StringComparer.Ordinal);
|
|
var scriptSourceById = draft.Scripts
|
|
.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
|
|
|
|
// Identify calc tags (tags on a Calculation-typed device) + their RawPath + scriptId in one pass.
|
|
var calcTags = new List<(Tag Tag, string? RawPath, string? ScriptId)>();
|
|
var calcRawPaths = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var t in draft.Tags)
|
|
{
|
|
if (!driverTypeByDevice.TryGetValue(t.DeviceId, out var dt)
|
|
|| !string.Equals(dt, Core.Abstractions.DriverTypeNames.Calculation, StringComparison.Ordinal))
|
|
continue;
|
|
var rawPath = resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
|
calcTags.Add((t, rawPath, ParseScriptId(t.TagConfig)));
|
|
if (rawPath is not null) calcRawPaths.Add(rawPath);
|
|
}
|
|
if (calcTags.Count == 0) return;
|
|
|
|
// Rule 1 — scriptId existence.
|
|
foreach (var (t, _, scriptId) in calcTags)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(scriptId))
|
|
errors.Add(new("CalculationScriptMissing",
|
|
$"Calculation tag '{t.TagId}' has no 'scriptId' in its TagConfig", t.TagId));
|
|
else if (!scriptExists.Contains(scriptId!))
|
|
errors.Add(new("CalculationScriptNotFound",
|
|
$"Calculation tag '{t.TagId}' references script '{scriptId}', which does not exist in the draft",
|
|
t.TagId));
|
|
}
|
|
|
|
// Rule 2 — cycle gate. Edges run from a calc tag to each dep that is ITSELF a calc tag.
|
|
var graph = new DependencyGraph();
|
|
foreach (var (_, rawPath, scriptId) in calcTags)
|
|
{
|
|
if (rawPath is null) continue; // broken chain — flagged by the charset rule; not a graph node
|
|
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var src) ? src : null;
|
|
var calcDeps = EquipmentScriptPaths.ExtractDependencyRefs(source)
|
|
.Where(calcRawPaths.Contains)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
graph.Add(rawPath, calcDeps);
|
|
}
|
|
foreach (var cycle in graph.DetectCycles())
|
|
errors.Add(new("CalculationDependencyCycle",
|
|
"Calculation tags form a dependency cycle (members: " + string.Join(", ", cycle) +
|
|
"); a calc→calc cycle is a live oscillation loop and must be broken before deploy",
|
|
cycle.Count > 0 ? cycle[0] : null));
|
|
|
|
// Rule 3 — effective-trigger gate (R07:U5). A calc tag evaluates on a declared dependency
|
|
// changing (change-trigger: the literal ctx.GetTag paths) and/or on a timer. A change-triggered
|
|
// tag whose script has NO literal reads (`return ctx.Now.Hour;`, a dynamic-path read) and no timer
|
|
// never evaluates at all: it sits BadWaitingForInitialData forever while the driver reports
|
|
// Healthy, and nothing warned. Same for a tag that is neither change- nor timer-triggered.
|
|
foreach (var (t, _, scriptId) in calcTags)
|
|
{
|
|
var (changeTriggered, timerIntervalMs) = ParseTriggers(t.TagConfig);
|
|
if (timerIntervalMs is > 0) continue;
|
|
var source = scriptId is not null && scriptSourceById.TryGetValue(scriptId, out var s) ? s : null;
|
|
// An unresolved scriptId is already CalculationScriptMissing / CalculationScriptNotFound; with no
|
|
// source there are no reads to count, so a change-triggered tag would get a second, misleading
|
|
// "no literal ctx.GetTag read" error. (changeTriggered=false + no timer is source-independent.)
|
|
if (changeTriggered && source is null) continue;
|
|
var depCount = EquipmentScriptPaths.ExtractDependencyRefs(source).Count;
|
|
if (changeTriggered && depCount > 0) continue;
|
|
errors.Add(new("CalculationNoEffectiveTrigger",
|
|
changeTriggered
|
|
? $"Calculation tag '{t.TagId}' is change-triggered but its script has no literal ctx.GetTag read, " +
|
|
"and it has no timerIntervalMs — nothing would ever evaluate it. Add a dependency read or a timer."
|
|
: $"Calculation tag '{t.TagId}' has changeTriggered=false and no timerIntervalMs — nothing would ever evaluate it. " +
|
|
"Enable the change trigger or add a timer.",
|
|
t.TagId));
|
|
}
|
|
}
|
|
|
|
/// <summary>Read the trigger fields from a calc tag's <c>TagConfig</c>: <c>changeTriggered</c>
|
|
/// (default <c>true</c>, the driver's own default) and <c>timerIntervalMs</c> (null when absent or not a
|
|
/// number). Never throws — malformed JSON yields the defaults.</summary>
|
|
private static (bool ChangeTriggered, int? TimerIntervalMs) ParseTriggers(string? tagConfig)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tagConfig)) return (true, null);
|
|
try
|
|
{
|
|
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
|
var root = doc.RootElement;
|
|
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return (true, null);
|
|
var changeTriggered = true;
|
|
if (root.TryGetProperty("changeTriggered", out var ct)
|
|
&& ct.ValueKind is System.Text.Json.JsonValueKind.False or System.Text.Json.JsonValueKind.True)
|
|
changeTriggered = ct.GetBoolean();
|
|
int? timer = null;
|
|
if (root.TryGetProperty("timerIntervalMs", out var tm)
|
|
&& tm.ValueKind == System.Text.Json.JsonValueKind.Number && tm.TryGetInt32(out var ms))
|
|
timer = ms;
|
|
return (changeTriggered, timer);
|
|
}
|
|
catch (System.Text.Json.JsonException)
|
|
{
|
|
return (true, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>Extract the <c>scriptId</c> string from a calc tag's schemaless <c>TagConfig</c> JSON. Never
|
|
/// throws — malformed/blank/non-object JSON or an absent/non-string <c>scriptId</c> yields null.</summary>
|
|
private static string? ParseScriptId(string? tagConfig)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(tagConfig)) return null;
|
|
try
|
|
{
|
|
using var doc = System.Text.Json.JsonDocument.Parse(tagConfig);
|
|
var root = doc.RootElement;
|
|
if (root.ValueKind != System.Text.Json.JsonValueKind.Object) return null;
|
|
if (root.TryGetProperty("scriptId", out var el)
|
|
&& el.ValueKind == System.Text.Json.JsonValueKind.String)
|
|
{
|
|
var v = el.GetString();
|
|
return string.IsNullOrWhiteSpace(v) ? null : v;
|
|
}
|
|
return null;
|
|
}
|
|
catch (System.Text.Json.JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Builds the shared <see cref="RawPathResolver"/> from the draft's raw topology so the
|
|
/// tagname-length rule computes the SAME RawPath the deploy artifact injects into drivers.</summary>
|
|
private static RawPathResolver BuildRawPathResolver(DraftSnapshot draft)
|
|
{
|
|
var folders = draft.RawFolders.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
|
var drivers = draft.DriverInstances.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
|
var devices = draft.Devices.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
|
|
var groups = draft.TagGroups.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
|
|
return new RawPathResolver(folders, drivers, devices, groups);
|
|
}
|
|
|
|
/// <summary>v3: every raw Name (RawFolder / DriverInstance / Device / TagGroup / Tag) must be a legal
|
|
/// RawPath segment — no <c>/</c>, no leading/trailing whitespace, non-empty — because <c>/</c> is the
|
|
/// RawPath separator and would corrupt the NodeId. Uses <see cref="RawPaths.ValidateSegment"/>, the same
|
|
/// charset authority the path builder enforces.</summary>
|
|
private static void ValidateRawNameCharset(DraftSnapshot draft, List<ValidationError> errors)
|
|
{
|
|
void Check(string kind, string id, string name)
|
|
{
|
|
var error = RawPaths.ValidateSegment(name);
|
|
if (error is not null)
|
|
errors.Add(new("RawNameInvalid", $"{kind} name '{name}' is not a legal RawPath segment: {error}", id));
|
|
}
|
|
|
|
foreach (var f in draft.RawFolders) Check("RawFolder", f.RawFolderId, f.Name);
|
|
foreach (var d in draft.DriverInstances) Check("DriverInstance", d.DriverInstanceId, d.Name);
|
|
foreach (var d in draft.Devices) Check("Device", d.DeviceId, d.Name);
|
|
foreach (var g in draft.TagGroups) Check("TagGroup", g.TagGroupId, g.Name);
|
|
foreach (var t in draft.Tags) Check("Tag", t.TagId, t.Name);
|
|
}
|
|
|
|
/// <summary>v3: a historized tag's EFFECTIVE historian tagname (the <c>historianTagname</c> override
|
|
/// else the tag's RawPath) must be ≤ 255 chars — the live-verified AVEVA historian limit (256 rejected).
|
|
/// A longer name is a deploy error telling the author to set a shorter <c>historianTagname</c> override,
|
|
/// never a silent truncation.</summary>
|
|
private static void ValidateHistorizedTagnameLength(DraftSnapshot draft, List<ValidationError> errors)
|
|
{
|
|
const int MaxHistorianTagname = 255;
|
|
var resolver = BuildRawPathResolver(draft);
|
|
foreach (var t in draft.Tags)
|
|
{
|
|
var intent = TagConfigIntent.Parse(t.TagConfig);
|
|
if (!intent.IsHistorized) continue;
|
|
// Effective tagname = explicit override else the computed RawPath (null when the chain is broken —
|
|
// a separate rule/charset check flags that; nothing to length-check here).
|
|
var effective = intent.HistorianTagname ?? resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
|
if (effective is null || effective.Length <= MaxHistorianTagname) continue;
|
|
errors.Add(new("HistorianTagnameTooLong",
|
|
$"tag '{t.TagId}' historized effective tagname is {effective.Length} chars (max {MaxHistorianTagname}); " +
|
|
"set a shorter 'historianTagname' override in its TagConfig",
|
|
t.TagId));
|
|
}
|
|
}
|
|
|
|
/// <summary>v3: within an equipment, the EFFECTIVE leaf name must be unique across its UnsTagReferences
|
|
/// (DisplayNameOverride else the backing raw tag's Name), its VirtualTags (Name), and its ScriptedAlarms
|
|
/// (Name) — the three share the equipment's UNS NodeId space (<c>{EquipmentId}/{EffectiveName}</c>).
|
|
/// Enforced here at the deploy gate (as well as at authoring) so a raw-tag rename that induces a clash the
|
|
/// authoring check never saw is caught before it ships.</summary>
|
|
private static void ValidateUnsEffectiveLeafUniqueness(DraftSnapshot draft, List<ValidationError> errors)
|
|
{
|
|
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
|
|
|
// (EquipmentId, EffectiveName) → source descriptions, so a collision names both sources.
|
|
var byLeaf = new Dictionary<(string Eq, string Name), List<string>>();
|
|
void Add(string equipmentId, string? effectiveName, string source)
|
|
{
|
|
if (string.IsNullOrEmpty(effectiveName)) return;
|
|
var key = (equipmentId, effectiveName);
|
|
if (!byLeaf.TryGetValue(key, out var list)) byLeaf[key] = list = new List<string>();
|
|
list.Add(source);
|
|
}
|
|
|
|
foreach (var r in draft.UnsTagReferences)
|
|
{
|
|
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
|
Add(r.EquipmentId, effective, $"reference '{r.UnsTagReferenceId}'");
|
|
}
|
|
foreach (var v in draft.VirtualTags)
|
|
Add(v.EquipmentId, v.Name, $"VirtualTag '{v.VirtualTagId}'");
|
|
foreach (var a in draft.ScriptedAlarms)
|
|
Add(a.EquipmentId, a.Name, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
|
|
|
|
foreach (var ((eq, name), sources) in byLeaf)
|
|
{
|
|
if (sources.Count <= 1) continue;
|
|
errors.Add(new("UnsEffectiveNameCollision",
|
|
$"{sources.Count} UNS signals collide on effective name '{name}' within equipment '{eq}': " +
|
|
string.Join(", ", sources) + "; effective names must be unique across references, VirtualTags, and ScriptedAlarms",
|
|
eq));
|
|
}
|
|
}
|
|
|
|
/// <summary>v3: every <c>{{equip}}/<RefName></c> used in an equipment's VirtualTag script,
|
|
/// ScriptedAlarm predicate script, or ScriptedAlarm message template must resolve to one of the owning
|
|
/// equipment's <c>UnsTagReference</c> effective names (<c>DisplayNameOverride</c> else the backing raw
|
|
/// tag's <c>Name</c>) — the same set the compose seams substitute against. An unresolved <c><RefName></c>
|
|
/// is a deploy error (replacing the v2 silent null-base no-substitution), naming the script/alarm, the
|
|
/// equipment, and the missing ref. This is the deploy-gate half of the invariant the Monaco diagnostic +
|
|
/// the authoring gate enforce (editor accepts ⇔ publish accepts).</summary>
|
|
private static void ValidateEquipReferenceResolution(DraftSnapshot draft, List<ValidationError> errors)
|
|
{
|
|
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
|
|
|
// equipmentId → set of reference effective names ({{equip}}/<RefName> resolves through REFERENCES
|
|
// only — VirtualTags/ScriptedAlarms are computed signals with no backing RawPath).
|
|
var refNamesByEquip = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
|
|
foreach (var r in draft.UnsTagReferences)
|
|
{
|
|
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
|
|
if (string.IsNullOrEmpty(effective)) continue;
|
|
if (!refNamesByEquip.TryGetValue(r.EquipmentId, out var set))
|
|
refNamesByEquip[r.EquipmentId] = set = new HashSet<string>(StringComparer.Ordinal);
|
|
set.Add(effective!);
|
|
}
|
|
|
|
var scriptSourceById = draft.Scripts.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
|
|
|
|
void CheckRefs(string equipmentId, IEnumerable<string> refNames, string source)
|
|
{
|
|
var have = refNamesByEquip.TryGetValue(equipmentId, out var set) ? set : null;
|
|
foreach (var name in refNames)
|
|
{
|
|
if (have is not null && have.Contains(name)) continue;
|
|
errors.Add(new("EquipReferenceUnresolved",
|
|
$"{source} uses '{EquipmentScriptPaths.EquipTokenPrefix}{name}' but equipment '{equipmentId}' has " +
|
|
$"no reference named '{name}'; add a reference with that effective name or correct the script.",
|
|
equipmentId));
|
|
}
|
|
}
|
|
|
|
foreach (var v in draft.VirtualTags)
|
|
{
|
|
var src = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null;
|
|
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
|
|
if (used.Count > 0) CheckRefs(v.EquipmentId, used, $"VirtualTag '{v.VirtualTagId}'");
|
|
}
|
|
|
|
foreach (var a in draft.ScriptedAlarms)
|
|
{
|
|
var src = scriptSourceById.TryGetValue(a.PredicateScriptId, out var s) ? s : null;
|
|
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src)
|
|
.Concat(EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(a.MessageTemplate))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.ToList();
|
|
if (used.Count > 0) CheckRefs(a.EquipmentId, used, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
|
|
}
|
|
}
|
|
|
|
// The rule itself lives on UnsSegment, which the AdminUI's authoring guards also call — a second
|
|
// copy here is exactly how the two drifted apart. Only the per-level wording is local.
|
|
private static void ValidateUnsSegments(DraftSnapshot draft, List<ValidationError> errors)
|
|
{
|
|
foreach (var a in draft.UnsAreas)
|
|
if (!UnsSegment.IsValid(a.Name))
|
|
errors.Add(new("UnsSegmentInvalid", UnsSegment.Describe("UnsArea.Name", a.Name), a.UnsAreaId));
|
|
|
|
foreach (var l in draft.UnsLines)
|
|
if (!UnsSegment.IsValid(l.Name))
|
|
errors.Add(new("UnsSegmentInvalid", UnsSegment.Describe("UnsLine.Name", l.Name), l.UnsLineId));
|
|
|
|
foreach (var e in draft.Equipment)
|
|
if (!UnsSegment.IsValid(e.Name))
|
|
errors.Add(new("UnsSegmentInvalid", UnsSegment.Describe("Equipment.Name", e.Name), e.EquipmentId));
|
|
}
|
|
|
|
/// <summary>Cluster.Enterprise + Site + area + line + equipment + 4 slashes ≤ 200 chars.</summary>
|
|
private static void ValidatePathLength(DraftSnapshot draft, List<ValidationError> errors)
|
|
{
|
|
// Use actual Enterprise/Site lengths when the snapshot carries them (populated by
|
|
// DraftValidationService from the ServerCluster row). Fall back to a conservative
|
|
// 32-char upper bound per segment when not supplied — over-penalises short values
|
|
// but never under-penalises long ones, which is acceptable for the fallback case.
|
|
var enterpriseLen = draft.Enterprise?.Length ?? 32;
|
|
var siteLen = draft.Site?.Length ?? 32;
|
|
|
|
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;
|
|
|
|
var len = enterpriseLen + siteLen + 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 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>EquipmentId = 'EQ-' + lowercase first 12 hex chars of the UUID.</summary>
|
|
/// <param name="uuid">The equipment UUID to derive the ID from.</param>
|
|
/// <returns>The derived <c>EQ-</c>-prefixed EquipmentId.</returns>
|
|
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));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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>
|
|
/// <param name="cluster">The server cluster to validate.</param>
|
|
/// <param name="clusterNodes">The cluster nodes to validate against the cluster configuration.</param>
|
|
/// <returns>Every failing topology rule found; empty when the cluster's declared and enabled-node
|
|
/// topology is consistent with its <see cref="ServerCluster.RedundancyMode"/>.</returns>
|
|
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));
|
|
|
|
// v2: the v1 "exactly one Primary per cluster" invariant is gone. RedundancyRole was
|
|
// dropped; in v2 the Akka cluster's role-leader-of-"driver" elects the
|
|
// primary at runtime, so there is no static configuration to validate here.
|
|
|
|
return errors;
|
|
}
|
|
}
|