Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Flattening/RevisionHashService.cs
T

358 lines
15 KiB
C#

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>
/// <para>
/// Native alarm source bindings
/// (<see cref="ResolvedNativeAlarmSource"/>) now participate in the hash so
/// that binding edits flag the instance stale. The hashable property is
/// populated NULL-WHEN-EMPTY and the serializer omits null properties
/// (<see cref="JsonIgnoreCondition.WhenWritingNull"/>), so every
/// native-source-free configuration keeps its byte-identical pre-migration
/// hash — no global staleness storm. Native-source-bearing instances will
/// report stale once after upgrade; redeploy clears it — deliberate.
/// </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>
/// <returns>A hex-encoded SHA-256 hash string of the canonical configuration representation.</returns>
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,
ElementDataType = a.ElementDataType,
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,
// true-only (null when false) so existing revision hashes are unchanged.
IsMesReportable = a.IsMesReportable ? true : null,
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,
// Include the per-script timeout so a change to it
// is detected as a configuration change (staleness/redeploy).
ExecutionTimeoutSeconds = s.ExecutionTimeoutSeconds
})
.ToList(),
NativeAlarmSources = configuration.NativeAlarmSources is { Count: > 0 }
? configuration.NativeAlarmSources
.OrderBy(n => n.CanonicalName, StringComparer.Ordinal)
.Select(n => new HashableNativeAlarmSource
{
CanonicalName = n.CanonicalName,
ConditionFilter = n.ConditionFilter,
ConnectionName = n.ConnectionName,
IsLocked = n.IsLocked,
// true-only (null when false) so existing revision hashes are unchanged.
IsMesReportable = n.IsMesReportable ? true : null,
SourceReference = n.SourceReference
})
.ToList()
: null,
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 native alarm source bindings in the configuration.
/// Null when the configuration carries no native alarm sources so that
/// native-source-free configs hash identically to before this field was
/// added (see the MIGRATION note on the class summary).
/// </summary>
public List<HashableNativeAlarmSource>? NativeAlarmSources { get; init; }
/// <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>
/// For List attributes: the element scalar type name; null otherwise.
/// Folded into the hash so a List element-type change (e.g.
/// Int32 → Double) with identical JSON-encoded values is detected as a
/// staleness/revision change. Null for scalar attributes — and because
/// the serializer ignores null properties
/// (<see cref="JsonIgnoreCondition.WhenWritingNull"/>), scalar-only
/// configurations hash identically to before this field was added.
/// </summary>
public string? ElementDataType { 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>
/// MES-reportable flag; null when false (the serializer drops nulls), so
/// pre-existing configurations hash identically.
/// </summary>
public bool? IsMesReportable { 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 HashableNativeAlarmSource
{
/// <summary>
/// The path-qualified canonical name of the native alarm source binding.
/// </summary>
public string CanonicalName { get; init; } = string.Empty;
/// <summary>
/// Optional condition filter; null = mirror all conditions under the source.
/// </summary>
public string? ConditionFilter { get; init; }
/// <summary>
/// The data connection name that owns the alarm feed.
/// </summary>
public string ConnectionName { get; init; } = string.Empty;
/// <summary>
/// Whether the native alarm source binding is locked.
/// </summary>
public bool IsLocked { get; init; }
/// <summary>
/// MES-reportable binding flag; null when false (the serializer drops nulls), so
/// pre-existing configurations hash identically.
/// </summary>
public bool? IsMesReportable { get; init; }
/// <summary>
/// The source reference (OPC UA SourceNode/notifier nodeId, or MxAccess object/area).
/// </summary>
public string SourceReference { 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>
/// The per-script execution timeout in seconds (null = global).
/// </summary>
public int? ExecutionTimeoutSeconds { get; init; }
/// <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; }
}
}