fix(controlplane): keep the revision hash byte-identical to the pre-refactor composer
The one-snapshot refactor re-sorted every collection in memory with an ordinal comparer, while the old composer's SQL ORDER BY used the database's case-insensitive collation; mixed-case keys therefore re-hashed an unchanged configuration (spurious drift fleet-wide, a "new" revision on a no-op redeploy). The snapshot factory now reads every composed table ORDER BY its natural key server-side and Compose serialises in the order given. Pinned byte-for-byte against a verbatim replica of the old composer on the real SQL Server. Refs #733 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -9,8 +9,9 @@ namespace ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
|
||||
/// <summary>
|
||||
/// Pure snapshot composer: reads the current live-edit state from <see cref="OtOpcUaConfigDbContext"/>
|
||||
/// and serialises it into a deterministic, gzip-compressed byte[] artifact + SHA-256 hex revision hash.
|
||||
/// Determinism comes from sorting every collection by its natural key before serialising, so two snapshots
|
||||
/// over the same DB state always produce the same hash regardless of EF row ordering; compression wraps the
|
||||
/// Determinism comes from reading every collection ordered by its natural key on the server
|
||||
/// (<see cref="DraftSnapshotFactory"/>), so two snapshots over the same DB state always produce the same hash
|
||||
/// — and the same hash as the pre-R03:3.2 composer, which used those same queries; compression wraps the
|
||||
/// final bytes and reorders nothing. The hash is taken over the COMPRESSED bytes — see <see cref="Pack"/>.
|
||||
/// </summary>
|
||||
public static class ConfigComposer
|
||||
@@ -41,6 +42,11 @@ public static class ConfigComposer
|
||||
/// Serialises an already-read <see cref="DraftSnapshot"/> into the deterministic artifact. Pure over
|
||||
/// its input: the same snapshot always yields the same bytes and hash, whatever the DB holds by now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Serialises the collections IN THE ORDER GIVEN. Determinism comes from
|
||||
/// <see cref="DraftSnapshotFactory.FromConfigDbAsync"/>, which reads every table ordered by its natural key
|
||||
/// on the server; a snapshot built any other way must be ordered the same way or its hash will differ.
|
||||
/// </remarks>
|
||||
/// <param name="snapshot">The rows to compose — the SAME rows the validator was run over.</param>
|
||||
/// <returns>The compressed artifact blob and its revision hash.</returns>
|
||||
public static ConfigArtifact Compose(DraftSnapshot snapshot)
|
||||
@@ -52,34 +58,35 @@ public static class ConfigComposer
|
||||
// projection (UnsAreas/UnsLines/Equipment/UnsTagReferences) and the retained VirtualTags/ScriptedAlarms/
|
||||
// PollGroups/NodeAcls/Scripts. The Namespaces snapshot is RETIRED (the two OPC UA namespaces are implicit).
|
||||
// RevisionHash inputs follow the blob automatically — adding/removing a table shifts the SHA-256.
|
||||
// Every collection is sorted by its natural key with an ORDINAL comparer so the hash is independent of
|
||||
// both EF row order and the process culture (a culture-aware sort could order "a-1"/"A-1" differently
|
||||
// on two central nodes and make the same config hash twice).
|
||||
// NO re-sort here (fix round 1): the snapshot's collections arrive ordered by their natural key from
|
||||
// DraftSnapshotFactory's server-side ORDER BY — the same order, and so the same bytes and RevisionHash,
|
||||
// as the pre-R03:3.2 composer's own queries. An in-memory ordinal re-sort orders mixed-case keys
|
||||
// differently from the SQL collation and silently re-hashed every unchanged configuration.
|
||||
var ordered = new
|
||||
{
|
||||
Clusters = snapshot.ServerClusters.OrderBy(x => x.ClusterId, StringComparer.Ordinal).ToList(),
|
||||
Nodes = snapshot.ClusterNodes.OrderBy(x => x.NodeId, StringComparer.Ordinal).ToList(),
|
||||
RawFolders = snapshot.RawFolders.OrderBy(x => x.RawFolderId, StringComparer.Ordinal).ToList(),
|
||||
DriverInstances = snapshot.DriverInstances.OrderBy(x => x.DriverInstanceId, StringComparer.Ordinal).ToList(),
|
||||
Devices = snapshot.Devices.OrderBy(x => x.DeviceId, StringComparer.Ordinal).ToList(),
|
||||
TagGroups = snapshot.TagGroups.OrderBy(x => x.TagGroupId, StringComparer.Ordinal).ToList(),
|
||||
Equipment = snapshot.Equipment.OrderBy(x => x.EquipmentId, StringComparer.Ordinal).ToList(),
|
||||
Tags = snapshot.Tags.OrderBy(x => x.TagId, StringComparer.Ordinal).ToList(),
|
||||
UnsTagReferences = snapshot.UnsTagReferences.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal).ToList(),
|
||||
PollGroups = snapshot.PollGroups.OrderBy(x => x.PollGroupId, StringComparer.Ordinal).ToList(),
|
||||
UnsAreas = snapshot.UnsAreas.OrderBy(x => x.UnsAreaId, StringComparer.Ordinal).ToList(),
|
||||
UnsLines = snapshot.UnsLines.OrderBy(x => x.UnsLineId, StringComparer.Ordinal).ToList(),
|
||||
NodeAcls = snapshot.NodeAcls.OrderBy(x => x.NodeAclId, StringComparer.Ordinal).ToList(),
|
||||
Clusters = snapshot.ServerClusters,
|
||||
Nodes = snapshot.ClusterNodes,
|
||||
RawFolders = snapshot.RawFolders,
|
||||
DriverInstances = snapshot.DriverInstances,
|
||||
Devices = snapshot.Devices,
|
||||
TagGroups = snapshot.TagGroups,
|
||||
Equipment = snapshot.Equipment,
|
||||
Tags = snapshot.Tags,
|
||||
UnsTagReferences = snapshot.UnsTagReferences,
|
||||
PollGroups = snapshot.PollGroups,
|
||||
UnsAreas = snapshot.UnsAreas,
|
||||
UnsLines = snapshot.UnsLines,
|
||||
NodeAcls = snapshot.NodeAcls,
|
||||
// the data-plane credentials travel with the config, because a driver-only node
|
||||
// has no ConfigDb to read them from (per-cluster mesh Phase 4) and would otherwise deny every
|
||||
// UserName token. Only the peppered HMAC of each secret ships — never the secret — and the
|
||||
// pepper is node configuration that is deliberately NOT in the artifact, so a stolen blob
|
||||
// still cannot be turned into a working credential. This is also why revoking a key reaches a
|
||||
// driver-only node only on the next deployment; see the ApiKey entity's remarks.
|
||||
ApiKeys = snapshot.ApiKeys.OrderBy(x => x.ApiKeyId, StringComparer.Ordinal).ToList(),
|
||||
Scripts = snapshot.Scripts.OrderBy(x => x.ScriptId, StringComparer.Ordinal).ToList(),
|
||||
VirtualTags = snapshot.VirtualTags.OrderBy(x => x.VirtualTagId, StringComparer.Ordinal).ToList(),
|
||||
ScriptedAlarms = snapshot.ScriptedAlarms.OrderBy(x => x.ScriptedAlarmId, StringComparer.Ordinal).ToList(),
|
||||
ApiKeys = snapshot.ApiKeys,
|
||||
Scripts = snapshot.Scripts,
|
||||
VirtualTags = snapshot.VirtualTags,
|
||||
ScriptedAlarms = snapshot.ScriptedAlarms,
|
||||
};
|
||||
|
||||
return Pack(JsonSerializer.SerializeToUtf8Bytes(ordered, JsonOptions));
|
||||
|
||||
Reference in New Issue
Block a user