9a87d7a328
Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.
Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.
Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.
Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.
Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.
Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.
Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
97 lines
5.1 KiB
C#
97 lines
5.1 KiB
C#
using System.Diagnostics;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using Google.Protobuf.Reflection;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
|
|
|
/// <summary>Compiles source descriptors and compares the complete vendored wire contract.</summary>
|
|
public class ProtoDescriptorContractTests
|
|
{
|
|
[Fact]
|
|
public async Task XC4_SourceDescriptorsMatchVendoredMessagesAndServices()
|
|
{
|
|
var compiled = await CompileAsync();
|
|
foreach (var generated in Generated())
|
|
Assert.Equal(CanonicalGenerated(generated), compiled.File.Single(f => f.Name == generated.Name));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("string site_identifier = 1;", "string renamed_site = 1;")]
|
|
[InlineData("string site_identifier = 1;", "bytes site_identifier = 1;")]
|
|
[InlineData("rpc Heartbeat(HeartbeatDto)", "rpc RenamedHeartbeat(HeartbeatDto)")]
|
|
public async Task GuardDetectsNameTypeAndServiceDrift(string before, string after)
|
|
{
|
|
var compiled = await CompileAsync(before, after);
|
|
var generated = CentralControlReflection.Descriptor;
|
|
Assert.NotEqual(CanonicalGenerated(generated), compiled.File.Single(f => f.Name == generated.Name));
|
|
}
|
|
|
|
// C# embeds descriptors with implicit JSON names omitted; descriptor_set_out expands
|
|
// them. Compare effective names so explicit json_name drift remains detectable too.
|
|
private static FileDescriptorProto CanonicalGenerated(FileDescriptor file)
|
|
{
|
|
var proto = file.ToProto();
|
|
foreach (var message in proto.MessageType)
|
|
FillJsonNames(message, file.MessageTypes.Single(m => m.Name == message.Name));
|
|
return proto;
|
|
}
|
|
|
|
private static void FillJsonNames(DescriptorProto proto, MessageDescriptor descriptor)
|
|
{
|
|
foreach (var field in proto.Field)
|
|
field.JsonName = descriptor.FindFieldByNumber(field.Number).JsonName;
|
|
foreach (var nested in proto.NestedType)
|
|
FillJsonNames(nested, descriptor.NestedTypes.Single(m => m.Name == nested.Name));
|
|
}
|
|
|
|
private static FileDescriptor[] Generated() =>
|
|
[SitestreamReflection.Descriptor, SiteCommandReflection.Descriptor, CentralControlReflection.Descriptor];
|
|
|
|
private static async Task<FileDescriptorSet> CompileAsync(string? before = null, string? after = null)
|
|
{
|
|
var tools = typeof(ProtoDescriptorContractTests).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
|
|
.Single(a => a.Key == "GrpcToolsRoot").Value!;
|
|
var platform = OperatingSystem.IsMacOS() ? "macosx_x64"
|
|
: OperatingSystem.IsWindows() ? (RuntimeInformation.ProcessArchitecture == Architecture.X86 ? "windows_x86" : "windows_x64")
|
|
: RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "linux_arm64"
|
|
: RuntimeInformation.ProcessArchitecture == Architecture.X86 ? "linux_x86" : "linux_x64";
|
|
var executable = Path.Combine(tools, "tools", platform, OperatingSystem.IsWindows() ? "protoc.exe" : "protoc");
|
|
var directory = Path.Combine(Path.GetTempPath(), "scadabridge-proto-contract-" + Guid.NewGuid().ToString("N"));
|
|
Directory.CreateDirectory(Path.Combine(directory, "Protos"));
|
|
try
|
|
{
|
|
foreach (var file in Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "ProtoSources", "Protos"), "*.proto"))
|
|
{
|
|
var source = await File.ReadAllTextAsync(file);
|
|
if (before is not null && Path.GetFileName(file) == "central_control.proto")
|
|
{
|
|
Assert.Contains(before, source);
|
|
source = source.Replace(before, after, StringComparison.Ordinal);
|
|
}
|
|
await File.WriteAllTextAsync(Path.Combine(directory, "Protos", Path.GetFileName(file)), source);
|
|
}
|
|
var output = Path.Combine(directory, "descriptor.bin");
|
|
var start = new ProcessStartInfo(executable)
|
|
{
|
|
WorkingDirectory = directory, RedirectStandardError = true, RedirectStandardOutput = true,
|
|
UseShellExecute = false, CreateNoWindow = true
|
|
};
|
|
start.ArgumentList.Add("--proto_path=" + directory);
|
|
start.ArgumentList.Add("--proto_path=" + Path.Combine(tools, "build", "native", "include"));
|
|
start.ArgumentList.Add("--descriptor_set_out=" + output);
|
|
foreach (var file in Directory.GetFiles(Path.Combine(directory, "Protos"), "*.proto").Order())
|
|
start.ArgumentList.Add("Protos/" + Path.GetFileName(file));
|
|
using var process = Process.Start(start)!;
|
|
var stderr = process.StandardError.ReadToEndAsync();
|
|
var stdout = process.StandardOutput.ReadToEndAsync();
|
|
try { await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30)); }
|
|
catch { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); throw; }
|
|
Assert.True(process.ExitCode == 0, await stderr + await stdout);
|
|
return FileDescriptorSet.Parser.ParseFrom(await File.ReadAllBytesAsync(output));
|
|
}
|
|
finally { Directory.Delete(directory, recursive: true); }
|
|
}
|
|
}
|