3b0c2b3bfa
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
358 lines
15 KiB
C#
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; }
|
|
}
|
|
}
|