Files
lmxopcua/src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Validation/DraftValidator.cs
T
Joseph Doherty 1919a8e538 feat(config): reject a persisted Sql connectionString at the deploy gate (#498)
The Sql driver's "a pasted literal connection string is never persisted" guarantee was
enforced only on the READ path: SqlDriverConfigDto has no connectionString property and
UnmappedMemberHandling.Skip drops the key on deserialization. Nothing stopped it being
WRITTEN. 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
{"connectionString":"Server=...;Password=..."} would put a live credential in the
ConfigDb — persisted, replicated to every node's artifact cache, 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.

DraftValidator.ValidateSqlConnectionStringNotPersisted fails the deploy
(SqlConnectionStringPersisted) when a Sql-typed driver instance's DriverConfig, or the
DeviceConfig of any device beneath it, carries a top-level connectionString key.

- Both surfaces are checked because DeviceConfig is merged onto DriverConfig before the
  DTO sees it — a credential pasted into the device blob is the identical leak.
- The key is matched case-insensitively: System.Text.Json binds ConnectionString to a
  connectionString property by default, so an ordinal match would leave the obvious
  bypass open.
- Only the top level is scanned; the DTO is flat, so a nested occurrence cannot bind and
  is not the mistake this rule exists to catch.
- The message never echoes the value — validation errors reach the AdminUI, the deploy
  log and the audit trail, and repeating the string there would leak the credential the
  rule is refusing to store. Pinned by a test.
- Malformed/blank/non-object JSON never throws: a parse failure would take down every
  other rule in the same pass.

16 tests. Verified falsifiable — with the rule disabled the 6 positive assertions fail
and the 10 negatives stay green, so none of them passes vacuously.

Design §8.2's "if an inline connectionString is ever permitted (dev convenience only),
the AdminUI redacts it" carve-out is withdrawn in the same change: redaction only hides a
credential that has already been written.

Not covered, and filed as a follow-up: ClusterNode.DriverConfigOverridesJson is a third
persistence surface for a driver config blob, but it is not part of DraftSnapshot, so
this validator cannot see it.
2026-07-25 15:37:48 -04:00

557 lines
30 KiB
C#

using System.Text.RegularExpressions;
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 static readonly Regex UnsSegment = new(@"^[a-z0-9-]{1,32}$", RegexOptions.Compiled);
private const string UnsDefaultSegment = "_default";
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 (WP4). 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);
return errors;
}
/// <summary>
/// The <c>Sql</c> driver's "a pasted literal connection string is never persisted" guarantee, enforced
/// at the deploy gate (Gitea #498).
/// </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>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 = "connectionString";
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 (!HasTopLevelKey(d.DriverConfig, ForbiddenKey)) 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 (!HasTopLevelKey(dev.DeviceConfig, ForbiddenKey)) 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>
/// True when <paramref name="json"/> is a JSON object carrying <paramref name="key"/> at its top level,
/// matched case-insensitively. Never throws — a blank, malformed or non-object blob simply has no keys,
/// and shaping the config JSON is another rule's job.
/// </summary>
/// <param name="json">The config blob to inspect.</param>
/// <param name="key">The property name to look for.</param>
/// <returns><see langword="true"/> when the key is present at the top level.</returns>
private static bool HasTopLevelKey(string? json, string key)
{
if (string.IsNullOrWhiteSpace(json)) return false;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Object) return false;
foreach (var property in doc.RootElement.EnumerateObject())
{
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) return true;
}
return false;
}
catch (System.Text.Json.JsonException)
{
return false;
}
}
/// <summary>WP7 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));
}
/// <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}}/&lt;RefName&gt;</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>&lt;RefName&gt;</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}'");
}
}
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)
{
// 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;
}
}