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; /// /// Pure snapshot composer: reads the current live-edit state from /// 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 . /// 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, }; /// Reads the current configuration and returns a deterministic snapshot blob with revision hash. /// The configuration database context. /// The cancellation token for the operation. /// A deterministic containing the serialised snapshot bytes and its revision hash. public static async Task 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)); } /// /// 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. /// /// /// /// Compression happens before hashing on purpose. The invariant the whole deploy path /// rests on is RevisionHash == SHA-256(stored/wire bytes): a FetchAndCache 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 DeploymentArtifact.Open looks inside the blob, /// which is what makes the change invisible to the cache and the gRPC serve path. /// /// /// gzip with the standard magic 1F 8B and no framing of our own: valid JSON can never /// start with 0x1F, so the read side sniffs the first two bytes and pre-upgrade /// uncompressed rows keep parsing unchanged. 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. /// /// /// 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. /// /// /// The deterministic UTF-8 JSON snapshot. /// The compressed artifact blob and the SHA-256 hex digest OF THAT BLOB. 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))); } /// Returns the SHA-256 hex digest of the supplied artifact bytes (lowercase, no prefix). /// The bytes to hash. /// The lowercase hex-encoded SHA-256 digest. public static string HashOf(ReadOnlySpan blob) => Convert.ToHexStringLower(SHA256.HashData(blob)); }