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,270 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
||||
|
||||
/// <summary>
|
||||
/// Produces a deterministic SHA-256 hash of a FlattenedConfiguration.
|
||||
/// Same content always produces the same hash across runs.
|
||||
/// <para>
|
||||
/// DETERMINISM CONTRACT: System.Text.Json serializes properties in CLR
|
||||
/// declaration order, which it does NOT sort. Stable output therefore relies
|
||||
/// on the private <c>Hashable*</c> records below declaring their properties
|
||||
/// in alphabetical order. A contributor adding a property out of alphabetical
|
||||
/// order would silently change every revision hash; the ordering is guarded
|
||||
/// by <c>RevisionHashServiceTests.HashableRecords_PropertiesDeclaredAlphabetically</c>.
|
||||
/// Collections are explicitly sorted by <c>CanonicalName</c> before hashing.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class RevisionHashService
|
||||
{
|
||||
private static readonly JsonSerializerOptions CanonicalJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic SHA-256 hash of the given flattened configuration.
|
||||
/// The hash is computed over a canonical JSON representation with sorted keys,
|
||||
/// excluding volatile fields like GeneratedAtUtc.
|
||||
/// </summary>
|
||||
/// <param name="configuration">The flattened configuration to hash.</param>
|
||||
public string ComputeHash(FlattenedConfiguration configuration)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
|
||||
// Create a hashable representation that excludes volatile fields
|
||||
var hashInput = new HashableConfiguration
|
||||
{
|
||||
InstanceUniqueName = configuration.InstanceUniqueName,
|
||||
TemplateId = configuration.TemplateId,
|
||||
SiteId = configuration.SiteId,
|
||||
AreaId = configuration.AreaId,
|
||||
Attributes = configuration.Attributes
|
||||
.OrderBy(a => a.CanonicalName, StringComparer.Ordinal)
|
||||
.Select(a => new HashableAttribute
|
||||
{
|
||||
CanonicalName = a.CanonicalName,
|
||||
Value = a.Value,
|
||||
DataType = a.DataType,
|
||||
Description = a.Description,
|
||||
IsLocked = a.IsLocked,
|
||||
DataSourceReference = a.DataSourceReference,
|
||||
BoundDataConnectionId = a.BoundDataConnectionId
|
||||
})
|
||||
.ToList(),
|
||||
Alarms = configuration.Alarms
|
||||
.OrderBy(a => a.CanonicalName, StringComparer.Ordinal)
|
||||
.Select(a => new HashableAlarm
|
||||
{
|
||||
CanonicalName = a.CanonicalName,
|
||||
Description = a.Description,
|
||||
PriorityLevel = a.PriorityLevel,
|
||||
IsLocked = a.IsLocked,
|
||||
TriggerType = a.TriggerType,
|
||||
TriggerConfiguration = a.TriggerConfiguration,
|
||||
OnTriggerScriptCanonicalName = a.OnTriggerScriptCanonicalName
|
||||
})
|
||||
.ToList(),
|
||||
Scripts = configuration.Scripts
|
||||
.OrderBy(s => s.CanonicalName, StringComparer.Ordinal)
|
||||
.Select(s => new HashableScript
|
||||
{
|
||||
CanonicalName = s.CanonicalName,
|
||||
Code = s.Code,
|
||||
IsLocked = s.IsLocked,
|
||||
TriggerType = s.TriggerType,
|
||||
TriggerConfiguration = s.TriggerConfiguration,
|
||||
ParameterDefinitions = s.ParameterDefinitions,
|
||||
ReturnDefinition = s.ReturnDefinition,
|
||||
MinTimeBetweenRunsTicks = s.MinTimeBetweenRuns?.Ticks
|
||||
})
|
||||
.ToList(),
|
||||
Connections = configuration.Connections is { Count: > 0 }
|
||||
? new SortedDictionary<string, HashableConnection>(
|
||||
configuration.Connections.ToDictionary(
|
||||
kvp => kvp.Key,
|
||||
kvp => new HashableConnection
|
||||
{
|
||||
BackupConfigurationJson = kvp.Value.BackupConfigurationJson,
|
||||
ConfigurationJson = kvp.Value.ConfigurationJson,
|
||||
FailoverRetryCount = kvp.Value.FailoverRetryCount,
|
||||
Protocol = kvp.Value.Protocol
|
||||
}),
|
||||
StringComparer.Ordinal)
|
||||
: null
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(hashInput, CanonicalJsonOptions);
|
||||
var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
|
||||
return $"sha256:{Convert.ToHexStringLower(hashBytes)}";
|
||||
}
|
||||
|
||||
// Internal types for deterministic serialization. Properties MUST be declared
|
||||
// in alphabetical order — System.Text.Json emits them in declaration order and
|
||||
// does not sort. See the DETERMINISM CONTRACT note on the class summary.
|
||||
private sealed record HashableConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Collection of alarms in the configuration.
|
||||
/// </summary>
|
||||
public List<HashableAlarm> Alarms { get; init; } = [];
|
||||
/// <summary>
|
||||
/// The area identifier.
|
||||
/// </summary>
|
||||
public int? AreaId { get; init; }
|
||||
/// <summary>
|
||||
/// Collection of attributes in the configuration.
|
||||
/// </summary>
|
||||
public List<HashableAttribute> Attributes { get; init; } = [];
|
||||
/// <summary>
|
||||
/// Data connection configurations keyed by connection name. Sorted by key
|
||||
/// (ordinal) to keep serialization deterministic. Null when the deployment
|
||||
/// package carries no connections.
|
||||
/// </summary>
|
||||
public SortedDictionary<string, HashableConnection>? Connections { get; init; }
|
||||
/// <summary>
|
||||
/// The unique instance name.
|
||||
/// </summary>
|
||||
public string InstanceUniqueName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Collection of scripts in the configuration.
|
||||
/// </summary>
|
||||
public List<HashableScript> Scripts { get; init; } = [];
|
||||
/// <summary>
|
||||
/// The site identifier.
|
||||
/// </summary>
|
||||
public int SiteId { get; init; }
|
||||
/// <summary>
|
||||
/// The template identifier.
|
||||
/// </summary>
|
||||
public int TemplateId { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableAttribute
|
||||
{
|
||||
/// <summary>
|
||||
/// The data connection identifier the attribute is bound to.
|
||||
/// </summary>
|
||||
public int? BoundDataConnectionId { get; init; }
|
||||
/// <summary>
|
||||
/// The canonical name of the attribute.
|
||||
/// </summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The data source reference for the attribute.
|
||||
/// </summary>
|
||||
public string? DataSourceReference { get; init; }
|
||||
/// <summary>
|
||||
/// The data type of the attribute.
|
||||
/// </summary>
|
||||
public string DataType { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The attribute description (authoring-time documentation that still
|
||||
/// travels with the deployed payload).
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
/// <summary>
|
||||
/// Whether the attribute is locked.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>
|
||||
/// The attribute value.
|
||||
/// </summary>
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
|
||||
private sealed record HashableAlarm
|
||||
{
|
||||
/// <summary>
|
||||
/// The canonical name of the alarm.
|
||||
/// </summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The alarm description (authoring-time documentation that still
|
||||
/// travels with the deployed payload).
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
/// <summary>
|
||||
/// Whether the alarm is locked.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>
|
||||
/// The canonical name of the script triggered by this alarm.
|
||||
/// </summary>
|
||||
public string? OnTriggerScriptCanonicalName { get; init; }
|
||||
/// <summary>
|
||||
/// The priority level of the alarm.
|
||||
/// </summary>
|
||||
public int PriorityLevel { get; init; }
|
||||
/// <summary>
|
||||
/// The trigger configuration for the alarm.
|
||||
/// </summary>
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
/// <summary>
|
||||
/// The trigger type of the alarm.
|
||||
/// </summary>
|
||||
public string TriggerType { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record HashableConnection
|
||||
{
|
||||
/// <summary>
|
||||
/// Backup connection configuration JSON, if any.
|
||||
/// </summary>
|
||||
public string? BackupConfigurationJson { get; init; }
|
||||
/// <summary>
|
||||
/// Primary connection configuration JSON.
|
||||
/// </summary>
|
||||
public string? ConfigurationJson { get; init; }
|
||||
/// <summary>
|
||||
/// Number of failover retries before giving up.
|
||||
/// </summary>
|
||||
public int FailoverRetryCount { get; init; }
|
||||
/// <summary>
|
||||
/// Protocol name (e.g. "OpcUa").
|
||||
/// </summary>
|
||||
public string Protocol { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed record HashableScript
|
||||
{
|
||||
/// <summary>
|
||||
/// The canonical name of the script.
|
||||
/// </summary>
|
||||
public string CanonicalName { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The script code.
|
||||
/// </summary>
|
||||
public string Code { get; init; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Whether the script is locked.
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
/// <summary>
|
||||
/// The minimum time between runs in ticks.
|
||||
/// </summary>
|
||||
public long? MinTimeBetweenRunsTicks { get; init; }
|
||||
/// <summary>
|
||||
/// JSON representation of parameter definitions.
|
||||
/// </summary>
|
||||
public string? ParameterDefinitions { get; init; }
|
||||
/// <summary>
|
||||
/// JSON representation of the return type definition.
|
||||
/// </summary>
|
||||
public string? ReturnDefinition { get; init; }
|
||||
/// <summary>
|
||||
/// The trigger configuration for the script.
|
||||
/// </summary>
|
||||
public string? TriggerConfiguration { get; init; }
|
||||
/// <summary>
|
||||
/// The trigger type of the script.
|
||||
/// </summary>
|
||||
public string? TriggerType { get; init; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user