refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)
Solution + 23 src projects + 26 test projects renamed; folders, csproj, namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated. ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated. SQL roles/logins, LDAP domains, CLI command name, and CLI config dir (~/.scadalink → ~/.scadabridge) also renamed. Build green; 5 Host.Tests fail awaiting SQL login rename in next commit. Pre-existing StaleTagMonitor timing flakes unchanged. Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
@@ -0,0 +1,478 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming bundle DTO against the existing entity in the target
|
||||
/// database and produces a single <see cref="ImportPreviewItem"/> that
|
||||
/// classifies the conflict and (for <c>Modified</c>) carries a coarse field
|
||||
/// diff in JSON.
|
||||
/// <para>
|
||||
/// "Coarse" means: each persistent field is compared as a value; differing
|
||||
/// fields appear in <c>changes</c> with old/new values (or hashes for large
|
||||
/// blobs like script code). Per-line / Myers-style diff is explicitly out of
|
||||
/// scope for v1 — the design plan defers it to a follow-up task. Script
|
||||
/// bodies record only a line-count delta to give the operator a sense of the
|
||||
/// change without paying the diff cost up front.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Entity versions are not yet tracked on the POCOs, so the
|
||||
/// <see cref="ImportPreviewItem.ExistingVersion"/> / <see cref="ImportPreviewItem.IncomingVersion"/>
|
||||
/// fields are always <c>null</c> here. They are reserved for a future
|
||||
/// optimistic-concurrency feature.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ArtifactDiff
|
||||
{
|
||||
private static readonly JsonSerializerOptions DiffJsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
// ---- Templates ----
|
||||
/// <summary>
|
||||
/// Compares an incoming template against the existing template in the database.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming template from the bundle.</param>
|
||||
/// <param name="existing">The existing template in the database, or null if new.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareTemplate(TemplateDto incoming, Template? existing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null)
|
||||
{
|
||||
return New("Template", incoming.Name);
|
||||
}
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
AddIfDifferent(changes, "Description", existing.Description, incoming.Description);
|
||||
AddIfDifferent(changes, "FolderName", FolderNameOf(existing), incoming.FolderName);
|
||||
AddIfDifferent(changes, "BaseTemplateName", BaseTemplateNameOf(existing), incoming.BaseTemplateName);
|
||||
|
||||
// Children: compare each child collection by name. We track which names
|
||||
// were added, which were removed, and which existed on both sides but
|
||||
// diverged in body. We use coarse value equality / line counts for
|
||||
// scripts so the diff JSON stays under a few KB per item.
|
||||
DiffChildren(
|
||||
existing.Attributes,
|
||||
incoming.Attributes,
|
||||
e => e.Name,
|
||||
i => i.Name,
|
||||
AttributesEqual,
|
||||
"Attributes",
|
||||
changes);
|
||||
|
||||
DiffChildren(
|
||||
existing.Alarms,
|
||||
incoming.Alarms,
|
||||
e => e.Name,
|
||||
i => i.Name,
|
||||
AlarmsEqual,
|
||||
"Alarms",
|
||||
changes);
|
||||
|
||||
DiffScriptChildren(existing.Scripts, incoming.Scripts, changes);
|
||||
|
||||
// Compositions diff by InstanceName since ComposedTemplateId vs
|
||||
// ComposedTemplateName aren't directly comparable. The bundle side
|
||||
// already serializes to the name form, so the comparison reduces to
|
||||
// (InstanceName, ComposedTemplateName) tuple equality.
|
||||
DiffChildren(
|
||||
existing.Compositions,
|
||||
incoming.Compositions,
|
||||
e => e.InstanceName,
|
||||
i => i.InstanceName,
|
||||
(e, i) => CompositionTargetNameOf(e) == i.ComposedTemplateName,
|
||||
"Compositions",
|
||||
changes);
|
||||
|
||||
return BuildItem("Template", incoming.Name, changes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming shared script against the existing shared script in the database.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming shared script from the bundle.</param>
|
||||
/// <param name="existing">The existing shared script in the database, or null if new.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareSharedScript(SharedScriptDto incoming, SharedScript? existing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null) return New("SharedScript", incoming.Name);
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
|
||||
AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
|
||||
AddCodeChangeIfDifferent(changes, "Code", existing.Code, incoming.Code);
|
||||
|
||||
return BuildItem("SharedScript", incoming.Name, changes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming external system against the existing external system in the database.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming external system from the bundle.</param>
|
||||
/// <param name="existing">The existing external system in the database, or null if new.</param>
|
||||
/// <param name="existingMethods">The existing external system methods, or null if new.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareExternalSystem(ExternalSystemDto incoming, ExternalSystemDefinition? existing, IReadOnlyList<ExternalSystemMethod>? existingMethods)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null) return New("ExternalSystem", incoming.Name);
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
AddIfDifferent(changes, "BaseUrl", existing.EndpointUrl, incoming.BaseUrl);
|
||||
AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType);
|
||||
|
||||
// Secrets: presence-only comparison (we never echo the value in the diff).
|
||||
var existingHasSecret = !string.IsNullOrEmpty(existing.AuthConfiguration);
|
||||
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("AuthConfiguration");
|
||||
if (existingHasSecret != incomingHasSecret)
|
||||
{
|
||||
changes.Add(new FieldChange("Secrets.AuthConfiguration",
|
||||
existingHasSecret ? "<present>" : null,
|
||||
incomingHasSecret ? "<present>" : null));
|
||||
}
|
||||
|
||||
// Methods are name-keyed children.
|
||||
var existingForCompare = existingMethods ?? Array.Empty<ExternalSystemMethod>();
|
||||
DiffChildren(
|
||||
existingForCompare,
|
||||
incoming.Methods,
|
||||
e => e.Name,
|
||||
i => i.Name,
|
||||
ExternalSystemMethodsEqual,
|
||||
"Methods",
|
||||
changes);
|
||||
|
||||
return BuildItem("ExternalSystem", incoming.Name, changes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming database connection against the existing database connection in the database.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming database connection from the bundle.</param>
|
||||
/// <param name="existing">The existing database connection in the database, or null if new.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareDatabaseConnection(DatabaseConnectionDto incoming, DatabaseConnectionDefinition? existing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null) return New("DatabaseConnection", incoming.Name);
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
|
||||
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
|
||||
|
||||
// ConnectionString lives in Secrets only — presence-only comparison.
|
||||
var existingHasSecret = !string.IsNullOrEmpty(existing.ConnectionString);
|
||||
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("ConnectionString");
|
||||
if (existingHasSecret != incomingHasSecret)
|
||||
{
|
||||
changes.Add(new FieldChange("Secrets.ConnectionString",
|
||||
existingHasSecret ? "<present>" : null,
|
||||
incomingHasSecret ? "<present>" : null));
|
||||
}
|
||||
|
||||
return BuildItem("DatabaseConnection", incoming.Name, changes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming notification list against the existing notification list in the database.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming notification list from the bundle.</param>
|
||||
/// <param name="existing">The existing notification list in the database, or null if new.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareNotificationList(NotificationListDto incoming, NotificationList? existing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null) return New("NotificationList", incoming.Name);
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
AddIfDifferent(changes, "Type", existing.Type.ToString(), incoming.Type.ToString());
|
||||
|
||||
DiffChildren(
|
||||
existing.Recipients,
|
||||
incoming.Recipients,
|
||||
e => e.Name,
|
||||
i => i.Name,
|
||||
(e, i) => e.EmailAddress == i.EmailAddress,
|
||||
"Recipients",
|
||||
changes);
|
||||
|
||||
return BuildItem("NotificationList", incoming.Name, changes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming SMTP configuration against the existing SMTP configuration in the database.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming SMTP configuration from the bundle.</param>
|
||||
/// <param name="existing">The existing SMTP configuration in the database, or null if new.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareSmtpConfiguration(SmtpConfigDto incoming, SmtpConfiguration? existing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null) return New("SmtpConfiguration", incoming.Host);
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
AddIfDifferent(changes, "Port", existing.Port, incoming.Port);
|
||||
AddIfDifferent(changes, "AuthType", existing.AuthType, incoming.AuthType);
|
||||
AddIfDifferent(changes, "FromAddress", existing.FromAddress, incoming.FromAddress);
|
||||
AddIfDifferent(changes, "TlsMode", existing.TlsMode, incoming.TlsMode);
|
||||
AddIfDifferent(changes, "ConnectionTimeoutSeconds", existing.ConnectionTimeoutSeconds, incoming.ConnectionTimeoutSeconds);
|
||||
AddIfDifferent(changes, "MaxConcurrentConnections", existing.MaxConcurrentConnections, incoming.MaxConcurrentConnections);
|
||||
AddIfDifferent(changes, "MaxRetries", existing.MaxRetries, incoming.MaxRetries);
|
||||
AddIfDifferent(changes, "RetryDelay", existing.RetryDelay.ToString(), incoming.RetryDelay.ToString());
|
||||
|
||||
var existingHasSecret = !string.IsNullOrEmpty(existing.Credentials);
|
||||
var incomingHasSecret = incoming.Secrets is not null && incoming.Secrets.Values.ContainsKey("Credentials");
|
||||
if (existingHasSecret != incomingHasSecret)
|
||||
{
|
||||
changes.Add(new FieldChange("Secrets.Credentials",
|
||||
existingHasSecret ? "<present>" : null,
|
||||
incomingHasSecret ? "<present>" : null));
|
||||
}
|
||||
|
||||
return BuildItem("SmtpConfiguration", incoming.Host, changes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming API key against the existing API key in the database.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming API key from the bundle.</param>
|
||||
/// <param name="existing">The existing API key in the database, or null if new.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareApiKey(ApiKeyDto incoming, ApiKey? existing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null) return New("ApiKey", incoming.Name);
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
AddIfDifferent(changes, "IsEnabled", existing.IsEnabled, incoming.IsEnabled);
|
||||
// KeyHash is opaque — record only changed/unchanged, not the value.
|
||||
if (!string.Equals(existing.KeyHash, incoming.KeyHash, StringComparison.Ordinal))
|
||||
{
|
||||
changes.Add(new FieldChange("KeyHash", "<changed>", "<changed>"));
|
||||
}
|
||||
return BuildItem("ApiKey", incoming.Name, changes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming API method against the existing API method in the database.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming API method from the bundle.</param>
|
||||
/// <param name="existing">The existing API method in the database, or null if new.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareApiMethod(ApiMethodDto incoming, ApiMethod? existing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null) return New("ApiMethod", incoming.Name);
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
AddIfDifferent(changes, "ApprovedApiKeyIds", existing.ApprovedApiKeyIds, incoming.ApprovedApiKeyIds);
|
||||
AddIfDifferent(changes, "ParameterDefinitions", existing.ParameterDefinitions, incoming.ParameterDefinitions);
|
||||
AddIfDifferent(changes, "ReturnDefinition", existing.ReturnDefinition, incoming.ReturnDefinition);
|
||||
AddIfDifferent(changes, "TimeoutSeconds", existing.TimeoutSeconds, incoming.TimeoutSeconds);
|
||||
AddCodeChangeIfDifferent(changes, "Script", existing.Script, incoming.Script);
|
||||
|
||||
return BuildItem("ApiMethod", incoming.Name, changes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming template folder against the existing template folder in the database.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming template folder from the bundle.</param>
|
||||
/// <param name="existing">The existing template folder in the database, or null if new.</param>
|
||||
/// <param name="folderNameById">A mapping of folder IDs to names for resolving parent folder references.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareTemplateFolder(TemplateFolderDto incoming, TemplateFolder? existing, IReadOnlyDictionary<int, string> folderNameById)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null) return New("TemplateFolder", incoming.Name);
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
AddIfDifferent(changes, "SortOrder", existing.SortOrder, incoming.SortOrder);
|
||||
var existingParentName = existing.ParentFolderId is { } pid && folderNameById.TryGetValue(pid, out var n) ? n : null;
|
||||
AddIfDifferent(changes, "ParentName", existingParentName, incoming.ParentName);
|
||||
return BuildItem("TemplateFolder", incoming.Name, changes);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static ImportPreviewItem New(string entityType, string name) =>
|
||||
new(entityType, name, ExistingVersion: null, IncomingVersion: null, ConflictKind.New, FieldDiffJson: null, BlockerReason: null);
|
||||
|
||||
private static ImportPreviewItem BuildItem(string entityType, string name, List<FieldChange> changes)
|
||||
{
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Identical, FieldDiffJson: null, BlockerReason: null);
|
||||
}
|
||||
var diff = new FieldDiff(
|
||||
Adds: Array.Empty<string>(),
|
||||
Removes: Array.Empty<string>(),
|
||||
Changes: changes);
|
||||
return new ImportPreviewItem(entityType, name, null, null, ConflictKind.Modified, FieldDiffJson: JsonSerializer.Serialize(diff, DiffJsonOptions), BlockerReason: null);
|
||||
}
|
||||
|
||||
private static void AddIfDifferent<T>(List<FieldChange> changes, string field, T existing, T incoming)
|
||||
{
|
||||
if (!Equals(existing, incoming))
|
||||
{
|
||||
changes.Add(new FieldChange(field, existing?.ToString(), incoming?.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCodeChangeIfDifferent(List<FieldChange> changes, string field, string? existing, string? incoming)
|
||||
{
|
||||
// Script bodies can be large — record a line-count delta + change marker
|
||||
// instead of inlining the full text so the diff JSON stays compact.
|
||||
var sameNullness = existing is null == incoming is null;
|
||||
var bothPresentAndEqual = sameNullness && (existing is null || string.Equals(existing, incoming, StringComparison.Ordinal));
|
||||
if (bothPresentAndEqual) return;
|
||||
|
||||
var oldLines = existing?.Split('\n').Length ?? 0;
|
||||
var newLines = incoming?.Split('\n').Length ?? 0;
|
||||
changes.Add(new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>"));
|
||||
}
|
||||
|
||||
private static void DiffChildren<TExisting, TIncoming>(
|
||||
IEnumerable<TExisting> existing,
|
||||
IEnumerable<TIncoming> incoming,
|
||||
Func<TExisting, string> existingKey,
|
||||
Func<TIncoming, string> incomingKey,
|
||||
Func<TExisting, TIncoming, bool> equal,
|
||||
string childCategory,
|
||||
List<FieldChange> changes)
|
||||
{
|
||||
var existingByName = existing.GroupBy(existingKey, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
var incomingByName = incoming.GroupBy(incomingKey, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
|
||||
var added = incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
var removed = existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal).ToList();
|
||||
|
||||
foreach (var name in added)
|
||||
{
|
||||
changes.Add(new FieldChange($"{childCategory}.{name}", null, "<added>"));
|
||||
}
|
||||
foreach (var name in removed)
|
||||
{
|
||||
changes.Add(new FieldChange($"{childCategory}.{name}", "<present>", null));
|
||||
}
|
||||
foreach (var (name, e) in existingByName)
|
||||
{
|
||||
if (!incomingByName.TryGetValue(name, out var i)) continue;
|
||||
if (!equal(e, i))
|
||||
{
|
||||
changes.Add(new FieldChange($"{childCategory}.{name}", "<modified>", "<modified>"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scripts get a finer-grained per-row line-count delta so the preview UI
|
||||
/// can show the operator which scripts changed and roughly how much.
|
||||
/// </summary>
|
||||
private static void DiffScriptChildren(
|
||||
IEnumerable<TemplateScript> existing,
|
||||
IEnumerable<TemplateScriptDto> incoming,
|
||||
List<FieldChange> changes)
|
||||
{
|
||||
var existingByName = existing.GroupBy(s => s.Name, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
var incomingByName = incoming.GroupBy(s => s.Name, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
|
||||
foreach (var name in incomingByName.Keys.Where(k => !existingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
var inc = incomingByName[name];
|
||||
var newLines = inc.Code.Split('\n').Length;
|
||||
changes.Add(new FieldChange($"Scripts.{name}", null, $"<added, {newLines} lines>"));
|
||||
}
|
||||
foreach (var name in existingByName.Keys.Where(k => !incomingByName.ContainsKey(k)).OrderBy(k => k, StringComparer.Ordinal))
|
||||
{
|
||||
var ex = existingByName[name];
|
||||
var oldLines = ex.Code.Split('\n').Length;
|
||||
changes.Add(new FieldChange($"Scripts.{name}", $"<present, {oldLines} lines>", null));
|
||||
}
|
||||
foreach (var (name, ex) in existingByName)
|
||||
{
|
||||
if (!incomingByName.TryGetValue(name, out var inc)) continue;
|
||||
if (!ScriptsEqual(ex, inc))
|
||||
{
|
||||
var oldLines = ex.Code.Split('\n').Length;
|
||||
var newLines = inc.Code.Split('\n').Length;
|
||||
changes.Add(new FieldChange($"Scripts.{name}", $"<{oldLines} lines>", $"<{newLines} lines>"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool AttributesEqual(TemplateAttribute e, TemplateAttributeDto i) =>
|
||||
e.Value == i.Value
|
||||
&& e.DataType == i.DataType
|
||||
&& e.IsLocked == i.IsLocked
|
||||
&& e.Description == i.Description
|
||||
&& e.DataSourceReference == i.DataSourceReference;
|
||||
|
||||
private static bool AlarmsEqual(TemplateAlarm e, TemplateAlarmDto i) =>
|
||||
e.Description == i.Description
|
||||
&& e.PriorityLevel == i.PriorityLevel
|
||||
&& e.TriggerType == i.TriggerType
|
||||
&& e.TriggerConfiguration == i.TriggerConfiguration
|
||||
&& e.IsLocked == i.IsLocked;
|
||||
|
||||
private static bool ScriptsEqual(TemplateScript e, TemplateScriptDto i) =>
|
||||
string.Equals(e.Code, i.Code, StringComparison.Ordinal)
|
||||
&& e.TriggerType == i.TriggerType
|
||||
&& e.TriggerConfiguration == i.TriggerConfiguration
|
||||
&& e.ParameterDefinitions == i.ParameterDefinitions
|
||||
&& e.ReturnDefinition == i.ReturnDefinition
|
||||
&& e.IsLocked == i.IsLocked;
|
||||
|
||||
private static bool ExternalSystemMethodsEqual(ExternalSystemMethod e, ExternalSystemMethodDto i) =>
|
||||
e.HttpMethod == i.HttpMethod
|
||||
&& e.Path == i.Path
|
||||
&& e.ParameterDefinitions == i.ParameterDefinitions
|
||||
&& e.ReturnDefinition == i.ReturnDefinition;
|
||||
|
||||
private static string? FolderNameOf(Template t)
|
||||
{
|
||||
// Templates carry only a FK to the folder; the EntitySerializer projects
|
||||
// it to a name. The diff doesn't have access to a folder lookup at
|
||||
// CompareTemplate scope, so fall back to "<id:N>" when only the id is
|
||||
// known. The PreviewAsync caller can pass a hydrated Template (via
|
||||
// GetTemplateWithChildrenAsync) and the folder name typically isn't on
|
||||
// it — this branch is a deliberate best-effort.
|
||||
return t.FolderId is null ? null : $"<id:{t.FolderId}>";
|
||||
}
|
||||
|
||||
private static string? BaseTemplateNameOf(Template t)
|
||||
{
|
||||
return t.ParentTemplateId is null ? null : $"<id:{t.ParentTemplateId}>";
|
||||
}
|
||||
|
||||
private static string CompositionTargetNameOf(TemplateComposition comp)
|
||||
{
|
||||
return $"<id:{comp.ComposedTemplateId}>";
|
||||
}
|
||||
|
||||
private sealed record FieldChange(
|
||||
[property: JsonPropertyName("field")] string Field,
|
||||
[property: JsonPropertyName("oldValue")] string? OldValue,
|
||||
[property: JsonPropertyName("newValue")] string? NewValue);
|
||||
|
||||
private sealed record FieldDiff(
|
||||
[property: JsonPropertyName("adds")] IReadOnlyList<string> Adds,
|
||||
[property: JsonPropertyName("removes")] IReadOnlyList<string> Removes,
|
||||
[property: JsonPropertyName("changes")] IReadOnlyList<FieldChange> Changes);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
/// <summary>
|
||||
/// T-003: thrown by <see cref="BundleImporter.LoadAsync"/> when an encrypted bundle has
|
||||
/// exceeded the configured failed-unlock attempt limit
|
||||
/// (<see cref="TransportOptions.MaxUnlockAttemptsPerSession"/>). The lockout is tracked
|
||||
/// server-side keyed by <c>BundleManifest.ContentHash</c>, so a second tab / CLI caller
|
||||
/// re-uploading the same bytes hits the same counter and cannot side-step the limit.
|
||||
/// </summary>
|
||||
public sealed class BundleLockedException : Exception
|
||||
{
|
||||
/// <summary>Number of recorded unlock failures for this bundle.</summary>
|
||||
public int FailedAttempts { get; }
|
||||
|
||||
/// <summary>SHA-256 (hex) of the bundle's content bytes, the lockout's tracking key.</summary>
|
||||
public string BundleContentHash { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleLockedException"/>.
|
||||
/// </summary>
|
||||
/// <param name="bundleContentHash">SHA-256 hex from <c>BundleManifest.ContentHash</c>.</param>
|
||||
/// <param name="failedAttempts">Number of failures recorded against this bundle.</param>
|
||||
public BundleLockedException(string bundleContentHash, int failedAttempts)
|
||||
: base(
|
||||
$"Bundle is locked after {failedAttempts} failed unlock attempts. "
|
||||
+ "Wait for the lockout window to expire or re-export the bundle to obtain a new content hash.")
|
||||
{
|
||||
BundleContentHash = bundleContentHash ?? throw new ArgumentNullException(nameof(bundleContentHash));
|
||||
FailedAttempts = failedAttempts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
/// <summary>
|
||||
/// T-007: periodic background sweep that drives <see cref="IBundleSessionStore.EvictExpired"/>
|
||||
/// so abandoned import sessions clear from memory on their own, without needing a
|
||||
/// new <see cref="IBundleSessionStore.Get"/> to trigger lazy eviction. Each session
|
||||
/// owns the decrypted bundle content (potentially up to ~100 MB of secrets — DB
|
||||
/// connection strings, SMTP credentials, external-system auth configs), and the
|
||||
/// design contract is "bundles are not retained server-side after ApplyAsync
|
||||
/// commits". This service keeps abandoned / failed sessions from pinning that
|
||||
/// plaintext for the full 30-minute TTL when no other traffic flows.
|
||||
/// </summary>
|
||||
internal sealed class BundleSessionEvictionService : BackgroundService
|
||||
{
|
||||
private static readonly TimeSpan SweepInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly IBundleSessionStore _sessionStore;
|
||||
private readonly ILogger<BundleSessionEvictionService> _logger;
|
||||
|
||||
public BundleSessionEvictionService(
|
||||
IBundleSessionStore sessionStore,
|
||||
ILogger<BundleSessionEvictionService> logger)
|
||||
{
|
||||
_sessionStore = sessionStore ?? throw new ArgumentNullException(nameof(sessionStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(SweepInterval, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_sessionStore.EvictExpired();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Bundle session sweep failed; will retry on next interval.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="IBundleSessionStore"/> backed by a
|
||||
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>. Sessions are evicted lazily
|
||||
/// at read time (<see cref="Get"/>) and on-demand via <see cref="EvictExpired"/>;
|
||||
/// there is no background timer.
|
||||
/// <para>
|
||||
/// Thread-safety: backed by <see cref="ConcurrentDictionary{TKey,TValue}"/> of
|
||||
/// <see cref="Guid"/> to <see cref="BundleSession"/>. All store operations
|
||||
/// (<see cref="Get"/> / <see cref="Open"/> / <see cref="Remove"/> /
|
||||
/// <see cref="EvictExpired"/>) use the concurrent dictionary's safe primitives
|
||||
/// (<c>TryGetValue</c>, indexer assignment, <c>TryRemove</c>) and are safe
|
||||
/// under concurrent callers. The <see cref="BundleSession"/> instance itself
|
||||
/// is NOT thread-safe — callers that share a session reference (e.g. two
|
||||
/// importers mutating <c>FailedUnlockAttempts</c> on the same session) MUST
|
||||
/// serialize their mutations on that shared reference.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// TTL is supplied by the importer via <see cref="BundleSession.ExpiresAt"/>;
|
||||
/// this store does not impose its own. The injected <see cref="TimeProvider"/>
|
||||
/// is used purely to determine <c>now</c> when checking <c>ExpiresAt</c>, which
|
||||
/// keeps unit tests deterministic.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The 3-strike unlock lockout is owned by <see cref="BundleSession"/>
|
||||
/// (<c>FailedUnlockAttempts</c> / <c>Locked</c>); the store just hands out the
|
||||
/// shared session reference so the importer can mutate the counter in place.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BundleSessionStore : IBundleSessionStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<Guid, BundleSession> _sessions = new();
|
||||
|
||||
/// <summary>
|
||||
/// T-003: per-bundle unlock-failure counters, keyed by <see cref="BundleManifest.ContentHash"/>
|
||||
/// (SHA-256 hex of the bundle's content bytes). Failures are tracked here — not on
|
||||
/// <see cref="BundleSession"/> — so retries against the same bundle bytes from a
|
||||
/// second tab / CLI caller share the counter and cannot side-step the lockout. Entries
|
||||
/// expire on the same TTL as a session.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, UnlockFailureRecord> _unlockFailures = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IOptions<TransportOptions> _options;
|
||||
|
||||
/// <summary>T-003: per-bundle unlock-failure entry with expiry.</summary>
|
||||
private sealed class UnlockFailureRecord
|
||||
{
|
||||
public int Count;
|
||||
public DateTimeOffset ExpiresAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleSessionStore"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">Transport options. <see cref="TransportOptions.BundleSessionTtlMinutes"/> is also used as the TTL for the T-003 per-bundle unlock-failure tracker.</param>
|
||||
/// <param name="timeProvider">Time provider used to evaluate session expiry.</param>
|
||||
public BundleSessionStore(IOptions<TransportOptions> options, TimeProvider timeProvider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_options = options;
|
||||
_ = options.Value;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BundleSession Open(BundleSession session)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
// Overwrite on collision is defensive: GUIDs are random so practical
|
||||
// collisions don't happen, but if a caller reuses an id we always
|
||||
// honor the latest Open call.
|
||||
_sessions[session.SessionId] = session;
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BundleSession? Get(Guid sessionId)
|
||||
{
|
||||
if (!_sessions.TryGetValue(sessionId, out var session)) return null;
|
||||
if (session.ExpiresAt > _timeProvider.GetUtcNow()) return session;
|
||||
_sessions.TryRemove(sessionId, out _);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Remove(Guid sessionId)
|
||||
{
|
||||
_sessions.TryRemove(sessionId, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EvictExpired()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
foreach (var kv in _sessions)
|
||||
{
|
||||
if (kv.Value.ExpiresAt <= now)
|
||||
{
|
||||
_sessions.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// T-003: also expire stale per-bundle unlock-failure entries so a bundle
|
||||
// that was previously locked clears once the lockout window passes.
|
||||
foreach (var kv in _unlockFailures)
|
||||
{
|
||||
if (kv.Value.ExpiresAt <= now)
|
||||
{
|
||||
_unlockFailures.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int GetUnlockFailureCount(string bundleContentHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
|
||||
if (!_unlockFailures.TryGetValue(bundleContentHash, out var record))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Lazy expiry — if the entry has aged past its window treat it as cleared.
|
||||
if (record.ExpiresAt <= _timeProvider.GetUtcNow())
|
||||
{
|
||||
_unlockFailures.TryRemove(bundleContentHash, out _);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return record.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int IncrementUnlockFailureCount(string bundleContentHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
|
||||
var ttl = TimeSpan.FromMinutes(_options.Value.BundleSessionTtlMinutes);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var record = _unlockFailures.AddOrUpdate(
|
||||
bundleContentHash,
|
||||
_ => new UnlockFailureRecord { Count = 1, ExpiresAt = now + ttl },
|
||||
(_, existing) =>
|
||||
{
|
||||
// Treat an expired record as a fresh start so a legitimate operator
|
||||
// returning hours later does not face a stale lockout.
|
||||
if (existing.ExpiresAt <= now)
|
||||
{
|
||||
existing.Count = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Count++;
|
||||
}
|
||||
|
||||
existing.ExpiresAt = now + ttl;
|
||||
return existing;
|
||||
});
|
||||
|
||||
return record.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearUnlockFailures(string bundleContentHash)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
|
||||
_unlockFailures.TryRemove(bundleContentHash, out _);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-004: thrown by <see cref="BundleImporter.LoadAsync"/> when the caller
|
||||
/// has exceeded the configured per-IP-per-hour unlock attempt cap
|
||||
/// (<see cref="TransportOptions.MaxUnlockAttemptsPerIpPerHour"/>). The 429-equivalent
|
||||
/// signal: the caller must wait for the trailing-hour window to roll forward before
|
||||
/// another passphrase attempt is accepted.
|
||||
/// </summary>
|
||||
public sealed class BundleUnlockRateLimitedException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Rate-limit key the limiter rejected the attempt against — the caller IP when
|
||||
/// supplied, or the bundle's content hash as the architectural fallback (the
|
||||
/// importer has no <c>IHttpContext</c> dependency by design).
|
||||
/// </summary>
|
||||
public string ClientKey { get; }
|
||||
|
||||
/// <summary>Per-window cap that was reached.</summary>
|
||||
public int Limit { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleUnlockRateLimitedException"/>.
|
||||
/// </summary>
|
||||
/// <param name="clientKey">The rate-limit key that exceeded its budget.</param>
|
||||
/// <param name="limit">The configured per-window cap.</param>
|
||||
public BundleUnlockRateLimitedException(string clientKey, int limit)
|
||||
: base(
|
||||
$"Bundle unlock rate limit reached ({limit} attempts per hour). "
|
||||
+ "Wait for the trailing-hour window to expire before retrying.")
|
||||
{
|
||||
ClientKey = clientKey ?? throw new ArgumentNullException(nameof(clientKey));
|
||||
Limit = limit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-004: in-memory sliding-window rate limiter for bundle-unlock passphrase
|
||||
/// attempts, keyed by client IP. The design doc (§11) declares a per-IP-per-hour cap
|
||||
/// (default 10) as a brute-force defence against a stolen bundle; this class is the
|
||||
/// minimal server-side implementation.
|
||||
/// <para>
|
||||
/// Algorithm: each key (an IP string, or any opaque caller identifier) holds a queue
|
||||
/// of attempt timestamps. <see cref="TryRegisterAttempt"/> first prunes entries older
|
||||
/// than the configured window, then either appends the current timestamp and returns
|
||||
/// <c>true</c> if the count is still under the threshold, or refuses to append and
|
||||
/// returns <c>false</c> if appending would cross it. The trailing-hour count is the
|
||||
/// queue length post-prune.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Storage is a process-local <see cref="ConcurrentDictionary{TKey,TValue}"/>. The
|
||||
/// counters do not survive a host restart — that is by design: a restart resets the
|
||||
/// brute-force window in favour of legitimate operators after an outage. Persisting
|
||||
/// the counters would require a multi-node consensus story the simple in-memory
|
||||
/// design avoids.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Thread-safety: the per-key queue is protected by a per-key lock taken inside the
|
||||
/// dictionary value; the dictionary itself is concurrent. The class is safe to call
|
||||
/// from multiple threads / circuits without external coordination.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class BundleUnlockRateLimiter
|
||||
{
|
||||
/// <summary>
|
||||
/// Default trailing window. The design doc's "per-IP-per-hour" wording fixes this
|
||||
/// at 60 minutes; a constructor overload accepts a different window for tests.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultWindow = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly ConcurrentDictionary<string, AttemptBucket> _buckets = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly TimeSpan _window;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> using the documented
|
||||
/// 1-hour trailing window and the system clock. Suitable for production DI.
|
||||
/// </summary>
|
||||
public BundleUnlockRateLimiter() : this(TimeProvider.System, DefaultWindow)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="BundleUnlockRateLimiter"/> with an injected clock
|
||||
/// (for deterministic tests) and a custom trailing window.
|
||||
/// </summary>
|
||||
/// <param name="timeProvider">Clock used for both timestamping new attempts and pruning expired ones.</param>
|
||||
/// <param name="window">Trailing window over which attempts are counted (typically 1 hour).</param>
|
||||
public BundleUnlockRateLimiter(TimeProvider timeProvider, TimeSpan window)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(timeProvider);
|
||||
if (window <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(window), "Window must be positive.");
|
||||
}
|
||||
|
||||
_timeProvider = timeProvider;
|
||||
_window = window;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to register a new passphrase try against the configured per-key
|
||||
/// limit. Returns <c>true</c> when the attempt is permitted (and recorded);
|
||||
/// returns <c>false</c> when the key has exhausted its budget for the trailing
|
||||
/// window — the caller should reject the unlock request with a 429-equivalent.
|
||||
/// </summary>
|
||||
/// <param name="clientKey">
|
||||
/// Opaque caller identifier — typically the remote IP, but any stable per-source
|
||||
/// string is acceptable (the limiter does not interpret it). Trimmed for matching.
|
||||
/// </param>
|
||||
/// <param name="maxAttemptsPerWindow">
|
||||
/// The trailing-window cap (e.g. <c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>,
|
||||
/// default 10). Must be at least 1.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the attempt was registered (within budget); <c>false</c> if the
|
||||
/// caller has already used <paramref name="maxAttemptsPerWindow"/> within the
|
||||
/// trailing window.
|
||||
/// </returns>
|
||||
public bool TryRegisterAttempt(string clientKey, int maxAttemptsPerWindow)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
|
||||
if (maxAttemptsPerWindow < 1)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(maxAttemptsPerWindow), "Limit must be at least 1.");
|
||||
}
|
||||
|
||||
var bucket = _buckets.GetOrAdd(clientKey.Trim(), _ => new AttemptBucket());
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var cutoff = now - _window;
|
||||
|
||||
lock (bucket)
|
||||
{
|
||||
// Prune expired entries first so a caller that paused longer than the
|
||||
// window starts the next round at zero — not penalised by stale rows.
|
||||
while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
|
||||
{
|
||||
bucket.Timestamps.Dequeue();
|
||||
}
|
||||
|
||||
if (bucket.Timestamps.Count >= maxAttemptsPerWindow)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bucket.Timestamps.Enqueue(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of recorded attempts for <paramref name="clientKey"/> still
|
||||
/// within the trailing window. Primarily for tests / diagnostics; not part of the
|
||||
/// hot-path.
|
||||
/// </summary>
|
||||
public int GetAttemptCount(string clientKey)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
|
||||
if (!_buckets.TryGetValue(clientKey.Trim(), out var bucket))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var cutoff = _timeProvider.GetUtcNow() - _window;
|
||||
lock (bucket)
|
||||
{
|
||||
while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
|
||||
{
|
||||
bucket.Timestamps.Dequeue();
|
||||
}
|
||||
|
||||
return bucket.Timestamps.Count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-key queue of attempt timestamps. A class (rather than a bare
|
||||
/// <see cref="Queue{T}"/>) so the dictionary value identity is stable across
|
||||
/// concurrent <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey,Func{TKey,TValue})"/>
|
||||
/// races — letting the per-bucket lock guard the queue mutations.
|
||||
/// </summary>
|
||||
private sealed class AttemptBucket
|
||||
{
|
||||
public Queue<DateTimeOffset> Timestamps { get; } = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Transport.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the post-apply semantic validation pass detects that the merged
|
||||
/// target configuration would not be deployable — e.g. a template script
|
||||
/// references a SharedScript or ExternalSystem that exists in neither the
|
||||
/// bundle nor the (post-merge) target database.
|
||||
/// <para>
|
||||
/// The exception is caught inside <see cref="BundleImporter.ApplyAsync"/> to
|
||||
/// roll back the transaction, emit a <c>BundleImportFailed</c> audit row, and
|
||||
/// re-throw to the caller so the UI can surface the specific errors. It is
|
||||
/// deliberately distinct from <see cref="InvalidOperationException"/> so the
|
||||
/// caller can distinguish "your bundle is bad" from "the import infra is bad".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SemanticValidationException : Exception
|
||||
{
|
||||
/// <summary>Gets the list of semantic validation error messages that caused this exception.</summary>
|
||||
public IReadOnlyList<string> Errors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new <see cref="SemanticValidationException"/> with the given error list.
|
||||
/// </summary>
|
||||
/// <param name="errors">The list of validation error messages to include in the exception.</param>
|
||||
public SemanticValidationException(IReadOnlyList<string> errors)
|
||||
: base(BuildMessage(errors))
|
||||
{
|
||||
Errors = errors ?? throw new ArgumentNullException(nameof(errors));
|
||||
}
|
||||
|
||||
private static string BuildMessage(IReadOnlyList<string> errors)
|
||||
{
|
||||
if (errors is null || errors.Count == 0)
|
||||
{
|
||||
return "Bundle semantic validation failed.";
|
||||
}
|
||||
return "Bundle semantic validation failed: " + string.Join("; ", errors);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user