Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs
T
Joseph Doherty 9dbd5e96f7 perf(controlplane): gzip artifact blob inside the hashed bytes (~10-20x smaller)
ConfigComposer gzips the serialised snapshot (CompressionLevel.Fastest)
immediately after SerializeToUtf8Bytes and BEFORE taking the SHA-256, so
the compressed bytes ARE the artifact everywhere: Deployment.ArtifactBlob,
the LocalDb cache chunks, the gRPC fetch stream, and the RevisionHash a
FetchAndCache node verifies against. The invariant the deploy path rests on
— RevisionHash == SHA-256(stored/wire bytes) — is preserved, which is what
leaves the fetcher, the cache and the serve path untouched: nothing between
the composer and DeploymentArtifact.Open ever looks inside the blob.

Read side: Open() sniffs the gzip magic 1F 8B (valid JSON can never start
with 0x1F, so no framing of our own is needed) and inflates through a size
guard — MaxDecompressedBytes, 512 MiB, internal + mutable so the guard can
be exercised without inflating half a gigabyte per test run. Pre-upgrade
uncompressed blobs take the identical path they always did.

Every decompression failure — corrupt stream, truncated stream, over-limit
inflation — is reported as JsonException, the same exception family the
malformed-JSON path already owns. So the lenient static wrappers still
degrade to empty results and the apply paths still fail the apply and keep
last-known-good (#485); no caller's decision changes because the reason the
bytes are unusable changed.

gzip output is not guaranteed byte-stable across .NET releases. Harmless
here: the hash is computed FROM the emitted bytes at compose time and never
re-derived from the JSON, so the worst an implementation change costs is
one spurious "changed" revision after a runtime upgrade. Snapshot
determinism is unaffected — compression wraps the final bytes and reorders
nothing.

Tests: gzip/uncompressed parity through both the instance parses and the
statics, corrupt-gzip leniency through the wrappers, the size guard (with a
falsifiability control proving the same bytes inflate fine under the
production limit), and composer-side blob-starts-with-1F8B +
RevisionHash == SHA256(blob) with a control proving the hash is NOT over
the JSON.
2026-08-15 17:00:19 -04:00

117 lines
7.5 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
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
/// final bytes and reorders nothing. The hash is taken over the COMPRESSED bytes — see <see cref="Pack"/>.
/// </summary>
public static class ConfigComposer
{
public sealed record ConfigArtifact(byte[] Blob, string RevisionHash);
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
};
/// <summary>Reads the current configuration and returns a deterministic snapshot blob with revision hash.</summary>
/// <param name="db">The configuration database context.</param>
/// <param name="ct">The cancellation token for the operation.</param>
/// <returns>A deterministic <see cref="ConfigArtifact"/> containing the serialised snapshot bytes and its revision hash.</returns>
public static async Task<ConfigArtifact> SnapshotAndFlattenAsync(
OtOpcUaConfigDbContext db, CancellationToken ct = default)
{
// v3 greenfield snapshot: the Raw tree (RawFolders → DriverInstances(+RawFolderId) → Devices(+DeviceConfig)
// → TagGroups → Tags(DeviceId/TagGroupId, no EquipmentId/FolderPath/DriverInstanceId)) plus the UNS
// 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.
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),
// #520 stage 4: 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 = 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),
};
return Pack(JsonSerializer.SerializeToUtf8Bytes(snapshot, JsonOptions));
}
/// <summary>
/// Compress the serialised snapshot and hash the COMPRESSED bytes — the emitted artifact and its
/// revision hash, in one step, so the two can never be derived from different byte sequences.
/// </summary>
/// <remarks>
/// <para>
/// <b>Compression happens before hashing on purpose.</b> The invariant the whole deploy path
/// rests on is <c>RevisionHash == SHA-256(stored/wire bytes)</c>: a <c>FetchAndCache</c> node
/// verifies the bytes it fetched against the hash its dispatch already carried, and the LocalDb
/// cache stores those same bytes. Hashing the uncompressed JSON instead would break every one of
/// those checks. Nothing between here and <c>DeploymentArtifact.Open</c> looks inside the blob,
/// which is what makes the change invisible to the cache and the gRPC serve path.
/// </para>
/// <para>
/// gzip with the standard magic <c>1F 8B</c> and no framing of our own: valid JSON can never
/// start with <c>0x1F</c>, so the read side sniffs the first two bytes and pre-upgrade
/// uncompressed rows keep parsing unchanged. <see cref="CompressionLevel.Fastest"/> because the
/// artifact is highly repetitive JSON — the marginal ratio from a slower level is small next to
/// the ~10–20x the first level already delivers.
/// </para>
/// <para>
/// gzip output is not guaranteed byte-stable across .NET releases. Harmless here: the hash is
/// computed from the bytes emitted at compose time and never re-derived from the JSON, so an
/// implementation change costs at most one spurious "changed" revision after a runtime upgrade.
/// Determinism of the snapshot itself is unaffected — compression wraps the final bytes and
/// reorders nothing.
/// </para>
/// </remarks>
/// <param name="json">The deterministic UTF-8 JSON snapshot.</param>
/// <returns>The compressed artifact blob and the SHA-256 hex digest OF THAT BLOB.</returns>
internal static ConfigArtifact Pack(byte[] json)
{
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 new ConfigArtifact(blob, Convert.ToHexStringLower(SHA256.HashData(blob)));
}
/// <summary>Returns the SHA-256 hex digest of the supplied artifact bytes (lowercase, no prefix).</summary>
/// <param name="blob">The bytes to hash.</param>
/// <returns>The lowercase hex-encoded SHA-256 digest.</returns>
public static string HashOf(ReadOnlySpan<byte> blob) =>
Convert.ToHexStringLower(SHA256.HashData(blob));
}