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:
Joseph Doherty
2026-10-22 13:48:40 -04:00
parent 9466b8b242
commit 2adeacc655
3 changed files with 139 additions and 39 deletions
@@ -56,6 +56,12 @@ public static class DraftSnapshotFactory
}
}
// Every composed table is read ORDERED BY ITS NATURAL KEY, server-side. That order IS the deployment
// artifact's order (ConfigComposer.Compose serialises the snapshot as given): it is exactly the order the
// pre-R03:3.2 composer's own queries produced — the database collation's, case-insensitive on SQL Server —
// so the revision hash of an unchanged configuration is byte-identical across the refactor (fix round 1;
// pinned by ConfigComposerSqlServerHashStabilityTests). Re-sorting in memory with any other comparer would
// re-hash every mixed-case configuration and make drift detection report the whole fleet drifted.
private static async Task<DraftSnapshot> ReadAsync(OtOpcUaConfigDbContext db, CancellationToken ct)
=> new DraftSnapshot
{
@@ -64,26 +70,26 @@ public static class DraftSnapshotFactory
// EquipmentUuid checks run in separate validator passes — no rule here reads these fields.
GenerationId = 0, // generation model dropped; placeholder (no rule reads it)
ClusterId = string.Empty, // global snapshot; rules compare entity ClusterId fields, not this
ServerClusters = await db.ServerClusters.AsNoTracking().ToListAsync(ct),
ClusterNodes = await db.ClusterNodes.AsNoTracking().ToListAsync(ct),
ServerClusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
ClusterNodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct),
// v3: Namespace entity retired — no namespace list to materialize. The Raw tree
// (RawFolders/TagGroups) + UnsTagReferences feed the v3 rules (charset, tagname length,
// effective-leaf uniqueness).
RawFolders = await db.RawFolders.AsNoTracking().ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().ToListAsync(ct),
TagGroups = await db.TagGroups.AsNoTracking().ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().ToListAsync(ct),
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().ToListAsync(ct),
ApiKeys = await db.ApiKeys.AsNoTracking().ToListAsync(ct),
RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct),
TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct),
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct),
PriorEquipment = [], // intentional: no prior-generation table to diff against at this level
ActiveReservations = await db.ExternalIdReservations
.AsNoTracking()
@@ -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));
@@ -0,0 +1,87 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;
/// <summary>
/// Fix round 1 minor — revision-hash stability across the R03:3.2 refactor. The pre-change composer read
/// every table with a SQL <c>ORDER BY</c> (the database's case-INSENSITIVE collation); the refactor re-sorted
/// in memory with an ORDINAL comparer, which orders mixed-case keys differently ("B-1" before "a-2"). The
/// same configuration would then hash differently after the upgrade: drift detection reports every node
/// drifted, and a re-deploy of an unchanged config shows a "new" revision. This pins the composer's output
/// BYTE-identical to a verbatim replica of the pre-change composer on the real provider.
/// </summary>
[Trait("Category", "SchemaCompliance")]
public sealed class ConfigComposerSqlServerHashStabilityTests
{
[Fact]
public async Task Composer_output_is_byte_identical_to_the_pre_change_composer_for_mixed_case_keys()
{
using var sql = new SqlServerTestDatabase();
await using (var seed = sql.NewContext())
{
// Collation order a-2, B-1, c-3; ordinal order B-1, a-2, c-3.
foreach (var id in new[] { "c-3", "B-1", "a-2" })
seed.Scripts.Add(new Script { ScriptId = id, Name = id, SourceCode = "return 1;", SourceHash = id });
await seed.SaveChangesAsync();
}
await using var legacyDb = sql.NewContext();
var legacy = await LegacySnapshotAndFlattenAsync(legacyDb);
await using var viaDb = sql.NewContext();
var current = await ConfigComposer.SnapshotAndFlattenAsync(viaDb);
current.Blob.ShouldBe(legacy.Blob);
current.RevisionHash.ShouldBe(legacy.RevisionHash);
// The deploy path's form (validate + compose from ONE snapshot) must agree too.
await using var deployDb = sql.NewContext();
var viaSnapshot = ConfigComposer.Compose(await DraftSnapshotFactory.FromConfigDbAsync(deployDb));
viaSnapshot.RevisionHash.ShouldBe(legacy.RevisionHash);
}
/// <summary>Verbatim replica of <c>ConfigComposer.SnapshotAndFlattenAsync</c> before R03:3.2 (ad2b5c40).</summary>
private static async Task<(byte[] Blob, string RevisionHash)> LegacySnapshotAndFlattenAsync(OtOpcUaConfigDbContext db)
{
var ct = CancellationToken.None;
var snapshot = new
{
Clusters = await db.ServerClusters.AsNoTracking().OrderBy(x => x.ClusterId).ToListAsync(ct),
Nodes = await db.ClusterNodes.AsNoTracking().OrderBy(x => x.NodeId).ToListAsync(ct),
RawFolders = await db.RawFolders.AsNoTracking().OrderBy(x => x.RawFolderId).ToListAsync(ct),
DriverInstances = await db.DriverInstances.AsNoTracking().OrderBy(x => x.DriverInstanceId).ToListAsync(ct),
Devices = await db.Devices.AsNoTracking().OrderBy(x => x.DeviceId).ToListAsync(ct),
TagGroups = await db.TagGroups.AsNoTracking().OrderBy(x => x.TagGroupId).ToListAsync(ct),
Equipment = await db.Equipment.AsNoTracking().OrderBy(x => x.EquipmentId).ToListAsync(ct),
Tags = await db.Tags.AsNoTracking().OrderBy(x => x.TagId).ToListAsync(ct),
UnsTagReferences = await db.UnsTagReferences.AsNoTracking().OrderBy(x => x.UnsTagReferenceId).ToListAsync(ct),
PollGroups = await db.PollGroups.AsNoTracking().OrderBy(x => x.PollGroupId).ToListAsync(ct),
UnsAreas = await db.UnsAreas.AsNoTracking().OrderBy(x => x.UnsAreaId).ToListAsync(ct),
UnsLines = await db.UnsLines.AsNoTracking().OrderBy(x => x.UnsLineId).ToListAsync(ct),
NodeAcls = await db.NodeAcls.AsNoTracking().OrderBy(x => x.NodeAclId).ToListAsync(ct),
ApiKeys = await db.ApiKeys.AsNoTracking().OrderBy(x => x.ApiKeyId).ToListAsync(ct),
Scripts = await db.Scripts.AsNoTracking().OrderBy(x => x.ScriptId).ToListAsync(ct),
VirtualTags = await db.VirtualTags.AsNoTracking().OrderBy(x => x.VirtualTagId).ToListAsync(ct),
ScriptedAlarms = await db.ScriptedAlarms.AsNoTracking().OrderBy(x => x.ScriptedAlarmId).ToListAsync(ct),
};
var json = JsonSerializer.SerializeToUtf8Bytes(snapshot, new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
});
using var output = new MemoryStream(json.Length / 8);
using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true))
gzip.Write(json, 0, json.Length);
var blob = output.ToArray();
return (blob, Convert.ToHexStringLower(SHA256.HashData(blob)));
}
}