From d7455577a8cb176d305748a22bbf2c04118a5885 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 22 Jul 2026 18:28:27 -0400 Subject: [PATCH 1/5] =?UTF-8?q?feat(grpc):=20central=5Fcontrol.proto=20+?= =?UTF-8?q?=20DTO=20mapper=20for=20the=207=20site=E2=86=92central=20contro?= =?UTF-8?q?l=20RPCs=20(T1A.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1A of the ClusterClient→gRPC migration needs a wire contract for the seven messages SiteCommunicationActor forwards to /user/central-communication. This lands the contract and its mapper only — hosting (T1A.2) and the site-side transport seam (T1A.3) follow. `Protos/central_control.proto` (package scadabridge.centralcontrol.v1, service CentralControlService) declares SubmitNotification, QueryNotificationStatus, IngestAuditEvents, IngestCachedTelemetry, ReconcileSite, ReportSiteHealth and Heartbeat. Note the direction is the inverse of SiteStreamService: here the site dials and central serves. Decisions worth recording: - The two ingest RPCs IMPORT sitestream.proto and reuse AuditEventBatch / CachedTelemetryBatch / IngestAck rather than redeclaring them. The site telemetry actor already builds those messages, so a second copy would fork one wire contract into two kept in lockstep by hand. ForwardState / IngestedAtUtc stay off-wire exactly as they are today. - Heartbeat replies google.protobuf.Empty — it is fire-and-forget and must never surface a fault onto the heartbeat timer path. - The three NULLABLE SiteHealthReport collections travel inside single-field wrapper messages (ConnectionEndpointMapDto / TagQualityMapDto / NodeStatusListDto). proto3 cannot express presence on repeated/map fields, but null and empty genuinely differ here — SiteHealthCollector emits `ClusterNodes: _clusterNodes?.ToList()` and the central health surface reads null as "not reported", not as "reported empty". Same reasoning drives the BoolValue/Int64Value/DoubleValue wrappers on LocalDbReplicationConnected, LocalDbOplogBacklog and the two age gauges, whose docs are explicit that null is not zero/false. - ConnectionHealthEnum reserves 0 for UNSPECIFIED instead of mapping Connected onto it, and the decoder resolves anything unknown to ConnectionHealth.Error. An unrecognised connection state must not render as "healthy". - Guid? execution ids travel as "D" strings with empty meaning null; a malformed non-empty value throws rather than being laundered into "no correlation". - DateTimeOffset normalizes to a UTC instant (a protobuf Timestamp has no offset). Lossless in practice — every producer stamps UTC — and documented + asserted rather than left implicit. SiteCallDtoMapper gains a ToDto(SiteCall) overload. Its doc comment previously asserted such a method "would be dead code"; that held only while ClusterClient was the sole path from IngestCachedTelemetryCommand (which carries SiteCall, not SiteCallOperational) to central. Comment corrected alongside. Golden tests round-trip every message through a real protobuf encode/decode — DTO → proto → bytes → proto → DTO — with a fully-populated case and a null/empty/minimal case for every optional member. Verified to have teeth by mutation: dropping a scalar, collapsing an empty nullable collection to absent, and nulling a gauge each fail a test. Codegen stays CHECKED IN under CentralControlGrpc/ (protoc segfaults in the linux_arm64 Docker image); no active item is committed. docker/regen-proto.sh is generalized to `regen-proto.sh [sitestream| centralcontrol|all]` — it now injects the ItemGroup rather than unwrapping a comment, so it no longer depends on there being exactly one Protobuf line, and it restores the csproj verbatim on every exit path. --- docker/regen-proto.sh | 124 +- .../CentralControlGrpc/CentralControl.cs | 6112 +++++++++++++++++ .../CentralControlGrpc/CentralControlGrpc.cs | 704 ++ .../Grpc/CentralControlDtoMapper.cs | 764 +++ .../Grpc/SiteCallDtoMapper.cs | 63 +- .../Protos/central_control.proto | 247 + ...ZB.MOM.WW.ScadaBridge.Communication.csproj | 28 +- .../Grpc/CentralControlDtoMapperTests.cs | 643 ++ 8 files changed, 8619 insertions(+), 66 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControlGrpc.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Protos/central_control.proto create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs diff --git a/docker/regen-proto.sh b/docker/regen-proto.sh index d1c2ab29..68083b48 100755 --- a/docker/regen-proto.sh +++ b/docker/regen-proto.sh @@ -1,92 +1,118 @@ #!/usr/bin/env bash # -# Regenerates the gRPC C# files from sitestream.proto. +# Regenerates the gRPC C# files from the Communication project's .proto files. # # Background: protoc (linux/arm64) segfaults inside our Docker build container -# (Grpc.Tools 2.71.0). As a workaround the generated Sitestream.cs + -# SitestreamGrpc.cs are checked into src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/ -# and the Protobuf ItemGroup in the .csproj is commented out — Docker just -# compiles the checked-in C# files. +# (Grpc.Tools 2.71.0). As a workaround the generated C# is checked into +# src/ZB.MOM.WW.ScadaBridge.Communication/ — SiteStreamGrpc/ for sitestream.proto +# and CentralControlGrpc/ for central_control.proto — and the Protobuf ItemGroup +# in the .csproj is commented out, so Docker just compiles the checked-in files. # -# Run this script ON YOUR DEV MACHINE whenever Protos/sitestream.proto changes: +# Run this script ON YOUR DEV MACHINE whenever a .proto changes: # -# 1. Temporarily uncomments the Protobuf ItemGroup so Grpc.Tools runs. -# 2. dotnet build (regen writes fresh files to obj/). -# 3. Copies the regenerated files back into SiteStreamGrpc/. -# 4. Re-comments the Protobuf ItemGroup so Docker builds stay safe. +# docker/regen-proto.sh [sitestream|centralcontrol|all] (default: all) +# +# 1. Injects a Protobuf ItemGroup for the selected proto(s) so Grpc.Tools runs. +# 2. Deletes the stale checked-in C# so a failed regen is obvious. +# 3. dotnet build (regen writes fresh files to obj/). +# 4. Copies the regenerated files back into the source tree. +# 5. Restores the original csproj so no active Protobuf item is left behind. +# +# Only the SELECTED protos get a Protobuf item. Enabling one whose generated C# +# is still checked in would define every generated type twice, which is why the +# per-proto selection exists. central_control.proto imports sitestream.proto, +# but protoc resolves that from the project-relative path — the import needs no +# Protobuf item of its own. # # Once we move to a Dockerfile base image that ships a working linux/arm64 -# protoc, this script can be retired and Docker can regen the proto on every +# protoc, this script can be retired and Docker can regen the protos on every # build like every other normal .NET project. set -euo pipefail +TARGET="${1:-all}" +case "$TARGET" in + sitestream|centralcontrol|all) ;; + *) echo "usage: $0 [sitestream|centralcontrol|all]" >&2; exit 2 ;; +esac + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" COMM_DIR="$REPO_ROOT/src/ZB.MOM.WW.ScadaBridge.Communication" CSPROJ="$COMM_DIR/ZB.MOM.WW.ScadaBridge.Communication.csproj" -GEN_DIR="$COMM_DIR/SiteStreamGrpc" +GEN="$COMM_DIR/obj/Debug/net10.0/Protos" -echo "=== Regenerating gRPC files from sitestream.proto ===" +echo "=== Regenerating gRPC files ($TARGET) ===" if [[ ! -f "$CSPROJ" ]]; then echo "ERROR: csproj not found at $CSPROJ" >&2 exit 1 fi -# Backup so we can always restore the comment state on failure. +# Backup so we can always restore the comment state on failure. Leaving the +# csproj with an active Protobuf item is the one outcome that breaks Docker, so +# every exit path restores this copy. BACKUP="$(mktemp)" cp "$CSPROJ" "$BACKUP" trap 'cp "$BACKUP" "$CSPROJ"; rm -f "$BACKUP"; echo "Restored csproj from backup."' ERR -# 1. Uncomment the Protobuf ItemGroup (strip the surrounding wrapper). -python3 - <\s*\n\s*]*/>\s*\n\s*)\s*\n\s*-->", - r"\1", - src, - count=1, -) -if new == src: - raise SystemExit("Couldn't find commented Protobuf ItemGroup to enable.") -p.write_text(new) +# 1. Inject an ItemGroup holding just the selected protos, immediately before +# the closing . The documented commented-out block is left alone. +python3 - "$CSPROJ" "$TARGET" <<'PY' +import pathlib, sys + +csproj, target = pathlib.Path(sys.argv[1]), sys.argv[2] + +protos = [] +if target in ("sitestream", "all"): + protos.append("sitestream.proto") +if target in ("centralcontrol", "all"): + protos.append("central_control.proto") + +items = "\n".join( + f' ' for p in protos) +block = f" \n{items}\n \n\n" + +src = csproj.read_text() +if "" not in src: + raise SystemExit("Couldn't find to inject the Protobuf ItemGroup before.") +csproj.write_text(src.replace("", block, 1)) PY # 2. Delete the stale files so any failure to regen is obvious. -rm -f "$GEN_DIR/Sitestream.cs" "$GEN_DIR/SitestreamGrpc.cs" +if [[ "$TARGET" == "sitestream" || "$TARGET" == "all" ]]; then + rm -f "$COMM_DIR/SiteStreamGrpc/Sitestream.cs" "$COMM_DIR/SiteStreamGrpc/SitestreamGrpc.cs" +fi +if [[ "$TARGET" == "centralcontrol" || "$TARGET" == "all" ]]; then + rm -f "$COMM_DIR/CentralControlGrpc/CentralControl.cs" \ + "$COMM_DIR/CentralControlGrpc/CentralControlGrpc.cs" +fi # 3. Regenerate by building. echo "Building Communication project (regen)..." dotnet build "$CSPROJ" --nologo -v minimal | tail -5 # 4. Copy generated files back into the source tree. -mkdir -p "$GEN_DIR" -cp "$COMM_DIR/obj/Debug/net10.0/Protos/Sitestream.cs" "$GEN_DIR/Sitestream.cs" -cp "$COMM_DIR/obj/Debug/net10.0/Protos/SitestreamGrpc.cs" "$GEN_DIR/SitestreamGrpc.cs" -echo "Copied regenerated files to $GEN_DIR/" - -# 5. Re-comment the Protobuf ItemGroup so Docker builds keep working. -python3 - <\s*\n\s*]*/>\s*\n\s*)", - r"\n ", - src, - count=1, -) -p.write_text(new) -PY +if [[ "$TARGET" == "sitestream" || "$TARGET" == "all" ]]; then + mkdir -p "$COMM_DIR/SiteStreamGrpc" + cp "$GEN/Sitestream.cs" "$GEN/SitestreamGrpc.cs" "$COMM_DIR/SiteStreamGrpc/" + echo "Copied regenerated files to SiteStreamGrpc/" +fi +if [[ "$TARGET" == "centralcontrol" || "$TARGET" == "all" ]]; then + mkdir -p "$COMM_DIR/CentralControlGrpc" + cp "$GEN/CentralControl.cs" "$GEN/CentralControlGrpc.cs" "$COMM_DIR/CentralControlGrpc/" + echo "Copied regenerated files to CentralControlGrpc/" +fi +# 5. Restore the backed-up csproj — i.e. drop the injected ItemGroup — so Docker +# builds keep working. +cp "$BACKUP" "$CSPROJ" rm -f "$BACKUP" trap - ERR echo "" echo "Done. Review and commit:" -echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/Protos/sitestream.proto" +echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/Protos/" echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/SiteStreamGrpc/" +echo " git diff src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/" +echo " git diff -- src/ZB.MOM.WW.ScadaBridge.Communication/*.csproj # must be EMPTY" diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs new file mode 100644 index 00000000..e8675699 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControl.cs @@ -0,0 +1,6112 @@ +// +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: Protos/central_control.proto +// +#pragma warning disable 1591, 0612, 3021, 8981 +#region Designer generated code + +using pb = global::Google.Protobuf; +using pbc = global::Google.Protobuf.Collections; +using pbr = global::Google.Protobuf.Reflection; +using scg = global::System.Collections.Generic; +namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { + + /// Holder for reflection information generated from Protos/central_control.proto + public static partial class CentralControlReflection { + + #region Descriptor + /// File descriptor for Protos/central_control.proto + public static pbr::FileDescriptor Descriptor { + get { return descriptor; } + } + private static pbr::FileDescriptor descriptor; + + static CentralControlReflection() { + byte[] descriptorData = global::System.Convert.FromBase64String( + string.Concat( + "ChxQcm90b3MvY2VudHJhbF9jb250cm9sLnByb3RvEh1zY2FkYWJyaWRnZS5j", + "ZW50cmFsY29udHJvbC52MRobZ29vZ2xlL3Byb3RvYnVmL2VtcHR5LnByb3Rv", + "Gh9nb29nbGUvcHJvdG9idWYvdGltZXN0YW1wLnByb3RvGh5nb29nbGUvcHJv", + "dG9idWYvd3JhcHBlcnMucHJvdG8aF1Byb3Rvcy9zaXRlc3RyZWFtLnByb3Rv", + "IrkCChVOb3RpZmljYXRpb25TdWJtaXREdG8SFwoPbm90aWZpY2F0aW9uX2lk", + "GAEgASgJEhEKCWxpc3RfbmFtZRgCIAEoCRIPCgdzdWJqZWN0GAMgASgJEgwK", + "BGJvZHkYBCABKAkSFgoOc291cmNlX3NpdGVfaWQYBSABKAkSGgoSc291cmNl", + "X2luc3RhbmNlX2lkGAYgASgJEhUKDXNvdXJjZV9zY3JpcHQYByABKAkSNAoQ", + "c2l0ZV9lbnF1ZXVlZF9hdBgIIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l", + "c3RhbXASGwoTb3JpZ2luX2V4ZWN1dGlvbl9pZBgJIAEoCRIiChpvcmlnaW5f", + "cGFyZW50X2V4ZWN1dGlvbl9pZBgKIAEoCRITCgtzb3VyY2Vfbm9kZRgLIAEo", + "CSJUChhOb3RpZmljYXRpb25TdWJtaXRBY2tEdG8SFwoPbm90aWZpY2F0aW9u", + "X2lkGAEgASgJEhAKCGFjY2VwdGVkGAIgASgIEg0KBWVycm9yGAMgASgJIk0K", + "Gk5vdGlmaWNhdGlvblN0YXR1c1F1ZXJ5RHRvEhYKDmNvcnJlbGF0aW9uX2lk", + "GAEgASgJEhcKD25vdGlmaWNhdGlvbl9pZBgCIAEoCSKxAQodTm90aWZpY2F0", + "aW9uU3RhdHVzUmVzcG9uc2VEdG8SFgoOY29ycmVsYXRpb25faWQYASABKAkS", + "DQoFZm91bmQYAiABKAgSDgoGc3RhdHVzGAMgASgJEhMKC3JldHJ5X2NvdW50", + "GAQgASgFEhIKCmxhc3RfZXJyb3IYBSABKAkSMAoMZGVsaXZlcmVkX2F0GAYg", + "ASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCL9AQoXUmVjb25jaWxl", + "U2l0ZVJlcXVlc3REdG8SFwoPc2l0ZV9pZGVudGlmaWVyGAEgASgJEg8KB25v", + "ZGVfaWQYAiABKAkSeAobbG9jYWxfbmFtZV90b19yZXZpc2lvbl9oYXNoGAMg", + "AygLMlMuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuUmVjb25jaWxl", + "U2l0ZVJlcXVlc3REdG8uTG9jYWxOYW1lVG9SZXZpc2lvbkhhc2hFbnRyeRo+", + "ChxMb2NhbE5hbWVUb1JldmlzaW9uSGFzaEVudHJ5EgsKA2tleRgBIAEoCRIN", + "CgV2YWx1ZRgCIAEoCToCOAEikQEKGFJlY29uY2lsZVNpdGVSZXNwb25zZUR0", + "bxI/CgNnYXAYASADKAsyMi5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52", + "MS5SZWNvbmNpbGVHYXBJdGVtRHRvEhQKDG9ycGhhbl9uYW1lcxgCIAMoCRIe", + "ChZjZW50cmFsX2ZldGNoX2Jhc2VfdXJsGAMgASgJIooBChNSZWNvbmNpbGVH", + "YXBJdGVtRHRvEhwKFGluc3RhbmNlX3VuaXF1ZV9uYW1lGAEgASgJEhUKDWRl", + "cGxveW1lbnRfaWQYAiABKAkSFQoNcmV2aXNpb25faGFzaBgDIAEoCRISCgpp", + "c19lbmFibGVkGAQgASgIEhMKC2ZldGNoX3Rva2VuGAUgASgJIlEKFlRhZ1Jl", + "c29sdXRpb25TdGF0dXNEdG8SGAoQdG90YWxfc3Vic2NyaWJlZBgBIAEoBRId", + "ChVzdWNjZXNzZnVsbHlfcmVzb2x2ZWQYAiABKAUiQwoTVGFnUXVhbGl0eUNv", + "dW50c0R0bxIMCgRnb29kGAEgASgFEgsKA2JhZBgCIAEoBRIRCgl1bmNlcnRh", + "aW4YAyABKAUiQgoNTm9kZVN0YXR1c0R0bxIQCghob3N0bmFtZRgBIAEoCRIR", + "Cglpc19vbmxpbmUYAiABKAgSDAoEcm9sZRgDIAEoCSKDAQobU2l0ZUF1ZGl0", + "QmFja2xvZ1NuYXBzaG90RHRvEhUKDXBlbmRpbmdfY291bnQYASABKAUSNgoS", + "b2xkZXN0X3BlbmRpbmdfdXRjGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRp", + "bWVzdGFtcBIVCg1vbl9kaXNrX2J5dGVzGAMgASgDIqEBChhDb25uZWN0aW9u", + "RW5kcG9pbnRNYXBEdG8SVQoHZW50cmllcxgBIAMoCzJELnNjYWRhYnJpZGdl", + "LmNlbnRyYWxjb250cm9sLnYxLkNvbm5lY3Rpb25FbmRwb2ludE1hcER0by5F", + "bnRyaWVzRW50cnkaLgoMRW50cmllc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2", + "YWx1ZRgCIAEoCToCOAEixQEKEFRhZ1F1YWxpdHlNYXBEdG8STQoHZW50cmll", + "cxgBIAMoCzI8LnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLlRhZ1F1", + "YWxpdHlNYXBEdG8uRW50cmllc0VudHJ5GmIKDEVudHJpZXNFbnRyeRILCgNr", + "ZXkYASABKAkSQQoFdmFsdWUYAiABKAsyMi5zY2FkYWJyaWRnZS5jZW50cmFs", + "Y29udHJvbC52MS5UYWdRdWFsaXR5Q291bnRzRHRvOgI4ASJQChFOb2RlU3Rh", + "dHVzTGlzdER0bxI7CgVub2RlcxgBIAMoCzIsLnNjYWRhYnJpZGdlLmNlbnRy", + "YWxjb250cm9sLnYxLk5vZGVTdGF0dXNEdG8iig4KE1NpdGVIZWFsdGhSZXBv", + "cnREdG8SDwoHc2l0ZV9pZBgBIAEoCRIXCg9zZXF1ZW5jZV9udW1iZXIYAiAB", + "KAMSNAoQcmVwb3J0X3RpbWVzdGFtcBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1", + "Zi5UaW1lc3RhbXAScAoYZGF0YV9jb25uZWN0aW9uX3N0YXR1c2VzGAQgAygL", + "Mk4uc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuU2l0ZUhlYWx0aFJl", + "cG9ydER0by5EYXRhQ29ubmVjdGlvblN0YXR1c2VzRW50cnkSagoVdGFnX3Jl", + "c29sdXRpb25fY291bnRzGAUgAygLMksuc2NhZGFicmlkZ2UuY2VudHJhbGNv", + "bnRyb2wudjEuU2l0ZUhlYWx0aFJlcG9ydER0by5UYWdSZXNvbHV0aW9uQ291", + "bnRzRW50cnkSGgoSc2NyaXB0X2Vycm9yX2NvdW50GAYgASgFEiQKHGFsYXJt", + "X2V2YWx1YXRpb25fZXJyb3JfY291bnQYByABKAUSfAofc3RvcmVfYW5kX2Zv", + "cndhcmRfYnVmZmVyX2RlcHRocxgIIAMoCzJTLnNjYWRhYnJpZGdlLmNlbnRy", + "YWxjb250cm9sLnYxLlNpdGVIZWFsdGhSZXBvcnREdG8uU3RvcmVBbmRGb3J3", + "YXJkQnVmZmVyRGVwdGhzRW50cnkSGQoRZGVhZF9sZXR0ZXJfY291bnQYCSAB", + "KAUSHwoXZGVwbG95ZWRfaW5zdGFuY2VfY291bnQYCiABKAUSHgoWZW5hYmxl", + "ZF9pbnN0YW5jZV9jb3VudBgLIAEoBRIfChdkaXNhYmxlZF9pbnN0YW5jZV9j", + "b3VudBgMIAEoBRIRCglub2RlX3JvbGUYDSABKAkSFQoNbm9kZV9ob3N0bmFt", + "ZRgOIAEoCRJaChlkYXRhX2Nvbm5lY3Rpb25fZW5kcG9pbnRzGA8gASgLMjcu", + "c2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuQ29ubmVjdGlvbkVuZHBv", + "aW50TWFwRHRvElQKG2RhdGFfY29ubmVjdGlvbl90YWdfcXVhbGl0eRgQIAEo", + "CzIvLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLlRhZ1F1YWxpdHlN", + "YXBEdG8SHAoUcGFya2VkX21lc3NhZ2VfY291bnQYESABKAUSRwoNY2x1c3Rl", + "cl9ub2RlcxgSIAEoCzIwLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYx", + "Lk5vZGVTdGF0dXNMaXN0RHRvEiEKGXNpdGVfYXVkaXRfd3JpdGVfZmFpbHVy", + "ZXMYEyABKAUSHwoXYXVkaXRfcmVkYWN0aW9uX2ZhaWx1cmUYFCABKAUSVgoS", + "c2l0ZV9hdWRpdF9iYWNrbG9nGBUgASgLMjouc2NhZGFicmlkZ2UuY2VudHJh", + "bGNvbnRyb2wudjEuU2l0ZUF1ZGl0QmFja2xvZ1NuYXBzaG90RHRvEiUKHXNp", + "dGVfZXZlbnRfbG9nX3dyaXRlX2ZhaWx1cmVzGBYgASgDEkcKIW9sZGVzdF9w", + "YXJrZWRfbWVzc2FnZV9hZ2Vfc2Vjb25kcxgXIAEoCzIcLmdvb2dsZS5wcm90", + "b2J1Zi5Eb3VibGVWYWx1ZRIaChJzY3JpcHRfcXVldWVfZGVwdGgYGCABKAUS", + "GwoTc2NyaXB0X2J1c3lfdGhyZWFkcxgZIAEoBRJECh5zY3JpcHRfb2xkZXN0", + "X2J1c3lfYWdlX3NlY29uZHMYGiABKAsyHC5nb29nbGUucHJvdG9idWYuRG91", + "YmxlVmFsdWUSQgoebG9jYWxfZGJfcmVwbGljYXRpb25fY29ubmVjdGVkGBsg", + "ASgLMhouZ29vZ2xlLnByb3RvYnVmLkJvb2xWYWx1ZRI7ChZsb2NhbF9kYl9v", + "cGxvZ19iYWNrbG9nGBwgASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDY0VmFs", + "dWUacgobRGF0YUNvbm5lY3Rpb25TdGF0dXNlc0VudHJ5EgsKA2tleRgBIAEo", + "CRJCCgV2YWx1ZRgCIAEoDjIzLnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9s", + "LnYxLkNvbm5lY3Rpb25IZWFsdGhFbnVtOgI4ARpxChhUYWdSZXNvbHV0aW9u", + "Q291bnRzRW50cnkSCwoDa2V5GAEgASgJEkQKBXZhbHVlGAIgASgLMjUuc2Nh", + "ZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuVGFnUmVzb2x1dGlvblN0YXR1", + "c0R0bzoCOAEaQgogU3RvcmVBbmRGb3J3YXJkQnVmZmVyRGVwdGhzRW50cnkS", + "CwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgFOgI4ASJjChZTaXRlSGVhbHRo", + "UmVwb3J0QWNrRHRvEg8KB3NpdGVfaWQYASABKAkSFwoPc2VxdWVuY2VfbnVt", + "YmVyGAIgASgDEhAKCGFjY2VwdGVkGAMgASgIEg0KBWVycm9yGAQgASgJIngK", + "DEhlYXJ0YmVhdER0bxIPCgdzaXRlX2lkGAEgASgJEhUKDW5vZGVfaG9zdG5h", + "bWUYAiABKAkSEQoJaXNfYWN0aXZlGAMgASgIEi0KCXRpbWVzdGFtcBgEIAEo", + "CzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAqvQEKFENvbm5lY3Rpb25I", + "ZWFsdGhFbnVtEiEKHUNPTk5FQ1RJT05fSEVBTFRIX1VOU1BFQ0lGSUVEEAAS", + "HwobQ09OTkVDVElPTl9IRUFMVEhfQ09OTkVDVEVEEAESIgoeQ09OTkVDVElP", + "Tl9IRUFMVEhfRElTQ09OTkVDVEVEEAISIAocQ09OTkVDVElPTl9IRUFMVEhf", + "Q09OTkVDVElORxADEhsKF0NPTk5FQ1RJT05fSEVBTFRIX0VSUk9SEAQyoQYK", + "FUNlbnRyYWxDb250cm9sU2VydmljZRKDAQoSU3VibWl0Tm90aWZpY2F0aW9u", + "EjQuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuTm90aWZpY2F0aW9u", + "U3VibWl0RHRvGjcuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuTm90", + "aWZpY2F0aW9uU3VibWl0QWNrRHRvEpIBChdRdWVyeU5vdGlmaWNhdGlvblN0", + "YXR1cxI5LnNjYWRhYnJpZGdlLmNlbnRyYWxjb250cm9sLnYxLk5vdGlmaWNh", + "dGlvblN0YXR1c1F1ZXJ5RHRvGjwuc2NhZGFicmlkZ2UuY2VudHJhbGNvbnRy", + "b2wudjEuTm90aWZpY2F0aW9uU3RhdHVzUmVzcG9uc2VEdG8SRwoRSW5nZXN0", + "QXVkaXRFdmVudHMSGy5zaXRlc3RyZWFtLkF1ZGl0RXZlbnRCYXRjaBoVLnNp", + "dGVzdHJlYW0uSW5nZXN0QWNrElAKFUluZ2VzdENhY2hlZFRlbGVtZXRyeRIg", + "LnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1ldHJ5QmF0Y2gaFS5zaXRlc3RyZWFt", + "LkluZ2VzdEFjaxKAAQoNUmVjb25jaWxlU2l0ZRI2LnNjYWRhYnJpZGdlLmNl", + "bnRyYWxjb250cm9sLnYxLlJlY29uY2lsZVNpdGVSZXF1ZXN0RHRvGjcuc2Nh", + "ZGFicmlkZ2UuY2VudHJhbGNvbnRyb2wudjEuUmVjb25jaWxlU2l0ZVJlc3Bv", + "bnNlRHRvEn0KEFJlcG9ydFNpdGVIZWFsdGgSMi5zY2FkYWJyaWRnZS5jZW50", + "cmFsY29udHJvbC52MS5TaXRlSGVhbHRoUmVwb3J0RHRvGjUuc2NhZGFicmlk", + "Z2UuY2VudHJhbGNvbnRyb2wudjEuU2l0ZUhlYWx0aFJlcG9ydEFja0R0bxJQ", + "CglIZWFydGJlYXQSKy5zY2FkYWJyaWRnZS5jZW50cmFsY29udHJvbC52MS5I", + "ZWFydGJlYXREdG8aFi5nb29nbGUucHJvdG9idWYuRW1wdHlCK6oCKFpCLk1P", + "TS5XVy5TY2FkYUJyaWRnZS5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw==")); + descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, + new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.EmptyReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SitestreamReflection.Descriptor, }, + new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionHealthEnum), }, null, new pbr::GeneratedClrTypeInfo[] { + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto.Parser, new[]{ "NotificationId", "ListName", "Subject", "Body", "SourceSiteId", "SourceInstanceId", "SourceScript", "SiteEnqueuedAt", "OriginExecutionId", "OriginParentExecutionId", "SourceNode" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto.Parser, new[]{ "NotificationId", "Accepted", "Error" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto.Parser, new[]{ "CorrelationId", "NotificationId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto.Parser, new[]{ "CorrelationId", "Found", "Status", "RetryCount", "LastError", "DeliveredAt" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto.Parser, new[]{ "SiteIdentifier", "NodeId", "LocalNameToRevisionHash" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, }), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto.Parser, new[]{ "Gap", "OrphanNames", "CentralFetchBaseUrl" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileGapItemDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileGapItemDto.Parser, new[]{ "InstanceUniqueName", "DeploymentId", "RevisionHash", "IsEnabled", "FetchToken" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagResolutionStatusDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagResolutionStatusDto.Parser, new[]{ "TotalSubscribed", "SuccessfullyResolved" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityCountsDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityCountsDto.Parser, new[]{ "Good", "Bad", "Uncertain" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusDto.Parser, new[]{ "Hostname", "IsOnline", "Role" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto.Parser, new[]{ "PendingCount", "OldestPendingUtc", "OnDiskBytes" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto.Parser, new[]{ "Entries" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, }), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto.Parser, new[]{ "Entries" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, }), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto.Parser, new[]{ "Nodes" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto.Parser, new[]{ "SiteId", "SequenceNumber", "ReportTimestamp", "DataConnectionStatuses", "TagResolutionCounts", "ScriptErrorCount", "AlarmEvaluationErrorCount", "StoreAndForwardBufferDepths", "DeadLetterCount", "DeployedInstanceCount", "EnabledInstanceCount", "DisabledInstanceCount", "NodeRole", "NodeHostname", "DataConnectionEndpoints", "DataConnectionTagQuality", "ParkedMessageCount", "ClusterNodes", "SiteAuditWriteFailures", "AuditRedactionFailure", "SiteAuditBacklog", "SiteEventLogWriteFailures", "OldestParkedMessageAgeSeconds", "ScriptQueueDepth", "ScriptBusyThreads", "ScriptOldestBusyAgeSeconds", "LocalDbReplicationConnected", "LocalDbOplogBacklog" }, null, null, null, new pbr::GeneratedClrTypeInfo[] { null, null, null, }), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto.Parser, new[]{ "SiteId", "SequenceNumber", "Accepted", "Error" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto), global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto.Parser, new[]{ "SiteId", "NodeHostname", "IsActive", "Timestamp" }, null, null, null, null) + })); + } + #endregion + + } + #region Enums + /// + /// Wire form of the Commons ConnectionHealth enum. + /// + /// CONNECTION_HEALTH_UNSPECIFIED exists only to keep the proto3 zero value from + /// meaning something. Mapping Connected onto 0 would make an absent/garbled + /// value decode as "healthy", which is precisely the wrong direction to fail; + /// the mapper decodes UNSPECIFIED as Error instead and never emits it. + /// + public enum ConnectionHealthEnum { + [pbr::OriginalName("CONNECTION_HEALTH_UNSPECIFIED")] ConnectionHealthUnspecified = 0, + [pbr::OriginalName("CONNECTION_HEALTH_CONNECTED")] ConnectionHealthConnected = 1, + [pbr::OriginalName("CONNECTION_HEALTH_DISCONNECTED")] ConnectionHealthDisconnected = 2, + [pbr::OriginalName("CONNECTION_HEALTH_CONNECTING")] ConnectionHealthConnecting = 3, + [pbr::OriginalName("CONNECTION_HEALTH_ERROR")] ConnectionHealthError = 4, + } + + #endregion + + #region Messages + /// + /// Site -> Central: submit a buffered notification for central delivery. + /// Mirrors Commons NotificationSubmit. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class NotificationSubmitDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new NotificationSubmitDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[0]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationSubmitDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationSubmitDto(NotificationSubmitDto other) : this() { + notificationId_ = other.notificationId_; + listName_ = other.listName_; + subject_ = other.subject_; + body_ = other.body_; + sourceSiteId_ = other.sourceSiteId_; + sourceInstanceId_ = other.sourceInstanceId_; + sourceScript_ = other.sourceScript_; + siteEnqueuedAt_ = other.siteEnqueuedAt_ != null ? other.siteEnqueuedAt_.Clone() : null; + originExecutionId_ = other.originExecutionId_; + originParentExecutionId_ = other.originParentExecutionId_; + sourceNode_ = other.sourceNode_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationSubmitDto Clone() { + return new NotificationSubmitDto(this); + } + + /// Field number for the "notification_id" field. + public const int NotificationIdFieldNumber = 1; + private string notificationId_ = ""; + /// + /// GUID string, the idempotency key + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string NotificationId { + get { return notificationId_; } + set { + notificationId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "list_name" field. + public const int ListNameFieldNumber = 2; + private string listName_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string ListName { + get { return listName_; } + set { + listName_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "subject" field. + public const int SubjectFieldNumber = 3; + private string subject_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Subject { + get { return subject_; } + set { + subject_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "body" field. + public const int BodyFieldNumber = 4; + private string body_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Body { + get { return body_; } + set { + body_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "source_site_id" field. + public const int SourceSiteIdFieldNumber = 5; + private string sourceSiteId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SourceSiteId { + get { return sourceSiteId_; } + set { + sourceSiteId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "source_instance_id" field. + public const int SourceInstanceIdFieldNumber = 6; + private string sourceInstanceId_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SourceInstanceId { + get { return sourceInstanceId_; } + set { + sourceInstanceId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "source_script" field. + public const int SourceScriptFieldNumber = 7; + private string sourceScript_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SourceScript { + get { return sourceScript_; } + set { + sourceScript_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "site_enqueued_at" field. + public const int SiteEnqueuedAtFieldNumber = 8; + private global::Google.Protobuf.WellKnownTypes.Timestamp siteEnqueuedAt_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::Google.Protobuf.WellKnownTypes.Timestamp SiteEnqueuedAt { + get { return siteEnqueuedAt_; } + set { + siteEnqueuedAt_ = value; + } + } + + /// Field number for the "origin_execution_id" field. + public const int OriginExecutionIdFieldNumber = 9; + private string originExecutionId_ = ""; + /// + /// GUID string; empty represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string OriginExecutionId { + get { return originExecutionId_; } + set { + originExecutionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "origin_parent_execution_id" field. + public const int OriginParentExecutionIdFieldNumber = 10; + private string originParentExecutionId_ = ""; + /// + /// GUID string; empty represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string OriginParentExecutionId { + get { return originParentExecutionId_; } + set { + originParentExecutionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "source_node" field. + public const int SourceNodeFieldNumber = 11; + private string sourceNode_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SourceNode { + get { return sourceNode_; } + set { + sourceNode_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as NotificationSubmitDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(NotificationSubmitDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (NotificationId != other.NotificationId) return false; + if (ListName != other.ListName) return false; + if (Subject != other.Subject) return false; + if (Body != other.Body) return false; + if (SourceSiteId != other.SourceSiteId) return false; + if (SourceInstanceId != other.SourceInstanceId) return false; + if (SourceScript != other.SourceScript) return false; + if (!object.Equals(SiteEnqueuedAt, other.SiteEnqueuedAt)) return false; + if (OriginExecutionId != other.OriginExecutionId) return false; + if (OriginParentExecutionId != other.OriginParentExecutionId) return false; + if (SourceNode != other.SourceNode) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (NotificationId.Length != 0) hash ^= NotificationId.GetHashCode(); + if (ListName.Length != 0) hash ^= ListName.GetHashCode(); + if (Subject.Length != 0) hash ^= Subject.GetHashCode(); + if (Body.Length != 0) hash ^= Body.GetHashCode(); + if (SourceSiteId.Length != 0) hash ^= SourceSiteId.GetHashCode(); + if (SourceInstanceId.Length != 0) hash ^= SourceInstanceId.GetHashCode(); + if (SourceScript.Length != 0) hash ^= SourceScript.GetHashCode(); + if (siteEnqueuedAt_ != null) hash ^= SiteEnqueuedAt.GetHashCode(); + if (OriginExecutionId.Length != 0) hash ^= OriginExecutionId.GetHashCode(); + if (OriginParentExecutionId.Length != 0) hash ^= OriginParentExecutionId.GetHashCode(); + if (SourceNode.Length != 0) hash ^= SourceNode.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (NotificationId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(NotificationId); + } + if (ListName.Length != 0) { + output.WriteRawTag(18); + output.WriteString(ListName); + } + if (Subject.Length != 0) { + output.WriteRawTag(26); + output.WriteString(Subject); + } + if (Body.Length != 0) { + output.WriteRawTag(34); + output.WriteString(Body); + } + if (SourceSiteId.Length != 0) { + output.WriteRawTag(42); + output.WriteString(SourceSiteId); + } + if (SourceInstanceId.Length != 0) { + output.WriteRawTag(50); + output.WriteString(SourceInstanceId); + } + if (SourceScript.Length != 0) { + output.WriteRawTag(58); + output.WriteString(SourceScript); + } + if (siteEnqueuedAt_ != null) { + output.WriteRawTag(66); + output.WriteMessage(SiteEnqueuedAt); + } + if (OriginExecutionId.Length != 0) { + output.WriteRawTag(74); + output.WriteString(OriginExecutionId); + } + if (OriginParentExecutionId.Length != 0) { + output.WriteRawTag(82); + output.WriteString(OriginParentExecutionId); + } + if (SourceNode.Length != 0) { + output.WriteRawTag(90); + output.WriteString(SourceNode); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (NotificationId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(NotificationId); + } + if (ListName.Length != 0) { + output.WriteRawTag(18); + output.WriteString(ListName); + } + if (Subject.Length != 0) { + output.WriteRawTag(26); + output.WriteString(Subject); + } + if (Body.Length != 0) { + output.WriteRawTag(34); + output.WriteString(Body); + } + if (SourceSiteId.Length != 0) { + output.WriteRawTag(42); + output.WriteString(SourceSiteId); + } + if (SourceInstanceId.Length != 0) { + output.WriteRawTag(50); + output.WriteString(SourceInstanceId); + } + if (SourceScript.Length != 0) { + output.WriteRawTag(58); + output.WriteString(SourceScript); + } + if (siteEnqueuedAt_ != null) { + output.WriteRawTag(66); + output.WriteMessage(SiteEnqueuedAt); + } + if (OriginExecutionId.Length != 0) { + output.WriteRawTag(74); + output.WriteString(OriginExecutionId); + } + if (OriginParentExecutionId.Length != 0) { + output.WriteRawTag(82); + output.WriteString(OriginParentExecutionId); + } + if (SourceNode.Length != 0) { + output.WriteRawTag(90); + output.WriteString(SourceNode); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (NotificationId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(NotificationId); + } + if (ListName.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(ListName); + } + if (Subject.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Subject); + } + if (Body.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Body); + } + if (SourceSiteId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SourceSiteId); + } + if (SourceInstanceId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SourceInstanceId); + } + if (SourceScript.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SourceScript); + } + if (siteEnqueuedAt_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(SiteEnqueuedAt); + } + if (OriginExecutionId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(OriginExecutionId); + } + if (OriginParentExecutionId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(OriginParentExecutionId); + } + if (SourceNode.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SourceNode); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(NotificationSubmitDto other) { + if (other == null) { + return; + } + if (other.NotificationId.Length != 0) { + NotificationId = other.NotificationId; + } + if (other.ListName.Length != 0) { + ListName = other.ListName; + } + if (other.Subject.Length != 0) { + Subject = other.Subject; + } + if (other.Body.Length != 0) { + Body = other.Body; + } + if (other.SourceSiteId.Length != 0) { + SourceSiteId = other.SourceSiteId; + } + if (other.SourceInstanceId.Length != 0) { + SourceInstanceId = other.SourceInstanceId; + } + if (other.SourceScript.Length != 0) { + SourceScript = other.SourceScript; + } + if (other.siteEnqueuedAt_ != null) { + if (siteEnqueuedAt_ == null) { + SiteEnqueuedAt = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + SiteEnqueuedAt.MergeFrom(other.SiteEnqueuedAt); + } + if (other.OriginExecutionId.Length != 0) { + OriginExecutionId = other.OriginExecutionId; + } + if (other.OriginParentExecutionId.Length != 0) { + OriginParentExecutionId = other.OriginParentExecutionId; + } + if (other.SourceNode.Length != 0) { + SourceNode = other.SourceNode; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + NotificationId = input.ReadString(); + break; + } + case 18: { + ListName = input.ReadString(); + break; + } + case 26: { + Subject = input.ReadString(); + break; + } + case 34: { + Body = input.ReadString(); + break; + } + case 42: { + SourceSiteId = input.ReadString(); + break; + } + case 50: { + SourceInstanceId = input.ReadString(); + break; + } + case 58: { + SourceScript = input.ReadString(); + break; + } + case 66: { + if (siteEnqueuedAt_ == null) { + SiteEnqueuedAt = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(SiteEnqueuedAt); + break; + } + case 74: { + OriginExecutionId = input.ReadString(); + break; + } + case 82: { + OriginParentExecutionId = input.ReadString(); + break; + } + case 90: { + SourceNode = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + NotificationId = input.ReadString(); + break; + } + case 18: { + ListName = input.ReadString(); + break; + } + case 26: { + Subject = input.ReadString(); + break; + } + case 34: { + Body = input.ReadString(); + break; + } + case 42: { + SourceSiteId = input.ReadString(); + break; + } + case 50: { + SourceInstanceId = input.ReadString(); + break; + } + case 58: { + SourceScript = input.ReadString(); + break; + } + case 66: { + if (siteEnqueuedAt_ == null) { + SiteEnqueuedAt = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(SiteEnqueuedAt); + break; + } + case 74: { + OriginExecutionId = input.ReadString(); + break; + } + case 82: { + OriginParentExecutionId = input.ReadString(); + break; + } + case 90: { + SourceNode = input.ReadString(); + break; + } + } + } + } + #endif + + } + + /// + /// Central -> Site: ack sent after the Notifications row is persisted. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class NotificationSubmitAckDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new NotificationSubmitAckDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[1]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationSubmitAckDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationSubmitAckDto(NotificationSubmitAckDto other) : this() { + notificationId_ = other.notificationId_; + accepted_ = other.accepted_; + error_ = other.error_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationSubmitAckDto Clone() { + return new NotificationSubmitAckDto(this); + } + + /// Field number for the "notification_id" field. + public const int NotificationIdFieldNumber = 1; + private string notificationId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string NotificationId { + get { return notificationId_; } + set { + notificationId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "accepted" field. + public const int AcceptedFieldNumber = 2; + private bool accepted_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Accepted { + get { return accepted_; } + set { + accepted_ = value; + } + } + + /// Field number for the "error" field. + public const int ErrorFieldNumber = 3; + private string error_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Error { + get { return error_; } + set { + error_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as NotificationSubmitAckDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(NotificationSubmitAckDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (NotificationId != other.NotificationId) return false; + if (Accepted != other.Accepted) return false; + if (Error != other.Error) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (NotificationId.Length != 0) hash ^= NotificationId.GetHashCode(); + if (Accepted != false) hash ^= Accepted.GetHashCode(); + if (Error.Length != 0) hash ^= Error.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (NotificationId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(NotificationId); + } + if (Accepted != false) { + output.WriteRawTag(16); + output.WriteBool(Accepted); + } + if (Error.Length != 0) { + output.WriteRawTag(26); + output.WriteString(Error); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (NotificationId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(NotificationId); + } + if (Accepted != false) { + output.WriteRawTag(16); + output.WriteBool(Accepted); + } + if (Error.Length != 0) { + output.WriteRawTag(26); + output.WriteString(Error); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (NotificationId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(NotificationId); + } + if (Accepted != false) { + size += 1 + 1; + } + if (Error.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Error); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(NotificationSubmitAckDto other) { + if (other == null) { + return; + } + if (other.NotificationId.Length != 0) { + NotificationId = other.NotificationId; + } + if (other.Accepted != false) { + Accepted = other.Accepted; + } + if (other.Error.Length != 0) { + Error = other.Error; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + NotificationId = input.ReadString(); + break; + } + case 16: { + Accepted = input.ReadBool(); + break; + } + case 26: { + Error = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + NotificationId = input.ReadString(); + break; + } + case 16: { + Accepted = input.ReadBool(); + break; + } + case 26: { + Error = input.ReadString(); + break; + } + } + } + } + #endif + + } + + /// + /// Site -> Central: Notify.Status(id) lookup against the central outbox. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class NotificationStatusQueryDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new NotificationStatusQueryDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[2]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationStatusQueryDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationStatusQueryDto(NotificationStatusQueryDto other) : this() { + correlationId_ = other.correlationId_; + notificationId_ = other.notificationId_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationStatusQueryDto Clone() { + return new NotificationStatusQueryDto(this); + } + + /// Field number for the "correlation_id" field. + public const int CorrelationIdFieldNumber = 1; + private string correlationId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string CorrelationId { + get { return correlationId_; } + set { + correlationId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "notification_id" field. + public const int NotificationIdFieldNumber = 2; + private string notificationId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string NotificationId { + get { return notificationId_; } + set { + notificationId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as NotificationStatusQueryDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(NotificationStatusQueryDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (CorrelationId != other.CorrelationId) return false; + if (NotificationId != other.NotificationId) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (CorrelationId.Length != 0) hash ^= CorrelationId.GetHashCode(); + if (NotificationId.Length != 0) hash ^= NotificationId.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (CorrelationId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(CorrelationId); + } + if (NotificationId.Length != 0) { + output.WriteRawTag(18); + output.WriteString(NotificationId); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (CorrelationId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(CorrelationId); + } + if (NotificationId.Length != 0) { + output.WriteRawTag(18); + output.WriteString(NotificationId); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (CorrelationId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(CorrelationId); + } + if (NotificationId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(NotificationId); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(NotificationStatusQueryDto other) { + if (other == null) { + return; + } + if (other.CorrelationId.Length != 0) { + CorrelationId = other.CorrelationId; + } + if (other.NotificationId.Length != 0) { + NotificationId = other.NotificationId; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + CorrelationId = input.ReadString(); + break; + } + case 18: { + NotificationId = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + CorrelationId = input.ReadString(); + break; + } + case 18: { + NotificationId = input.ReadString(); + break; + } + } + } + } + #endif + + } + + /// + /// Central -> Site: current central delivery state for a queried notification. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class NotificationStatusResponseDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new NotificationStatusResponseDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[3]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationStatusResponseDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationStatusResponseDto(NotificationStatusResponseDto other) : this() { + correlationId_ = other.correlationId_; + found_ = other.found_; + status_ = other.status_; + retryCount_ = other.retryCount_; + lastError_ = other.lastError_; + deliveredAt_ = other.deliveredAt_ != null ? other.deliveredAt_.Clone() : null; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NotificationStatusResponseDto Clone() { + return new NotificationStatusResponseDto(this); + } + + /// Field number for the "correlation_id" field. + public const int CorrelationIdFieldNumber = 1; + private string correlationId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string CorrelationId { + get { return correlationId_; } + set { + correlationId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "found" field. + public const int FoundFieldNumber = 2; + private bool found_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Found { + get { return found_; } + set { + found_ = value; + } + } + + /// Field number for the "status" field. + public const int StatusFieldNumber = 3; + private string status_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Status { + get { return status_; } + set { + status_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "retry_count" field. + public const int RetryCountFieldNumber = 4; + private int retryCount_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int RetryCount { + get { return retryCount_; } + set { + retryCount_ = value; + } + } + + /// Field number for the "last_error" field. + public const int LastErrorFieldNumber = 5; + private string lastError_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string LastError { + get { return lastError_; } + set { + lastError_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "delivered_at" field. + public const int DeliveredAtFieldNumber = 6; + private global::Google.Protobuf.WellKnownTypes.Timestamp deliveredAt_; + /// + /// absent when null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::Google.Protobuf.WellKnownTypes.Timestamp DeliveredAt { + get { return deliveredAt_; } + set { + deliveredAt_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as NotificationStatusResponseDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(NotificationStatusResponseDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (CorrelationId != other.CorrelationId) return false; + if (Found != other.Found) return false; + if (Status != other.Status) return false; + if (RetryCount != other.RetryCount) return false; + if (LastError != other.LastError) return false; + if (!object.Equals(DeliveredAt, other.DeliveredAt)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (CorrelationId.Length != 0) hash ^= CorrelationId.GetHashCode(); + if (Found != false) hash ^= Found.GetHashCode(); + if (Status.Length != 0) hash ^= Status.GetHashCode(); + if (RetryCount != 0) hash ^= RetryCount.GetHashCode(); + if (LastError.Length != 0) hash ^= LastError.GetHashCode(); + if (deliveredAt_ != null) hash ^= DeliveredAt.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (CorrelationId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(CorrelationId); + } + if (Found != false) { + output.WriteRawTag(16); + output.WriteBool(Found); + } + if (Status.Length != 0) { + output.WriteRawTag(26); + output.WriteString(Status); + } + if (RetryCount != 0) { + output.WriteRawTag(32); + output.WriteInt32(RetryCount); + } + if (LastError.Length != 0) { + output.WriteRawTag(42); + output.WriteString(LastError); + } + if (deliveredAt_ != null) { + output.WriteRawTag(50); + output.WriteMessage(DeliveredAt); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (CorrelationId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(CorrelationId); + } + if (Found != false) { + output.WriteRawTag(16); + output.WriteBool(Found); + } + if (Status.Length != 0) { + output.WriteRawTag(26); + output.WriteString(Status); + } + if (RetryCount != 0) { + output.WriteRawTag(32); + output.WriteInt32(RetryCount); + } + if (LastError.Length != 0) { + output.WriteRawTag(42); + output.WriteString(LastError); + } + if (deliveredAt_ != null) { + output.WriteRawTag(50); + output.WriteMessage(DeliveredAt); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (CorrelationId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(CorrelationId); + } + if (Found != false) { + size += 1 + 1; + } + if (Status.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Status); + } + if (RetryCount != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(RetryCount); + } + if (LastError.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(LastError); + } + if (deliveredAt_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(DeliveredAt); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(NotificationStatusResponseDto other) { + if (other == null) { + return; + } + if (other.CorrelationId.Length != 0) { + CorrelationId = other.CorrelationId; + } + if (other.Found != false) { + Found = other.Found; + } + if (other.Status.Length != 0) { + Status = other.Status; + } + if (other.RetryCount != 0) { + RetryCount = other.RetryCount; + } + if (other.LastError.Length != 0) { + LastError = other.LastError; + } + if (other.deliveredAt_ != null) { + if (deliveredAt_ == null) { + DeliveredAt = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + DeliveredAt.MergeFrom(other.DeliveredAt); + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + CorrelationId = input.ReadString(); + break; + } + case 16: { + Found = input.ReadBool(); + break; + } + case 26: { + Status = input.ReadString(); + break; + } + case 32: { + RetryCount = input.ReadInt32(); + break; + } + case 42: { + LastError = input.ReadString(); + break; + } + case 50: { + if (deliveredAt_ == null) { + DeliveredAt = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(DeliveredAt); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + CorrelationId = input.ReadString(); + break; + } + case 16: { + Found = input.ReadBool(); + break; + } + case 26: { + Status = input.ReadString(); + break; + } + case 32: { + RetryCount = input.ReadInt32(); + break; + } + case 42: { + LastError = input.ReadString(); + break; + } + case 50: { + if (deliveredAt_ == null) { + DeliveredAt = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(DeliveredAt); + break; + } + } + } + } + #endif + + } + + /// + /// Site -> Central: the node's local deployed inventory at startup. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class ReconcileSiteRequestDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new ReconcileSiteRequestDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[4]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReconcileSiteRequestDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReconcileSiteRequestDto(ReconcileSiteRequestDto other) : this() { + siteIdentifier_ = other.siteIdentifier_; + nodeId_ = other.nodeId_; + localNameToRevisionHash_ = other.localNameToRevisionHash_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReconcileSiteRequestDto Clone() { + return new ReconcileSiteRequestDto(this); + } + + /// Field number for the "site_identifier" field. + public const int SiteIdentifierFieldNumber = 1; + private string siteIdentifier_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SiteIdentifier { + get { return siteIdentifier_; } + set { + siteIdentifier_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "node_id" field. + public const int NodeIdFieldNumber = 2; + private string nodeId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string NodeId { + get { return nodeId_; } + set { + nodeId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "local_name_to_revision_hash" field. + public const int LocalNameToRevisionHashFieldNumber = 3; + private static readonly pbc::MapField.Codec _map_localNameToRevisionHash_codec + = new pbc::MapField.Codec(pb::FieldCodec.ForString(10, ""), pb::FieldCodec.ForString(18, ""), 26); + private readonly pbc::MapField localNameToRevisionHash_ = new pbc::MapField(); + /// + /// Instance unique name -> revision hash of the config the node currently holds. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::MapField LocalNameToRevisionHash { + get { return localNameToRevisionHash_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as ReconcileSiteRequestDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(ReconcileSiteRequestDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (SiteIdentifier != other.SiteIdentifier) return false; + if (NodeId != other.NodeId) return false; + if (!LocalNameToRevisionHash.Equals(other.LocalNameToRevisionHash)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (SiteIdentifier.Length != 0) hash ^= SiteIdentifier.GetHashCode(); + if (NodeId.Length != 0) hash ^= NodeId.GetHashCode(); + hash ^= LocalNameToRevisionHash.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (SiteIdentifier.Length != 0) { + output.WriteRawTag(10); + output.WriteString(SiteIdentifier); + } + if (NodeId.Length != 0) { + output.WriteRawTag(18); + output.WriteString(NodeId); + } + localNameToRevisionHash_.WriteTo(output, _map_localNameToRevisionHash_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (SiteIdentifier.Length != 0) { + output.WriteRawTag(10); + output.WriteString(SiteIdentifier); + } + if (NodeId.Length != 0) { + output.WriteRawTag(18); + output.WriteString(NodeId); + } + localNameToRevisionHash_.WriteTo(ref output, _map_localNameToRevisionHash_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (SiteIdentifier.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SiteIdentifier); + } + if (NodeId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(NodeId); + } + size += localNameToRevisionHash_.CalculateSize(_map_localNameToRevisionHash_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(ReconcileSiteRequestDto other) { + if (other == null) { + return; + } + if (other.SiteIdentifier.Length != 0) { + SiteIdentifier = other.SiteIdentifier; + } + if (other.NodeId.Length != 0) { + NodeId = other.NodeId; + } + localNameToRevisionHash_.MergeFrom(other.localNameToRevisionHash_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + SiteIdentifier = input.ReadString(); + break; + } + case 18: { + NodeId = input.ReadString(); + break; + } + case 26: { + localNameToRevisionHash_.AddEntriesFrom(input, _map_localNameToRevisionHash_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + SiteIdentifier = input.ReadString(); + break; + } + case 18: { + NodeId = input.ReadString(); + break; + } + case 26: { + localNameToRevisionHash_.AddEntriesFrom(ref input, _map_localNameToRevisionHash_codec); + break; + } + } + } + } + #endif + + } + + /// + /// Central -> Site: the gap the node must (re)fetch, plus orphans to log. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class ReconcileSiteResponseDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new ReconcileSiteResponseDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[5]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReconcileSiteResponseDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReconcileSiteResponseDto(ReconcileSiteResponseDto other) : this() { + gap_ = other.gap_.Clone(); + orphanNames_ = other.orphanNames_.Clone(); + centralFetchBaseUrl_ = other.centralFetchBaseUrl_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReconcileSiteResponseDto Clone() { + return new ReconcileSiteResponseDto(this); + } + + /// Field number for the "gap" field. + public const int GapFieldNumber = 1; + private static readonly pb::FieldCodec _repeated_gap_codec + = pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileGapItemDto.Parser); + private readonly pbc::RepeatedField gap_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Gap { + get { return gap_; } + } + + /// Field number for the "orphan_names" field. + public const int OrphanNamesFieldNumber = 2; + private static readonly pb::FieldCodec _repeated_orphanNames_codec + = pb::FieldCodec.ForString(18); + private readonly pbc::RepeatedField orphanNames_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField OrphanNames { + get { return orphanNames_; } + } + + /// Field number for the "central_fetch_base_url" field. + public const int CentralFetchBaseUrlFieldNumber = 3; + private string centralFetchBaseUrl_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string CentralFetchBaseUrl { + get { return centralFetchBaseUrl_; } + set { + centralFetchBaseUrl_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as ReconcileSiteResponseDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(ReconcileSiteResponseDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if(!gap_.Equals(other.gap_)) return false; + if(!orphanNames_.Equals(other.orphanNames_)) return false; + if (CentralFetchBaseUrl != other.CentralFetchBaseUrl) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + hash ^= gap_.GetHashCode(); + hash ^= orphanNames_.GetHashCode(); + if (CentralFetchBaseUrl.Length != 0) hash ^= CentralFetchBaseUrl.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + gap_.WriteTo(output, _repeated_gap_codec); + orphanNames_.WriteTo(output, _repeated_orphanNames_codec); + if (CentralFetchBaseUrl.Length != 0) { + output.WriteRawTag(26); + output.WriteString(CentralFetchBaseUrl); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + gap_.WriteTo(ref output, _repeated_gap_codec); + orphanNames_.WriteTo(ref output, _repeated_orphanNames_codec); + if (CentralFetchBaseUrl.Length != 0) { + output.WriteRawTag(26); + output.WriteString(CentralFetchBaseUrl); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + size += gap_.CalculateSize(_repeated_gap_codec); + size += orphanNames_.CalculateSize(_repeated_orphanNames_codec); + if (CentralFetchBaseUrl.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(CentralFetchBaseUrl); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(ReconcileSiteResponseDto other) { + if (other == null) { + return; + } + gap_.Add(other.gap_); + orphanNames_.Add(other.orphanNames_); + if (other.CentralFetchBaseUrl.Length != 0) { + CentralFetchBaseUrl = other.CentralFetchBaseUrl; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + gap_.AddEntriesFrom(input, _repeated_gap_codec); + break; + } + case 18: { + orphanNames_.AddEntriesFrom(input, _repeated_orphanNames_codec); + break; + } + case 26: { + CentralFetchBaseUrl = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + gap_.AddEntriesFrom(ref input, _repeated_gap_codec); + break; + } + case 18: { + orphanNames_.AddEntriesFrom(ref input, _repeated_orphanNames_codec); + break; + } + case 26: { + CentralFetchBaseUrl = input.ReadString(); + break; + } + } + } + } + #endif + + } + + /// + /// One instance the node must (re)fetch, with a freshly-minted short-TTL token. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class ReconcileGapItemDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new ReconcileGapItemDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[6]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReconcileGapItemDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReconcileGapItemDto(ReconcileGapItemDto other) : this() { + instanceUniqueName_ = other.instanceUniqueName_; + deploymentId_ = other.deploymentId_; + revisionHash_ = other.revisionHash_; + isEnabled_ = other.isEnabled_; + fetchToken_ = other.fetchToken_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ReconcileGapItemDto Clone() { + return new ReconcileGapItemDto(this); + } + + /// Field number for the "instance_unique_name" field. + public const int InstanceUniqueNameFieldNumber = 1; + private string instanceUniqueName_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string InstanceUniqueName { + get { return instanceUniqueName_; } + set { + instanceUniqueName_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "deployment_id" field. + public const int DeploymentIdFieldNumber = 2; + private string deploymentId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string DeploymentId { + get { return deploymentId_; } + set { + deploymentId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "revision_hash" field. + public const int RevisionHashFieldNumber = 3; + private string revisionHash_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string RevisionHash { + get { return revisionHash_; } + set { + revisionHash_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "is_enabled" field. + public const int IsEnabledFieldNumber = 4; + private bool isEnabled_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool IsEnabled { + get { return isEnabled_; } + set { + isEnabled_ = value; + } + } + + /// Field number for the "fetch_token" field. + public const int FetchTokenFieldNumber = 5; + private string fetchToken_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string FetchToken { + get { return fetchToken_; } + set { + fetchToken_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as ReconcileGapItemDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(ReconcileGapItemDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (InstanceUniqueName != other.InstanceUniqueName) return false; + if (DeploymentId != other.DeploymentId) return false; + if (RevisionHash != other.RevisionHash) return false; + if (IsEnabled != other.IsEnabled) return false; + if (FetchToken != other.FetchToken) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (InstanceUniqueName.Length != 0) hash ^= InstanceUniqueName.GetHashCode(); + if (DeploymentId.Length != 0) hash ^= DeploymentId.GetHashCode(); + if (RevisionHash.Length != 0) hash ^= RevisionHash.GetHashCode(); + if (IsEnabled != false) hash ^= IsEnabled.GetHashCode(); + if (FetchToken.Length != 0) hash ^= FetchToken.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (InstanceUniqueName.Length != 0) { + output.WriteRawTag(10); + output.WriteString(InstanceUniqueName); + } + if (DeploymentId.Length != 0) { + output.WriteRawTag(18); + output.WriteString(DeploymentId); + } + if (RevisionHash.Length != 0) { + output.WriteRawTag(26); + output.WriteString(RevisionHash); + } + if (IsEnabled != false) { + output.WriteRawTag(32); + output.WriteBool(IsEnabled); + } + if (FetchToken.Length != 0) { + output.WriteRawTag(42); + output.WriteString(FetchToken); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (InstanceUniqueName.Length != 0) { + output.WriteRawTag(10); + output.WriteString(InstanceUniqueName); + } + if (DeploymentId.Length != 0) { + output.WriteRawTag(18); + output.WriteString(DeploymentId); + } + if (RevisionHash.Length != 0) { + output.WriteRawTag(26); + output.WriteString(RevisionHash); + } + if (IsEnabled != false) { + output.WriteRawTag(32); + output.WriteBool(IsEnabled); + } + if (FetchToken.Length != 0) { + output.WriteRawTag(42); + output.WriteString(FetchToken); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (InstanceUniqueName.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(InstanceUniqueName); + } + if (DeploymentId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(DeploymentId); + } + if (RevisionHash.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(RevisionHash); + } + if (IsEnabled != false) { + size += 1 + 1; + } + if (FetchToken.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(FetchToken); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(ReconcileGapItemDto other) { + if (other == null) { + return; + } + if (other.InstanceUniqueName.Length != 0) { + InstanceUniqueName = other.InstanceUniqueName; + } + if (other.DeploymentId.Length != 0) { + DeploymentId = other.DeploymentId; + } + if (other.RevisionHash.Length != 0) { + RevisionHash = other.RevisionHash; + } + if (other.IsEnabled != false) { + IsEnabled = other.IsEnabled; + } + if (other.FetchToken.Length != 0) { + FetchToken = other.FetchToken; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + InstanceUniqueName = input.ReadString(); + break; + } + case 18: { + DeploymentId = input.ReadString(); + break; + } + case 26: { + RevisionHash = input.ReadString(); + break; + } + case 32: { + IsEnabled = input.ReadBool(); + break; + } + case 42: { + FetchToken = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + InstanceUniqueName = input.ReadString(); + break; + } + case 18: { + DeploymentId = input.ReadString(); + break; + } + case 26: { + RevisionHash = input.ReadString(); + break; + } + case 32: { + IsEnabled = input.ReadBool(); + break; + } + case 42: { + FetchToken = input.ReadString(); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class TagResolutionStatusDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new TagResolutionStatusDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[7]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TagResolutionStatusDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TagResolutionStatusDto(TagResolutionStatusDto other) : this() { + totalSubscribed_ = other.totalSubscribed_; + successfullyResolved_ = other.successfullyResolved_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TagResolutionStatusDto Clone() { + return new TagResolutionStatusDto(this); + } + + /// Field number for the "total_subscribed" field. + public const int TotalSubscribedFieldNumber = 1; + private int totalSubscribed_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int TotalSubscribed { + get { return totalSubscribed_; } + set { + totalSubscribed_ = value; + } + } + + /// Field number for the "successfully_resolved" field. + public const int SuccessfullyResolvedFieldNumber = 2; + private int successfullyResolved_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int SuccessfullyResolved { + get { return successfullyResolved_; } + set { + successfullyResolved_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as TagResolutionStatusDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(TagResolutionStatusDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (TotalSubscribed != other.TotalSubscribed) return false; + if (SuccessfullyResolved != other.SuccessfullyResolved) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (TotalSubscribed != 0) hash ^= TotalSubscribed.GetHashCode(); + if (SuccessfullyResolved != 0) hash ^= SuccessfullyResolved.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (TotalSubscribed != 0) { + output.WriteRawTag(8); + output.WriteInt32(TotalSubscribed); + } + if (SuccessfullyResolved != 0) { + output.WriteRawTag(16); + output.WriteInt32(SuccessfullyResolved); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (TotalSubscribed != 0) { + output.WriteRawTag(8); + output.WriteInt32(TotalSubscribed); + } + if (SuccessfullyResolved != 0) { + output.WriteRawTag(16); + output.WriteInt32(SuccessfullyResolved); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (TotalSubscribed != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(TotalSubscribed); + } + if (SuccessfullyResolved != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(SuccessfullyResolved); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(TagResolutionStatusDto other) { + if (other == null) { + return; + } + if (other.TotalSubscribed != 0) { + TotalSubscribed = other.TotalSubscribed; + } + if (other.SuccessfullyResolved != 0) { + SuccessfullyResolved = other.SuccessfullyResolved; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + TotalSubscribed = input.ReadInt32(); + break; + } + case 16: { + SuccessfullyResolved = input.ReadInt32(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + TotalSubscribed = input.ReadInt32(); + break; + } + case 16: { + SuccessfullyResolved = input.ReadInt32(); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class TagQualityCountsDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new TagQualityCountsDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[8]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TagQualityCountsDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TagQualityCountsDto(TagQualityCountsDto other) : this() { + good_ = other.good_; + bad_ = other.bad_; + uncertain_ = other.uncertain_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TagQualityCountsDto Clone() { + return new TagQualityCountsDto(this); + } + + /// Field number for the "good" field. + public const int GoodFieldNumber = 1; + private int good_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int Good { + get { return good_; } + set { + good_ = value; + } + } + + /// Field number for the "bad" field. + public const int BadFieldNumber = 2; + private int bad_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int Bad { + get { return bad_; } + set { + bad_ = value; + } + } + + /// Field number for the "uncertain" field. + public const int UncertainFieldNumber = 3; + private int uncertain_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int Uncertain { + get { return uncertain_; } + set { + uncertain_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as TagQualityCountsDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(TagQualityCountsDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (Good != other.Good) return false; + if (Bad != other.Bad) return false; + if (Uncertain != other.Uncertain) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (Good != 0) hash ^= Good.GetHashCode(); + if (Bad != 0) hash ^= Bad.GetHashCode(); + if (Uncertain != 0) hash ^= Uncertain.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (Good != 0) { + output.WriteRawTag(8); + output.WriteInt32(Good); + } + if (Bad != 0) { + output.WriteRawTag(16); + output.WriteInt32(Bad); + } + if (Uncertain != 0) { + output.WriteRawTag(24); + output.WriteInt32(Uncertain); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (Good != 0) { + output.WriteRawTag(8); + output.WriteInt32(Good); + } + if (Bad != 0) { + output.WriteRawTag(16); + output.WriteInt32(Bad); + } + if (Uncertain != 0) { + output.WriteRawTag(24); + output.WriteInt32(Uncertain); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (Good != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(Good); + } + if (Bad != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(Bad); + } + if (Uncertain != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(Uncertain); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(TagQualityCountsDto other) { + if (other == null) { + return; + } + if (other.Good != 0) { + Good = other.Good; + } + if (other.Bad != 0) { + Bad = other.Bad; + } + if (other.Uncertain != 0) { + Uncertain = other.Uncertain; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + Good = input.ReadInt32(); + break; + } + case 16: { + Bad = input.ReadInt32(); + break; + } + case 24: { + Uncertain = input.ReadInt32(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + Good = input.ReadInt32(); + break; + } + case 16: { + Bad = input.ReadInt32(); + break; + } + case 24: { + Uncertain = input.ReadInt32(); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class NodeStatusDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new NodeStatusDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[9]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NodeStatusDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NodeStatusDto(NodeStatusDto other) : this() { + hostname_ = other.hostname_; + isOnline_ = other.isOnline_; + role_ = other.role_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NodeStatusDto Clone() { + return new NodeStatusDto(this); + } + + /// Field number for the "hostname" field. + public const int HostnameFieldNumber = 1; + private string hostname_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Hostname { + get { return hostname_; } + set { + hostname_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "is_online" field. + public const int IsOnlineFieldNumber = 2; + private bool isOnline_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool IsOnline { + get { return isOnline_; } + set { + isOnline_ = value; + } + } + + /// Field number for the "role" field. + public const int RoleFieldNumber = 3; + private string role_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Role { + get { return role_; } + set { + role_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as NodeStatusDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(NodeStatusDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (Hostname != other.Hostname) return false; + if (IsOnline != other.IsOnline) return false; + if (Role != other.Role) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (Hostname.Length != 0) hash ^= Hostname.GetHashCode(); + if (IsOnline != false) hash ^= IsOnline.GetHashCode(); + if (Role.Length != 0) hash ^= Role.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (Hostname.Length != 0) { + output.WriteRawTag(10); + output.WriteString(Hostname); + } + if (IsOnline != false) { + output.WriteRawTag(16); + output.WriteBool(IsOnline); + } + if (Role.Length != 0) { + output.WriteRawTag(26); + output.WriteString(Role); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (Hostname.Length != 0) { + output.WriteRawTag(10); + output.WriteString(Hostname); + } + if (IsOnline != false) { + output.WriteRawTag(16); + output.WriteBool(IsOnline); + } + if (Role.Length != 0) { + output.WriteRawTag(26); + output.WriteString(Role); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (Hostname.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Hostname); + } + if (IsOnline != false) { + size += 1 + 1; + } + if (Role.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Role); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(NodeStatusDto other) { + if (other == null) { + return; + } + if (other.Hostname.Length != 0) { + Hostname = other.Hostname; + } + if (other.IsOnline != false) { + IsOnline = other.IsOnline; + } + if (other.Role.Length != 0) { + Role = other.Role; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + Hostname = input.ReadString(); + break; + } + case 16: { + IsOnline = input.ReadBool(); + break; + } + case 26: { + Role = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + Hostname = input.ReadString(); + break; + } + case 16: { + IsOnline = input.ReadBool(); + break; + } + case 26: { + Role = input.ReadString(); + break; + } + } + } + } + #endif + + } + + /// + /// Point-in-time snapshot of the site-local SQLite audit queue. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class SiteAuditBacklogSnapshotDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new SiteAuditBacklogSnapshotDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[10]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public SiteAuditBacklogSnapshotDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public SiteAuditBacklogSnapshotDto(SiteAuditBacklogSnapshotDto other) : this() { + pendingCount_ = other.pendingCount_; + oldestPendingUtc_ = other.oldestPendingUtc_ != null ? other.oldestPendingUtc_.Clone() : null; + onDiskBytes_ = other.onDiskBytes_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public SiteAuditBacklogSnapshotDto Clone() { + return new SiteAuditBacklogSnapshotDto(this); + } + + /// Field number for the "pending_count" field. + public const int PendingCountFieldNumber = 1; + private int pendingCount_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int PendingCount { + get { return pendingCount_; } + set { + pendingCount_ = value; + } + } + + /// Field number for the "oldest_pending_utc" field. + public const int OldestPendingUtcFieldNumber = 2; + private global::Google.Protobuf.WellKnownTypes.Timestamp oldestPendingUtc_; + /// + /// absent when the queue is empty + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::Google.Protobuf.WellKnownTypes.Timestamp OldestPendingUtc { + get { return oldestPendingUtc_; } + set { + oldestPendingUtc_ = value; + } + } + + /// Field number for the "on_disk_bytes" field. + public const int OnDiskBytesFieldNumber = 3; + private long onDiskBytes_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public long OnDiskBytes { + get { return onDiskBytes_; } + set { + onDiskBytes_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as SiteAuditBacklogSnapshotDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(SiteAuditBacklogSnapshotDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (PendingCount != other.PendingCount) return false; + if (!object.Equals(OldestPendingUtc, other.OldestPendingUtc)) return false; + if (OnDiskBytes != other.OnDiskBytes) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (PendingCount != 0) hash ^= PendingCount.GetHashCode(); + if (oldestPendingUtc_ != null) hash ^= OldestPendingUtc.GetHashCode(); + if (OnDiskBytes != 0L) hash ^= OnDiskBytes.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (PendingCount != 0) { + output.WriteRawTag(8); + output.WriteInt32(PendingCount); + } + if (oldestPendingUtc_ != null) { + output.WriteRawTag(18); + output.WriteMessage(OldestPendingUtc); + } + if (OnDiskBytes != 0L) { + output.WriteRawTag(24); + output.WriteInt64(OnDiskBytes); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (PendingCount != 0) { + output.WriteRawTag(8); + output.WriteInt32(PendingCount); + } + if (oldestPendingUtc_ != null) { + output.WriteRawTag(18); + output.WriteMessage(OldestPendingUtc); + } + if (OnDiskBytes != 0L) { + output.WriteRawTag(24); + output.WriteInt64(OnDiskBytes); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (PendingCount != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(PendingCount); + } + if (oldestPendingUtc_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(OldestPendingUtc); + } + if (OnDiskBytes != 0L) { + size += 1 + pb::CodedOutputStream.ComputeInt64Size(OnDiskBytes); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(SiteAuditBacklogSnapshotDto other) { + if (other == null) { + return; + } + if (other.PendingCount != 0) { + PendingCount = other.PendingCount; + } + if (other.oldestPendingUtc_ != null) { + if (oldestPendingUtc_ == null) { + OldestPendingUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + OldestPendingUtc.MergeFrom(other.OldestPendingUtc); + } + if (other.OnDiskBytes != 0L) { + OnDiskBytes = other.OnDiskBytes; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + PendingCount = input.ReadInt32(); + break; + } + case 18: { + if (oldestPendingUtc_ == null) { + OldestPendingUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(OldestPendingUtc); + break; + } + case 24: { + OnDiskBytes = input.ReadInt64(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + PendingCount = input.ReadInt32(); + break; + } + case 18: { + if (oldestPendingUtc_ == null) { + OldestPendingUtc = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(OldestPendingUtc); + break; + } + case 24: { + OnDiskBytes = input.ReadInt64(); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class ConnectionEndpointMapDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new ConnectionEndpointMapDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[11]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConnectionEndpointMapDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConnectionEndpointMapDto(ConnectionEndpointMapDto other) : this() { + entries_ = other.entries_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ConnectionEndpointMapDto Clone() { + return new ConnectionEndpointMapDto(this); + } + + /// Field number for the "entries" field. + public const int EntriesFieldNumber = 1; + private static readonly pbc::MapField.Codec _map_entries_codec + = new pbc::MapField.Codec(pb::FieldCodec.ForString(10, ""), pb::FieldCodec.ForString(18, ""), 10); + private readonly pbc::MapField entries_ = new pbc::MapField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::MapField Entries { + get { return entries_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as ConnectionEndpointMapDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(ConnectionEndpointMapDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (!Entries.Equals(other.Entries)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + hash ^= Entries.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + entries_.WriteTo(output, _map_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + entries_.WriteTo(ref output, _map_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + size += entries_.CalculateSize(_map_entries_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(ConnectionEndpointMapDto other) { + if (other == null) { + return; + } + entries_.MergeFrom(other.entries_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + entries_.AddEntriesFrom(input, _map_entries_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + entries_.AddEntriesFrom(ref input, _map_entries_codec); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class TagQualityMapDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new TagQualityMapDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[12]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TagQualityMapDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TagQualityMapDto(TagQualityMapDto other) : this() { + entries_ = other.entries_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TagQualityMapDto Clone() { + return new TagQualityMapDto(this); + } + + /// Field number for the "entries" field. + public const int EntriesFieldNumber = 1; + private static readonly pbc::MapField.Codec _map_entries_codec + = new pbc::MapField.Codec(pb::FieldCodec.ForString(10, ""), pb::FieldCodec.ForMessage(18, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityCountsDto.Parser), 10); + private readonly pbc::MapField entries_ = new pbc::MapField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::MapField Entries { + get { return entries_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as TagQualityMapDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(TagQualityMapDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (!Entries.Equals(other.Entries)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + hash ^= Entries.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + entries_.WriteTo(output, _map_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + entries_.WriteTo(ref output, _map_entries_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + size += entries_.CalculateSize(_map_entries_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(TagQualityMapDto other) { + if (other == null) { + return; + } + entries_.MergeFrom(other.entries_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + entries_.AddEntriesFrom(input, _map_entries_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + entries_.AddEntriesFrom(ref input, _map_entries_codec); + break; + } + } + } + } + #endif + + } + + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class NodeStatusListDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new NodeStatusListDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[13]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NodeStatusListDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NodeStatusListDto(NodeStatusListDto other) : this() { + nodes_ = other.nodes_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public NodeStatusListDto Clone() { + return new NodeStatusListDto(this); + } + + /// Field number for the "nodes" field. + public const int NodesFieldNumber = 1; + private static readonly pb::FieldCodec _repeated_nodes_codec + = pb::FieldCodec.ForMessage(10, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusDto.Parser); + private readonly pbc::RepeatedField nodes_ = new pbc::RepeatedField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Nodes { + get { return nodes_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as NodeStatusListDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(NodeStatusListDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if(!nodes_.Equals(other.nodes_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + hash ^= nodes_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + nodes_.WriteTo(output, _repeated_nodes_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + nodes_.WriteTo(ref output, _repeated_nodes_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + size += nodes_.CalculateSize(_repeated_nodes_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(NodeStatusListDto other) { + if (other == null) { + return; + } + nodes_.Add(other.nodes_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + nodes_.AddEntriesFrom(input, _repeated_nodes_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + nodes_.AddEntriesFrom(ref input, _repeated_nodes_codec); + break; + } + } + } + } + #endif + + } + + /// + /// Site -> Central: periodic site health report. Mirrors Commons SiteHealthReport. + /// Additive-only evolution: field numbers are never reused. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class SiteHealthReportDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new SiteHealthReportDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[14]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public SiteHealthReportDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public SiteHealthReportDto(SiteHealthReportDto other) : this() { + siteId_ = other.siteId_; + sequenceNumber_ = other.sequenceNumber_; + reportTimestamp_ = other.reportTimestamp_ != null ? other.reportTimestamp_.Clone() : null; + dataConnectionStatuses_ = other.dataConnectionStatuses_.Clone(); + tagResolutionCounts_ = other.tagResolutionCounts_.Clone(); + scriptErrorCount_ = other.scriptErrorCount_; + alarmEvaluationErrorCount_ = other.alarmEvaluationErrorCount_; + storeAndForwardBufferDepths_ = other.storeAndForwardBufferDepths_.Clone(); + deadLetterCount_ = other.deadLetterCount_; + deployedInstanceCount_ = other.deployedInstanceCount_; + enabledInstanceCount_ = other.enabledInstanceCount_; + disabledInstanceCount_ = other.disabledInstanceCount_; + nodeRole_ = other.nodeRole_; + nodeHostname_ = other.nodeHostname_; + dataConnectionEndpoints_ = other.dataConnectionEndpoints_ != null ? other.dataConnectionEndpoints_.Clone() : null; + dataConnectionTagQuality_ = other.dataConnectionTagQuality_ != null ? other.dataConnectionTagQuality_.Clone() : null; + parkedMessageCount_ = other.parkedMessageCount_; + clusterNodes_ = other.clusterNodes_ != null ? other.clusterNodes_.Clone() : null; + siteAuditWriteFailures_ = other.siteAuditWriteFailures_; + auditRedactionFailure_ = other.auditRedactionFailure_; + siteAuditBacklog_ = other.siteAuditBacklog_ != null ? other.siteAuditBacklog_.Clone() : null; + siteEventLogWriteFailures_ = other.siteEventLogWriteFailures_; + OldestParkedMessageAgeSeconds = other.OldestParkedMessageAgeSeconds; + scriptQueueDepth_ = other.scriptQueueDepth_; + scriptBusyThreads_ = other.scriptBusyThreads_; + ScriptOldestBusyAgeSeconds = other.ScriptOldestBusyAgeSeconds; + LocalDbReplicationConnected = other.LocalDbReplicationConnected; + LocalDbOplogBacklog = other.LocalDbOplogBacklog; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public SiteHealthReportDto Clone() { + return new SiteHealthReportDto(this); + } + + /// Field number for the "site_id" field. + public const int SiteIdFieldNumber = 1; + private string siteId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SiteId { + get { return siteId_; } + set { + siteId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "sequence_number" field. + public const int SequenceNumberFieldNumber = 2; + private long sequenceNumber_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public long SequenceNumber { + get { return sequenceNumber_; } + set { + sequenceNumber_ = value; + } + } + + /// Field number for the "report_timestamp" field. + public const int ReportTimestampFieldNumber = 3; + private global::Google.Protobuf.WellKnownTypes.Timestamp reportTimestamp_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::Google.Protobuf.WellKnownTypes.Timestamp ReportTimestamp { + get { return reportTimestamp_; } + set { + reportTimestamp_ = value; + } + } + + /// Field number for the "data_connection_statuses" field. + public const int DataConnectionStatusesFieldNumber = 4; + private static readonly pbc::MapField.Codec _map_dataConnectionStatuses_codec + = new pbc::MapField.Codec(pb::FieldCodec.ForString(10, ""), pb::FieldCodec.ForEnum(16, x => (int) x, x => (global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionHealthEnum) x, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionHealthEnum.ConnectionHealthUnspecified), 34); + private readonly pbc::MapField dataConnectionStatuses_ = new pbc::MapField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::MapField DataConnectionStatuses { + get { return dataConnectionStatuses_; } + } + + /// Field number for the "tag_resolution_counts" field. + public const int TagResolutionCountsFieldNumber = 5; + private static readonly pbc::MapField.Codec _map_tagResolutionCounts_codec + = new pbc::MapField.Codec(pb::FieldCodec.ForString(10, ""), pb::FieldCodec.ForMessage(18, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagResolutionStatusDto.Parser), 42); + private readonly pbc::MapField tagResolutionCounts_ = new pbc::MapField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::MapField TagResolutionCounts { + get { return tagResolutionCounts_; } + } + + /// Field number for the "script_error_count" field. + public const int ScriptErrorCountFieldNumber = 6; + private int scriptErrorCount_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ScriptErrorCount { + get { return scriptErrorCount_; } + set { + scriptErrorCount_ = value; + } + } + + /// Field number for the "alarm_evaluation_error_count" field. + public const int AlarmEvaluationErrorCountFieldNumber = 7; + private int alarmEvaluationErrorCount_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int AlarmEvaluationErrorCount { + get { return alarmEvaluationErrorCount_; } + set { + alarmEvaluationErrorCount_ = value; + } + } + + /// Field number for the "store_and_forward_buffer_depths" field. + public const int StoreAndForwardBufferDepthsFieldNumber = 8; + private static readonly pbc::MapField.Codec _map_storeAndForwardBufferDepths_codec + = new pbc::MapField.Codec(pb::FieldCodec.ForString(10, ""), pb::FieldCodec.ForInt32(16, 0), 66); + private readonly pbc::MapField storeAndForwardBufferDepths_ = new pbc::MapField(); + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::MapField StoreAndForwardBufferDepths { + get { return storeAndForwardBufferDepths_; } + } + + /// Field number for the "dead_letter_count" field. + public const int DeadLetterCountFieldNumber = 9; + private int deadLetterCount_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int DeadLetterCount { + get { return deadLetterCount_; } + set { + deadLetterCount_ = value; + } + } + + /// Field number for the "deployed_instance_count" field. + public const int DeployedInstanceCountFieldNumber = 10; + private int deployedInstanceCount_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int DeployedInstanceCount { + get { return deployedInstanceCount_; } + set { + deployedInstanceCount_ = value; + } + } + + /// Field number for the "enabled_instance_count" field. + public const int EnabledInstanceCountFieldNumber = 11; + private int enabledInstanceCount_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int EnabledInstanceCount { + get { return enabledInstanceCount_; } + set { + enabledInstanceCount_ = value; + } + } + + /// Field number for the "disabled_instance_count" field. + public const int DisabledInstanceCountFieldNumber = 12; + private int disabledInstanceCount_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int DisabledInstanceCount { + get { return disabledInstanceCount_; } + set { + disabledInstanceCount_ = value; + } + } + + /// Field number for the "node_role" field. + public const int NodeRoleFieldNumber = 13; + private string nodeRole_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string NodeRole { + get { return nodeRole_; } + set { + nodeRole_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "node_hostname" field. + public const int NodeHostnameFieldNumber = 14; + private string nodeHostname_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string NodeHostname { + get { return nodeHostname_; } + set { + nodeHostname_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "data_connection_endpoints" field. + public const int DataConnectionEndpointsFieldNumber = 15; + private global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto dataConnectionEndpoints_; + /// + /// absent when null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto DataConnectionEndpoints { + get { return dataConnectionEndpoints_; } + set { + dataConnectionEndpoints_ = value; + } + } + + /// Field number for the "data_connection_tag_quality" field. + public const int DataConnectionTagQualityFieldNumber = 16; + private global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto dataConnectionTagQuality_; + /// + /// absent when null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto DataConnectionTagQuality { + get { return dataConnectionTagQuality_; } + set { + dataConnectionTagQuality_ = value; + } + } + + /// Field number for the "parked_message_count" field. + public const int ParkedMessageCountFieldNumber = 17; + private int parkedMessageCount_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ParkedMessageCount { + get { return parkedMessageCount_; } + set { + parkedMessageCount_ = value; + } + } + + /// Field number for the "cluster_nodes" field. + public const int ClusterNodesFieldNumber = 18; + private global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto clusterNodes_; + /// + /// absent when null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto ClusterNodes { + get { return clusterNodes_; } + set { + clusterNodes_ = value; + } + } + + /// Field number for the "site_audit_write_failures" field. + public const int SiteAuditWriteFailuresFieldNumber = 19; + private int siteAuditWriteFailures_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int SiteAuditWriteFailures { + get { return siteAuditWriteFailures_; } + set { + siteAuditWriteFailures_ = value; + } + } + + /// Field number for the "audit_redaction_failure" field. + public const int AuditRedactionFailureFieldNumber = 20; + private int auditRedactionFailure_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int AuditRedactionFailure { + get { return auditRedactionFailure_; } + set { + auditRedactionFailure_ = value; + } + } + + /// Field number for the "site_audit_backlog" field. + public const int SiteAuditBacklogFieldNumber = 21; + private global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto siteAuditBacklog_; + /// + /// absent when no data yet + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto SiteAuditBacklog { + get { return siteAuditBacklog_; } + set { + siteAuditBacklog_ = value; + } + } + + /// Field number for the "site_event_log_write_failures" field. + public const int SiteEventLogWriteFailuresFieldNumber = 22; + private long siteEventLogWriteFailures_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public long SiteEventLogWriteFailures { + get { return siteEventLogWriteFailures_; } + set { + siteEventLogWriteFailures_ = value; + } + } + + /// Field number for the "oldest_parked_message_age_seconds" field. + public const int OldestParkedMessageAgeSecondsFieldNumber = 23; + private static readonly pb::FieldCodec _single_oldestParkedMessageAgeSeconds_codec = pb::FieldCodec.ForStructWrapper(186); + private double? oldestParkedMessageAgeSeconds_; + /// + /// absent when nothing parked + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public double? OldestParkedMessageAgeSeconds { + get { return oldestParkedMessageAgeSeconds_; } + set { + oldestParkedMessageAgeSeconds_ = value; + } + } + + + /// Field number for the "script_queue_depth" field. + public const int ScriptQueueDepthFieldNumber = 24; + private int scriptQueueDepth_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ScriptQueueDepth { + get { return scriptQueueDepth_; } + set { + scriptQueueDepth_ = value; + } + } + + /// Field number for the "script_busy_threads" field. + public const int ScriptBusyThreadsFieldNumber = 25; + private int scriptBusyThreads_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int ScriptBusyThreads { + get { return scriptBusyThreads_; } + set { + scriptBusyThreads_ = value; + } + } + + /// Field number for the "script_oldest_busy_age_seconds" field. + public const int ScriptOldestBusyAgeSecondsFieldNumber = 26; + private static readonly pb::FieldCodec _single_scriptOldestBusyAgeSeconds_codec = pb::FieldCodec.ForStructWrapper(210); + private double? scriptOldestBusyAgeSeconds_; + /// + /// absent when the pool is idle + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public double? ScriptOldestBusyAgeSeconds { + get { return scriptOldestBusyAgeSeconds_; } + set { + scriptOldestBusyAgeSeconds_ = value; + } + } + + + /// Field number for the "local_db_replication_connected" field. + public const int LocalDbReplicationConnectedFieldNumber = 27; + private static readonly pb::FieldCodec _single_localDbReplicationConnected_codec = pb::FieldCodec.ForStructWrapper(218); + private bool? localDbReplicationConnected_; + /// + /// Nullable on purpose: absent means "replication not wired on this node", + /// which is NOT the same as false ("wired but currently disconnected"). + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool? LocalDbReplicationConnected { + get { return localDbReplicationConnected_; } + set { + localDbReplicationConnected_ = value; + } + } + + + /// Field number for the "local_db_oplog_backlog" field. + public const int LocalDbOplogBacklogFieldNumber = 28; + private static readonly pb::FieldCodec _single_localDbOplogBacklog_codec = pb::FieldCodec.ForStructWrapper(226); + private long? localDbOplogBacklog_; + /// + /// Absent means UNKNOWN, never zero — a failed backlog read rendered as 0 + /// would report a broken replication pair as perfectly healthy. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public long? LocalDbOplogBacklog { + get { return localDbOplogBacklog_; } + set { + localDbOplogBacklog_ = value; + } + } + + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as SiteHealthReportDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(SiteHealthReportDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (SiteId != other.SiteId) return false; + if (SequenceNumber != other.SequenceNumber) return false; + if (!object.Equals(ReportTimestamp, other.ReportTimestamp)) return false; + if (!DataConnectionStatuses.Equals(other.DataConnectionStatuses)) return false; + if (!TagResolutionCounts.Equals(other.TagResolutionCounts)) return false; + if (ScriptErrorCount != other.ScriptErrorCount) return false; + if (AlarmEvaluationErrorCount != other.AlarmEvaluationErrorCount) return false; + if (!StoreAndForwardBufferDepths.Equals(other.StoreAndForwardBufferDepths)) return false; + if (DeadLetterCount != other.DeadLetterCount) return false; + if (DeployedInstanceCount != other.DeployedInstanceCount) return false; + if (EnabledInstanceCount != other.EnabledInstanceCount) return false; + if (DisabledInstanceCount != other.DisabledInstanceCount) return false; + if (NodeRole != other.NodeRole) return false; + if (NodeHostname != other.NodeHostname) return false; + if (!object.Equals(DataConnectionEndpoints, other.DataConnectionEndpoints)) return false; + if (!object.Equals(DataConnectionTagQuality, other.DataConnectionTagQuality)) return false; + if (ParkedMessageCount != other.ParkedMessageCount) return false; + if (!object.Equals(ClusterNodes, other.ClusterNodes)) return false; + if (SiteAuditWriteFailures != other.SiteAuditWriteFailures) return false; + if (AuditRedactionFailure != other.AuditRedactionFailure) return false; + if (!object.Equals(SiteAuditBacklog, other.SiteAuditBacklog)) return false; + if (SiteEventLogWriteFailures != other.SiteEventLogWriteFailures) return false; + if (!pbc::ProtobufEqualityComparers.BitwiseNullableDoubleEqualityComparer.Equals(OldestParkedMessageAgeSeconds, other.OldestParkedMessageAgeSeconds)) return false; + if (ScriptQueueDepth != other.ScriptQueueDepth) return false; + if (ScriptBusyThreads != other.ScriptBusyThreads) return false; + if (!pbc::ProtobufEqualityComparers.BitwiseNullableDoubleEqualityComparer.Equals(ScriptOldestBusyAgeSeconds, other.ScriptOldestBusyAgeSeconds)) return false; + if (LocalDbReplicationConnected != other.LocalDbReplicationConnected) return false; + if (LocalDbOplogBacklog != other.LocalDbOplogBacklog) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (SiteId.Length != 0) hash ^= SiteId.GetHashCode(); + if (SequenceNumber != 0L) hash ^= SequenceNumber.GetHashCode(); + if (reportTimestamp_ != null) hash ^= ReportTimestamp.GetHashCode(); + hash ^= DataConnectionStatuses.GetHashCode(); + hash ^= TagResolutionCounts.GetHashCode(); + if (ScriptErrorCount != 0) hash ^= ScriptErrorCount.GetHashCode(); + if (AlarmEvaluationErrorCount != 0) hash ^= AlarmEvaluationErrorCount.GetHashCode(); + hash ^= StoreAndForwardBufferDepths.GetHashCode(); + if (DeadLetterCount != 0) hash ^= DeadLetterCount.GetHashCode(); + if (DeployedInstanceCount != 0) hash ^= DeployedInstanceCount.GetHashCode(); + if (EnabledInstanceCount != 0) hash ^= EnabledInstanceCount.GetHashCode(); + if (DisabledInstanceCount != 0) hash ^= DisabledInstanceCount.GetHashCode(); + if (NodeRole.Length != 0) hash ^= NodeRole.GetHashCode(); + if (NodeHostname.Length != 0) hash ^= NodeHostname.GetHashCode(); + if (dataConnectionEndpoints_ != null) hash ^= DataConnectionEndpoints.GetHashCode(); + if (dataConnectionTagQuality_ != null) hash ^= DataConnectionTagQuality.GetHashCode(); + if (ParkedMessageCount != 0) hash ^= ParkedMessageCount.GetHashCode(); + if (clusterNodes_ != null) hash ^= ClusterNodes.GetHashCode(); + if (SiteAuditWriteFailures != 0) hash ^= SiteAuditWriteFailures.GetHashCode(); + if (AuditRedactionFailure != 0) hash ^= AuditRedactionFailure.GetHashCode(); + if (siteAuditBacklog_ != null) hash ^= SiteAuditBacklog.GetHashCode(); + if (SiteEventLogWriteFailures != 0L) hash ^= SiteEventLogWriteFailures.GetHashCode(); + if (oldestParkedMessageAgeSeconds_ != null) hash ^= pbc::ProtobufEqualityComparers.BitwiseNullableDoubleEqualityComparer.GetHashCode(OldestParkedMessageAgeSeconds); + if (ScriptQueueDepth != 0) hash ^= ScriptQueueDepth.GetHashCode(); + if (ScriptBusyThreads != 0) hash ^= ScriptBusyThreads.GetHashCode(); + if (scriptOldestBusyAgeSeconds_ != null) hash ^= pbc::ProtobufEqualityComparers.BitwiseNullableDoubleEqualityComparer.GetHashCode(ScriptOldestBusyAgeSeconds); + if (localDbReplicationConnected_ != null) hash ^= LocalDbReplicationConnected.GetHashCode(); + if (localDbOplogBacklog_ != null) hash ^= LocalDbOplogBacklog.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (SiteId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(SiteId); + } + if (SequenceNumber != 0L) { + output.WriteRawTag(16); + output.WriteInt64(SequenceNumber); + } + if (reportTimestamp_ != null) { + output.WriteRawTag(26); + output.WriteMessage(ReportTimestamp); + } + dataConnectionStatuses_.WriteTo(output, _map_dataConnectionStatuses_codec); + tagResolutionCounts_.WriteTo(output, _map_tagResolutionCounts_codec); + if (ScriptErrorCount != 0) { + output.WriteRawTag(48); + output.WriteInt32(ScriptErrorCount); + } + if (AlarmEvaluationErrorCount != 0) { + output.WriteRawTag(56); + output.WriteInt32(AlarmEvaluationErrorCount); + } + storeAndForwardBufferDepths_.WriteTo(output, _map_storeAndForwardBufferDepths_codec); + if (DeadLetterCount != 0) { + output.WriteRawTag(72); + output.WriteInt32(DeadLetterCount); + } + if (DeployedInstanceCount != 0) { + output.WriteRawTag(80); + output.WriteInt32(DeployedInstanceCount); + } + if (EnabledInstanceCount != 0) { + output.WriteRawTag(88); + output.WriteInt32(EnabledInstanceCount); + } + if (DisabledInstanceCount != 0) { + output.WriteRawTag(96); + output.WriteInt32(DisabledInstanceCount); + } + if (NodeRole.Length != 0) { + output.WriteRawTag(106); + output.WriteString(NodeRole); + } + if (NodeHostname.Length != 0) { + output.WriteRawTag(114); + output.WriteString(NodeHostname); + } + if (dataConnectionEndpoints_ != null) { + output.WriteRawTag(122); + output.WriteMessage(DataConnectionEndpoints); + } + if (dataConnectionTagQuality_ != null) { + output.WriteRawTag(130, 1); + output.WriteMessage(DataConnectionTagQuality); + } + if (ParkedMessageCount != 0) { + output.WriteRawTag(136, 1); + output.WriteInt32(ParkedMessageCount); + } + if (clusterNodes_ != null) { + output.WriteRawTag(146, 1); + output.WriteMessage(ClusterNodes); + } + if (SiteAuditWriteFailures != 0) { + output.WriteRawTag(152, 1); + output.WriteInt32(SiteAuditWriteFailures); + } + if (AuditRedactionFailure != 0) { + output.WriteRawTag(160, 1); + output.WriteInt32(AuditRedactionFailure); + } + if (siteAuditBacklog_ != null) { + output.WriteRawTag(170, 1); + output.WriteMessage(SiteAuditBacklog); + } + if (SiteEventLogWriteFailures != 0L) { + output.WriteRawTag(176, 1); + output.WriteInt64(SiteEventLogWriteFailures); + } + if (oldestParkedMessageAgeSeconds_ != null) { + _single_oldestParkedMessageAgeSeconds_codec.WriteTagAndValue(output, OldestParkedMessageAgeSeconds); + } + if (ScriptQueueDepth != 0) { + output.WriteRawTag(192, 1); + output.WriteInt32(ScriptQueueDepth); + } + if (ScriptBusyThreads != 0) { + output.WriteRawTag(200, 1); + output.WriteInt32(ScriptBusyThreads); + } + if (scriptOldestBusyAgeSeconds_ != null) { + _single_scriptOldestBusyAgeSeconds_codec.WriteTagAndValue(output, ScriptOldestBusyAgeSeconds); + } + if (localDbReplicationConnected_ != null) { + _single_localDbReplicationConnected_codec.WriteTagAndValue(output, LocalDbReplicationConnected); + } + if (localDbOplogBacklog_ != null) { + _single_localDbOplogBacklog_codec.WriteTagAndValue(output, LocalDbOplogBacklog); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (SiteId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(SiteId); + } + if (SequenceNumber != 0L) { + output.WriteRawTag(16); + output.WriteInt64(SequenceNumber); + } + if (reportTimestamp_ != null) { + output.WriteRawTag(26); + output.WriteMessage(ReportTimestamp); + } + dataConnectionStatuses_.WriteTo(ref output, _map_dataConnectionStatuses_codec); + tagResolutionCounts_.WriteTo(ref output, _map_tagResolutionCounts_codec); + if (ScriptErrorCount != 0) { + output.WriteRawTag(48); + output.WriteInt32(ScriptErrorCount); + } + if (AlarmEvaluationErrorCount != 0) { + output.WriteRawTag(56); + output.WriteInt32(AlarmEvaluationErrorCount); + } + storeAndForwardBufferDepths_.WriteTo(ref output, _map_storeAndForwardBufferDepths_codec); + if (DeadLetterCount != 0) { + output.WriteRawTag(72); + output.WriteInt32(DeadLetterCount); + } + if (DeployedInstanceCount != 0) { + output.WriteRawTag(80); + output.WriteInt32(DeployedInstanceCount); + } + if (EnabledInstanceCount != 0) { + output.WriteRawTag(88); + output.WriteInt32(EnabledInstanceCount); + } + if (DisabledInstanceCount != 0) { + output.WriteRawTag(96); + output.WriteInt32(DisabledInstanceCount); + } + if (NodeRole.Length != 0) { + output.WriteRawTag(106); + output.WriteString(NodeRole); + } + if (NodeHostname.Length != 0) { + output.WriteRawTag(114); + output.WriteString(NodeHostname); + } + if (dataConnectionEndpoints_ != null) { + output.WriteRawTag(122); + output.WriteMessage(DataConnectionEndpoints); + } + if (dataConnectionTagQuality_ != null) { + output.WriteRawTag(130, 1); + output.WriteMessage(DataConnectionTagQuality); + } + if (ParkedMessageCount != 0) { + output.WriteRawTag(136, 1); + output.WriteInt32(ParkedMessageCount); + } + if (clusterNodes_ != null) { + output.WriteRawTag(146, 1); + output.WriteMessage(ClusterNodes); + } + if (SiteAuditWriteFailures != 0) { + output.WriteRawTag(152, 1); + output.WriteInt32(SiteAuditWriteFailures); + } + if (AuditRedactionFailure != 0) { + output.WriteRawTag(160, 1); + output.WriteInt32(AuditRedactionFailure); + } + if (siteAuditBacklog_ != null) { + output.WriteRawTag(170, 1); + output.WriteMessage(SiteAuditBacklog); + } + if (SiteEventLogWriteFailures != 0L) { + output.WriteRawTag(176, 1); + output.WriteInt64(SiteEventLogWriteFailures); + } + if (oldestParkedMessageAgeSeconds_ != null) { + _single_oldestParkedMessageAgeSeconds_codec.WriteTagAndValue(ref output, OldestParkedMessageAgeSeconds); + } + if (ScriptQueueDepth != 0) { + output.WriteRawTag(192, 1); + output.WriteInt32(ScriptQueueDepth); + } + if (ScriptBusyThreads != 0) { + output.WriteRawTag(200, 1); + output.WriteInt32(ScriptBusyThreads); + } + if (scriptOldestBusyAgeSeconds_ != null) { + _single_scriptOldestBusyAgeSeconds_codec.WriteTagAndValue(ref output, ScriptOldestBusyAgeSeconds); + } + if (localDbReplicationConnected_ != null) { + _single_localDbReplicationConnected_codec.WriteTagAndValue(ref output, LocalDbReplicationConnected); + } + if (localDbOplogBacklog_ != null) { + _single_localDbOplogBacklog_codec.WriteTagAndValue(ref output, LocalDbOplogBacklog); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (SiteId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SiteId); + } + if (SequenceNumber != 0L) { + size += 1 + pb::CodedOutputStream.ComputeInt64Size(SequenceNumber); + } + if (reportTimestamp_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(ReportTimestamp); + } + size += dataConnectionStatuses_.CalculateSize(_map_dataConnectionStatuses_codec); + size += tagResolutionCounts_.CalculateSize(_map_tagResolutionCounts_codec); + if (ScriptErrorCount != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(ScriptErrorCount); + } + if (AlarmEvaluationErrorCount != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(AlarmEvaluationErrorCount); + } + size += storeAndForwardBufferDepths_.CalculateSize(_map_storeAndForwardBufferDepths_codec); + if (DeadLetterCount != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(DeadLetterCount); + } + if (DeployedInstanceCount != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(DeployedInstanceCount); + } + if (EnabledInstanceCount != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(EnabledInstanceCount); + } + if (DisabledInstanceCount != 0) { + size += 1 + pb::CodedOutputStream.ComputeInt32Size(DisabledInstanceCount); + } + if (NodeRole.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(NodeRole); + } + if (NodeHostname.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(NodeHostname); + } + if (dataConnectionEndpoints_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(DataConnectionEndpoints); + } + if (dataConnectionTagQuality_ != null) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(DataConnectionTagQuality); + } + if (ParkedMessageCount != 0) { + size += 2 + pb::CodedOutputStream.ComputeInt32Size(ParkedMessageCount); + } + if (clusterNodes_ != null) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(ClusterNodes); + } + if (SiteAuditWriteFailures != 0) { + size += 2 + pb::CodedOutputStream.ComputeInt32Size(SiteAuditWriteFailures); + } + if (AuditRedactionFailure != 0) { + size += 2 + pb::CodedOutputStream.ComputeInt32Size(AuditRedactionFailure); + } + if (siteAuditBacklog_ != null) { + size += 2 + pb::CodedOutputStream.ComputeMessageSize(SiteAuditBacklog); + } + if (SiteEventLogWriteFailures != 0L) { + size += 2 + pb::CodedOutputStream.ComputeInt64Size(SiteEventLogWriteFailures); + } + if (oldestParkedMessageAgeSeconds_ != null) { + size += _single_oldestParkedMessageAgeSeconds_codec.CalculateSizeWithTag(OldestParkedMessageAgeSeconds); + } + if (ScriptQueueDepth != 0) { + size += 2 + pb::CodedOutputStream.ComputeInt32Size(ScriptQueueDepth); + } + if (ScriptBusyThreads != 0) { + size += 2 + pb::CodedOutputStream.ComputeInt32Size(ScriptBusyThreads); + } + if (scriptOldestBusyAgeSeconds_ != null) { + size += _single_scriptOldestBusyAgeSeconds_codec.CalculateSizeWithTag(ScriptOldestBusyAgeSeconds); + } + if (localDbReplicationConnected_ != null) { + size += _single_localDbReplicationConnected_codec.CalculateSizeWithTag(LocalDbReplicationConnected); + } + if (localDbOplogBacklog_ != null) { + size += _single_localDbOplogBacklog_codec.CalculateSizeWithTag(LocalDbOplogBacklog); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(SiteHealthReportDto other) { + if (other == null) { + return; + } + if (other.SiteId.Length != 0) { + SiteId = other.SiteId; + } + if (other.SequenceNumber != 0L) { + SequenceNumber = other.SequenceNumber; + } + if (other.reportTimestamp_ != null) { + if (reportTimestamp_ == null) { + ReportTimestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + ReportTimestamp.MergeFrom(other.ReportTimestamp); + } + dataConnectionStatuses_.MergeFrom(other.dataConnectionStatuses_); + tagResolutionCounts_.MergeFrom(other.tagResolutionCounts_); + if (other.ScriptErrorCount != 0) { + ScriptErrorCount = other.ScriptErrorCount; + } + if (other.AlarmEvaluationErrorCount != 0) { + AlarmEvaluationErrorCount = other.AlarmEvaluationErrorCount; + } + storeAndForwardBufferDepths_.MergeFrom(other.storeAndForwardBufferDepths_); + if (other.DeadLetterCount != 0) { + DeadLetterCount = other.DeadLetterCount; + } + if (other.DeployedInstanceCount != 0) { + DeployedInstanceCount = other.DeployedInstanceCount; + } + if (other.EnabledInstanceCount != 0) { + EnabledInstanceCount = other.EnabledInstanceCount; + } + if (other.DisabledInstanceCount != 0) { + DisabledInstanceCount = other.DisabledInstanceCount; + } + if (other.NodeRole.Length != 0) { + NodeRole = other.NodeRole; + } + if (other.NodeHostname.Length != 0) { + NodeHostname = other.NodeHostname; + } + if (other.dataConnectionEndpoints_ != null) { + if (dataConnectionEndpoints_ == null) { + DataConnectionEndpoints = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto(); + } + DataConnectionEndpoints.MergeFrom(other.DataConnectionEndpoints); + } + if (other.dataConnectionTagQuality_ != null) { + if (dataConnectionTagQuality_ == null) { + DataConnectionTagQuality = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto(); + } + DataConnectionTagQuality.MergeFrom(other.DataConnectionTagQuality); + } + if (other.ParkedMessageCount != 0) { + ParkedMessageCount = other.ParkedMessageCount; + } + if (other.clusterNodes_ != null) { + if (clusterNodes_ == null) { + ClusterNodes = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto(); + } + ClusterNodes.MergeFrom(other.ClusterNodes); + } + if (other.SiteAuditWriteFailures != 0) { + SiteAuditWriteFailures = other.SiteAuditWriteFailures; + } + if (other.AuditRedactionFailure != 0) { + AuditRedactionFailure = other.AuditRedactionFailure; + } + if (other.siteAuditBacklog_ != null) { + if (siteAuditBacklog_ == null) { + SiteAuditBacklog = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto(); + } + SiteAuditBacklog.MergeFrom(other.SiteAuditBacklog); + } + if (other.SiteEventLogWriteFailures != 0L) { + SiteEventLogWriteFailures = other.SiteEventLogWriteFailures; + } + if (other.oldestParkedMessageAgeSeconds_ != null) { + if (oldestParkedMessageAgeSeconds_ == null || other.OldestParkedMessageAgeSeconds != 0D) { + OldestParkedMessageAgeSeconds = other.OldestParkedMessageAgeSeconds; + } + } + if (other.ScriptQueueDepth != 0) { + ScriptQueueDepth = other.ScriptQueueDepth; + } + if (other.ScriptBusyThreads != 0) { + ScriptBusyThreads = other.ScriptBusyThreads; + } + if (other.scriptOldestBusyAgeSeconds_ != null) { + if (scriptOldestBusyAgeSeconds_ == null || other.ScriptOldestBusyAgeSeconds != 0D) { + ScriptOldestBusyAgeSeconds = other.ScriptOldestBusyAgeSeconds; + } + } + if (other.localDbReplicationConnected_ != null) { + if (localDbReplicationConnected_ == null || other.LocalDbReplicationConnected != false) { + LocalDbReplicationConnected = other.LocalDbReplicationConnected; + } + } + if (other.localDbOplogBacklog_ != null) { + if (localDbOplogBacklog_ == null || other.LocalDbOplogBacklog != 0L) { + LocalDbOplogBacklog = other.LocalDbOplogBacklog; + } + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + SiteId = input.ReadString(); + break; + } + case 16: { + SequenceNumber = input.ReadInt64(); + break; + } + case 26: { + if (reportTimestamp_ == null) { + ReportTimestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(ReportTimestamp); + break; + } + case 34: { + dataConnectionStatuses_.AddEntriesFrom(input, _map_dataConnectionStatuses_codec); + break; + } + case 42: { + tagResolutionCounts_.AddEntriesFrom(input, _map_tagResolutionCounts_codec); + break; + } + case 48: { + ScriptErrorCount = input.ReadInt32(); + break; + } + case 56: { + AlarmEvaluationErrorCount = input.ReadInt32(); + break; + } + case 66: { + storeAndForwardBufferDepths_.AddEntriesFrom(input, _map_storeAndForwardBufferDepths_codec); + break; + } + case 72: { + DeadLetterCount = input.ReadInt32(); + break; + } + case 80: { + DeployedInstanceCount = input.ReadInt32(); + break; + } + case 88: { + EnabledInstanceCount = input.ReadInt32(); + break; + } + case 96: { + DisabledInstanceCount = input.ReadInt32(); + break; + } + case 106: { + NodeRole = input.ReadString(); + break; + } + case 114: { + NodeHostname = input.ReadString(); + break; + } + case 122: { + if (dataConnectionEndpoints_ == null) { + DataConnectionEndpoints = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto(); + } + input.ReadMessage(DataConnectionEndpoints); + break; + } + case 130: { + if (dataConnectionTagQuality_ == null) { + DataConnectionTagQuality = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto(); + } + input.ReadMessage(DataConnectionTagQuality); + break; + } + case 136: { + ParkedMessageCount = input.ReadInt32(); + break; + } + case 146: { + if (clusterNodes_ == null) { + ClusterNodes = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto(); + } + input.ReadMessage(ClusterNodes); + break; + } + case 152: { + SiteAuditWriteFailures = input.ReadInt32(); + break; + } + case 160: { + AuditRedactionFailure = input.ReadInt32(); + break; + } + case 170: { + if (siteAuditBacklog_ == null) { + SiteAuditBacklog = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto(); + } + input.ReadMessage(SiteAuditBacklog); + break; + } + case 176: { + SiteEventLogWriteFailures = input.ReadInt64(); + break; + } + case 186: { + double? value = _single_oldestParkedMessageAgeSeconds_codec.Read(input); + if (oldestParkedMessageAgeSeconds_ == null || value != 0D) { + OldestParkedMessageAgeSeconds = value; + } + break; + } + case 192: { + ScriptQueueDepth = input.ReadInt32(); + break; + } + case 200: { + ScriptBusyThreads = input.ReadInt32(); + break; + } + case 210: { + double? value = _single_scriptOldestBusyAgeSeconds_codec.Read(input); + if (scriptOldestBusyAgeSeconds_ == null || value != 0D) { + ScriptOldestBusyAgeSeconds = value; + } + break; + } + case 218: { + bool? value = _single_localDbReplicationConnected_codec.Read(input); + if (localDbReplicationConnected_ == null || value != false) { + LocalDbReplicationConnected = value; + } + break; + } + case 226: { + long? value = _single_localDbOplogBacklog_codec.Read(input); + if (localDbOplogBacklog_ == null || value != 0L) { + LocalDbOplogBacklog = value; + } + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + SiteId = input.ReadString(); + break; + } + case 16: { + SequenceNumber = input.ReadInt64(); + break; + } + case 26: { + if (reportTimestamp_ == null) { + ReportTimestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(ReportTimestamp); + break; + } + case 34: { + dataConnectionStatuses_.AddEntriesFrom(ref input, _map_dataConnectionStatuses_codec); + break; + } + case 42: { + tagResolutionCounts_.AddEntriesFrom(ref input, _map_tagResolutionCounts_codec); + break; + } + case 48: { + ScriptErrorCount = input.ReadInt32(); + break; + } + case 56: { + AlarmEvaluationErrorCount = input.ReadInt32(); + break; + } + case 66: { + storeAndForwardBufferDepths_.AddEntriesFrom(ref input, _map_storeAndForwardBufferDepths_codec); + break; + } + case 72: { + DeadLetterCount = input.ReadInt32(); + break; + } + case 80: { + DeployedInstanceCount = input.ReadInt32(); + break; + } + case 88: { + EnabledInstanceCount = input.ReadInt32(); + break; + } + case 96: { + DisabledInstanceCount = input.ReadInt32(); + break; + } + case 106: { + NodeRole = input.ReadString(); + break; + } + case 114: { + NodeHostname = input.ReadString(); + break; + } + case 122: { + if (dataConnectionEndpoints_ == null) { + DataConnectionEndpoints = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ConnectionEndpointMapDto(); + } + input.ReadMessage(DataConnectionEndpoints); + break; + } + case 130: { + if (dataConnectionTagQuality_ == null) { + DataConnectionTagQuality = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.TagQualityMapDto(); + } + input.ReadMessage(DataConnectionTagQuality); + break; + } + case 136: { + ParkedMessageCount = input.ReadInt32(); + break; + } + case 146: { + if (clusterNodes_ == null) { + ClusterNodes = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NodeStatusListDto(); + } + input.ReadMessage(ClusterNodes); + break; + } + case 152: { + SiteAuditWriteFailures = input.ReadInt32(); + break; + } + case 160: { + AuditRedactionFailure = input.ReadInt32(); + break; + } + case 170: { + if (siteAuditBacklog_ == null) { + SiteAuditBacklog = new global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteAuditBacklogSnapshotDto(); + } + input.ReadMessage(SiteAuditBacklog); + break; + } + case 176: { + SiteEventLogWriteFailures = input.ReadInt64(); + break; + } + case 186: { + double? value = _single_oldestParkedMessageAgeSeconds_codec.Read(ref input); + if (oldestParkedMessageAgeSeconds_ == null || value != 0D) { + OldestParkedMessageAgeSeconds = value; + } + break; + } + case 192: { + ScriptQueueDepth = input.ReadInt32(); + break; + } + case 200: { + ScriptBusyThreads = input.ReadInt32(); + break; + } + case 210: { + double? value = _single_scriptOldestBusyAgeSeconds_codec.Read(ref input); + if (scriptOldestBusyAgeSeconds_ == null || value != 0D) { + ScriptOldestBusyAgeSeconds = value; + } + break; + } + case 218: { + bool? value = _single_localDbReplicationConnected_codec.Read(ref input); + if (localDbReplicationConnected_ == null || value != false) { + LocalDbReplicationConnected = value; + } + break; + } + case 226: { + long? value = _single_localDbOplogBacklog_codec.Read(ref input); + if (localDbOplogBacklog_ == null || value != 0L) { + LocalDbOplogBacklog = value; + } + break; + } + } + } + } + #endif + + } + + /// + /// Central -> Site: health report ack, so a lost report is observable. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class SiteHealthReportAckDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new SiteHealthReportAckDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[15]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public SiteHealthReportAckDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public SiteHealthReportAckDto(SiteHealthReportAckDto other) : this() { + siteId_ = other.siteId_; + sequenceNumber_ = other.sequenceNumber_; + accepted_ = other.accepted_; + error_ = other.error_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public SiteHealthReportAckDto Clone() { + return new SiteHealthReportAckDto(this); + } + + /// Field number for the "site_id" field. + public const int SiteIdFieldNumber = 1; + private string siteId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SiteId { + get { return siteId_; } + set { + siteId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "sequence_number" field. + public const int SequenceNumberFieldNumber = 2; + private long sequenceNumber_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public long SequenceNumber { + get { return sequenceNumber_; } + set { + sequenceNumber_ = value; + } + } + + /// Field number for the "accepted" field. + public const int AcceptedFieldNumber = 3; + private bool accepted_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Accepted { + get { return accepted_; } + set { + accepted_ = value; + } + } + + /// Field number for the "error" field. + public const int ErrorFieldNumber = 4; + private string error_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Error { + get { return error_; } + set { + error_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as SiteHealthReportAckDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(SiteHealthReportAckDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (SiteId != other.SiteId) return false; + if (SequenceNumber != other.SequenceNumber) return false; + if (Accepted != other.Accepted) return false; + if (Error != other.Error) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (SiteId.Length != 0) hash ^= SiteId.GetHashCode(); + if (SequenceNumber != 0L) hash ^= SequenceNumber.GetHashCode(); + if (Accepted != false) hash ^= Accepted.GetHashCode(); + if (Error.Length != 0) hash ^= Error.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (SiteId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(SiteId); + } + if (SequenceNumber != 0L) { + output.WriteRawTag(16); + output.WriteInt64(SequenceNumber); + } + if (Accepted != false) { + output.WriteRawTag(24); + output.WriteBool(Accepted); + } + if (Error.Length != 0) { + output.WriteRawTag(34); + output.WriteString(Error); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (SiteId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(SiteId); + } + if (SequenceNumber != 0L) { + output.WriteRawTag(16); + output.WriteInt64(SequenceNumber); + } + if (Accepted != false) { + output.WriteRawTag(24); + output.WriteBool(Accepted); + } + if (Error.Length != 0) { + output.WriteRawTag(34); + output.WriteString(Error); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (SiteId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SiteId); + } + if (SequenceNumber != 0L) { + size += 1 + pb::CodedOutputStream.ComputeInt64Size(SequenceNumber); + } + if (Accepted != false) { + size += 1 + 1; + } + if (Error.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Error); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(SiteHealthReportAckDto other) { + if (other == null) { + return; + } + if (other.SiteId.Length != 0) { + SiteId = other.SiteId; + } + if (other.SequenceNumber != 0L) { + SequenceNumber = other.SequenceNumber; + } + if (other.Accepted != false) { + Accepted = other.Accepted; + } + if (other.Error.Length != 0) { + Error = other.Error; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + SiteId = input.ReadString(); + break; + } + case 16: { + SequenceNumber = input.ReadInt64(); + break; + } + case 24: { + Accepted = input.ReadBool(); + break; + } + case 34: { + Error = input.ReadString(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + SiteId = input.ReadString(); + break; + } + case 16: { + SequenceNumber = input.ReadInt64(); + break; + } + case 24: { + Accepted = input.ReadBool(); + break; + } + case 34: { + Error = input.ReadString(); + break; + } + } + } + } + #endif + + } + + /// + /// Site -> Central: application heartbeat (fire-and-forget; reply is Empty). + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class HeartbeatDto : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new HeartbeatDto()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.MessageTypes[16]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public HeartbeatDto() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public HeartbeatDto(HeartbeatDto other) : this() { + siteId_ = other.siteId_; + nodeHostname_ = other.nodeHostname_; + isActive_ = other.isActive_; + timestamp_ = other.timestamp_ != null ? other.timestamp_.Clone() : null; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public HeartbeatDto Clone() { + return new HeartbeatDto(this); + } + + /// Field number for the "site_id" field. + public const int SiteIdFieldNumber = 1; + private string siteId_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string SiteId { + get { return siteId_; } + set { + siteId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "node_hostname" field. + public const int NodeHostnameFieldNumber = 2; + private string nodeHostname_ = ""; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string NodeHostname { + get { return nodeHostname_; } + set { + nodeHostname_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "is_active" field. + public const int IsActiveFieldNumber = 3; + private bool isActive_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool IsActive { + get { return isActive_; } + set { + isActive_ = value; + } + } + + /// Field number for the "timestamp" field. + public const int TimestampFieldNumber = 4; + private global::Google.Protobuf.WellKnownTypes.Timestamp timestamp_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::Google.Protobuf.WellKnownTypes.Timestamp Timestamp { + get { return timestamp_; } + set { + timestamp_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as HeartbeatDto); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(HeartbeatDto other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (SiteId != other.SiteId) return false; + if (NodeHostname != other.NodeHostname) return false; + if (IsActive != other.IsActive) return false; + if (!object.Equals(Timestamp, other.Timestamp)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (SiteId.Length != 0) hash ^= SiteId.GetHashCode(); + if (NodeHostname.Length != 0) hash ^= NodeHostname.GetHashCode(); + if (IsActive != false) hash ^= IsActive.GetHashCode(); + if (timestamp_ != null) hash ^= Timestamp.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (SiteId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(SiteId); + } + if (NodeHostname.Length != 0) { + output.WriteRawTag(18); + output.WriteString(NodeHostname); + } + if (IsActive != false) { + output.WriteRawTag(24); + output.WriteBool(IsActive); + } + if (timestamp_ != null) { + output.WriteRawTag(34); + output.WriteMessage(Timestamp); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (SiteId.Length != 0) { + output.WriteRawTag(10); + output.WriteString(SiteId); + } + if (NodeHostname.Length != 0) { + output.WriteRawTag(18); + output.WriteString(NodeHostname); + } + if (IsActive != false) { + output.WriteRawTag(24); + output.WriteBool(IsActive); + } + if (timestamp_ != null) { + output.WriteRawTag(34); + output.WriteMessage(Timestamp); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (SiteId.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(SiteId); + } + if (NodeHostname.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(NodeHostname); + } + if (IsActive != false) { + size += 1 + 1; + } + if (timestamp_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(Timestamp); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(HeartbeatDto other) { + if (other == null) { + return; + } + if (other.SiteId.Length != 0) { + SiteId = other.SiteId; + } + if (other.NodeHostname.Length != 0) { + NodeHostname = other.NodeHostname; + } + if (other.IsActive != false) { + IsActive = other.IsActive; + } + if (other.timestamp_ != null) { + if (timestamp_ == null) { + Timestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + Timestamp.MergeFrom(other.Timestamp); + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + SiteId = input.ReadString(); + break; + } + case 18: { + NodeHostname = input.ReadString(); + break; + } + case 24: { + IsActive = input.ReadBool(); + break; + } + case 34: { + if (timestamp_ == null) { + Timestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(Timestamp); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + SiteId = input.ReadString(); + break; + } + case 18: { + NodeHostname = input.ReadString(); + break; + } + case 24: { + IsActive = input.ReadBool(); + break; + } + case 34: { + if (timestamp_ == null) { + Timestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(Timestamp); + break; + } + } + } + } + #endif + + } + + #endregion + +} + +#endregion Designer generated code diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControlGrpc.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControlGrpc.cs new file mode 100644 index 00000000..e0275fc7 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CentralControlGrpc/CentralControlGrpc.cs @@ -0,0 +1,704 @@ +// +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: Protos/central_control.proto +// +#pragma warning disable 0414, 1591, 8981, 0612 +#region Designer generated code + +using grpc = global::Grpc.Core; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc { + /// + /// Central-hosted control plane (Phase 1A of the ClusterClient→gRPC migration). + /// + /// Direction: SITE is the client, CENTRAL is the server — the inverse of + /// SiteStreamService, where central dials the site. That asymmetry is deliberate + /// and mirrors the direction the Akka ClusterClient traffic flows today: these + /// seven calls are exactly the seven messages SiteCommunicationActor sends to + /// /user/central-communication. + /// + /// Every call is gated by ControlPlaneAuthInterceptor: `authorization: Bearer <psk>` + /// plus the `x-scadabridge-site` metadata header naming which site's preshared key + /// central must verify against. + /// + public static partial class CentralControlService + { + static readonly string __ServiceName = "scadabridge.centralcontrol.v1.CentralControlService"; + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static void __Helper_SerializeMessage(global::Google.Protobuf.IMessage message, grpc::SerializationContext context) + { + #if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION + if (message is global::Google.Protobuf.IBufferMessage) + { + context.SetPayloadLength(message.CalculateSize()); + global::Google.Protobuf.MessageExtensions.WriteTo(message, context.GetBufferWriter()); + context.Complete(); + return; + } + #endif + context.Complete(global::Google.Protobuf.MessageExtensions.ToByteArray(message)); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static class __Helper_MessageCache + { + public static readonly bool IsBufferMessage = global::System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(global::Google.Protobuf.IBufferMessage)).IsAssignableFrom(typeof(T)); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static T __Helper_DeserializeMessage(grpc::DeserializationContext context, global::Google.Protobuf.MessageParser parser) where T : global::Google.Protobuf.IMessage + { + #if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION + if (__Helper_MessageCache.IsBufferMessage) + { + return parser.ParseFrom(context.PayloadAsReadOnlySequence()); + } + #endif + return parser.ParseFrom(context.PayloadAsNewBuffer()); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_scadabridge_centralcontrol_v1_NotificationSubmitDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_scadabridge_centralcontrol_v1_NotificationSubmitAckDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_scadabridge_centralcontrol_v1_NotificationStatusQueryDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_scadabridge_centralcontrol_v1_NotificationStatusResponseDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_sitestream_AuditEventBatch = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_sitestream_IngestAck = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_sitestream_CachedTelemetryBatch = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_scadabridge_centralcontrol_v1_ReconcileSiteRequestDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_scadabridge_centralcontrol_v1_ReconcileSiteResponseDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_scadabridge_centralcontrol_v1_SiteHealthReportDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_scadabridge_centralcontrol_v1_SiteHealthReportAckDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_scadabridge_centralcontrol_v1_HeartbeatDto = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_google_protobuf_Empty = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::Google.Protobuf.WellKnownTypes.Empty.Parser)); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_SubmitNotification = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "SubmitNotification", + __Marshaller_scadabridge_centralcontrol_v1_NotificationSubmitDto, + __Marshaller_scadabridge_centralcontrol_v1_NotificationSubmitAckDto); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_QueryNotificationStatus = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "QueryNotificationStatus", + __Marshaller_scadabridge_centralcontrol_v1_NotificationStatusQueryDto, + __Marshaller_scadabridge_centralcontrol_v1_NotificationStatusResponseDto); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_IngestAuditEvents = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "IngestAuditEvents", + __Marshaller_sitestream_AuditEventBatch, + __Marshaller_sitestream_IngestAck); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_IngestCachedTelemetry = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "IngestCachedTelemetry", + __Marshaller_sitestream_CachedTelemetryBatch, + __Marshaller_sitestream_IngestAck); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_ReconcileSite = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "ReconcileSite", + __Marshaller_scadabridge_centralcontrol_v1_ReconcileSiteRequestDto, + __Marshaller_scadabridge_centralcontrol_v1_ReconcileSiteResponseDto); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_ReportSiteHealth = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "ReportSiteHealth", + __Marshaller_scadabridge_centralcontrol_v1_SiteHealthReportDto, + __Marshaller_scadabridge_centralcontrol_v1_SiteHealthReportAckDto); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_Heartbeat = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "Heartbeat", + __Marshaller_scadabridge_centralcontrol_v1_HeartbeatDto, + __Marshaller_google_protobuf_Empty); + + /// Service descriptor + public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor + { + get { return global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlReflection.Descriptor.Services[0]; } + } + + /// Base class for server-side implementations of CentralControlService + [grpc::BindServiceMethod(typeof(CentralControlService), "BindService")] + public abstract partial class CentralControlServiceBase + { + /// + /// Store-and-forward handoff of one notification for central delivery. The + /// ack is idempotent on notification_id — a duplicate submit after a lost ack + /// must not produce a second delivery. + /// + /// The request received from the client. + /// The context of the server-side call handler being invoked. + /// The response to send back to the client (wrapped by a task). + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task SubmitNotification(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + /// + /// Notify.Status(id) round-trip for a notification that has already left the + /// site buffer. `found = false` sends the caller back to the site-local buffer + /// to decide Forwarding vs Unknown. + /// + /// The request received from the client. + /// The context of the server-side call handler being invoked. + /// The response to send back to the client (wrapped by a task). + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task QueryNotificationStatus(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + /// + /// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the + /// batch a site drains from its SQLite hot path is byte-identical whichever + /// transport carries it. + /// + /// The request received from the client. + /// The context of the server-side call handler being invoked. + /// The response to send back to the client (wrapped by a task). + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task IngestAuditEvents(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + /// + /// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls + /// operational upsert, written in one central transaction). + /// + /// The request received from the client. + /// The context of the server-side call handler being invoked. + /// The response to send back to the client (wrapped by a task). + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task IngestCachedTelemetry(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + /// + /// Node-startup self-heal: the node's local deployed inventory in, fetch + /// tokens for whatever it is missing or stale out. + /// + /// The request received from the client. + /// The context of the server-side call handler being invoked. + /// The response to send back to the client (wrapped by a task). + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task ReconcileSite(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + /// + /// Periodic site health report (30 s cadence). The ack makes delivery + /// observable end-to-end so the sender can restore its per-interval counters + /// when a report is lost. + /// + /// The request received from the client. + /// The context of the server-side call handler being invoked. + /// The response to send back to the client (wrapped by a task). + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task ReportSiteHealth(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + /// + /// Application heartbeat. Returns Empty because the message is + /// fire-and-forget: nothing on the site consumes a reply, and a failure here + /// must never surface as a fault on the heartbeat timer path. + /// + /// The request received from the client. + /// The context of the server-side call handler being invoked. + /// The response to send back to the client (wrapped by a task). + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task Heartbeat(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + } + + /// Client for CentralControlService + public partial class CentralControlServiceClient : grpc::ClientBase + { + /// Creates a new client for CentralControlService + /// The channel to use to make remote calls. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public CentralControlServiceClient(grpc::ChannelBase channel) : base(channel) + { + } + /// Creates a new client for CentralControlService that uses a custom CallInvoker. + /// The callInvoker to use to make remote calls. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public CentralControlServiceClient(grpc::CallInvoker callInvoker) : base(callInvoker) + { + } + /// Protected parameterless constructor to allow creation of test doubles. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + protected CentralControlServiceClient() : base() + { + } + /// Protected constructor to allow creation of configured clients. + /// The client configuration. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + protected CentralControlServiceClient(ClientBaseConfiguration configuration) : base(configuration) + { + } + + /// + /// Store-and-forward handoff of one notification for central delivery. The + /// ack is idempotent on notification_id — a duplicate submit after a lost ack + /// must not produce a second delivery. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto SubmitNotification(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return SubmitNotification(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Store-and-forward handoff of one notification for central delivery. The + /// ack is idempotent on notification_id — a duplicate submit after a lost ack + /// must not produce a second delivery. + /// + /// The request to send to the server. + /// The options for the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitAckDto SubmitNotification(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_SubmitNotification, null, options, request); + } + /// + /// Store-and-forward handoff of one notification for central delivery. The + /// ack is idempotent on notification_id — a duplicate submit after a lost ack + /// must not produce a second delivery. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall SubmitNotificationAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return SubmitNotificationAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Store-and-forward handoff of one notification for central delivery. The + /// ack is idempotent on notification_id — a duplicate submit after a lost ack + /// must not produce a second delivery. + /// + /// The request to send to the server. + /// The options for the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall SubmitNotificationAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationSubmitDto request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_SubmitNotification, null, options, request); + } + /// + /// Notify.Status(id) round-trip for a notification that has already left the + /// site buffer. `found = false` sends the caller back to the site-local buffer + /// to decide Forwarding vs Unknown. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto QueryNotificationStatus(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return QueryNotificationStatus(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Notify.Status(id) round-trip for a notification that has already left the + /// site buffer. `found = false` sends the caller back to the site-local buffer + /// to decide Forwarding vs Unknown. + /// + /// The request to send to the server. + /// The options for the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusResponseDto QueryNotificationStatus(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_QueryNotificationStatus, null, options, request); + } + /// + /// Notify.Status(id) round-trip for a notification that has already left the + /// site buffer. `found = false` sends the caller back to the site-local buffer + /// to decide Forwarding vs Unknown. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall QueryNotificationStatusAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return QueryNotificationStatusAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Notify.Status(id) round-trip for a notification that has already left the + /// site buffer. `found = false` sends the caller back to the site-local buffer + /// to decide Forwarding vs Unknown. + /// + /// The request to send to the server. + /// The options for the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall QueryNotificationStatusAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.NotificationStatusQueryDto request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_QueryNotificationStatus, null, options, request); + } + /// + /// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the + /// batch a site drains from its SQLite hot path is byte-identical whichever + /// transport carries it. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck IngestAuditEvents(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return IngestAuditEvents(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the + /// batch a site drains from its SQLite hot path is byte-identical whichever + /// transport carries it. + /// + /// The request to send to the server. + /// The options for the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck IngestAuditEvents(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_IngestAuditEvents, null, options, request); + } + /// + /// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the + /// batch a site drains from its SQLite hot path is byte-identical whichever + /// transport carries it. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall IngestAuditEventsAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return IngestAuditEventsAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the + /// batch a site drains from its SQLite hot path is byte-identical whichever + /// transport carries it. + /// + /// The request to send to the server. + /// The options for the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall IngestAuditEventsAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.AuditEventBatch request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_IngestAuditEvents, null, options, request); + } + /// + /// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls + /// operational upsert, written in one central transaction). + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck IngestCachedTelemetry(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return IngestCachedTelemetry(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls + /// operational upsert, written in one central transaction). + /// + /// The request to send to the server. + /// The options for the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.IngestAck IngestCachedTelemetry(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_IngestCachedTelemetry, null, options, request); + } + /// + /// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls + /// operational upsert, written in one central transaction). + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall IngestCachedTelemetryAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return IngestCachedTelemetryAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls + /// operational upsert, written in one central transaction). + /// + /// The request to send to the server. + /// The options for the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall IngestCachedTelemetryAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.CachedTelemetryBatch request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_IngestCachedTelemetry, null, options, request); + } + /// + /// Node-startup self-heal: the node's local deployed inventory in, fetch + /// tokens for whatever it is missing or stale out. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto ReconcileSite(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return ReconcileSite(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Node-startup self-heal: the node's local deployed inventory in, fetch + /// tokens for whatever it is missing or stale out. + /// + /// The request to send to the server. + /// The options for the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteResponseDto ReconcileSite(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_ReconcileSite, null, options, request); + } + /// + /// Node-startup self-heal: the node's local deployed inventory in, fetch + /// tokens for whatever it is missing or stale out. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall ReconcileSiteAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return ReconcileSiteAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Node-startup self-heal: the node's local deployed inventory in, fetch + /// tokens for whatever it is missing or stale out. + /// + /// The request to send to the server. + /// The options for the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall ReconcileSiteAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.ReconcileSiteRequestDto request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_ReconcileSite, null, options, request); + } + /// + /// Periodic site health report (30 s cadence). The ack makes delivery + /// observable end-to-end so the sender can restore its per-interval counters + /// when a report is lost. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto ReportSiteHealth(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return ReportSiteHealth(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Periodic site health report (30 s cadence). The ack makes delivery + /// observable end-to-end so the sender can restore its per-interval counters + /// when a report is lost. + /// + /// The request to send to the server. + /// The options for the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportAckDto ReportSiteHealth(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_ReportSiteHealth, null, options, request); + } + /// + /// Periodic site health report (30 s cadence). The ack makes delivery + /// observable end-to-end so the sender can restore its per-interval counters + /// when a report is lost. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall ReportSiteHealthAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return ReportSiteHealthAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Periodic site health report (30 s cadence). The ack makes delivery + /// observable end-to-end so the sender can restore its per-interval counters + /// when a report is lost. + /// + /// The request to send to the server. + /// The options for the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall ReportSiteHealthAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteHealthReportDto request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_ReportSiteHealth, null, options, request); + } + /// + /// Application heartbeat. Returns Empty because the message is + /// fire-and-forget: nothing on the site consumes a reply, and a failure here + /// must never surface as a fault on the heartbeat timer path. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::Google.Protobuf.WellKnownTypes.Empty Heartbeat(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return Heartbeat(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Application heartbeat. Returns Empty because the message is + /// fire-and-forget: nothing on the site consumes a reply, and a failure here + /// must never surface as a fault on the heartbeat timer path. + /// + /// The request to send to the server. + /// The options for the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::Google.Protobuf.WellKnownTypes.Empty Heartbeat(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_Heartbeat, null, options, request); + } + /// + /// Application heartbeat. Returns Empty because the message is + /// fire-and-forget: nothing on the site consumes a reply, and a failure here + /// must never surface as a fault on the heartbeat timer path. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall HeartbeatAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return HeartbeatAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Application heartbeat. Returns Empty because the message is + /// fire-and-forget: nothing on the site consumes a reply, and a failure here + /// must never surface as a fault on the heartbeat timer path. + /// + /// The request to send to the server. + /// The options for the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall HeartbeatAsync(global::ZB.MOM.WW.ScadaBridge.Communication.Grpc.HeartbeatDto request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_Heartbeat, null, options, request); + } + /// Creates a new instance of client from given ClientBaseConfiguration. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + protected override CentralControlServiceClient NewInstance(ClientBaseConfiguration configuration) + { + return new CentralControlServiceClient(configuration); + } + } + + /// Creates service definition that can be registered with a server + /// An object implementing the server-side handling logic. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public static grpc::ServerServiceDefinition BindService(CentralControlServiceBase serviceImpl) + { + return grpc::ServerServiceDefinition.CreateBuilder() + .AddMethod(__Method_SubmitNotification, serviceImpl.SubmitNotification) + .AddMethod(__Method_QueryNotificationStatus, serviceImpl.QueryNotificationStatus) + .AddMethod(__Method_IngestAuditEvents, serviceImpl.IngestAuditEvents) + .AddMethod(__Method_IngestCachedTelemetry, serviceImpl.IngestCachedTelemetry) + .AddMethod(__Method_ReconcileSite, serviceImpl.ReconcileSite) + .AddMethod(__Method_ReportSiteHealth, serviceImpl.ReportSiteHealth) + .AddMethod(__Method_Heartbeat, serviceImpl.Heartbeat).Build(); + } + + /// Register service method with a service binder with or without implementation. Useful when customizing the service binding logic. + /// Note: this method is part of an experimental API that can change or be removed without any prior notice. + /// Service methods will be bound by calling AddMethod on this object. + /// An object implementing the server-side handling logic. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public static void BindService(grpc::ServiceBinderBase serviceBinder, CentralControlServiceBase serviceImpl) + { + serviceBinder.AddMethod(__Method_SubmitNotification, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.SubmitNotification)); + serviceBinder.AddMethod(__Method_QueryNotificationStatus, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.QueryNotificationStatus)); + serviceBinder.AddMethod(__Method_IngestAuditEvents, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.IngestAuditEvents)); + serviceBinder.AddMethod(__Method_IngestCachedTelemetry, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.IngestCachedTelemetry)); + serviceBinder.AddMethod(__Method_ReconcileSite, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.ReconcileSite)); + serviceBinder.AddMethod(__Method_ReportSiteHealth, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.ReportSiteHealth)); + serviceBinder.AddMethod(__Method_Heartbeat, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.Heartbeat)); + } + + } +} +#endregion diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs new file mode 100644 index 00000000..53c3568a --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlDtoMapper.cs @@ -0,0 +1,764 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +/// +/// Canonical bridge between the seven in-process messages the site sends to central +/// and the wire format of CentralControlService (Protos/central_control.proto). +/// +/// +/// +/// The seven pairs mirror, one for one, the seven messages +/// SiteCommunicationActor forwards to /user/central-communication over +/// Akka ClusterClient today. Both transports carry the SAME message types +/// end-to-end — central's handlers are untouched by the migration — so this mapper is +/// the only place the two representations meet, and a field that does not survive a +/// round-trip here is a field the gRPC transport silently drops. +/// +/// +/// Ingest is deliberately absent from the message list. IngestAuditEvents +/// and IngestCachedTelemetry reuse the AuditEventBatch / +/// CachedTelemetryBatch / IngestAck messages already defined for the +/// site-hosted SiteStreamService, so the per-row work is delegated to the +/// existing and ; only +/// the batch/ack envelopes are assembled here. +/// +/// +/// Conventions, applied uniformly across every method below. +/// +/// +/// Nullable strings ↔ empty strings. A proto3 scalar string cannot be absent, +/// so a null .NET string is written as and an empty wire +/// string is read back as . This is the convention already in +/// force on , and it is why no field on this wire +/// may distinguish "null" from "deliberately empty". +/// +/// +/// Nullable ↔ string. Execution ids travel as their "D" +/// string form; the empty string means . A malformed non-empty +/// value throws out of FromDto rather than degrading to null — a corrupt +/// correlation id must not be laundered into "no correlation". +/// +/// +/// Nullable numbers and booleans ↔ protobuf wrapper types. +/// Int32Value/Int64Value/DoubleValue/BoolValue preserve +/// true null. Several health gauges (LocalDbOplogBacklog, +/// LocalDbReplicationConnected) are documented as "null means unknown, and +/// that is NOT the same as zero/false"; collapsing them to a bare scalar would +/// report a broken replication pair as healthy. +/// +/// +/// Nullable collections ↔ wrapper messages. proto3 cannot express presence on +/// a repeated or map field, so the three nullable +/// collections travel inside single-field wrapper +/// messages (ConnectionEndpointMapDto, TagQualityMapDto, +/// NodeStatusListDto). An absent wrapper is null; a present-but-empty wrapper +/// is an empty collection. +/// +/// +/// normalizes to a UTC instant. A protobuf +/// Timestamp is an instant, not an offset-qualified local time, so the offset +/// component is dropped and the value round-trips with Offset == TimeSpan.Zero. +/// Every producer in this system stamps UTC (the repo-wide invariant; e.g. +/// Notify.Send uses DateTimeOffset.UtcNow), so this is lossless in +/// practice and the instant is preserved regardless. +/// +/// +/// +public static class CentralControlDtoMapper +{ + // ----------------------------------------------------------------------- + // Notification Outbox (#21) + // ----------------------------------------------------------------------- + + /// Projects a onto its wire DTO. + /// The notification submission to project. + /// The wire-format DTO; null strings and null execution ids collapse to empty strings. + public static NotificationSubmitDto ToDto(NotificationSubmit msg) + { + ArgumentNullException.ThrowIfNull(msg); + + return new NotificationSubmitDto + { + NotificationId = msg.NotificationId, + ListName = msg.ListName, + Subject = msg.Subject, + Body = msg.Body, + SourceSiteId = msg.SourceSiteId, + SourceInstanceId = msg.SourceInstanceId ?? string.Empty, + SourceScript = msg.SourceScript ?? string.Empty, + SiteEnqueuedAt = Timestamp.FromDateTimeOffset(msg.SiteEnqueuedAt), + OriginExecutionId = GuidToWire(msg.OriginExecutionId), + OriginParentExecutionId = GuidToWire(msg.OriginParentExecutionId), + SourceNode = msg.SourceNode ?? string.Empty, + }; + } + + /// Reconstructs a from its wire DTO. + /// The wire-format DTO to reconstruct. + /// The in-process message; empty strings rehydrate as null. + public static NotificationSubmit FromDto(NotificationSubmitDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + return new NotificationSubmit( + NotificationId: dto.NotificationId, + ListName: dto.ListName, + Subject: dto.Subject, + Body: dto.Body, + SourceSiteId: dto.SourceSiteId, + SourceInstanceId: NullIfEmpty(dto.SourceInstanceId), + SourceScript: NullIfEmpty(dto.SourceScript), + SiteEnqueuedAt: dto.SiteEnqueuedAt.ToDateTimeOffset(), + OriginExecutionId: GuidFromWire(dto.OriginExecutionId), + OriginParentExecutionId: GuidFromWire(dto.OriginParentExecutionId), + SourceNode: NullIfEmpty(dto.SourceNode)); + } + + /// Projects a onto its wire DTO. + /// The ack to project. + /// The wire-format DTO; a null error collapses to an empty string. + public static NotificationSubmitAckDto ToDto(NotificationSubmitAck msg) + { + ArgumentNullException.ThrowIfNull(msg); + + return new NotificationSubmitAckDto + { + NotificationId = msg.NotificationId, + Accepted = msg.Accepted, + Error = msg.Error ?? string.Empty, + }; + } + + /// Reconstructs a from its wire DTO. + /// The wire-format DTO to reconstruct. + /// The in-process ack; an empty error rehydrates as null. + public static NotificationSubmitAck FromDto(NotificationSubmitAckDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + return new NotificationSubmitAck( + NotificationId: dto.NotificationId, + Accepted: dto.Accepted, + Error: NullIfEmpty(dto.Error)); + } + + /// Projects a onto its wire DTO. + /// The status query to project. + /// The wire-format DTO. + public static NotificationStatusQueryDto ToDto(NotificationStatusQuery msg) + { + ArgumentNullException.ThrowIfNull(msg); + + return new NotificationStatusQueryDto + { + CorrelationId = msg.CorrelationId, + NotificationId = msg.NotificationId, + }; + } + + /// Reconstructs a from its wire DTO. + /// The wire-format DTO to reconstruct. + /// The in-process query. + public static NotificationStatusQuery FromDto(NotificationStatusQueryDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + return new NotificationStatusQuery( + CorrelationId: dto.CorrelationId, + NotificationId: dto.NotificationId); + } + + /// Projects a onto its wire DTO. + /// The status response to project. + /// The wire-format DTO; a null delivery timestamp leaves the field unset. + public static NotificationStatusResponseDto ToDto(NotificationStatusResponse msg) + { + ArgumentNullException.ThrowIfNull(msg); + + var dto = new NotificationStatusResponseDto + { + CorrelationId = msg.CorrelationId, + Found = msg.Found, + Status = msg.Status, + RetryCount = msg.RetryCount, + LastError = msg.LastError ?? string.Empty, + }; + + if (msg.DeliveredAt.HasValue) + { + dto.DeliveredAt = Timestamp.FromDateTimeOffset(msg.DeliveredAt.Value); + } + + return dto; + } + + /// Reconstructs a from its wire DTO. + /// The wire-format DTO to reconstruct. + /// The in-process response; an unset delivery timestamp rehydrates as null. + public static NotificationStatusResponse FromDto(NotificationStatusResponseDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + return new NotificationStatusResponse( + CorrelationId: dto.CorrelationId, + Found: dto.Found, + Status: dto.Status, + RetryCount: dto.RetryCount, + LastError: NullIfEmpty(dto.LastError), + DeliveredAt: dto.DeliveredAt?.ToDateTimeOffset()); + } + + // ----------------------------------------------------------------------- + // Audit Log (#23) ingest — envelopes only; rows go through the existing mappers + // ----------------------------------------------------------------------- + + /// + /// Projects an onto the shared + /// wire message. + /// + /// + /// The per-row projection is , which is lossy + /// by design: ForwardState is site-local storage state and IngestedAtUtc + /// is stamped centrally at ingest, so neither travels. + /// + /// The ingest command to project. + /// A batch carrying one DTO per audit event, in order. + public static AuditEventBatch ToDto(IngestAuditEventsCommand cmd) + { + ArgumentNullException.ThrowIfNull(cmd); + + var batch = new AuditEventBatch(); + foreach (var evt in cmd.Events) + { + batch.Events.Add(AuditEventDtoMapper.ToDto(evt)); + } + + return batch; + } + + /// + /// Reconstructs an from the shared + /// wire message — the shape central's + /// CentralCommunicationActor already handles. + /// + /// The wire batch to reconstruct. + /// The in-process ingest command. + public static IngestAuditEventsCommand FromDto(AuditEventBatch batch) + { + ArgumentNullException.ThrowIfNull(batch); + + var events = new List(batch.Events.Count); + foreach (var dto in batch.Events) + { + events.Add(AuditEventDtoMapper.FromDto(dto)); + } + + return new IngestAuditEventsCommand(events); + } + + /// + /// Projects an onto the shared + /// wire message. + /// + /// The cached-telemetry ingest command to project. + /// A batch carrying one packet (audit row + operational row) per entry, in order. + public static CachedTelemetryBatch ToDto(IngestCachedTelemetryCommand cmd) + { + ArgumentNullException.ThrowIfNull(cmd); + + var batch = new CachedTelemetryBatch(); + foreach (var entry in cmd.Entries) + { + batch.Packets.Add(new CachedTelemetryPacket + { + AuditEvent = AuditEventDtoMapper.ToDto(entry.Audit), + Operational = SiteCallDtoMapper.ToDto(entry.SiteCall), + }); + } + + return batch; + } + + /// + /// Reconstructs an from the shared + /// wire message. + /// + /// The wire batch to reconstruct. + /// The in-process dual-write ingest command. + public static IngestCachedTelemetryCommand FromDto(CachedTelemetryBatch batch) + { + ArgumentNullException.ThrowIfNull(batch); + + var entries = new List(batch.Packets.Count); + foreach (var packet in batch.Packets) + { + entries.Add(new CachedTelemetryEntry( + AuditEventDtoMapper.FromDto(packet.AuditEvent), + SiteCallDtoMapper.FromDto(packet.Operational))); + } + + return new IngestCachedTelemetryCommand(entries); + } + + /// + /// Projects the accepted-id list of an ingest reply onto the shared + /// wire message. Shared by both ingest RPCs — the two + /// central reply types differ only in which handler produced them. + /// + /// Ids central considers durably persisted. + /// The wire ack carrying the ids in "D" string form, in order. + public static IngestAck ToIngestAck(IReadOnlyList acceptedEventIds) + { + ArgumentNullException.ThrowIfNull(acceptedEventIds); + + var ack = new IngestAck(); + foreach (var id in acceptedEventIds) + { + ack.AcceptedEventIds.Add(id.ToString()); + } + + return ack; + } + + /// + /// Reads the accepted-id list back out of an . + /// + /// The wire ack to read. + /// The accepted event ids, in wire order. + public static IReadOnlyList FromIngestAck(IngestAck ack) + { + ArgumentNullException.ThrowIfNull(ack); + + var ids = new List(ack.AcceptedEventIds.Count); + foreach (var id in ack.AcceptedEventIds) + { + ids.Add(Guid.Parse(id)); + } + + return ids; + } + + // ----------------------------------------------------------------------- + // Startup reconciliation + // ----------------------------------------------------------------------- + + /// Projects a onto its wire DTO. + /// The reconcile request to project. + /// The wire-format DTO carrying the node's local name→revision-hash inventory. + public static ReconcileSiteRequestDto ToDto(ReconcileSiteRequest msg) + { + ArgumentNullException.ThrowIfNull(msg); + + var dto = new ReconcileSiteRequestDto + { + SiteIdentifier = msg.SiteIdentifier, + NodeId = msg.NodeId, + }; + + foreach (var (name, hash) in msg.LocalNameToRevisionHash) + { + dto.LocalNameToRevisionHash[name] = hash; + } + + return dto; + } + + /// Reconstructs a from its wire DTO. + /// The wire-format DTO to reconstruct. + /// The in-process reconcile request. + public static ReconcileSiteRequest FromDto(ReconcileSiteRequestDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + return new ReconcileSiteRequest( + SiteIdentifier: dto.SiteIdentifier, + NodeId: dto.NodeId, + LocalNameToRevisionHash: new Dictionary(dto.LocalNameToRevisionHash)); + } + + /// Projects a onto its wire DTO. + /// The reconcile response to project. + /// The wire-format DTO carrying the gap items, orphan names and fetch base URL. + public static ReconcileSiteResponseDto ToDto(ReconcileSiteResponse msg) + { + ArgumentNullException.ThrowIfNull(msg); + + var dto = new ReconcileSiteResponseDto + { + CentralFetchBaseUrl = msg.CentralFetchBaseUrl, + }; + + foreach (var item in msg.Gap) + { + dto.Gap.Add(new ReconcileGapItemDto + { + InstanceUniqueName = item.InstanceUniqueName, + DeploymentId = item.DeploymentId, + RevisionHash = item.RevisionHash, + IsEnabled = item.IsEnabled, + FetchToken = item.FetchToken, + }); + } + + dto.OrphanNames.AddRange(msg.OrphanNames); + + return dto; + } + + /// Reconstructs a from its wire DTO. + /// The wire-format DTO to reconstruct. + /// The in-process reconcile response. + public static ReconcileSiteResponse FromDto(ReconcileSiteResponseDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + var gap = new List(dto.Gap.Count); + foreach (var item in dto.Gap) + { + gap.Add(new ReconcileGapItem( + InstanceUniqueName: item.InstanceUniqueName, + DeploymentId: item.DeploymentId, + RevisionHash: item.RevisionHash, + IsEnabled: item.IsEnabled, + FetchToken: item.FetchToken)); + } + + return new ReconcileSiteResponse( + Gap: gap, + OrphanNames: dto.OrphanNames.ToList(), + CentralFetchBaseUrl: dto.CentralFetchBaseUrl); + } + + // ----------------------------------------------------------------------- + // Health Monitoring (#11) + // ----------------------------------------------------------------------- + + /// Projects a onto its wire DTO. + /// + /// The three nullable collections travel inside wrapper messages so a null stays + /// distinguishable from an empty collection; the nullable gauges travel in protobuf + /// wrapper types for the same reason. + /// + /// The health report to project. + /// The wire-format DTO. + public static SiteHealthReportDto ToDto(SiteHealthReport msg) + { + ArgumentNullException.ThrowIfNull(msg); + + var dto = new SiteHealthReportDto + { + SiteId = msg.SiteId, + SequenceNumber = msg.SequenceNumber, + ReportTimestamp = Timestamp.FromDateTimeOffset(msg.ReportTimestamp), + ScriptErrorCount = msg.ScriptErrorCount, + AlarmEvaluationErrorCount = msg.AlarmEvaluationErrorCount, + DeadLetterCount = msg.DeadLetterCount, + DeployedInstanceCount = msg.DeployedInstanceCount, + EnabledInstanceCount = msg.EnabledInstanceCount, + DisabledInstanceCount = msg.DisabledInstanceCount, + NodeRole = msg.NodeRole, + NodeHostname = msg.NodeHostname, + ParkedMessageCount = msg.ParkedMessageCount, + SiteAuditWriteFailures = msg.SiteAuditWriteFailures, + AuditRedactionFailure = msg.AuditRedactionFailure, + SiteEventLogWriteFailures = msg.SiteEventLogWriteFailures, + OldestParkedMessageAgeSeconds = msg.OldestParkedMessageAgeSeconds, + ScriptQueueDepth = msg.ScriptQueueDepth, + ScriptBusyThreads = msg.ScriptBusyThreads, + ScriptOldestBusyAgeSeconds = msg.ScriptOldestBusyAgeSeconds, + LocalDbReplicationConnected = msg.LocalDbReplicationConnected, + LocalDbOplogBacklog = msg.LocalDbOplogBacklog, + }; + + foreach (var (name, health) in msg.DataConnectionStatuses) + { + dto.DataConnectionStatuses[name] = ToDto(health); + } + + foreach (var (name, resolution) in msg.TagResolutionCounts) + { + dto.TagResolutionCounts[name] = new TagResolutionStatusDto + { + TotalSubscribed = resolution.TotalSubscribed, + SuccessfullyResolved = resolution.SuccessfullyResolved, + }; + } + + foreach (var (name, depth) in msg.StoreAndForwardBufferDepths) + { + dto.StoreAndForwardBufferDepths[name] = depth; + } + + if (msg.DataConnectionEndpoints is { } endpoints) + { + var wrapper = new ConnectionEndpointMapDto(); + foreach (var (name, endpoint) in endpoints) + { + wrapper.Entries[name] = endpoint; + } + + dto.DataConnectionEndpoints = wrapper; + } + + if (msg.DataConnectionTagQuality is { } tagQuality) + { + var wrapper = new TagQualityMapDto(); + foreach (var (name, counts) in tagQuality) + { + wrapper.Entries[name] = new TagQualityCountsDto + { + Good = counts.Good, + Bad = counts.Bad, + Uncertain = counts.Uncertain, + }; + } + + dto.DataConnectionTagQuality = wrapper; + } + + if (msg.ClusterNodes is { } clusterNodes) + { + var wrapper = new NodeStatusListDto(); + foreach (var node in clusterNodes) + { + wrapper.Nodes.Add(new NodeStatusDto + { + Hostname = node.Hostname, + IsOnline = node.IsOnline, + Role = node.Role, + }); + } + + dto.ClusterNodes = wrapper; + } + + if (msg.SiteAuditBacklog is { } backlog) + { + var snapshot = new SiteAuditBacklogSnapshotDto + { + PendingCount = backlog.PendingCount, + OnDiskBytes = backlog.OnDiskBytes, + }; + + if (backlog.OldestPendingUtc.HasValue) + { + snapshot.OldestPendingUtc = Timestamp.FromDateTime(EnsureUtc(backlog.OldestPendingUtc.Value)); + } + + dto.SiteAuditBacklog = snapshot; + } + + return dto; + } + + /// Reconstructs a from its wire DTO. + /// The wire-format DTO to reconstruct. + /// The in-process health report; absent wrappers rehydrate as null, not as empty. + public static SiteHealthReport FromDto(SiteHealthReportDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + var statuses = new Dictionary(dto.DataConnectionStatuses.Count); + foreach (var (name, health) in dto.DataConnectionStatuses) + { + statuses[name] = FromDto(health); + } + + var resolution = new Dictionary(dto.TagResolutionCounts.Count); + foreach (var (name, counts) in dto.TagResolutionCounts) + { + resolution[name] = new TagResolutionStatus(counts.TotalSubscribed, counts.SuccessfullyResolved); + } + + var bufferDepths = new Dictionary(dto.StoreAndForwardBufferDepths); + + Dictionary? endpoints = null; + if (dto.DataConnectionEndpoints is { } endpointWrapper) + { + endpoints = new Dictionary(endpointWrapper.Entries); + } + + Dictionary? tagQuality = null; + if (dto.DataConnectionTagQuality is { } tagQualityWrapper) + { + tagQuality = new Dictionary(tagQualityWrapper.Entries.Count); + foreach (var (name, counts) in tagQualityWrapper.Entries) + { + tagQuality[name] = new TagQualityCounts(counts.Good, counts.Bad, counts.Uncertain); + } + } + + List? clusterNodes = null; + if (dto.ClusterNodes is { } nodeWrapper) + { + clusterNodes = new List(nodeWrapper.Nodes.Count); + foreach (var node in nodeWrapper.Nodes) + { + clusterNodes.Add(new NodeStatus(node.Hostname, node.IsOnline, node.Role)); + } + } + + SiteAuditBacklogSnapshot? backlog = null; + if (dto.SiteAuditBacklog is { } snapshot) + { + backlog = new SiteAuditBacklogSnapshot( + PendingCount: snapshot.PendingCount, + OldestPendingUtc: snapshot.OldestPendingUtc is null + ? null + : DateTime.SpecifyKind(snapshot.OldestPendingUtc.ToDateTime(), DateTimeKind.Utc), + OnDiskBytes: snapshot.OnDiskBytes); + } + + return new SiteHealthReport( + SiteId: dto.SiteId, + SequenceNumber: dto.SequenceNumber, + ReportTimestamp: dto.ReportTimestamp.ToDateTimeOffset(), + DataConnectionStatuses: statuses, + TagResolutionCounts: resolution, + ScriptErrorCount: dto.ScriptErrorCount, + AlarmEvaluationErrorCount: dto.AlarmEvaluationErrorCount, + StoreAndForwardBufferDepths: bufferDepths, + DeadLetterCount: dto.DeadLetterCount, + DeployedInstanceCount: dto.DeployedInstanceCount, + EnabledInstanceCount: dto.EnabledInstanceCount, + DisabledInstanceCount: dto.DisabledInstanceCount, + NodeRole: dto.NodeRole, + NodeHostname: dto.NodeHostname, + DataConnectionEndpoints: endpoints, + DataConnectionTagQuality: tagQuality, + ParkedMessageCount: dto.ParkedMessageCount, + ClusterNodes: clusterNodes, + SiteAuditWriteFailures: dto.SiteAuditWriteFailures, + AuditRedactionFailure: dto.AuditRedactionFailure, + SiteAuditBacklog: backlog, + SiteEventLogWriteFailures: dto.SiteEventLogWriteFailures, + OldestParkedMessageAgeSeconds: dto.OldestParkedMessageAgeSeconds) + { + // Init-only members: SiteHealthReport surfaces the scheduler and LocalDb + // gauges as init properties rather than positional parameters, so they + // cannot be passed to the constructor above. + ScriptQueueDepth = dto.ScriptQueueDepth, + ScriptBusyThreads = dto.ScriptBusyThreads, + ScriptOldestBusyAgeSeconds = dto.ScriptOldestBusyAgeSeconds, + LocalDbReplicationConnected = dto.LocalDbReplicationConnected, + LocalDbOplogBacklog = dto.LocalDbOplogBacklog, + }; + } + + /// Projects a onto its wire DTO. + /// The ack to project. + /// The wire-format DTO; a null error collapses to an empty string. + public static SiteHealthReportAckDto ToDto(SiteHealthReportAck msg) + { + ArgumentNullException.ThrowIfNull(msg); + + return new SiteHealthReportAckDto + { + SiteId = msg.SiteId, + SequenceNumber = msg.SequenceNumber, + Accepted = msg.Accepted, + Error = msg.Error ?? string.Empty, + }; + } + + /// Reconstructs a from its wire DTO. + /// The wire-format DTO to reconstruct. + /// The in-process ack; an empty error rehydrates as null. + public static SiteHealthReportAck FromDto(SiteHealthReportAckDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + return new SiteHealthReportAck( + SiteId: dto.SiteId, + SequenceNumber: dto.SequenceNumber, + Accepted: dto.Accepted, + Error: NullIfEmpty(dto.Error)); + } + + /// Projects a onto its wire DTO. + /// The heartbeat to project. + /// The wire-format DTO. + public static HeartbeatDto ToDto(HeartbeatMessage msg) + { + ArgumentNullException.ThrowIfNull(msg); + + return new HeartbeatDto + { + SiteId = msg.SiteId, + NodeHostname = msg.NodeHostname, + IsActive = msg.IsActive, + Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp), + }; + } + + /// Reconstructs a from its wire DTO. + /// The wire-format DTO to reconstruct. + /// The in-process heartbeat. + public static HeartbeatMessage FromDto(HeartbeatDto dto) + { + ArgumentNullException.ThrowIfNull(dto); + + return new HeartbeatMessage( + SiteId: dto.SiteId, + NodeHostname: dto.NodeHostname, + IsActive: dto.IsActive, + Timestamp: dto.Timestamp.ToDateTimeOffset()); + } + + /// Projects a onto its wire enum. + /// The connection health to project. + /// The wire enum value. Never ConnectionHealthUnspecified. + /// + /// A value was added without extending this mapping. + /// Throwing beats a silent default: an unmapped state would otherwise be reported as + /// whichever value happened to be first. + /// + public static ConnectionHealthEnum ToDto(ConnectionHealth health) => health switch + { + ConnectionHealth.Connected => ConnectionHealthEnum.ConnectionHealthConnected, + ConnectionHealth.Disconnected => ConnectionHealthEnum.ConnectionHealthDisconnected, + ConnectionHealth.Connecting => ConnectionHealthEnum.ConnectionHealthConnecting, + ConnectionHealth.Error => ConnectionHealthEnum.ConnectionHealthError, + _ => throw new ArgumentOutOfRangeException(nameof(health), health, "Unmapped ConnectionHealth value"), + }; + + /// Reconstructs a from its wire enum. + /// + /// An unspecified or unknown wire value decodes to , + /// never to . A newer site sending a value this + /// build has never heard of must not have it rendered as "healthy" on the central + /// health page — the safe direction for an unknown connection state is "not working". + /// + /// The wire enum value to reconstruct. + /// The in-process connection health. + public static ConnectionHealth FromDto(ConnectionHealthEnum health) => health switch + { + ConnectionHealthEnum.ConnectionHealthConnected => ConnectionHealth.Connected, + ConnectionHealthEnum.ConnectionHealthDisconnected => ConnectionHealth.Disconnected, + ConnectionHealthEnum.ConnectionHealthConnecting => ConnectionHealth.Connecting, + _ => ConnectionHealth.Error, + }; + + private static string GuidToWire(Guid? value) => + value?.ToString() ?? string.Empty; + + private static Guid? GuidFromWire(string? value) => + string.IsNullOrEmpty(value) ? null : Guid.Parse(value); + + private static string? NullIfEmpty(string? value) => + string.IsNullOrEmpty(value) ? null : value; + + // All ScadaBridge timestamps are UTC by invariant; Timestamp.FromDateTime requires + // UTC kind. Specify (never convert) so a value read back from SQLite with Kind=Utc + // passes through and a defensively-unspecified one is treated as the UTC it already + // is. Mirrors AuditEventDtoMapper/SiteCallDtoMapper.EnsureUtc. + private static DateTime EnsureUtc(DateTime value) => + value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCallDtoMapper.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCallDtoMapper.cs index 00f283c5..186c22ed 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCallDtoMapper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/SiteCallDtoMapper.cs @@ -21,15 +21,17 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc; /// Mirrors the sibling . /// /// -/// Two directions are provided. rehydrates the central +/// Three directions are provided. rehydrates the central /// entity central writes into the SiteCalls table. -/// projects a site-local -/// onto the wire — used by the Site Call Audit PullSiteCalls -/// reconciliation handler (the central→site self-heal pull). The -/// entity itself is never mapped back onto the wire: -/// sites emit operational state from , never -/// from the central , so a SiteCall→DTO method -/// would be dead code. +/// projects a site-local +/// onto the wire — used by the Site Call Audit +/// PullSiteCalls reconciliation handler (the central→site self-heal pull). +/// projects the entity form back onto the wire; it +/// exists for the gRPC central control plane, whose site-side transport receives +/// an already-decoded IngestCachedTelemetryCommand (which carries +/// , not ) and must +/// re-encode it. It was previously documented here as necessarily dead code — +/// true only while ClusterClient was the sole path from that command to central. /// /// /// String nullability convention: proto3 scalar strings cannot be absent, so the @@ -120,6 +122,51 @@ public static class SiteCallDtoMapper return dto; } + /// + /// Projects a entity onto its wire-format DTO — the + /// inverse of , so the pair round-trips exactly. + /// + /// + /// is deliberately NOT written: it is + /// central-set inside the dual-write transaction and the value carried on the + /// wire is informational only ( stamps a placeholder that + /// the ingest actor overwrites). Every other field survives; null + /// SourceNode/LastError collapse to empty strings while the + /// nullable HttpStatus/TerminalAtUtc stay unset on the wire. + /// + /// The central operational-state entity to project. + /// A populated ready for transmission. + public static SiteCallOperationalDto ToDto(SiteCall siteCall) + { + ArgumentNullException.ThrowIfNull(siteCall); + + var dto = new SiteCallOperationalDto + { + TrackedOperationId = siteCall.TrackedOperationId.ToString(), + Channel = siteCall.Channel, + Target = siteCall.Target, + SourceSite = siteCall.SourceSite, + SourceNode = siteCall.SourceNode ?? string.Empty, + Status = siteCall.Status, + RetryCount = siteCall.RetryCount, + LastError = siteCall.LastError ?? string.Empty, + CreatedAtUtc = Timestamp.FromDateTime(EnsureUtc(siteCall.CreatedAtUtc)), + UpdatedAtUtc = Timestamp.FromDateTime(EnsureUtc(siteCall.UpdatedAtUtc)), + }; + + if (siteCall.HttpStatus.HasValue) + { + dto.HttpStatus = siteCall.HttpStatus.Value; + } + + if (siteCall.TerminalAtUtc.HasValue) + { + dto.TerminalAtUtc = Timestamp.FromDateTime(EnsureUtc(siteCall.TerminalAtUtc.Value)); + } + + return dto; + } + // All ScadaBridge timestamps are UTC by invariant; Timestamp.FromDateTime // requires UTC kind. Specify (never convert) so a row read back from SQLite // with Kind=Utc passes through and a defensively-unspecified value is diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/central_control.proto b/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/central_control.proto new file mode 100644 index 00000000..94f0c104 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Protos/central_control.proto @@ -0,0 +1,247 @@ +syntax = "proto3"; +option csharp_namespace = "ZB.MOM.WW.ScadaBridge.Communication.Grpc"; +package scadabridge.centralcontrol.v1; + +import "google/protobuf/empty.proto"; +import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; + +// The two ingest RPCs deliberately REUSE the batch/ack messages already defined +// for the site-hosted SiteStreamService rather than redeclaring them. The site +// telemetry actor builds AuditEventBatch / CachedTelemetryBatch today and hands +// them to ISiteStreamAuditClient; duplicating the shapes here would fork one +// wire contract into two that must be kept in lockstep by hand. +import "Protos/sitestream.proto"; + +// Central-hosted control plane (Phase 1A of the ClusterClient→gRPC migration). +// +// Direction: SITE is the client, CENTRAL is the server — the inverse of +// SiteStreamService, where central dials the site. That asymmetry is deliberate +// and mirrors the direction the Akka ClusterClient traffic flows today: these +// seven calls are exactly the seven messages SiteCommunicationActor sends to +// /user/central-communication. +// +// Every call is gated by ControlPlaneAuthInterceptor: `authorization: Bearer ` +// plus the `x-scadabridge-site` metadata header naming which site's preshared key +// central must verify against. +service CentralControlService { + // Store-and-forward handoff of one notification for central delivery. The + // ack is idempotent on notification_id — a duplicate submit after a lost ack + // must not produce a second delivery. + rpc SubmitNotification(NotificationSubmitDto) returns (NotificationSubmitAckDto); + + // Notify.Status(id) round-trip for a notification that has already left the + // site buffer. `found = false` sends the caller back to the site-local buffer + // to decide Forwarding vs Unknown. + rpc QueryNotificationStatus(NotificationStatusQueryDto) returns (NotificationStatusResponseDto); + + // Audit Log (#23) push telemetry. Reuses the SiteStreamService messages: the + // batch a site drains from its SQLite hot path is byte-identical whichever + // transport carries it. + rpc IngestAuditEvents(sitestream.AuditEventBatch) returns (sitestream.IngestAck); + + // Audit Log (#23) M3 combined cached-call telemetry (audit row + SiteCalls + // operational upsert, written in one central transaction). + rpc IngestCachedTelemetry(sitestream.CachedTelemetryBatch) returns (sitestream.IngestAck); + + // Node-startup self-heal: the node's local deployed inventory in, fetch + // tokens for whatever it is missing or stale out. + rpc ReconcileSite(ReconcileSiteRequestDto) returns (ReconcileSiteResponseDto); + + // Periodic site health report (30 s cadence). The ack makes delivery + // observable end-to-end so the sender can restore its per-interval counters + // when a report is lost. + rpc ReportSiteHealth(SiteHealthReportDto) returns (SiteHealthReportAckDto); + + // Application heartbeat. Returns Empty because the message is + // fire-and-forget: nothing on the site consumes a reply, and a failure here + // must never surface as a fault on the heartbeat timer path. + rpc Heartbeat(HeartbeatDto) returns (google.protobuf.Empty); +} + +// --------------------------------------------------------------------------- +// Notification Outbox (#21) +// --------------------------------------------------------------------------- + +// Site -> Central: submit a buffered notification for central delivery. +// Mirrors Commons NotificationSubmit. +message NotificationSubmitDto { + string notification_id = 1; // GUID string, the idempotency key + string list_name = 2; + string subject = 3; + string body = 4; + string source_site_id = 5; + string source_instance_id = 6; // empty string represents null + string source_script = 7; // empty string represents null + google.protobuf.Timestamp site_enqueued_at = 8; + string origin_execution_id = 9; // GUID string; empty represents null + string origin_parent_execution_id = 10; // GUID string; empty represents null + string source_node = 11; // empty string represents null +} + +// Central -> Site: ack sent after the Notifications row is persisted. +message NotificationSubmitAckDto { + string notification_id = 1; + bool accepted = 2; + string error = 3; // empty string represents null +} + +// Site -> Central: Notify.Status(id) lookup against the central outbox. +message NotificationStatusQueryDto { + string correlation_id = 1; + string notification_id = 2; +} + +// Central -> Site: current central delivery state for a queried notification. +message NotificationStatusResponseDto { + string correlation_id = 1; + bool found = 2; + string status = 3; + int32 retry_count = 4; + string last_error = 5; // empty string represents null + google.protobuf.Timestamp delivered_at = 6; // absent when null +} + +// --------------------------------------------------------------------------- +// Startup reconciliation (Deployment Manager) +// --------------------------------------------------------------------------- + +// Site -> Central: the node's local deployed inventory at startup. +message ReconcileSiteRequestDto { + string site_identifier = 1; + string node_id = 2; + // Instance unique name -> revision hash of the config the node currently holds. + map local_name_to_revision_hash = 3; +} + +// Central -> Site: the gap the node must (re)fetch, plus orphans to log. +message ReconcileSiteResponseDto { + repeated ReconcileGapItemDto gap = 1; + repeated string orphan_names = 2; + string central_fetch_base_url = 3; +} + +// One instance the node must (re)fetch, with a freshly-minted short-TTL token. +message ReconcileGapItemDto { + string instance_unique_name = 1; + string deployment_id = 2; + string revision_hash = 3; + bool is_enabled = 4; + string fetch_token = 5; +} + +// --------------------------------------------------------------------------- +// Health Monitoring (#11) +// --------------------------------------------------------------------------- + +// Wire form of the Commons ConnectionHealth enum. +// +// CONNECTION_HEALTH_UNSPECIFIED exists only to keep the proto3 zero value from +// meaning something. Mapping Connected onto 0 would make an absent/garbled +// value decode as "healthy", which is precisely the wrong direction to fail; +// the mapper decodes UNSPECIFIED as Error instead and never emits it. +enum ConnectionHealthEnum { + CONNECTION_HEALTH_UNSPECIFIED = 0; + CONNECTION_HEALTH_CONNECTED = 1; + CONNECTION_HEALTH_DISCONNECTED = 2; + CONNECTION_HEALTH_CONNECTING = 3; + CONNECTION_HEALTH_ERROR = 4; +} + +message TagResolutionStatusDto { + int32 total_subscribed = 1; + int32 successfully_resolved = 2; +} + +message TagQualityCountsDto { + int32 good = 1; + int32 bad = 2; + int32 uncertain = 3; +} + +message NodeStatusDto { + string hostname = 1; + bool is_online = 2; + string role = 3; +} + +// Point-in-time snapshot of the site-local SQLite audit queue. +message SiteAuditBacklogSnapshotDto { + int32 pending_count = 1; + google.protobuf.Timestamp oldest_pending_utc = 2; // absent when the queue is empty + int64 on_disk_bytes = 3; +} + +// The three collection wrappers below exist so null and empty stay +// distinguishable. proto3 cannot express presence on a `repeated` or `map` +// field — an unset one and an empty one are the same bytes — but the +// corresponding SiteHealthReport members are genuinely nullable +// (SiteHealthCollector emits `ClusterNodes: _clusterNodes?.ToList()`), and the +// central health surface reads null as "this producer doesn't report the +// signal" rather than "the signal is empty". Wrapping in a message restores +// message presence and makes the distinction survive the round-trip. + +message ConnectionEndpointMapDto { + map entries = 1; +} + +message TagQualityMapDto { + map entries = 1; +} + +message NodeStatusListDto { + repeated NodeStatusDto nodes = 1; +} + +// Site -> Central: periodic site health report. Mirrors Commons SiteHealthReport. +// Additive-only evolution: field numbers are never reused. +message SiteHealthReportDto { + string site_id = 1; + int64 sequence_number = 2; + google.protobuf.Timestamp report_timestamp = 3; + map data_connection_statuses = 4; + map tag_resolution_counts = 5; + int32 script_error_count = 6; + int32 alarm_evaluation_error_count = 7; + map store_and_forward_buffer_depths = 8; + int32 dead_letter_count = 9; + int32 deployed_instance_count = 10; + int32 enabled_instance_count = 11; + int32 disabled_instance_count = 12; + string node_role = 13; + string node_hostname = 14; + ConnectionEndpointMapDto data_connection_endpoints = 15; // absent when null + TagQualityMapDto data_connection_tag_quality = 16; // absent when null + int32 parked_message_count = 17; + NodeStatusListDto cluster_nodes = 18; // absent when null + int32 site_audit_write_failures = 19; + int32 audit_redaction_failure = 20; + SiteAuditBacklogSnapshotDto site_audit_backlog = 21; // absent when no data yet + int64 site_event_log_write_failures = 22; + google.protobuf.DoubleValue oldest_parked_message_age_seconds = 23; // absent when nothing parked + int32 script_queue_depth = 24; + int32 script_busy_threads = 25; + google.protobuf.DoubleValue script_oldest_busy_age_seconds = 26; // absent when the pool is idle + // Nullable on purpose: absent means "replication not wired on this node", + // which is NOT the same as false ("wired but currently disconnected"). + google.protobuf.BoolValue local_db_replication_connected = 27; + // Absent means UNKNOWN, never zero — a failed backlog read rendered as 0 + // would report a broken replication pair as perfectly healthy. + google.protobuf.Int64Value local_db_oplog_backlog = 28; +} + +// Central -> Site: health report ack, so a lost report is observable. +message SiteHealthReportAckDto { + string site_id = 1; + int64 sequence_number = 2; + bool accepted = 3; + string error = 4; // empty string represents null +} + +// Site -> Central: application heartbeat (fire-and-forget; reply is Empty). +message HeartbeatDto { + string site_id = 1; + string node_hostname = 2; + bool is_active = 3; + google.protobuf.Timestamp timestamp = 4; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj b/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj index 69074da1..116be08e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/ZB.MOM.WW.ScadaBridge.Communication.csproj @@ -32,20 +32,30 @@ - + central_control.proto imports sitestream.proto, so protoc resolves it + from the project-relative path without sitestream.proto needing its own + Protobuf item. + An ACTIVE Protobuf item must never be committed — it breaks the Docker + image build. Eventually we should switch the Docker build image to one + with a working protoc on arm64. --> diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs new file mode 100644 index 00000000..369841a9 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlDtoMapperTests.cs @@ -0,0 +1,643 @@ +using Google.Protobuf; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; + +/// +/// Round-trip golden tests for — the DTO bridge +/// for the seven CentralControlService RPCs (Phase 1A of the ClusterClient→gRPC +/// migration). +/// +/// +/// +/// Every message gets a pair: a FULLY-POPULATED case that proves no field is dropped, +/// and a MINIMAL case that proves every nullable/optional/empty-collection member comes +/// back as null-or-empty rather than as a zero-valued stand-in. The minimal cases are +/// the ones that matter: a per-field unit test happily passes while a whole optional +/// branch is silently never written, which is exactly the class of bug the round-trip +/// guard in PLAN-05 T8 caught five times over. +/// +/// +/// When a round-trip here fails, the mapper is wrong — not the test. +/// +/// +public class CentralControlDtoMapperTests +{ + private static readonly DateTimeOffset SampleInstant = + new(2026, 7, 22, 9, 30, 15, 250, TimeSpan.Zero); + + // ----------------------------------------------------------------------- + // NotificationSubmit / Ack + // ----------------------------------------------------------------------- + + [Fact] + public void NotificationSubmit_RoundTrip_FullyPopulated_PreservesEveryField() + { + var original = new NotificationSubmit( + NotificationId: Guid.NewGuid().ToString(), + ListName: "plant-ops", + Subject: "Tank 4 overfill", + Body: "Level exceeded 95% for 5 minutes.", + SourceSiteId: "site-a", + SourceInstanceId: "Tank04", + SourceScript: "OnLevelHigh", + SiteEnqueuedAt: SampleInstant, + OriginExecutionId: Guid.NewGuid(), + OriginParentExecutionId: Guid.NewGuid(), + SourceNode: "node-b"); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + // Every member is a scalar, so record value-equality is a true deep compare. + Assert.Equal(original, roundTripped); + } + + [Fact] + public void NotificationSubmit_RoundTrip_AllOptionalsNull_StayNull() + { + var original = new NotificationSubmit( + NotificationId: Guid.NewGuid().ToString(), + ListName: "plant-ops", + Subject: "s", + Body: "b", + SourceSiteId: "site-a", + SourceInstanceId: null, + SourceScript: null, + SiteEnqueuedAt: SampleInstant, + OriginExecutionId: null, + OriginParentExecutionId: null, + SourceNode: null); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + Assert.Equal(original, roundTripped); + Assert.Null(roundTripped.SourceInstanceId); + Assert.Null(roundTripped.SourceScript); + Assert.Null(roundTripped.OriginExecutionId); + Assert.Null(roundTripped.OriginParentExecutionId); + Assert.Null(roundTripped.SourceNode); + } + + [Fact] + public void NotificationSubmit_NonUtcOffset_NormalizesToTheSameInstant() + { + // Documented contract: a protobuf Timestamp is an instant, so the offset + // component of a DateTimeOffset does not survive. Every producer stamps UTC + // (Notify.Send uses DateTimeOffset.UtcNow), so the instant is what matters. + var melbourne = new DateTimeOffset(2026, 7, 22, 19, 30, 15, TimeSpan.FromHours(10)); + + var original = new NotificationSubmit( + "id", "list", "s", "b", "site-a", null, null, melbourne); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + Assert.Equal(melbourne.UtcDateTime, roundTripped.SiteEnqueuedAt.UtcDateTime); + Assert.Equal(TimeSpan.Zero, roundTripped.SiteEnqueuedAt.Offset); + } + + [Fact] + public void NotificationSubmitAck_RoundTrip_Accepted_And_Rejected() + { + var accepted = new NotificationSubmitAck("n1", Accepted: true, Error: null); + var rejected = new NotificationSubmitAck("n2", Accepted: false, Error: "list not found"); + + Assert.Equal(accepted, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted)))); + Assert.Equal(rejected, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(rejected)))); + Assert.Null(CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))).Error); + } + + // ----------------------------------------------------------------------- + // NotificationStatusQuery / Response + // ----------------------------------------------------------------------- + + [Fact] + public void NotificationStatusQuery_RoundTrip_PreservesEveryField() + { + var original = new NotificationStatusQuery("corr-1", Guid.NewGuid().ToString()); + + Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)))); + } + + [Fact] + public void NotificationStatusResponse_RoundTrip_FullyPopulated_PreservesEveryField() + { + var original = new NotificationStatusResponse( + CorrelationId: "corr-1", + Found: true, + Status: "Delivered", + RetryCount: 3, + LastError: "smtp 421", + DeliveredAt: SampleInstant); + + Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)))); + } + + [Fact] + public void NotificationStatusResponse_RoundTrip_NotFound_LeavesOptionalsNull() + { + var original = new NotificationStatusResponse( + CorrelationId: "corr-1", + Found: false, + Status: "Unknown", + RetryCount: 0, + LastError: null, + DeliveredAt: null); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + Assert.Equal(original, roundTripped); + Assert.Null(roundTripped.LastError); + Assert.Null(roundTripped.DeliveredAt); + } + + // ----------------------------------------------------------------------- + // Audit ingest envelopes (rows delegate to AuditEventDtoMapper / SiteCallDtoMapper) + // ----------------------------------------------------------------------- + + [Fact] + public void IngestAuditEventsCommand_RoundTrip_PreservesOrderAndRows() + { + var first = NewAuditEvent(sourceScript: "OnDemand"); + var second = NewAuditEvent(sourceScript: "OnTrigger"); + var original = new IngestAuditEventsCommand([first, second]); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + Assert.Equal(2, roundTripped.Events.Count); + Assert.Equal(first.AsRow().EventId, roundTripped.Events[0].AsRow().EventId); + Assert.Equal("OnDemand", roundTripped.Events[0].AsRow().SourceScript); + Assert.Equal(second.AsRow().EventId, roundTripped.Events[1].AsRow().EventId); + Assert.Equal("OnTrigger", roundTripped.Events[1].AsRow().SourceScript); + } + + [Fact] + public void IngestAuditEventsCommand_RoundTrip_EmptyBatch_StaysEmpty() + { + var roundTripped = CentralControlDtoMapper.FromDto( + Wire(CentralControlDtoMapper.ToDto(new IngestAuditEventsCommand([])))); + + Assert.Empty(roundTripped.Events); + } + + [Fact] + public void IngestCachedTelemetryCommand_RoundTrip_PreservesBothHalvesOfEachPacket() + { + var audit = NewAuditEvent(sourceScript: "OnDemand"); + var siteCall = NewSiteCall(); + var original = new IngestCachedTelemetryCommand([new CachedTelemetryEntry(audit, siteCall)]); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + var entry = Assert.Single(roundTripped.Entries); + Assert.Equal(audit.AsRow().EventId, entry.Audit.AsRow().EventId); + + // IngestedAtUtc is central-set inside the dual-write transaction and is + // deliberately off the wire, so it is the one member excluded from the compare. + Assert.Equal(siteCall with { IngestedAtUtc = default }, entry.SiteCall with { IngestedAtUtc = default }); + } + + [Fact] + public void IngestCachedTelemetryCommand_RoundTrip_NullableSiteCallFields_StayNull() + { + var siteCall = NewSiteCall() with + { + SourceNode = null, + LastError = null, + HttpStatus = null, + TerminalAtUtc = null, + }; + var original = new IngestCachedTelemetryCommand( + [new CachedTelemetryEntry(NewAuditEvent(), siteCall)]); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + var entry = Assert.Single(roundTripped.Entries); + Assert.Null(entry.SiteCall.SourceNode); + Assert.Null(entry.SiteCall.LastError); + Assert.Null(entry.SiteCall.HttpStatus); + Assert.Null(entry.SiteCall.TerminalAtUtc); + } + + [Fact] + public void IngestCachedTelemetryCommand_RoundTrip_EmptyBatch_StaysEmpty() + { + var roundTripped = CentralControlDtoMapper.FromDto( + Wire(CentralControlDtoMapper.ToDto(new IngestCachedTelemetryCommand([])))); + + Assert.Empty(roundTripped.Entries); + } + + [Fact] + public void IngestAck_RoundTrip_PreservesIdsAndOrder() + { + var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() }; + + var roundTripped = CentralControlDtoMapper.FromIngestAck( + Wire(CentralControlDtoMapper.ToIngestAck(ids))); + + Assert.Equal(ids, roundTripped); + } + + [Fact] + public void IngestAck_RoundTrip_NothingAccepted_StaysEmpty() + { + // An empty ack is meaningful — "zero rows persisted" leaves the site's rows + // Pending — so it must not be indistinguishable from a dropped field. + Assert.Empty(CentralControlDtoMapper.FromIngestAck( + Wire(CentralControlDtoMapper.ToIngestAck([])))); + } + + // ----------------------------------------------------------------------- + // Startup reconciliation + // ----------------------------------------------------------------------- + + [Fact] + public void ReconcileSiteRequest_RoundTrip_PreservesInventoryMap() + { + var original = new ReconcileSiteRequest( + SiteIdentifier: "site-a", + NodeId: "node-a", + LocalNameToRevisionHash: new Dictionary + { + ["Plant.Tank04"] = "hash-1", + ["Plant.Pump01"] = "hash-2", + }); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + Assert.Equal(original.SiteIdentifier, roundTripped.SiteIdentifier); + Assert.Equal(original.NodeId, roundTripped.NodeId); + Assert.Equal( + original.LocalNameToRevisionHash.OrderBy(kv => kv.Key), + roundTripped.LocalNameToRevisionHash.OrderBy(kv => kv.Key)); + } + + [Fact] + public void ReconcileSiteRequest_RoundTrip_EmptyInventory_StaysEmpty() + { + // A node with nothing deployed is the normal first-boot case, and central + // must see an empty inventory rather than a missing one. + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto( + new ReconcileSiteRequest("site-a", "node-a", new Dictionary())))); + + Assert.Empty(roundTripped.LocalNameToRevisionHash); + } + + [Fact] + public void ReconcileSiteResponse_RoundTrip_PreservesGapOrphansAndUrl() + { + var original = new ReconcileSiteResponse( + Gap: + [ + new ReconcileGapItem("Plant.Tank04", "dep-1", "hash-1", IsEnabled: true, "token-1"), + new ReconcileGapItem("Plant.Pump01", "dep-2", "hash-2", IsEnabled: false, "token-2"), + ], + OrphanNames: ["Plant.Retired01", "Plant.Retired02"], + CentralFetchBaseUrl: "http://scadabridge-central-a:5000"); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + Assert.Equal(original.Gap, roundTripped.Gap); + Assert.Equal(original.OrphanNames, roundTripped.OrphanNames); + Assert.Equal(original.CentralFetchBaseUrl, roundTripped.CentralFetchBaseUrl); + } + + [Fact] + public void ReconcileSiteResponse_RoundTrip_NoGap_StaysEmpty() + { + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto( + new ReconcileSiteResponse([], [], "http://central:5000")))); + + Assert.Empty(roundTripped.Gap); + Assert.Empty(roundTripped.OrphanNames); + } + + // ----------------------------------------------------------------------- + // Site health + // ----------------------------------------------------------------------- + + [Fact] + public void SiteHealthReport_RoundTrip_FullyPopulated_PreservesEveryField() + { + var original = FullyPopulatedHealthReport(); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + AssertHealthReportsEqual(original, roundTripped); + } + + [Fact] + public void SiteHealthReport_RoundTrip_MinimalReport_LeavesEveryOptionalNullOrEmpty() + { + // The shape a producer emits before any reporter has run: no endpoints map, no + // tag-quality map, no cluster-node list, no audit backlog, no nullable gauges. + var original = new SiteHealthReport( + SiteId: "site-a", + SequenceNumber: 1, + ReportTimestamp: SampleInstant, + DataConnectionStatuses: new Dictionary(), + TagResolutionCounts: new Dictionary(), + ScriptErrorCount: 0, + AlarmEvaluationErrorCount: 0, + StoreAndForwardBufferDepths: new Dictionary(), + DeadLetterCount: 0, + DeployedInstanceCount: 0, + EnabledInstanceCount: 0, + DisabledInstanceCount: 0); + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + AssertHealthReportsEqual(original, roundTripped); + + Assert.Empty(roundTripped.DataConnectionStatuses); + Assert.Empty(roundTripped.TagResolutionCounts); + Assert.Empty(roundTripped.StoreAndForwardBufferDepths); + Assert.Null(roundTripped.DataConnectionEndpoints); + Assert.Null(roundTripped.DataConnectionTagQuality); + Assert.Null(roundTripped.ClusterNodes); + Assert.Null(roundTripped.SiteAuditBacklog); + Assert.Null(roundTripped.OldestParkedMessageAgeSeconds); + Assert.Null(roundTripped.ScriptOldestBusyAgeSeconds); + Assert.Null(roundTripped.LocalDbReplicationConnected); + Assert.Null(roundTripped.LocalDbOplogBacklog); + } + + [Fact] + public void SiteHealthReport_RoundTrip_EmptyNullableCollections_StayEmptyNotNull() + { + // The distinction the three wrapper messages exist for. Null means "this node + // does not report the signal"; empty means "it reports it, and there is + // nothing in it". proto3 cannot express presence on repeated/map fields, so + // collapsing one into the other here would be invisible until an operator + // misread the health page. + var original = FullyPopulatedHealthReport() with + { + DataConnectionEndpoints = new Dictionary(), + DataConnectionTagQuality = new Dictionary(), + ClusterNodes = [], + }; + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + Assert.NotNull(roundTripped.DataConnectionEndpoints); + Assert.Empty(roundTripped.DataConnectionEndpoints); + Assert.NotNull(roundTripped.DataConnectionTagQuality); + Assert.Empty(roundTripped.DataConnectionTagQuality); + Assert.NotNull(roundTripped.ClusterNodes); + Assert.Empty(roundTripped.ClusterNodes); + } + + [Fact] + public void SiteHealthReport_RoundTrip_FalseAndZeroGauges_StayFalseAndZero() + { + // The mirror of the null case: LocalDbReplicationConnected=false ("configured + // but disconnected") and LocalDbOplogBacklog=0 ("healthy, nothing queued") are + // real values that must not decay into null on the wire. + var original = FullyPopulatedHealthReport() with + { + LocalDbReplicationConnected = false, + LocalDbOplogBacklog = 0, + OldestParkedMessageAgeSeconds = 0d, + ScriptOldestBusyAgeSeconds = 0d, + }; + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + Assert.False(roundTripped.LocalDbReplicationConnected); + Assert.Equal(0L, roundTripped.LocalDbOplogBacklog); + Assert.Equal(0d, roundTripped.OldestParkedMessageAgeSeconds); + Assert.Equal(0d, roundTripped.ScriptOldestBusyAgeSeconds); + } + + [Fact] + public void SiteHealthReport_RoundTrip_BacklogWithEmptyQueue_KeepsNullOldestPending() + { + var original = FullyPopulatedHealthReport() with + { + SiteAuditBacklog = new SiteAuditBacklogSnapshot(0, null, 4096), + }; + + var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))); + + Assert.Equal(new SiteAuditBacklogSnapshot(0, null, 4096), roundTripped.SiteAuditBacklog); + } + + [Fact] + public void SiteHealthReportAck_RoundTrip_Accepted_And_Rejected() + { + var accepted = new SiteHealthReportAck("site-a", 42, Accepted: true); + var rejected = new SiteHealthReportAck("site-a", 43, Accepted: false, Error: "aggregator down"); + + Assert.Equal(accepted, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted)))); + Assert.Equal(rejected, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(rejected)))); + Assert.Null(CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))).Error); + } + + // ----------------------------------------------------------------------- + // Heartbeat + ConnectionHealth enum + // ----------------------------------------------------------------------- + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void HeartbeatMessage_RoundTrip_PreservesEveryField(bool isActive) + { + var original = new HeartbeatMessage("site-a", "scadabridge-site-a-node-b", isActive, SampleInstant); + + Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)))); + } + + [Theory] + [InlineData(ConnectionHealth.Connected)] + [InlineData(ConnectionHealth.Disconnected)] + [InlineData(ConnectionHealth.Connecting)] + [InlineData(ConnectionHealth.Error)] + public void ConnectionHealth_RoundTrip_EveryValueSurvives(ConnectionHealth health) + { + Assert.Equal(health, CentralControlDtoMapper.FromDto(CentralControlDtoMapper.ToDto(health))); + } + + [Fact] + public void ConnectionHealth_Unspecified_DecodesToErrorNotConnected() + { + // Fail-safe direction: a wire value this build does not recognise must never + // render as "healthy" on the central health page. + Assert.Equal( + ConnectionHealth.Error, + CentralControlDtoMapper.FromDto(ConnectionHealthEnum.ConnectionHealthUnspecified)); + } + + [Fact] + public void ConnectionHealth_EveryEnumMemberIsMapped() + { + // Guards the ArgumentOutOfRangeException path: adding a ConnectionHealth value + // without extending the mapper should fail here, not silently on a rig. + foreach (var health in Enum.GetValues()) + { + var wire = CentralControlDtoMapper.ToDto(health); + Assert.NotEqual(ConnectionHealthEnum.ConnectionHealthUnspecified, wire); + } + } + + // ----------------------------------------------------------------------- + // Fixtures + // ----------------------------------------------------------------------- + + private static SiteHealthReport FullyPopulatedHealthReport() => + new( + SiteId: "site-a", + SequenceNumber: 987654321L, + ReportTimestamp: SampleInstant, + DataConnectionStatuses: new Dictionary + { + ["opc-main"] = ConnectionHealth.Connected, + ["mx-gateway"] = ConnectionHealth.Error, + ["opc-backup"] = ConnectionHealth.Connecting, + ["legacy"] = ConnectionHealth.Disconnected, + }, + TagResolutionCounts: new Dictionary + { + ["opc-main"] = new(TotalSubscribed: 120, SuccessfullyResolved: 118), + }, + ScriptErrorCount: 4, + AlarmEvaluationErrorCount: 2, + StoreAndForwardBufferDepths: new Dictionary + { + ["erp"] = 17, + ["mes"] = 0, + }, + DeadLetterCount: 5, + DeployedInstanceCount: 30, + EnabledInstanceCount: 28, + DisabledInstanceCount: 2, + NodeRole: "Active", + NodeHostname: "scadabridge-site-a-node-a", + DataConnectionEndpoints: new Dictionary + { + ["opc-main"] = "opc.tcp://opcua:4840", + }, + DataConnectionTagQuality: new Dictionary + { + ["opc-main"] = new(Good: 100, Bad: 3, Uncertain: 15), + }, + ParkedMessageCount: 6, + ClusterNodes: + [ + new NodeStatus("scadabridge-site-a-node-a", IsOnline: true, "Active"), + new NodeStatus("scadabridge-site-a-node-b", IsOnline: false, "Standby"), + ], + SiteAuditWriteFailures: 7, + AuditRedactionFailure: 8, + SiteAuditBacklog: new SiteAuditBacklogSnapshot( + PendingCount: 42, + OldestPendingUtc: new DateTime(2026, 7, 22, 8, 0, 0, DateTimeKind.Utc), + OnDiskBytes: 1_234_567L), + SiteEventLogWriteFailures: 9L, + OldestParkedMessageAgeSeconds: 3600.5d) + { + ScriptQueueDepth = 11, + ScriptBusyThreads = 3, + ScriptOldestBusyAgeSeconds = 12.25d, + LocalDbReplicationConnected = true, + LocalDbOplogBacklog = 250L, + }; + + /// + /// Compares two reports member by member. is a record, + /// but its dictionary/list members compare by reference under record equality, so + /// Assert.Equal(a, b) would pass trivially for the scalars and never look + /// inside the collections — the exact blind spot these goldens exist to close. + /// + private static void AssertHealthReportsEqual(SiteHealthReport expected, SiteHealthReport actual) + { + Assert.Equal(expected.SiteId, actual.SiteId); + Assert.Equal(expected.SequenceNumber, actual.SequenceNumber); + Assert.Equal(expected.ReportTimestamp, actual.ReportTimestamp); + Assert.Equal( + expected.DataConnectionStatuses.OrderBy(kv => kv.Key), + actual.DataConnectionStatuses.OrderBy(kv => kv.Key)); + Assert.Equal( + expected.TagResolutionCounts.OrderBy(kv => kv.Key), + actual.TagResolutionCounts.OrderBy(kv => kv.Key)); + Assert.Equal(expected.ScriptErrorCount, actual.ScriptErrorCount); + Assert.Equal(expected.AlarmEvaluationErrorCount, actual.AlarmEvaluationErrorCount); + Assert.Equal( + expected.StoreAndForwardBufferDepths.OrderBy(kv => kv.Key), + actual.StoreAndForwardBufferDepths.OrderBy(kv => kv.Key)); + Assert.Equal(expected.DeadLetterCount, actual.DeadLetterCount); + Assert.Equal(expected.DeployedInstanceCount, actual.DeployedInstanceCount); + Assert.Equal(expected.EnabledInstanceCount, actual.EnabledInstanceCount); + Assert.Equal(expected.DisabledInstanceCount, actual.DisabledInstanceCount); + Assert.Equal(expected.NodeRole, actual.NodeRole); + Assert.Equal(expected.NodeHostname, actual.NodeHostname); + Assert.Equal( + expected.DataConnectionEndpoints?.OrderBy(kv => kv.Key), + actual.DataConnectionEndpoints?.OrderBy(kv => kv.Key)); + Assert.Equal( + expected.DataConnectionTagQuality?.OrderBy(kv => kv.Key), + actual.DataConnectionTagQuality?.OrderBy(kv => kv.Key)); + Assert.Equal(expected.ParkedMessageCount, actual.ParkedMessageCount); + Assert.Equal(expected.ClusterNodes, actual.ClusterNodes); + Assert.Equal(expected.SiteAuditWriteFailures, actual.SiteAuditWriteFailures); + Assert.Equal(expected.AuditRedactionFailure, actual.AuditRedactionFailure); + Assert.Equal(expected.SiteAuditBacklog, actual.SiteAuditBacklog); + Assert.Equal(expected.SiteEventLogWriteFailures, actual.SiteEventLogWriteFailures); + Assert.Equal(expected.OldestParkedMessageAgeSeconds, actual.OldestParkedMessageAgeSeconds); + Assert.Equal(expected.ScriptQueueDepth, actual.ScriptQueueDepth); + Assert.Equal(expected.ScriptBusyThreads, actual.ScriptBusyThreads); + Assert.Equal(expected.ScriptOldestBusyAgeSeconds, actual.ScriptOldestBusyAgeSeconds); + Assert.Equal(expected.LocalDbReplicationConnected, actual.LocalDbReplicationConnected); + Assert.Equal(expected.LocalDbOplogBacklog, actual.LocalDbOplogBacklog); + } + + /// + /// Serializes a wire message and parses it back, so every round-trip below crosses a + /// real protobuf encode/decode rather than only exercising the mapper's object graph. + /// This is what proves the three collection wrapper messages keep their presence + /// when empty — an empty message encodes as a tag with zero-length payload, and a + /// mapper that used a bare repeated/map field instead would decode an + /// empty collection back as null with no test-visible difference at the object level. + /// + /// Generated protobuf message type. + /// The message to send through an encode/decode cycle. + /// An independent instance parsed from 's bytes. + private static T Wire(T message) where T : IMessage, new() => + new MessageParser(() => new T()).ParseFrom(message.ToByteArray()); + + private static ZB.MOM.WW.Audit.AuditEvent NewAuditEvent(string? sourceScript = null) => + ScadaBridgeAuditEventFactory.Create( + channel: AuditChannel.ApiOutbound, + kind: AuditKind.ApiCallCached, + status: AuditStatus.Forwarded, + eventId: Guid.NewGuid(), + occurredAtUtc: new DateTime(2026, 7, 22, 9, 0, 0, DateTimeKind.Utc), + target: "ERP.GetOrder", + sourceSiteId: "site-a", + sourceNode: "node-a", + sourceScript: sourceScript); + + private static SiteCall NewSiteCall() => new() + { + TrackedOperationId = TrackedOperationId.New(), + Channel = "ApiOutbound", + Target = "ERP.GetOrder", + SourceSite = "site-a", + SourceNode = "node-a", + Status = "Delivered", + RetryCount = 2, + LastError = "transient 503", + HttpStatus = 200, + CreatedAtUtc = new DateTime(2026, 7, 22, 8, 0, 0, DateTimeKind.Utc), + UpdatedAtUtc = new DateTime(2026, 7, 22, 8, 5, 0, DateTimeKind.Utc), + TerminalAtUtc = new DateTime(2026, 7, 22, 8, 10, 0, DateTimeKind.Utc), + IngestedAtUtc = new DateTime(2026, 7, 22, 8, 10, 1, DateTimeKind.Utc), + }; +} From 780bb9c3690715ef718cf3da612bbb52913aade0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 22 Jul 2026 18:53:59 -0400 Subject: [PATCH 2/5] feat(grpc): host CentralControlService on the central node (T1A.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Central now ALSO listens for the seven site→central control messages over gRPC, alongside the existing ClusterClient path. Nothing flips to gRPC yet — sites keep CentralTransport=Akka (T1A.3's job); central simply starts also accepting. - CentralControlGrpcService (Communication.Grpc): decodes each RPC onto the SAME in-process message the ClusterClient path carries, Asks the existing CentralCommunicationActor (zero handler-logic changes), encodes the reply via the T1A.1 mapper. Readiness-gated like SiteStreamGrpcServer.SetReady — Unavailable until AkkaHostedService hands the actor over. Heartbeat stays fire-and-forget (Tell, always-OK, never gated on readiness). Ingest reuses the shared SiteStreamGrpcServer.AuditIngestAskTimeout constant. Fault→status mapping is retry-aware: Unavailable (never dispatched, safe to cross-node retry) vs DeadlineExceeded/Internal (it ran, do not re-send elsewhere). - CentralControlAuthInterceptor (Host): a SEPARATE interceptor class, not a variant constructor on ControlPlaneAuthInterceptor. Central's model is per-site (verify the Bearer token against the key for the site in the required x-scadabridge-site header, via ISitePskProvider) where a site verifies its one own-key — a genuinely different model. Fail-closed on every branch: missing or blank header, unresolvable key, and mismatched token all → PermissionDenied, never pass-through. One public constructor only (the explicit-prefix ctor is internal), pinned by a reflection test — a second public ctor makes Grpc.AspNetCore's GetFactory() throw per-call and silently disables the gate. - Explicit Kestrel h2c listener on new option ScadaBridge:Node:CentralGrpcPort (default 8083, symmetric with sites), mirroring the Site branch. Additive to central's :5000 HTTP/1 surface, which is untouched — gRPC does NOT go through Traefik (HTTP/1 only). Registered by type on AddGrpc; service mapped with MapGrpcService. Port range-validated by NodeOptionsValidator. - Rig: publish the central gRPC port 9013:8083 / 9014:8083 on both central nodes so a later task can exercise it. Tests: CentralControlEndToEndTests (Host.Tests, TestServer + real interceptor + real service over a stub actor) proves auth positives/negatives are distinguishable and covers unary + the ingest bridge shapes; the interceptor is registered BY TYPE, never in DI. CentralControlAuthInterceptorTests pins the per-site gate + one-public-ctor invariant. CentralControlGrpcServiceTests (Communication.Tests, TestKit) covers the readiness gate, fire-and-forget heartbeat, and the DeadlineExceeded-vs-Unavailable status mapping. No active item. Communication.Tests (356) + Host.Tests (384) green. --- docker/docker-compose.yml | 2 + .../Grpc/CentralControlGrpcService.cs | 299 ++++++++++++++++++ .../Actors/AkkaHostedService.cs | 13 + .../CentralControlAuthInterceptor.cs | 245 ++++++++++++++ src/ZB.MOM.WW.ScadaBridge.Host/NodeOptions.cs | 9 + .../NodeOptionsValidator.cs | 1 + src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 42 +++ .../Grpc/CentralControlGrpcServiceTests.cs | 181 +++++++++++ .../CentralControlAuthInterceptorTests.cs | 197 ++++++++++++ .../CentralControlEndToEndTests.cs | 256 +++++++++++++++ 10 files changed, 1245 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlGrpcService.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Host/CentralControlAuthInterceptor.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlAuthInterceptorTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlEndToEndTests.cs diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2643b862..39db2252 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -39,6 +39,7 @@ services: ports: - "9001:5000" # Web UI + Inbound API - "9011:8081" # Akka remoting (host access for CLI/debugging) + - "9013:8083" # gRPC control plane (CentralControlService, T1A.2) volumes: - ./central-node-a/appsettings.Central.json:/app/appsettings.Central.json:ro - ./central-node-a/logs:/app/logs @@ -86,6 +87,7 @@ services: ports: - "9002:5000" # Web UI + Inbound API - "9012:8081" # Akka remoting + - "9014:8083" # gRPC control plane (CentralControlService, T1A.2) volumes: - ./central-node-b/appsettings.Central.json:/app/appsettings.Central.json:ro - ./central-node-b/logs:/app/logs diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlGrpcService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlGrpcService.cs new file mode 100644 index 00000000..5ffcba68 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralControlGrpcService.cs @@ -0,0 +1,299 @@ +using Akka.Actor; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using GrpcStatus = Grpc.Core.Status; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +/// +/// Central-hosted gRPC face of the seven site→central control messages +/// (Protos/central_control.proto). Decodes each request onto the SAME in-process +/// message type the Akka ClusterClient path already carries, Asks +/// , and encodes the reply back. +/// +/// +/// +/// Direction is inverted from . That server runs on a +/// site and central dials in; this one runs on CENTRAL and the site dials in. The two listen on +/// the same port number (8083) on their respective nodes, which is symmetry, not a collision — +/// a node is either central or a site, never both. +/// +/// +/// Zero handler logic lives here. Every RPC lands on a receive +/// CentralCommunicationActor already implements for the ClusterClient path, so the two +/// transports cannot drift in behaviour: the actor is the single implementation, and this class +/// is a codec plus an Ask. That is also why the service takes the actor through +/// rather than resolving anything from DI — the actor is created by the +/// host's Akka bootstrap, not by the container. +/// +/// +/// Fault semantics deliberately differ from 's ingest +/// RPCs. That server answers a failed audit ingest with an EMPTY IngestAck; this one +/// fails the call with a non-OK status. Both leave the site's rows Pending for the next +/// drain, so the outcome is the same — but this service replaces the ClusterClient path, whose +/// documented behaviour is to propagate the fault (CentralCommunicationActor's +/// HandleIngestAuditEvents pipes a Status.Failure back), and preserving that keeps +/// a lost batch visible as a failure rather than as a successful call that acked nothing. +/// +/// +/// Status mapping, and why it is not uniform. A site transport may safely re-send a call +/// to the peer central node only when the call provably never ran. So: +/// +/// +/// — this node is not ready; nothing was dispatched, +/// so a cross-node retry is safe and correct. +/// — the Ask timed out. The message WAS +/// delivered and may have been processed; retrying it on the other node would duplicate work. +/// — the handler faulted (a piped +/// , e.g. a database error inside reconcile). Same +/// reasoning: it ran, so do not re-send it elsewhere. +/// +/// +public sealed class CentralControlGrpcService : CentralControlService.CentralControlServiceBase +{ + private readonly ILogger _logger; + private readonly CommunicationOptions _options; + + // Null until the host's Akka bootstrap hands the actor over. Doubles as the readiness + // flag: a call arriving before then cannot be served and is refused with Unavailable. + // Volatile because SetReady runs on the startup thread while calls are served on + // Kestrel's thread pool. + private volatile IActorRef? _central; + + /// + /// Creates the service. This must remain the only public constructor — see + /// for how the actor arrives, and the Host's + /// CentralControlAuthInterceptor for the interceptor-side version of the same rule. + /// + /// Logger for readiness and fault diagnostics. + /// Communication options supplying the per-RPC Ask timeouts. + public CentralControlGrpcService( + ILogger logger, + IOptions options) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(options); + + _logger = logger; + _options = options.Value; + } + + /// + /// Hands the CentralCommunicationActor to the service and opens the gate. Mirrors + /// : the gRPC service is a DI singleton created + /// before the actor system exists, so the actor arrives post-construction. + /// + /// + /// The contract is deliberately narrow, exactly as on the site side: it asserts that the + /// actor exists and can receive, NOT that every downstream singleton proxy + /// (notification-outbox, audit-log-ingest) has registered itself yet. Those + /// register moments later in the same startup path, and the actor already answers a call + /// that beats them with the same "not available, retry" reply it gives on the ClusterClient + /// path — so gating readiness on them would add nothing but a longer window in which sites + /// see . + /// + /// The central communication actor. + public void SetReady(IActorRef centralCommunicationActor) + { + ArgumentNullException.ThrowIfNull(centralCommunicationActor); + _central = centralCommunicationActor; + } + + /// Exposed for wiring assertions in tests. + internal bool IsReady => _central is not null; + + /// + public override async Task SubmitNotification( + NotificationSubmitDto request, ServerCallContext context) + { + var central = RequireReady(context); + var ack = await AskAsync( + central, + CentralControlDtoMapper.FromDto(request), + _options.NotificationForwardTimeout, + context).ConfigureAwait(false); + + return CentralControlDtoMapper.ToDto(ack); + } + + /// + public override async Task QueryNotificationStatus( + NotificationStatusQueryDto request, ServerCallContext context) + { + var central = RequireReady(context); + var response = await AskAsync( + central, + CentralControlDtoMapper.FromDto(request), + _options.NotificationForwardTimeout, + context).ConfigureAwait(false); + + return CentralControlDtoMapper.ToDto(response); + } + + /// + public override async Task IngestAuditEvents( + AuditEventBatch request, ServerCallContext context) + { + // An empty batch is a no-op the actor need never see; answering it here also means a + // site that drains an empty queue does not fail against a not-yet-ready central. + if (request.Events.Count == 0) + { + return new IngestAck(); + } + + var central = RequireReady(context); + var reply = await AskAsync( + central, + CentralControlDtoMapper.FromDto(request), + SiteStreamGrpcServer.AuditIngestAskTimeout, + context).ConfigureAwait(false); + + return CentralControlDtoMapper.ToIngestAck(reply.AcceptedEventIds); + } + + /// + public override async Task IngestCachedTelemetry( + CachedTelemetryBatch request, ServerCallContext context) + { + if (request.Packets.Count == 0) + { + return new IngestAck(); + } + + var central = RequireReady(context); + var reply = await AskAsync( + central, + CentralControlDtoMapper.FromDto(request), + SiteStreamGrpcServer.AuditIngestAskTimeout, + context).ConfigureAwait(false); + + return CentralControlDtoMapper.ToIngestAck(reply.AcceptedEventIds); + } + + /// + public override async Task ReconcileSite( + ReconcileSiteRequestDto request, ServerCallContext context) + { + var central = RequireReady(context); + var response = await AskAsync( + central, + CentralControlDtoMapper.FromDto(request), + _options.QueryTimeout, + context).ConfigureAwait(false); + + return CentralControlDtoMapper.ToDto(response); + } + + /// + public override async Task ReportSiteHealth( + SiteHealthReportDto request, ServerCallContext context) + { + var central = RequireReady(context); + var ack = await AskAsync( + central, + CentralControlDtoMapper.FromDto(request), + _options.HealthReportTimeout, + context).ConfigureAwait(false); + + return CentralControlDtoMapper.ToDto(ack); + } + + /// + /// Application heartbeat — always answers OK, even when this node is not ready. + /// + /// + /// The heartbeat is fire-and-forget on both sides: nothing on the site consumes the reply, + /// and the site's heartbeat timer must never take a fault (a failing heartbeat that raised + /// an error would be a self-inflicted outage on a purely informational signal). So this is + /// the one RPC that does not go through : a heartbeat arriving + /// before the actor exists is logged and dropped, exactly as the actor itself drops one + /// that arrives before ICentralHealthAggregator is resolvable. Liveness is still + /// detected — the aggregator's offline timeout fires when the heartbeats stop landing. + /// + /// The heartbeat. + /// The gRPC call context. + /// An empty reply, always. + public override Task Heartbeat(HeartbeatDto request, ServerCallContext context) + { + var central = _central; + if (central is null) + { + _logger.LogDebug( + "Dropped a heartbeat from site {SiteId}: the central communication actor is not " + + "ready yet. Heartbeats are fire-and-forget, so the call still succeeds.", + request.SiteId); + return Task.FromResult(new Empty()); + } + + // Tell, never Ask: the actor's HandleHeartbeat sends no reply. + central.Tell(CentralControlDtoMapper.FromDto(request), ActorRefs.NoSender); + return Task.FromResult(new Empty()); + } + + /// + /// Returns the central communication actor, or throws + /// when the host has not finished bringing the actor system up. + /// + private IActorRef RequireReady(ServerCallContext context) + { + var central = _central; + if (central is not null) + { + return central; + } + + _logger.LogWarning( + "Refused a control-plane call to {Method}: the central communication actor is not " + + "ready yet. Nothing was dispatched, so the caller may retry (including against " + + "the peer central node).", + context.Method); + + throw new RpcException(new GrpcStatus( + StatusCode.Unavailable, + "Central control plane is not ready: the actor system is still starting.")); + } + + /// + /// Asks the central actor and maps a fault onto the status code that tells the caller + /// whether a cross-node retry is safe. See the class remarks for the mapping rationale. + /// + private async Task AskAsync( + IActorRef central, object message, TimeSpan timeout, ServerCallContext context) + { + try + { + return await central.Ask(message, timeout, context.CancellationToken) + .ConfigureAwait(false); + } + catch (AskTimeoutException ex) + { + _logger.LogWarning(ex, + "Control-plane call {Method} timed out after {Timeout} waiting for the central " + + "communication actor.", + context.Method, timeout); + throw new RpcException(new GrpcStatus( + StatusCode.DeadlineExceeded, + $"Central did not answer within {timeout}.")); + } + catch (OperationCanceledException) + { + // The client gave up or its deadline expired; there is no one left to answer. + throw new RpcException(new GrpcStatus( + StatusCode.Cancelled, "The call was cancelled.")); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Control-plane call {Method} faulted inside the central communication actor.", + context.Method); + throw new RpcException(new GrpcStatus( + StatusCode.Internal, "Central failed to process the call.")); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs index fb41a772..7d58cc63 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs @@ -436,6 +436,19 @@ akka {{ ClusterClientReceptionist.Get(_actorSystem).RegisterService(centralCommActor); _logger.LogInformation("CentralCommunicationActor registered with ClusterClientReceptionist"); + // Hand the same actor to the central-hosted gRPC control plane (T1A.2) and open its + // readiness gate — the gRPC face Asks this exact actor, so both transports resolve to + // one handler implementation. Mirrors SiteStreamGrpcServer.SetReady on the site side: + // the service is a DI singleton created before the actor system exists, so the actor + // arrives here post-construction. Null on a host that did not register the service + // (e.g. an in-process test harness), so the wiring is a guarded no-op there. + var centralControlGrpc = _serviceProvider + .GetService(); + centralControlGrpc?.SetReady(centralCommActor); + _logger.LogInformation( + "CentralControlGrpcService readiness set (service bound: {Bound})", + centralControlGrpc is not null); + // Wire up the CommunicationService with the actor reference var commService = _serviceProvider.GetService(); commService?.SetCommunicationActor(centralCommActor); diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/CentralControlAuthInterceptor.cs b/src/ZB.MOM.WW.ScadaBridge.Host/CentralControlAuthInterceptor.cs new file mode 100644 index 00000000..9fb71bb0 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Host/CentralControlAuthInterceptor.cs @@ -0,0 +1,245 @@ +using System.Security.Cryptography; +using System.Text; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +namespace ZB.MOM.WW.ScadaBridge.Host; + +/// +/// Gates the central-hosted gRPC control plane (CentralControlService) with each site's +/// preshared key. The sibling of , but with the +/// verification model inverted: a site checks one bearer token against its own single key, whereas +/// central must check the presented token against the key belonging to the SITE that sent it. +/// +/// +/// +/// Why a separate class rather than a second constructor on +/// . Grpc.AspNetCore registers a +/// type-registered interceptor through InterceptorRegistration.GetFactory(), which throws +/// "Multiple constructors accepting all given argument types have been found" the moment a +/// second public constructor is applicable. That throw lands inside the pipeline on every call and +/// surfaces as Unknown / "Exception was thrown by handler" — the node boots healthy and +/// every gated call dies looking like a handler bug, with correct/wrong/no key all producing the +/// identical error. It shipped once with a fully green suite and was caught only on the rig. +/// Central's model genuinely differs (per-site key by header, not the site's one own-key), so it +/// gets its own class with its own single public constructor rather than a variant ctor on the +/// site interceptor. Both classes are pinned by a reflection test asserting exactly one public +/// constructor. +/// +/// +/// Fail-closed on every branch. A gated call is refused with +/// when: the x-scadabridge-site header is +/// missing or blank; no key can be resolved for that site ( throws); +/// or the presented bearer token does not match. There is no pass-through — an unresolvable or +/// absent identity never degrades to "let it in". Non-gated services (should any share the +/// listener) return immediately, matching the site interceptor's shape. +/// +/// +/// The comparison is over UTF-8, the same +/// constant-time compare the site interceptor and LocalDb sync use. +/// +/// +public sealed class CentralControlAuthInterceptor : Interceptor +{ + /// + /// Service prefixes gated by default — the one central-hosted control-plane service. Taken + /// from the generated package scadabridge.centralcontrol.v1; service CentralControlService. + /// + public static readonly IReadOnlyList DefaultGatedPrefixes = + new[] { $"/{CentralControlService.Descriptor.FullName}/" }; + + private readonly IReadOnlyList _gatedPrefixes; + private readonly ISitePskProvider _pskProvider; + private readonly ILogger _logger; + + /// + /// Creates the interceptor gating . + /// + /// + /// This must remain the ONLY public constructor — see the class remarks for why a + /// second one silently disables the gate. Pinned by + /// CentralControlAuthInterceptorTests.TheInterceptorHasExactlyOnePublicConstructor. + /// + /// Resolves each site's preshared key. + /// Logger for denial diagnostics. + public CentralControlAuthInterceptor( + ISitePskProvider pskProvider, + ILogger logger) + : this(pskProvider, logger, DefaultGatedPrefixes) + { + } + + /// + /// Creates the interceptor gating an explicit prefix set. Internal — a public second + /// constructor would reintroduce the ambiguous-constructor defect described on the class. + /// + /// Resolves each site's preshared key. + /// Logger for denial diagnostics. + /// Method-path prefixes to gate. + internal CentralControlAuthInterceptor( + ISitePskProvider pskProvider, + ILogger logger, + IReadOnlyList gatedPrefixes) + { + ArgumentNullException.ThrowIfNull(pskProvider); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(gatedPrefixes); + + _pskProvider = pskProvider; + _logger = logger; + _gatedPrefixes = gatedPrefixes; + } + + /// + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + await AuthorizeAsync(context).ConfigureAwait(false); + return await continuation(request, context).ConfigureAwait(false); + } + + /// + public override async Task DuplexStreamingServerHandler( + IAsyncStreamReader requestStream, + IServerStreamWriter responseStream, + ServerCallContext context, + DuplexStreamingServerMethod continuation) + { + await AuthorizeAsync(context).ConfigureAwait(false); + await continuation(requestStream, responseStream, context).ConfigureAwait(false); + } + + /// + public override async Task ClientStreamingServerHandler( + IAsyncStreamReader requestStream, + ServerCallContext context, + ClientStreamingServerMethod continuation) + { + await AuthorizeAsync(context).ConfigureAwait(false); + return await continuation(requestStream, context).ConfigureAwait(false); + } + + /// + public override async Task ServerStreamingServerHandler( + TRequest request, + IServerStreamWriter responseStream, + ServerCallContext context, + ServerStreamingServerMethod continuation) + { + await AuthorizeAsync(context).ConfigureAwait(false); + await continuation(request, responseStream, context).ConfigureAwait(false); + } + + /// + /// Throws with unless a + /// gated call carries a valid x-scadabridge-site header AND a bearer token matching + /// that site's resolved key. Non-gated calls return immediately. + /// + private async Task AuthorizeAsync(ServerCallContext context) + { + if (!IsGated(context.Method)) + { + return; + } + + var siteId = ExtractSiteId(context.RequestHeaders); + if (string.IsNullOrWhiteSpace(siteId)) + { + _logger.LogWarning( + "Rejected a central control-plane call to {Method}: the required " + + "'{Header}' metadata header is missing or blank, so there is no per-site key to " + + "verify against.", + context.Method, ControlPlaneCredentials.SiteHeader); + throw Denied("missing site identity header"); + } + + string expected; + try + { + expected = await _pskProvider.GetAsync(siteId, context.CancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Fail-closed: an unresolvable key is a denial, never a pass-through. The provider + // has already logged the specific cause (missing secret / missing config entry). + _logger.LogWarning(ex, + "Rejected a central control-plane call to {Method}: no preshared key could be " + + "resolved for site {SiteId}.", + context.Method, siteId); + throw Denied("no key configured for the presented site"); + } + + var presented = ExtractBearerToken(context.RequestHeaders); + if (presented is null || !FixedTimeEquals(presented, expected)) + { + _logger.LogWarning( + "Rejected a central control-plane call to {Method} from site {SiteId}: {Reason}.", + context.Method, siteId, + presented is null ? "no bearer token presented" : "bearer token did not match"); + throw Denied("control plane authentication failed"); + } + } + + private static RpcException Denied(string reason) + => new(new Status(StatusCode.PermissionDenied, $"Control plane authentication failed: {reason}.")); + + private bool IsGated(string method) + { + foreach (var prefix in _gatedPrefixes) + { + if (method.StartsWith(prefix, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + private static string? ExtractSiteId(Metadata headers) + { + foreach (var entry in headers) + { + if (string.Equals(entry.Key, ControlPlaneCredentials.SiteHeader, + StringComparison.OrdinalIgnoreCase)) + { + return entry.Value; + } + } + return null; + } + + private static string? ExtractBearerToken(Metadata headers) + { + // gRPC lowercases header keys on the wire; compare case-insensitively so a hand-built + // Metadata in a test behaves the same as a real request. + foreach (var entry in headers) + { + if (!string.Equals(entry.Key, ControlPlaneCredentials.AuthorizationHeader, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var value = entry.Value; + if (value is not null && value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + return value["Bearer ".Length..]; + } + } + + return null; + } + + private static bool FixedTimeEquals(string presented, string expected) + => CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(presented), Encoding.UTF8.GetBytes(expected)); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/NodeOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Host/NodeOptions.cs index a4dd0c39..3c004478 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/NodeOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/NodeOptions.cs @@ -21,6 +21,15 @@ public class NodeOptions /// Gets or sets the gRPC port for the site stream server. public int GrpcPort { get; set; } = 8083; /// + /// HTTP/2 (h2c) port the CENTRAL node listens on for the site→central + /// CentralControlService gRPC control plane. Default 8083 — deliberately symmetric + /// with the site , since a node is either central or a site and the + /// two never share a process. This listener is distinct from central's :5000 HTTP/1 + /// surface (Central UI, Management/Inbound API), which stays exactly as-is: gRPC does NOT go + /// through Traefik (HTTP/1 only). Ignored on site nodes. + /// + public int CentralGrpcPort { get; set; } = 8083; + /// /// HTTP/1.1 port serving the Prometheus /metrics scrape endpoint on site nodes. /// Defaults to 8084 — deliberately distinct from (8082) /// and (8083) so the Kestrel metrics listener never contends diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/NodeOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Host/NodeOptionsValidator.cs index 1b5a7623..6890fa8a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/NodeOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/NodeOptionsValidator.cs @@ -23,6 +23,7 @@ public sealed class NodeOptionsValidator : OptionsValidatorBase RequirePort(builder, options.RemotingPort, nameof(NodeOptions.RemotingPort)); RequirePort(builder, options.GrpcPort, nameof(NodeOptions.GrpcPort)); RequirePort(builder, options.MetricsPort, nameof(NodeOptions.MetricsPort)); + RequirePort(builder, options.CentralGrpcPort, nameof(NodeOptions.CentralGrpcPort)); } // 0 stays valid (dynamic-port request); reject only out-of-TCP-range values. diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 9a576e15..2422f353 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -97,6 +97,24 @@ try // Windows Service support (no-op when not running as a Windows Service) builder.Host.UseWindowsService(); + // Explicit Kestrel h2c listener for the central-hosted gRPC control plane + // (CentralControlService). Mirrors the site branch's gRPC listener: HTTP/2-only, + // on its own port (default 8083, symmetric with the site GrpcPort — a node is + // either central or a site, never both). This is ADDITIVE to central's :5000 + // HTTP/1 surface (Central UI + Management/Inbound API from ASPNETCORE_URLS), + // which is untouched: gRPC does NOT go through Traefik (HTTP/1 only), sites reach + // this port by container name. T1A.2 hosts the server here; sites keep dialing over + // Akka ClusterClient until T1A.3 flips CentralTransport — this listener simply also + // accepts. + var centralGrpcPort = configuration.GetValue("ScadaBridge:Node:CentralGrpcPort", 8083); + builder.WebHost.ConfigureKestrel(options => + { + options.ListenAnyIP(centralGrpcPort, listenOptions => + { + listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2; + }); + }); + // Shared components builder.Services.AddClusterInfrastructure(); builder.Services.AddCommunication(); @@ -108,6 +126,22 @@ try // node uses for its single key). Registered before the clients that consume it. builder.Services.AddSingleton< ZB.MOM.WW.ScadaBridge.Communication.Grpc.ISitePskProvider, SitePskProvider>(); + + // Central-hosted gRPC control plane (T1A.2). CentralControlAuthInterceptor is the + // per-site sibling of the site's ControlPlaneAuthInterceptor: it verifies the Bearer + // token against the key for the site named in the required x-scadabridge-site header, + // resolved through the ISitePskProvider registered just above. Registered BY TYPE on + // AddGrpc exactly as the site branch registers its interceptors — NOT as a DI singleton, + // which would let DI hand the instance back and bypass Grpc.AspNetCore's own activation + // path (the shape that once hid a two-public-constructor defect until the rig caught it). + // CentralControlGrpcService decodes each request onto the same in-process message the + // ClusterClient path carries and Asks CentralCommunicationActor — zero handler logic here. + builder.Services.AddGrpc(options => + { + options.Interceptors.Add(); + }); + builder.Services.AddSingleton< + ZB.MOM.WW.ScadaBridge.Communication.Grpc.CentralControlGrpcService>(); builder.Services.AddHealthMonitoring(); builder.Services.AddCentralHealthAggregation(); builder.Services.AddExternalSystemGateway(); @@ -462,6 +496,14 @@ try // Requires endpoint routing (app.UseRouting() above). app.MapZbMetrics(); + // Central-hosted gRPC control plane (T1A.2) — the site→central CentralControlService. + // Runs on the dedicated h2c listener configured above (default :8083), gated by + // CentralControlAuthInterceptor and readiness-gated by the service itself (Unavailable + // until AkkaHostedService hands the CentralCommunicationActor over via SetReady). It + // shares the app's endpoint routing with the HTTP/1 surface; Kestrel steers each + // connection to the right pipeline by listener/protocol. + app.MapGrpcService(); + app.MapStaticAssets(); app.MapCentralUI(); app.MapInboundAPI(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceTests.cs new file mode 100644 index 00000000..9d2f0e2b --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Grpc/CentralControlGrpcServiceTests.cs @@ -0,0 +1,181 @@ +using Akka.Actor; +using Akka.TestKit; +using Akka.TestKit.Xunit2; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc; + +/// +/// Behaviour of that does not need a real gRPC pipeline: +/// the readiness gate, the fire-and-forget heartbeat contract, the ingest-timeout status +/// mapping, and the shared audit-ingest timeout constant. The auth interceptor + real transport +/// are proven separately in CentralControlEndToEndTests (Host.Tests). +/// +public class CentralControlGrpcServiceTests : TestKit +{ + private static ServerCallContext NewContext(CancellationToken ct = default) + { + var context = Substitute.For(); + context.CancellationToken.Returns(ct); + return context; + } + + private CentralControlGrpcService CreateService(CommunicationOptions? options = null) + => new( + NullLogger.Instance, + Options.Create(options ?? new CommunicationOptions())); + + [Fact] + public async Task BeforeSetReady_AUnaryCall_IsUnavailable() + { + // Nothing was dispatched, so Unavailable is the right status — it tells a site transport + // the call never ran and a cross-node retry is safe. + var service = CreateService(); + + var ex = await Assert.ThrowsAsync( + () => service.SubmitNotification(new NotificationSubmitDto(), NewContext())); + + Assert.Equal(StatusCode.Unavailable, ex.StatusCode); + } + + [Fact] + public async Task BeforeSetReady_ANonEmptyIngestBatch_IsUnavailable() + { + var service = CreateService(); + var batch = new AuditEventBatch(); + batch.Events.Add(NewAuditDto()); + + var ex = await Assert.ThrowsAsync( + () => service.IngestAuditEvents(batch, NewContext())); + + Assert.Equal(StatusCode.Unavailable, ex.StatusCode); + } + + [Fact] + public async Task AfterSetReady_AUnaryCall_ReachesTheActor() + { + var stub = Sys.ActorOf(Props.Create(() => new StubActor())); + var service = CreateService(); + service.SetReady(stub); + + var ack = await service.SubmitNotification(NewNotificationDto(), NewContext()); + + Assert.True(ack.Accepted); + } + + [Fact] + public async Task Heartbeat_BeforeSetReady_SucceedsAndIsDropped() + { + // Fire-and-forget end-to-end: a heartbeat must never fault the site's timer, so even + // with no actor wired the call returns OK rather than Unavailable. + var service = CreateService(); + + var reply = await service.Heartbeat(NewHeartbeatDto(), NewContext()); + + Assert.NotNull(reply); + } + + [Fact] + public async Task Heartbeat_AfterSetReady_TellsTheActor_AndNeverAsks() + { + // A black-hole actor that never replies would hang an Ask forever; the heartbeat still + // returns immediately, proving it is a Tell, not an Ask. + var blackHole = Sys.ActorOf(Props.Create(() => new NeverRepliesActor())); + var service = CreateService(); + service.SetReady(blackHole); + + var reply = await service.Heartbeat(NewHeartbeatDto(), NewContext()); + + Assert.NotNull(reply); + } + + [Fact] + public async Task WhenTheActorNeverReplies_TheCall_IsDeadlineExceeded_NotUnavailable() + { + // The message WAS delivered (Unavailable would wrongly invite a duplicate retry on the + // peer node); a timeout is DeadlineExceeded, which callers never cross-node-retry. + var blackHole = Sys.ActorOf(Props.Create(() => new NeverRepliesActor())); + var service = CreateService(new CommunicationOptions + { + NotificationForwardTimeout = TimeSpan.FromMilliseconds(200), + }); + service.SetReady(blackHole); + + var ex = await Assert.ThrowsAsync( + () => service.SubmitNotification(NewNotificationDto(), NewContext())); + + Assert.Equal(StatusCode.DeadlineExceeded, ex.StatusCode); + } + + [Fact] + public async Task EmptyIngestBatch_ShortCircuits_EvenBeforeSetReady() + { + // An empty batch is a no-op the actor need never see; it must not depend on readiness. + var service = CreateService(); + + var ack = await service.IngestAuditEvents(new AuditEventBatch(), NewContext()); + + Assert.Empty(ack.AcceptedEventIds); + } + + [Fact] + public void TheAuditIngestTimeout_IsTheOneSharedConstant() + { + // The plan calls SiteStreamGrpcServer.AuditIngestAskTimeout "one source of truth" shared + // between the two audit-ingest transports; the central service must not re-declare 30s. + Assert.Equal(TimeSpan.FromSeconds(30), SiteStreamGrpcServer.AuditIngestAskTimeout); + } + + // The mapper reads SiteEnqueuedAt unconditionally, so a DTO that reaches mapping must carry + // a timestamp. Only DTOs that get past the readiness/auth gate map, so the negative tests + // above can pass a bare DTO. + private static NotificationSubmitDto NewNotificationDto() => new() + { + NotificationId = Guid.NewGuid().ToString(), + ListName = "ops", + SiteEnqueuedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime( + DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)), + }; + + private static HeartbeatDto NewHeartbeatDto() => new() + { + SiteId = "site-a", + NodeHostname = "node-a", + IsActive = true, + Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime( + DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)), + }; + + private static AuditEventDto NewAuditDto() => new() + { + EventId = Guid.NewGuid().ToString(), + OccurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime( + DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)), + Channel = "ApiOutbound", + Kind = "ApiCall", + Status = "Delivered", + SourceSiteId = "site-a", + }; + + private sealed class StubActor : ReceiveActor + { + public StubActor() + { + Receive(msg => + Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null))); + } + } + + /// Swallows every message and never replies, so an Ask against it times out. + private sealed class NeverRepliesActor : ReceiveActor + { + public NeverRepliesActor() => ReceiveAny(_ => { }); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlAuthInterceptorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlAuthInterceptorTests.cs new file mode 100644 index 00000000..9e642ab7 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlAuthInterceptorTests.cs @@ -0,0 +1,197 @@ +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// Central's inbound gate for the site→central gRPC control plane (T1A.2). The mirror of +/// , but central's verification model is inverted: +/// it looks up the key for the site named in the x-scadabridge-site header and checks the +/// bearer token against THAT, rather than against one own-key. +/// +/// +/// Central genuinely needs a distinct verification model, so it is a separate class with its own +/// single public constructor — not a variant ctor on , +/// whose two-public-constructor form once silently disabled the gate. The one-public-ctor +/// invariant is pinned below exactly as the sibling pins it. +/// +public class CentralControlAuthInterceptorTests +{ + private const string ControlMethod = + "/scadabridge.centralcontrol.v1.CentralControlService/SubmitNotification"; + private const string SiteStreamMethod = "/sitestream.SiteStreamService/SubscribeInstance"; + + private const string SiteA = "site-a"; + private const string SiteAKey = "site-a-preshared-key"; + private const string SiteB = "site-b"; + private const string SiteBKey = "site-b-preshared-key"; + + /// An backed by a fixed site→key map; throws for unknown sites. + private sealed class MapPskProvider(IReadOnlyDictionary keys) : ISitePskProvider + { + public ValueTask GetAsync(string siteId, CancellationToken ct) + => keys.TryGetValue(siteId, out var key) + ? new ValueTask(key) + : throw new InvalidOperationException($"no key for '{siteId}'"); + + public void Invalidate(string siteId) { } + } + + private static CentralControlAuthInterceptor CreateInterceptor() + => new( + new MapPskProvider(new Dictionary { [SiteA] = SiteAKey, [SiteB] = SiteBKey }), + NullLogger.Instance); + + private static ServerCallContext CreateContext( + string method, string? siteHeader, string? authorizationHeader) + { + var headers = new Metadata(); + if (siteHeader is not null) + headers.Add(ControlPlaneCredentials.SiteHeader, siteHeader); + if (authorizationHeader is not null) + headers.Add(ControlPlaneCredentials.AuthorizationHeader, authorizationHeader); + + return new FakeServerCallContext(method, headers); + } + + private sealed class FakeServerCallContext(string method, Metadata requestHeaders) + : ServerCallContext + { + protected override string MethodCore => method; + protected override string HostCore => "localhost"; + protected override string PeerCore => "ipv4:127.0.0.1:12345"; + protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); + protected override Metadata RequestHeadersCore => requestHeaders; + protected override CancellationToken CancellationTokenCore => CancellationToken.None; + protected override Metadata ResponseTrailersCore { get; } = []; + protected override Status StatusCore { get; set; } + protected override WriteOptions? WriteOptionsCore { get; set; } + protected override AuthContext AuthContextCore { get; } = + new(null, new Dictionary>()); + + protected override ContextPropagationToken CreatePropagationTokenCore( + ContextPropagationOptions? options) + => throw new NotSupportedException(); + + protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) + => Task.CompletedTask; + } + + private static Task Invoke( + CentralControlAuthInterceptor interceptor, ServerCallContext context) + => interceptor.UnaryServerHandler( + "request", context, (_, _) => Task.FromResult("ok")); + + [Fact] + public async Task NonGatedMethod_PassesThrough() + { + // Central's interceptor gates only CentralControlService; anything else on the listener + // (there is nothing today) is not its concern. + var interceptor = CreateInterceptor(); + var context = CreateContext(SiteStreamMethod, siteHeader: null, authorizationHeader: null); + + Assert.Equal("ok", await Invoke(interceptor, context)); + } + + [Fact] + public async Task CorrectSiteAndKey_IsAccepted() + { + var interceptor = CreateInterceptor(); + var context = CreateContext(ControlMethod, SiteA, $"Bearer {SiteAKey}"); + + Assert.Equal("ok", await Invoke(interceptor, context)); + } + + [Fact] + public async Task MissingSiteHeader_IsDenied() + { + // Fail-closed: no header means no per-site key to verify against, so there is nothing to + // pass through TO. A present bearer token does not rescue it. + var interceptor = CreateInterceptor(); + var context = CreateContext(ControlMethod, siteHeader: null, $"Bearer {SiteAKey}"); + + var ex = await Assert.ThrowsAsync(() => Invoke(interceptor, context)); + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + [Fact] + public async Task BlankSiteHeader_IsDenied() + { + var interceptor = CreateInterceptor(); + var context = CreateContext(ControlMethod, siteHeader: " ", $"Bearer {SiteAKey}"); + + var ex = await Assert.ThrowsAsync(() => Invoke(interceptor, context)); + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + [Fact] + public async Task UnknownSite_IsDenied_NotPassedThrough() + { + // The provider throws for an unknown site; the interceptor must turn that into a denial, + // never swallow it and let the call proceed unauthenticated. + var interceptor = CreateInterceptor(); + var context = CreateContext(ControlMethod, "site-nonexistent", "Bearer whatever"); + + var ex = await Assert.ThrowsAsync(() => Invoke(interceptor, context)); + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + [Fact] + public async Task NoBearerToken_IsDenied() + { + var interceptor = CreateInterceptor(); + var context = CreateContext(ControlMethod, SiteA, authorizationHeader: null); + + var ex = await Assert.ThrowsAsync(() => Invoke(interceptor, context)); + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + [Fact] + public async Task WrongKeyForTheSite_IsDenied() + { + // site-a presents site-b's key. Both keys are valid keys; the point is the token must + // match the key for THIS site, not just be a key central knows. + var interceptor = CreateInterceptor(); + var context = CreateContext(ControlMethod, SiteA, $"Bearer {SiteBKey}"); + + var ex = await Assert.ThrowsAsync(() => Invoke(interceptor, context)); + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + [Fact] + public async Task EachSiteIsVerifiedAgainstItsOwnKey() + { + // site-b with site-b's key is accepted by the same interceptor instance that rejected + // site-a-with-site-b's-key above — proving the per-site lookup, not a single shared key. + var interceptor = CreateInterceptor(); + var context = CreateContext(ControlMethod, SiteB, $"Bearer {SiteBKey}"); + + Assert.Equal("ok", await Invoke(interceptor, context)); + } + + [Fact] + public void TheInterceptorHasExactlyOnePublicConstructor() + { + // Grpc.AspNetCore registers this interceptor BY TYPE; InterceptorRegistration.GetFactory() + // throws "Multiple constructors accepting all given argument types have been found" the + // moment a second public constructor is applicable, and the throw lands inside the + // pipeline on every call — a gate that authorizes nothing while looking like a handler + // bug. The explicit-prefix constructor is internal to keep it from recurring; this pins + // that. (Same invariant as ControlPlaneAuthInterceptorTests.) + var publicCtors = typeof(CentralControlAuthInterceptor).GetConstructors(); + + Assert.Single(publicCtors); + } + + [Fact] + public void DefaultGatedPrefixes_MatchTheRealCentralControlServicePath() + { + // A typo here disables the whole gate silently: every call would pass through. Pin it + // against the generated service descriptor, not the proto text. + var method = CentralControlService.Descriptor.FullName; + Assert.Contains( + CentralControlAuthInterceptor.DefaultGatedPrefixes, + p => p == $"/{method}/"); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlEndToEndTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlEndToEndTests.cs new file mode 100644 index 00000000..63d806aa --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralControlEndToEndTests.cs @@ -0,0 +1,256 @@ +using Akka.Actor; +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// End-to-end proof of the central-hosted gRPC control plane (T1A.2): the real +/// AND the real +/// over a real gRPC stack, the service Asking a real +/// (stub) CentralCommunicationActor stand-in. +/// +/// +/// +/// The interceptor is registered BY TYPE on AddGrpc, exactly as Program.cs +/// does, and is deliberately NOT placed in DI as a singleton — pre-registering it lets DI hand +/// the instance back and bypasses Grpc.AspNetCore's own activation path, which is how the +/// two-public-constructor defect escaped a green suite once before. This harness copies the +/// shape of for the same reason. +/// +/// +/// Runs in-process over : no ports, no containers. A tiny Akka actor +/// stands in for CentralCommunicationActor so the test never touches MSSQL or a cluster; +/// the method paths, message types and mapper are the real generated/production ones. +/// +/// +public class CentralControlEndToEndTests : IAsyncLifetime +{ + private const string SiteA = "site-a"; + private const string SiteAKey = "site-a-preshared-key"; + private const string SiteB = "site-b"; + private const string SiteBKey = "site-b-preshared-key"; + + // The deterministic id the stub actor "accepts" for every ingest batch, so the ingest + // bridge can be asserted without extracting ids out of a decoded AuditEvent. + private static readonly Guid AcceptedId = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + private IHost _host = null!; + private TestServer _server = null!; + private ActorSystem _actorSystem = null!; + private CentralControlGrpcService _service = null!; + + /// + public async Task InitializeAsync() + { + _actorSystem = ActorSystem.Create("centralcontrol-e2e-test"); + var stub = _actorSystem.ActorOf(Props.Create(() => new StubCentralActor(AcceptedId)), "central-stub"); + + _service = new CentralControlGrpcService( + NullLogger.Instance, + Options.Create(new CommunicationOptions())); + _service.SetReady(stub); + + var pskProvider = new MapPskProvider(new Dictionary + { + [SiteA] = SiteAKey, + [SiteB] = SiteBKey, + }); + + _host = await new HostBuilder() + .ConfigureWebHost(web => web + .UseTestServer() + .ConfigureServices(services => + { + // BY TYPE, and NOT also in DI — see the class remarks. + services.AddGrpc(o => o.Interceptors.Add()); + services.AddSingleton(pskProvider); + services.AddSingleton(_service); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(e => e.MapGrpcService()); + })) + .StartAsync(); + + _server = _host.GetTestServer(); + } + + /// + public async Task DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + await _actorSystem.Terminate(); + } + + /// Builds a channel credentialed for with . + private GrpcChannel Channel(string? key, string siteId) + { + var options = new GrpcChannelOptions { HttpHandler = _server.CreateHandler() }; + if (key is not null) + { + options.WithSiteCredentials(new FixedPskProvider(key), siteId); + } + return GrpcChannel.ForAddress(_server.BaseAddress, options); + } + + private CentralControlService.CentralControlServiceClient Client(string? key, string siteId) + => new(Channel(key, siteId)); + + // ---- Auth positives / negatives, all through the real pipeline ---- + + [Fact] + public async Task CorrectSiteAndKey_ReachesTheService_OnAUnaryCall() + { + var client = Client(SiteAKey, SiteA); + + var ack = await client.SubmitNotificationAsync(NewNotificationDto()); + + Assert.True(ack.Accepted); + } + + [Fact] + public async Task NoCredentialsAtAll_IsRejected_WithPermissionDenied() + { + var client = Client(key: null, SiteA); + + var ex = await Assert.ThrowsAsync( + async () => await client.SubmitNotificationAsync(new NotificationSubmitDto())); + + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + [Fact] + public async Task WrongKeyForTheSite_IsRejected_WithPermissionDenied() + { + // site-a presents site-b's (valid, but wrong-for-this-site) key. + var client = Client(SiteBKey, SiteA); + + var ex = await Assert.ThrowsAsync( + async () => await client.SubmitNotificationAsync(new NotificationSubmitDto())); + + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + [Fact] + public async Task UnknownSite_IsRejected_WithPermissionDenied() + { + var client = Client("any-key", "site-nonexistent"); + + var ex = await Assert.ThrowsAsync( + async () => await client.SubmitNotificationAsync(new NotificationSubmitDto())); + + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + [Fact] + public async Task TheAcceptAndRejectPathsAreDistinguishable() + { + // A gate whose accept and reject paths produce the same observable result is not a gate. + // Correct key → the service answers (Accepted:true); wrong key → PermissionDenied, + // never reaching the service. These are two different outcomes, which is the whole point. + var ok = await Client(SiteAKey, SiteA).SubmitNotificationAsync(NewNotificationDto()); + Assert.True(ok.Accepted); + + var ex = await Assert.ThrowsAsync( + async () => await Client("wrong", SiteA).SubmitNotificationAsync(new NotificationSubmitDto())); + Assert.Equal(StatusCode.PermissionDenied, ex.StatusCode); + } + + // ---- One RPC per shape: unary (above) + the ingest bridge ---- + + [Fact] + public async Task IngestAuditEvents_DecodesTheBatch_AsksTheActor_EncodesTheAck() + { + var client = Client(SiteAKey, SiteA); + + var batch = new AuditEventBatch(); + batch.Events.Add(NewAuditDto()); + batch.Events.Add(NewAuditDto()); + + var ack = await client.IngestAuditEventsAsync(batch); + + // The stub actor accepts one deterministic id per non-empty batch; its presence proves + // the full DTO→command→Ask→reply→ack bridge ran, gated call and all. + Assert.Contains(AcceptedId.ToString(), ack.AcceptedEventIds); + } + + [Fact] + public async Task IngestAuditEvents_EmptyBatch_ShortCircuits_WithoutAskingTheActor() + { + // Even the empty-batch fast path is behind the gate — it still needs a valid key. + var client = Client(SiteAKey, SiteA); + + var ack = await client.IngestAuditEventsAsync(new AuditEventBatch()); + + Assert.Empty(ack.AcceptedEventIds); + } + + // The mapper reads SiteEnqueuedAt unconditionally; only DTOs that clear the gate reach it, + // so the accept-path tests carry a timestamp while the negative tests can pass a bare DTO. + private static NotificationSubmitDto NewNotificationDto() => new() + { + NotificationId = Guid.NewGuid().ToString(), + ListName = "ops", + SiteEnqueuedAt = Timestamp.FromDateTime( + DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)), + }; + + private static AuditEventDto NewAuditDto() => new() + { + EventId = Guid.NewGuid().ToString(), + OccurredAtUtc = Timestamp.FromDateTime( + DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)), + Channel = "ApiOutbound", + Kind = "ApiCall", + Status = "Delivered", + SourceSiteId = SiteA, + }; + + private sealed class FixedPskProvider(string key) : ISitePskProvider + { + public ValueTask GetAsync(string siteId, CancellationToken ct) => new(key); + public void Invalidate(string siteId) { } + } + + private sealed class MapPskProvider(IReadOnlyDictionary keys) : ISitePskProvider + { + public ValueTask GetAsync(string siteId, CancellationToken ct) + => keys.TryGetValue(siteId, out var key) + ? new ValueTask(key) + : throw new InvalidOperationException($"no key for '{siteId}'"); + + public void Invalidate(string siteId) { } + } + + /// + /// Minimal stand-in for CentralCommunicationActor: answers the two RPC shapes this + /// test exercises. Replies straight to the Ask's temp sender, exactly as the real actor's + /// Forward/PipeTo paths do. + /// + private sealed class StubCentralActor : ReceiveActor + { + public StubCentralActor(Guid acceptedId) + { + Receive(msg => + Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null))); + + Receive(_ => + Sender.Tell(new IngestAuditEventsReply(new[] { acceptedId }))); + } + } +} From 33b15f10a496ee4fb49db06c7770b1018cff2349 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 22 Jul 2026 19:29:14 -0400 Subject: [PATCH 3/5] feat(grpc): site-side ICentralTransport seam + gRPC transport (T1A.3) Introduce ICentralTransport as the site->central choke point inside SiteCommunicationActor. The seven site->central sends (notification submit/ status, audit + cached-telemetry ingest, reconcile, health, heartbeat) now delegate to an injected transport instead of owning ClusterClient.Send inline. - AkkaCentralTransport: verbatim extraction of today's ClusterClient.Send path, including the exact sender-forwarding that routes central's reply straight back to the waiting Ask. Default when no transport is injected -> behaviour unchanged, existing SiteCommunicationActorTests pass as-is. - GrpcCentralTransport + CentralChannelProvider: dial CentralControlService with sticky failover + background failback (1s-doubling-cap-60s), PSK + x-scadabridge-site via ControlPlaneCredentials, per-call deadlines mirroring today's Ask timeouts. Cross-node retry ONLY on provably-unsent connect failures; never on DeadlineExceeded. Heartbeat stays fire-and-forget. - StaticSitePskProvider: site's single own-key provider (fail-closed). - CommunicationOptions: CentralTransport flag (default Akka) + CentralGrpcEndpoints (validator: required when transport=Grpc). Host selects the impl; the ClusterClient is created only on the Akka path. Tests: actor-with-fake-transport (7 delegations + fault routing + heartbeat no-fault), AkkaCentralTransport sender-forwarding, GrpcCentralTransport over in-process TestServer (failover flip, sticky, failback, PSK+header, deadline, no-retry-on-deadline), validator. Communication.Tests 371 green, Host.Tests 391 green; the three above-seam suites pass unmodified. --- .../Actors/AkkaCentralTransport.cs | 185 +++++++++ .../Actors/ICentralTransport.cs | 78 ++++ .../Actors/SiteCommunicationActor.cs | 215 +++------- .../CommunicationOptions.cs | 33 ++ .../CommunicationOptionsValidator.cs | 13 + .../Grpc/CentralChannelProvider.cs | 274 +++++++++++++ .../Grpc/GrpcCentralTransport.cs | 258 ++++++++++++ .../Grpc/StaticSitePskProvider.cs | 43 ++ .../Actors/AkkaHostedService.cs | 43 +- .../Actors/AkkaCentralTransportTests.cs | 67 ++++ .../SiteCommunicationActorTransportTests.cs | 166 ++++++++ .../CommunicationOptionsValidatorTests.cs | 53 +++ .../GrpcCentralTransportTests.cs | 373 ++++++++++++++++++ 13 files changed, 1642 insertions(+), 159 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Actors/AkkaCentralTransport.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Actors/ICentralTransport.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralChannelProvider.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/GrpcCentralTransport.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/StaticSitePskProvider.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/AkkaCentralTransportTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/SiteCommunicationActorTransportTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcCentralTransportTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/AkkaCentralTransport.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/AkkaCentralTransport.cs new file mode 100644 index 00000000..a2d9befc --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/AkkaCentralTransport.cs @@ -0,0 +1,185 @@ +using Akka.Actor; +using Akka.Cluster.Tools.Client; +using Akka.Event; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Actors; + +/// +/// The that carries the seven site→central sends over Akka +/// ClusterClient — the transport in production today, and the default. Every method is a +/// verbatim lift of the corresponding SiteCommunicationActor send block: it forwards a +/// to /user/central-communication with the +/// as the send's sender, so central's reply routes straight back to the +/// waiting Ask rather than through the site communication actor. +/// +/// +/// The ClusterClient reference arrives after construction via +/// (the actor forwards the RegisterCentralClient message it +/// receives once the Host builds the client). Until then — and if central contact points are not +/// configured at all — the client is null and each method answers the same transient-failure reply +/// the old inline handlers did. +/// +public sealed class AkkaCentralTransport : ICentralTransport +{ + /// The receptionist-registered path of the central communication actor. + private const string CentralPath = "/user/central-communication"; + + private readonly ILoggingAdapter? _log; + private IActorRef? _centralClient; + + /// Creates the transport with no logging adapter (behaviourally identical; warnings are dropped). + public AkkaCentralTransport() + { + } + + /// Creates the transport bound to the site communication actor's logging adapter. + /// Logging adapter used for the "no ClusterClient registered" warnings. + public AkkaCentralTransport(ILoggingAdapter log) + { + _log = log; + } + + /// + /// Registers the central ClusterClient once the Host has built it. Called from the site + /// communication actor's RegisterCentralClient handler. + /// + /// The ClusterClient reaching the central cluster. + public void SetCentralClient(IActorRef centralClient) + { + _centralClient = centralClient; + _log?.Info("Registered central ClusterClient"); + } + + /// + public void SubmitNotification(NotificationSubmit message, IActorRef replyTo) + { + if (_centralClient == null) + { + // No ClusterClient registered yet (e.g. central contact points not + // configured, or registration not yet completed). A non-accepted ack + // makes the S&F forwarder treat this as transient and retry later. + _log?.Warning( + "Cannot forward NotificationSubmit {0} — no central ClusterClient registered", + message.NotificationId); + replyTo.Tell(new NotificationSubmitAck( + message.NotificationId, Accepted: false, Error: "Central ClusterClient not registered")); + return; + } + + _log?.Debug("Forwarding NotificationSubmit {0} to central", message.NotificationId); + _centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo); + } + + /// + public void QueryNotificationStatus(NotificationStatusQuery message, IActorRef replyTo) + { + if (_centralClient == null) + { + // No ClusterClient registered yet. Reply Found: false so Notify.Status + // falls back to the site S&F buffer to decide Forwarding vs Unknown. + _log?.Warning( + "Cannot forward NotificationStatusQuery {0} — no central ClusterClient registered", + message.NotificationId); + replyTo.Tell(new NotificationStatusResponse( + message.CorrelationId, Found: false, Status: "Unknown", + RetryCount: 0, LastError: null, DeliveredAt: null)); + return; + } + + _log?.Debug("Forwarding NotificationStatusQuery {0} to central", message.NotificationId); + _centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo); + } + + /// + public void IngestAuditEvents(IngestAuditEventsCommand message, IActorRef replyTo) + { + if (_centralClient == null) + { + // No ClusterClient registered yet. Faulting the Ask makes the + // SiteAuditTelemetryActor drain loop treat this as transient and keep + // the rows Pending for the next tick. + _log?.Warning( + "Cannot forward IngestAuditEventsCommand ({0} events) — no central ClusterClient registered", + message.Events.Count); + replyTo.Tell(new Status.Failure( + new InvalidOperationException("Central ClusterClient not registered"))); + return; + } + + _log?.Debug("Forwarding IngestAuditEventsCommand ({0} events) to central", message.Events.Count); + _centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo); + } + + /// + public void IngestCachedTelemetry(IngestCachedTelemetryCommand message, IActorRef replyTo) + { + if (_centralClient == null) + { + _log?.Warning( + "Cannot forward IngestCachedTelemetryCommand ({0} entries) — no central ClusterClient registered", + message.Entries.Count); + replyTo.Tell(new Status.Failure( + new InvalidOperationException("Central ClusterClient not registered"))); + return; + } + + _log?.Debug("Forwarding IngestCachedTelemetryCommand ({0} entries) to central", message.Entries.Count); + _centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo); + } + + /// + public void ReconcileSite(ReconcileSiteRequest message, IActorRef replyTo) + { + if (_centralClient == null) + { + // No ClusterClient registered yet. Faulting the Ask makes the + // SiteReconciliationActor treat the pass as best-effort-failed; it + // logs a warning and retries reconcile on the next node startup. + _log?.Warning( + "Cannot forward ReconcileSiteRequest for site {0} node {1} — no central ClusterClient registered", + message.SiteIdentifier, message.NodeId); + replyTo.Tell(new Status.Failure( + new InvalidOperationException("Central ClusterClient not registered"))); + return; + } + + _log?.Debug( + "Forwarding ReconcileSiteRequest for site {0} node {1} ({2} local instance(s)) to central", + message.SiteIdentifier, message.NodeId, message.LocalNameToRevisionHash.Count); + _centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo); + } + + /// + public void ReportSiteHealth(SiteHealthReport message, IActorRef replyTo) + { + if (_centralClient == null) + { + // No ClusterClient registered yet. A non-accepted ack makes the + // sender's counter-restore path treat this tick as a loss. + _log?.Warning( + "Cannot forward SiteHealthReport #{0} — no central ClusterClient registered", + message.SequenceNumber); + replyTo.Tell(new SiteHealthReportAck( + message.SiteId, message.SequenceNumber, Accepted: false, + Error: "Central ClusterClient not registered")); + return; + } + + _centralClient.Tell(new ClusterClient.Send(CentralPath, message), replyTo); + } + + /// + public void SendHeartbeat(HeartbeatMessage message, IActorRef self) + { + if (_centralClient == null) + { + return; + } + + _centralClient.Tell(new ClusterClient.Send(CentralPath, message), self); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/ICentralTransport.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/ICentralTransport.cs new file mode 100644 index 00000000..99ef0977 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/ICentralTransport.cs @@ -0,0 +1,78 @@ +using Akka.Actor; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Actors; + +/// +/// The site→central transport seam: one method per the seven messages +/// sends to /user/central-communication today. +/// +/// +/// +/// The actor's receive handlers no longer own the wire plumbing — they capture the current +/// Sender and hand it to the transport as . Two implementations +/// exist behind the ScadaBridge:Communication:CentralTransport flag: the default +/// (verbatim of the old ClusterClient.Send path, +/// including the exact sender-forwarding that routes central's reply straight back to the waiting +/// Ask) and (a gRPC dial of CentralControlService). +/// +/// +/// Reply/fault contract, identical on both transports. Each Ask-returning method (all but +/// the heartbeat) guarantees exactly one reply eventually lands at : +/// either the real reply type the caller Asks for, or a transient-failure signal. The Akka path +/// sends a not-accepted ack / when no ClusterClient is registered; +/// the gRPC path sends on any non-OK status (a timeout or an +/// Unavailable that could not be failed over). Both are what the S&F / audit / health +/// layers above the seam already treat as transient — rows stay buffered, counters restore, the +/// pass re-runs. +/// +/// +/// The heartbeat stays fire-and-forget end-to-end. takes no +/// replyTo and never surfaces a fault: a transport failure is swallowed and logged, exactly +/// as the old Tell dropped it. A failing heartbeat must never fault the site's heartbeat +/// timer path. +/// +/// +public interface ICentralTransport +{ + /// Forwards a buffered notification; central replies to . + /// The notification submission. + /// The actor (the S&F forwarder's Ask) the ack routes back to. + void SubmitNotification(NotificationSubmit message, IActorRef replyTo); + + /// Forwards a Notify.Status query; central replies to . + /// The status query. + /// The actor (the Notify helper's Ask) the response routes back to. + void QueryNotificationStatus(NotificationStatusQuery message, IActorRef replyTo); + + /// Pushes a batch of audit events; central replies to . + /// The audit-event ingest command. + /// The actor (the telemetry drain's Ask) the reply routes back to. + void IngestAuditEvents(IngestAuditEventsCommand message, IActorRef replyTo); + + /// Pushes a batch of combined cached-call telemetry; central replies to . + /// The cached-telemetry ingest command. + /// The actor (the telemetry drain's Ask) the reply routes back to. + void IngestCachedTelemetry(IngestCachedTelemetryCommand message, IActorRef replyTo); + + /// Reports a node's startup inventory; central replies to . + /// The reconcile request. + /// The actor (the reconciliation Ask) the response routes back to. + void ReconcileSite(ReconcileSiteRequest message, IActorRef replyTo); + + /// Reports periodic site health; central replies to . + /// The health report. + /// The actor (the health transport's Ask) the ack routes back to. + void ReportSiteHealth(SiteHealthReport message, IActorRef replyTo); + + /// + /// Sends an application heartbeat, fire-and-forget. Never replies and never faults; a failure + /// is swallowed and logged. + /// + /// The heartbeat. + /// The site communication actor, used as the sender on the Akka path (ignored on gRPC). + void SendHeartbeat(HeartbeatMessage message, IActorRef self); +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs index 8199b80d..b8a2a6f0 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Actors/SiteCommunicationActor.cs @@ -1,6 +1,5 @@ using Akka.Actor; using Akka.Cluster; -using Akka.Cluster.Tools.Client; using Akka.Event; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; @@ -45,10 +44,14 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers private readonly IActorRef _deploymentManagerProxy; /// - /// ClusterClient reference for sending messages to the central cluster. - /// Set via RegisterCentralClient message. + /// The site→central transport. Finalized in to the injected instance, + /// or a default (ClusterClient) when none is supplied — so + /// the seven site→central sends delegate here rather than owning the wire plumbing inline. /// - private IActorRef? _centralClient; + private ICentralTransport _transport; + + /// The transport supplied by the Host (null selects the default Akka transport). + private readonly ICentralTransport? _injectedTransport; /// /// Local actor references for routing specific message patterns. @@ -73,24 +76,36 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers /// pass a stub so they do not need to load Akka.Cluster into the TestKit /// ActorSystem. /// + /// + /// The site→central transport. null (the default, used by every existing test and by + /// the Host's Akka path) selects an over ClusterClient; the + /// Host injects a when + /// ScadaBridge:Communication:CentralTransport is Grpc. + /// public SiteCommunicationActor( string siteId, CommunicationOptions options, IActorRef deploymentManagerProxy, Func? isActiveCheck = null, - Func? failOverRole = null) + Func? failOverRole = null, + ICentralTransport? transport = null) { _siteId = siteId; _options = options; _deploymentManagerProxy = deploymentManagerProxy; _isActiveCheck = isActiveCheck ?? DefaultIsActiveCheck; _failOverRole = failOverRole ?? DefaultFailOverRole; + _injectedTransport = transport; + // Finalized in PreStart (where _log is usable for the default transport); assigned here + // too so the field is definitely-assigned for the constructor's Receive closures. + _transport = transport!; - // Registration + // Registration. Feeding the ClusterClient into the transport is a no-op unless the + // default/Akka transport is in use — the gRPC transport dials configured endpoints and + // never receives this message (the Host does not create a ClusterClient for it). Receive(msg => { - _centralClient = msg.Client; - _log.Info("Registered central ClusterClient"); + (_transport as AkkaCentralTransport)?.SetCentralClient(msg.Client); }); Receive(HandleRegisterLocalHandler); @@ -274,157 +289,33 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers // from cluster state, not from who received the message. Receive(HandleTriggerSiteFailover); - // Notification Outbox: forward a buffered notification submitted by the site - // Store-and-Forward Engine to the central cluster. The original Sender (the - // S&F forwarder's Ask) is forwarded as the ClusterClient.Send sender so the - // NotificationSubmitAck routes straight back to the waiting Ask, not here. - Receive(msg => - { - if (_centralClient == null) - { - // No ClusterClient registered yet (e.g. central contact points not - // configured, or registration not yet completed). A non-accepted ack - // makes the S&F forwarder treat this as transient and retry later. - _log.Warning( - "Cannot forward NotificationSubmit {0} — no central ClusterClient registered", - msg.NotificationId); - Sender.Tell(new NotificationSubmitAck( - msg.NotificationId, Accepted: false, Error: "Central ClusterClient not registered")); - return; - } + // The seven site→central sends now delegate to the injected transport (ClusterClient by + // default, gRPC when configured). Each handler captures the current Sender as the reply + // target so central's reply routes straight back to the waiting Ask, not through this + // actor — the exact sender-forwarding the ClusterClient path relied on. The per-message + // "no transport / not-accepted" fallbacks live inside the transport now. - _log.Debug("Forwarding NotificationSubmit {0} to central", msg.NotificationId); - _centralClient.Tell( - new ClusterClient.Send("/user/central-communication", msg), Sender); - }); + // Notification Outbox: forward a buffered notification (S&F forwarder's Ask → ack back). + Receive(msg => _transport.SubmitNotification(msg, Sender)); - // Notification Outbox: forward a Notify.Status query to the central cluster. - // The original Sender (the Notify helper's Ask) is forwarded as the - // ClusterClient.Send sender so the NotificationStatusResponse routes straight - // back to the waiting Ask, not here. - Receive(msg => - { - if (_centralClient == null) - { - // No ClusterClient registered yet. Reply Found: false so Notify.Status - // falls back to the site S&F buffer to decide Forwarding vs Unknown. - _log.Warning( - "Cannot forward NotificationStatusQuery {0} — no central ClusterClient registered", - msg.NotificationId); - Sender.Tell(new NotificationStatusResponse( - msg.CorrelationId, Found: false, Status: "Unknown", - RetryCount: 0, LastError: null, DeliveredAt: null)); - return; - } + // Notification Outbox: forward a Notify.Status query (Notify helper's Ask → response back). + Receive(msg => _transport.QueryNotificationStatus(msg, Sender)); - _log.Debug("Forwarding NotificationStatusQuery {0} to central", msg.NotificationId); - _centralClient.Tell( - new ClusterClient.Send("/user/central-communication", msg), Sender); - }); + // Audit Log: forward a batch of site-local audit events (telemetry drain's Ask → reply back). + Receive(msg => _transport.IngestAuditEvents(msg, Sender)); - // Audit Log: forward a batch of site-local audit events to the - // central cluster. The site SiteAuditTelemetryActor drains its SQLite - // Pending queue through the ClusterClientSiteAuditClient, which Asks - // this actor; the original Sender (that Ask) is passed as the - // ClusterClient.Send sender so the IngestAuditEventsReply routes - // straight back to the waiting Ask, not here. Mirrors NotificationSubmit. - Receive(msg => - { - if (_centralClient == null) - { - // No ClusterClient registered yet (e.g. central contact points - // not configured, or registration not yet completed). Faulting - // the Ask makes the SiteAuditTelemetryActor drain loop treat - // this as transient and keep the rows Pending for the next tick. - _log.Warning( - "Cannot forward IngestAuditEventsCommand ({0} events) — no central ClusterClient registered", - msg.Events.Count); - Sender.Tell(new Status.Failure( - new InvalidOperationException("Central ClusterClient not registered"))); - return; - } + // Audit Log: forward a batch of combined cached-call telemetry (telemetry drain's Ask → reply back). + Receive(msg => _transport.IngestCachedTelemetry(msg, Sender)); - _log.Debug("Forwarding IngestAuditEventsCommand ({0} events) to central", msg.Events.Count); - _centralClient.Tell( - new ClusterClient.Send("/user/central-communication", msg), Sender); - }); + // Site startup reconciliation: forward the node's local-inventory request (reconcile Ask → response back). + Receive(msg => _transport.ReconcileSite(msg, Sender)); - // Audit Log: forward a batch of combined cached-call telemetry - // packets to the central cluster. Same forward + reply-routing pattern - // as IngestAuditEventsCommand; central replies with an - // IngestCachedTelemetryReply. - Receive(msg => - { - if (_centralClient == null) - { - _log.Warning( - "Cannot forward IngestCachedTelemetryCommand ({0} entries) — no central ClusterClient registered", - msg.Entries.Count); - Sender.Tell(new Status.Failure( - new InvalidOperationException("Central ClusterClient not registered"))); - return; - } - - _log.Debug("Forwarding IngestCachedTelemetryCommand ({0} entries) to central", msg.Entries.Count); - _centralClient.Tell( - new ClusterClient.Send("/user/central-communication", msg), Sender); - }); - - // Site startup reconciliation: forward the node's local-inventory - // ReconcileSiteRequest to the central cluster. The original Sender (the - // SiteReconciliationActor's Ask) is passed as the ClusterClient.Send sender so - // the ReconcileSiteResponse routes straight back to the waiting Ask, not here. - // Mirrors IngestAuditEventsCommand. - Receive(msg => - { - if (_centralClient == null) - { - // No ClusterClient registered yet (e.g. central contact points not - // configured, or registration not yet completed). Faulting the Ask makes - // the SiteReconciliationActor treat the pass as best-effort-failed; it - // logs a warning and retries reconcile on the next node startup. - _log.Warning( - "Cannot forward ReconcileSiteRequest for site {0} node {1} — no central ClusterClient registered", - msg.SiteIdentifier, msg.NodeId); - Sender.Tell(new Status.Failure( - new InvalidOperationException("Central ClusterClient not registered"))); - return; - } - - _log.Debug( - "Forwarding ReconcileSiteRequest for site {0} node {1} ({2} local instance(s)) to central", - msg.SiteIdentifier, msg.NodeId, msg.LocalNameToRevisionHash.Count); - _centralClient.Tell( - new ClusterClient.Send("/user/central-communication", msg), Sender); - }); - - // Internal: send heartbeat tick + // Internal: send heartbeat tick. Receive(_ => SendHeartbeatToCentral()); - // Internal: forward health report to central. The original Sender (the - // AkkaHealthReportTransport's Ask) is forwarded as the ClusterClient.Send - // sender so the central SiteHealthReportAck routes straight back to the - // waiting Ask — making report delivery observable end-to-end (review 01 - // [Medium]). Mirrors the NotificationSubmit ack pattern above. - Receive(msg => - { - if (_centralClient == null) - { - // No ClusterClient registered yet. A non-accepted ack makes the - // sender's counter-restore path treat this tick as a loss. - _log.Warning( - "Cannot forward SiteHealthReport #{0} — no central ClusterClient registered", - msg.SequenceNumber); - Sender.Tell(new SiteHealthReportAck( - msg.SiteId, msg.SequenceNumber, Accepted: false, - Error: "Central ClusterClient not registered")); - return; - } - - _centralClient.Tell( - new ClusterClient.Send("/user/central-communication", msg), Sender); - }); - + // Internal: forward the periodic health report (health transport's Ask → ack back), so a + // lost report is observable end-to-end and the sender can restore its per-interval counters. + Receive(msg => _transport.ReportSiteHealth(msg, Sender)); } /// @@ -443,6 +334,12 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers /// protected override void PreStart() { + // Finalize the transport now that the actor context (and _log) exist. The default Akka + // transport is given this actor's logging adapter so the "no ClusterClient registered" + // warnings are preserved exactly. PreStart always runs before any message, so the Receive + // closures above see a non-null _transport. + _transport = _injectedTransport ?? new AkkaCentralTransport(_log); + _log.Info("SiteCommunicationActor started for site {0}", _siteId); // Schedule periodic heartbeat to central. Uses the application heartbeat @@ -478,9 +375,6 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers private void SendHeartbeatToCentral() { - if (_centralClient == null) - return; - var hostname = Environment.MachineName; // Stamp HeartbeatMessage.IsActive with this node's @@ -512,8 +406,17 @@ public class SiteCommunicationActor : ReceiveActor, IWithTimers IsActive: isActive, DateTimeOffset.UtcNow); - _centralClient.Tell( - new ClusterClient.Send("/user/central-communication", heartbeat), Self); + // Fire-and-forget on both transports: a failure here must never fault the heartbeat timer + // path. Both real transports swallow their own errors; this catch is a belt-and-braces + // guarantee that no transport (including a future one) can turn a heartbeat into a fault. + try + { + _transport.SendHeartbeat(heartbeat, Self); + } + catch (Exception ex) + { + _log.Debug(ex, "Heartbeat send for site {0} failed; swallowed (heartbeats are fire-and-forget)", _siteId); + } } /// diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs index cf1ff099..410b3fda 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptions.cs @@ -1,11 +1,44 @@ namespace ZB.MOM.WW.ScadaBridge.Communication; +/// +/// Which transport carries the seven site→central control messages. Selected per node by +/// ScadaBridge:Communication:CentralTransport; the migration ships with +/// as the default so nothing flips until a node opts in. +/// +public enum CentralTransportMode +{ + /// Akka ClusterClient — the transport in production today, and the default. + Akka = 0, + + /// gRPC dial of the central CentralControlService (Phase 1A migration target). + Grpc = 1, +} + /// /// Configuration options for central-site communication, including per-pattern /// timeouts and transport heartbeat settings. /// public class CommunicationOptions { + /// + /// Which transport carries the site→central control messages. Default + /// (ClusterClient) — coexistence rule: a node flips to gRPC only by setting this to Grpc, + /// and rollback is flipping it back. Selecting Grpc requires . + /// + public CentralTransportMode CentralTransport { get; set; } = CentralTransportMode.Akka; + + /// + /// Central control-plane gRPC endpoints (preferred first), e.g. + /// ["http://scadabridge-central-a:8083", "http://scadabridge-central-b:8083"]. Dialled by + /// with sticky failover/failback. Required when + /// is , ignored otherwise. + /// + /// + /// Sites reach central by container/host name, NOT via Traefik (which is HTTP/1 only; gRPC is + /// h2c on the central node's dedicated CentralGrpcPort). + /// + public List CentralGrpcEndpoints { get; set; } = new(); + /// Timeout for deployment commands (typically longest due to apply logic). public TimeSpan DeploymentTimeout { get; set; } = TimeSpan.FromMinutes(2); diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs index ef43f354..1297a9d7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationOptionsValidator.cs @@ -66,6 +66,19 @@ public sealed class CommunicationOptionsValidator : OptionsValidatorBase 0, $"ScadaBridge:Communication:GrpcMaxConcurrentStreams must be positive (was {options.GrpcMaxConcurrentStreams})."); + // The gRPC site→central transport needs at least one central endpoint to dial. Only + // enforced when that transport is selected — the default Akka path ignores the list, so a + // node on ClusterClient must not be forced to declare gRPC endpoints it never uses. + if (options.CentralTransport == CentralTransportMode.Grpc) + { + builder.RequireThat( + options.CentralGrpcEndpoints.Count > 0 + && options.CentralGrpcEndpoints.All(e => !string.IsNullOrWhiteSpace(e)), + "ScadaBridge:Communication:CentralGrpcEndpoints must list at least one non-empty " + + "central gRPC endpoint when CentralTransport is Grpc " + + $"(was {options.CentralGrpcEndpoints.Count} entr{(options.CentralGrpcEndpoints.Count == 1 ? "y" : "ies")})."); + } + // ── Aggregated live alarm cache (plan #10, Task 6) ─────────────────────── // Linger drives a Timer dueTime; TimeSpan.Zero is valid (stop the aggregator // immediately when the last viewer leaves), only a negative value is invalid. diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralChannelProvider.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralChannelProvider.cs new file mode 100644 index 00000000..9b8f96fd --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/CentralChannelProvider.cs @@ -0,0 +1,274 @@ +using Google.Protobuf.WellKnownTypes; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +/// +/// A pair (or more) of gRPC channels to the central cluster's control-plane nodes, with the +/// sticky-failover + background-failback policy of the design (§3.5). The site holds one channel +/// per central endpoint, prefers the first, and stays on it until a call proves it unreachable — +/// only then flipping to the next, and only / connect +/// failures count (a never flips or retries, because the +/// call may have run). +/// +/// +/// +/// Sticky. All calls go to the current channel; a healthy preferred endpoint never +/// ping-pongs. flips to the next endpoint (round-robin) when the +/// caller sees the current one refuse a connection. +/// +/// +/// Failback. While off the preferred endpoint a background probe (a cheap Heartbeat +/// ping — always answered, even by a not-yet-ready node) re-checks the preferred one. On the first +/// success new calls return to it; in-flight calls finish where they are. The probe is +/// event-driven: it arms on a flip and stops the moment we are back on the preferred endpoint, so +/// a steady healthy pair spends no cycles. Its cadence backs off exponentially — 1 s, doubling, +/// capped at 60 s — while the preferred endpoint stays down, so a genuinely dead node is not +/// probed hard. +/// +/// +/// Auth. Every channel carries the site's own preshared key and its +/// x-scadabridge-site identity via — the same +/// insecure-h2c call-credentials shape the streaming client uses. The +/// seam lets a test point a channel at an in-process TestServer; production uses a +/// keepalive-configured . +/// +/// +public sealed class CentralChannelProvider : IDisposable +{ + private static readonly TimeSpan DefaultBackoffBase = TimeSpan.FromSeconds(1); + private static readonly TimeSpan DefaultBackoffCap = TimeSpan.FromSeconds(60); + private static readonly TimeSpan DefaultProbeDeadline = TimeSpan.FromSeconds(5); + + private readonly IReadOnlyList _endpoints; + private readonly GrpcChannel[] _channels; + private readonly CentralControlService.CentralControlServiceClient[] _clients; + private readonly ILogger _logger; + private readonly string _siteId; + private readonly TimeSpan _backoffBase; + private readonly TimeSpan _backoffCap; + private readonly TimeSpan _probeDeadline; + private readonly Timer? _failbackTimer; + private readonly object _gate = new(); + + private volatile int _current; // preferred == 0 + private int _consecutiveProbeFailures; + private bool _disposed; + + /// Creates the provider and opens one channel per endpoint. + /// Central control-plane endpoints, preferred first (index 0). Must be non-empty. + /// Resolves this site's preshared key (site-side: a single-key provider). + /// This site's identity, sent as the x-scadabridge-site header. + /// Communication options supplying gRPC keepalive settings. + /// Logger for flip/failback diagnostics. + /// Test seam: per-endpoint ; null uses a production socket handler. + /// Deadline for a failback probe. Null uses 5 s. + /// Initial failback-probe backoff. Null uses 1 s. + /// Maximum failback-probe backoff. Null uses 60 s. + public CentralChannelProvider( + IReadOnlyList endpoints, + ISitePskProvider pskProvider, + string siteId, + CommunicationOptions options, + ILogger logger, + Func? handlerFactory = null, + TimeSpan? probeDeadline = null, + TimeSpan? backoffBase = null, + TimeSpan? backoffCap = null) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(pskProvider); + ArgumentException.ThrowIfNullOrWhiteSpace(siteId); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + if (endpoints.Count == 0) + { + throw new ArgumentException("At least one central gRPC endpoint is required.", nameof(endpoints)); + } + + _endpoints = endpoints; + _logger = logger; + _siteId = siteId; + _backoffBase = backoffBase ?? DefaultBackoffBase; + _backoffCap = backoffCap ?? DefaultBackoffCap; + _probeDeadline = probeDeadline ?? DefaultProbeDeadline; + + _channels = new GrpcChannel[endpoints.Count]; + _clients = new CentralControlService.CentralControlServiceClient[endpoints.Count]; + for (var i = 0; i < endpoints.Count; i++) + { + var channelOptions = new GrpcChannelOptions + { + HttpHandler = handlerFactory?.Invoke(endpoints[i]) ?? new SocketsHttpHandler + { + KeepAlivePingDelay = options.GrpcKeepAlivePingDelay, + KeepAlivePingTimeout = options.GrpcKeepAlivePingTimeout, + KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, + EnableMultipleHttp2Connections = true, + }, + }.WithSiteCredentials(pskProvider, siteId); + + _channels[i] = GrpcChannel.ForAddress(endpoints[i], channelOptions); + _clients[i] = new CentralControlService.CentralControlServiceClient(_channels[i]); + } + + // Only a multi-endpoint pair can ever fail over, so a lone endpoint needs no probe. + if (endpoints.Count > 1) + { + _failbackTimer = new Timer(_ => _ = FailbackTickAsync(), null, Timeout.Infinite, Timeout.Infinite); + } + } + + /// The number of endpoints in the pair. + public int EndpointCount => _endpoints.Count; + + /// The index of the endpoint calls are currently routed to (preferred == 0). + public int CurrentIndex => _current; + + /// The endpoint address calls are currently routed to. + public string CurrentEndpoint => _endpoints[_current]; + + /// + /// The endpoint index and client calls should use right now. Captured together so a caller can + /// tell exactly which endpoint failed even if a concurrent flip + /// has already moved . + /// + /// The current endpoint index and its client. + public (int Index, CentralControlService.CentralControlServiceClient Client) Current() + { + var idx = _current; + return (idx, _clients[idx]); + } + + /// + /// Reports that the endpoint at refused a connection (an + /// / connect failure). If it is still the current endpoint + /// and another exists, flips to the next one and — when now off the preferred endpoint — arms + /// the failback probe. Idempotent under a concurrent flip: a stale index is ignored. + /// + /// The endpoint index the caller's failed call used. + public void ReportUnavailable(int failedIndex) + { + if (_endpoints.Count < 2) + { + return; // nothing to fail over to + } + + lock (_gate) + { + if (_disposed || failedIndex != _current) + { + return; // a concurrent flip already moved us; do not double-flip + } + + var next = (failedIndex + 1) % _endpoints.Count; + _current = next; + _logger.LogWarning( + "Central control-plane endpoint {Failed} is unavailable; site {SiteId} failed over to {Next}.", + _endpoints[failedIndex], _siteId, _endpoints[next]); + + if (_current != 0) + { + _consecutiveProbeFailures = 0; + ArmFailback(_backoffBase); + } + } + } + + private void ArmFailback(TimeSpan due) + { + if (_disposed) + { + return; + } + + _failbackTimer?.Change(due, Timeout.InfiniteTimeSpan); + } + + private async Task FailbackTickAsync() + { + int currentAtTick = _current; + if (_disposed || currentAtTick == 0) + { + return; // already back on the preferred endpoint (or shutting down) + } + + var preferred = _clients[0]; + try + { + await preferred.HeartbeatAsync( + new HeartbeatDto + { + SiteId = _siteId, + NodeHostname = "failback-probe", + IsActive = false, + Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), + }, + deadline: DateTime.UtcNow.Add(_probeDeadline)).ConfigureAwait(false); + + // The preferred endpoint answered — return new calls to it. + lock (_gate) + { + if (_disposed) + { + return; + } + + _current = 0; + _consecutiveProbeFailures = 0; + } + + _logger.LogInformation( + "Central control-plane preferred endpoint {Preferred} is reachable again; site {SiteId} failed back.", + _endpoints[0], _siteId); + } + catch (Exception ex) + { + lock (_gate) + { + if (_disposed || _current == 0) + { + return; + } + + _consecutiveProbeFailures++; + var backoff = NextBackoff(_consecutiveProbeFailures); + _logger.LogDebug(ex, + "Failback probe of preferred central endpoint {Preferred} failed; re-probing in {Backoff}.", + _endpoints[0], backoff); + ArmFailback(backoff); + } + } + } + + private TimeSpan NextBackoff(int failures) + { + // 1 s, doubling, capped at 60 s. Guard the shift against overflow for a long outage. + var exponent = Math.Min(failures - 1, 20); + var scaled = _backoffBase.Ticks * (1L << exponent); + var cap = _backoffCap.Ticks; + return TimeSpan.FromTicks(scaled >= cap || scaled < 0 ? cap : scaled); + } + + /// + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + _failbackTimer?.Dispose(); + foreach (var channel in _channels) + { + channel.Dispose(); + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/GrpcCentralTransport.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/GrpcCentralTransport.cs new file mode 100644 index 00000000..5695c7b2 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/GrpcCentralTransport.cs @@ -0,0 +1,258 @@ +using Akka.Actor; +using Grpc.Core; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Communication.Actors; +using AkkaStatus = Akka.Actor.Status; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +/// +/// The that carries the seven site→central sends over gRPC — the +/// migration target for the Akka ClusterClient path. Each method encodes the message with +/// , dials CentralControlService through the sticky +/// , and delivers the decoded reply (or a transient-failure +/// signal) to the waiting Ask. +/// +/// +/// +/// The actor handlers are synchronous; each method here kicks off the RPC as a detached task and +/// Tells the result to when it completes — IActorRef.Tell +/// is thread-safe, so the reply lands at the Ask exactly as central's ClusterClient reply did. On +/// any non-OK status the reply is , which the S&F / audit / health +/// layers already treat as transient. +/// +/// +/// Cross-node retry only on provably-unsent failures. An +/// (connection refused / node not ready) flips the channel pair and retries once on the peer. +/// A is NEVER retried across nodes — a deploy / write / +/// failover may already have executed, and duplicating it is worse than surfacing a transient +/// failure the layer above tolerates. +/// +/// +/// Per-call deadlines mirror today's Ask timeouts. Notification submit/status → +/// NotificationForwardTimeout (30 s, the value the S&F forwarder and the central service +/// both use); health → HealthReportTimeout (10 s); reconcile → QueryTimeout (30 s); +/// both ingest RPCs → (the one shared 30 s +/// constant). The heartbeat, fire-and-forget with no server-side Ask, is merely bounded by +/// HealthReportTimeout and its failures are swallowed. +/// +/// +public sealed class GrpcCentralTransport : ICentralTransport +{ + private readonly CentralChannelProvider _channels; + private readonly CommunicationOptions _options; + private readonly ILogger _logger; + + /// Creates the transport over a channel pair. + /// The sticky central channel pair. + /// Communication options supplying the per-call deadlines. + /// Logger for failover/fault diagnostics. + public GrpcCentralTransport( + CentralChannelProvider channels, + CommunicationOptions options, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(channels); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); + + _channels = channels; + _options = options; + _logger = logger; + } + + /// + public void SubmitNotification(NotificationSubmit message, IActorRef replyTo) + { + var dto = CentralControlDtoMapper.ToDto(message); + Dispatch(replyTo, _options.NotificationForwardTimeout, + (c, o) => c.SubmitNotificationAsync(dto, o), + ack => CentralControlDtoMapper.FromDto(ack)); + } + + /// + public void QueryNotificationStatus(NotificationStatusQuery message, IActorRef replyTo) + { + var dto = CentralControlDtoMapper.ToDto(message); + Dispatch(replyTo, _options.NotificationForwardTimeout, + (c, o) => c.QueryNotificationStatusAsync(dto, o), + response => CentralControlDtoMapper.FromDto(response)); + } + + /// + public void IngestAuditEvents(IngestAuditEventsCommand message, IActorRef replyTo) + { + var batch = CentralControlDtoMapper.ToDto(message); + Dispatch(replyTo, SiteStreamGrpcServer.AuditIngestAskTimeout, + (c, o) => c.IngestAuditEventsAsync(batch, o), + ack => new IngestAuditEventsReply(CentralControlDtoMapper.FromIngestAck(ack))); + } + + /// + public void IngestCachedTelemetry(IngestCachedTelemetryCommand message, IActorRef replyTo) + { + var batch = CentralControlDtoMapper.ToDto(message); + Dispatch(replyTo, SiteStreamGrpcServer.AuditIngestAskTimeout, + (c, o) => c.IngestCachedTelemetryAsync(batch, o), + ack => new IngestCachedTelemetryReply(CentralControlDtoMapper.FromIngestAck(ack))); + } + + /// + public void ReconcileSite(ReconcileSiteRequest message, IActorRef replyTo) + { + var dto = CentralControlDtoMapper.ToDto(message); + Dispatch(replyTo, _options.QueryTimeout, + (c, o) => c.ReconcileSiteAsync(dto, o), + response => CentralControlDtoMapper.FromDto(response)); + } + + /// + public void ReportSiteHealth(SiteHealthReport message, IActorRef replyTo) + { + var dto = CentralControlDtoMapper.ToDto(message); + Dispatch(replyTo, _options.HealthReportTimeout, + (c, o) => c.ReportSiteHealthAsync(dto, o), + ack => CentralControlDtoMapper.FromDto(ack)); + } + + /// + public void SendHeartbeat(HeartbeatMessage message, IActorRef self) + { + _ = SendHeartbeatAsync(message); + } + + private async Task SendHeartbeatAsync(HeartbeatMessage message) + { + var (index, client) = _channels.Current(); + var dto = CentralControlDtoMapper.ToDto(message); + try + { + var options = new CallOptions(deadline: DateTime.UtcNow.Add(_options.HealthReportTimeout)); + using var call = client.HeartbeatAsync(dto, options); + await call.ResponseAsync.ConfigureAwait(false); + } + catch (RpcException ex) when (IsConnectFailure(ex)) + { + // Nudge the pair so the next call tries the peer, but never fault: a heartbeat + // failure must not surface on the site's heartbeat timer path. + _channels.ReportUnavailable(index); + _logger.LogDebug(ex, "Heartbeat to central endpoint {Endpoint} was unavailable.", CurrentEndpointSafe()); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Heartbeat to central failed (swallowed — heartbeats are fire-and-forget)."); + } + } + + /// + /// Runs a unary RPC on the current channel, delivers the decoded reply to + /// , and applies the sticky-failover / no-retry-on-deadline policy. + /// + private void Dispatch( + IActorRef replyTo, + TimeSpan timeout, + Func> call, + Func decode) + { + _ = DispatchAsync(replyTo, timeout, call, decode); + } + + private async Task DispatchAsync( + IActorRef replyTo, + TimeSpan timeout, + Func> call, + Func decode) + { + var (index, client) = _channels.Current(); + try + { + var reply = await InvokeAsync(client, timeout, call).ConfigureAwait(false); + replyTo.Tell(decode(reply)); + } + catch (RpcException ex) when (IsConnectFailure(ex)) + { + // Provably unsent: the connection was refused / the node was not ready. Fail over + // to the peer and retry ONCE. This is the only status we retry across nodes. + _channels.ReportUnavailable(index); + var (retryIndex, retryClient) = _channels.Current(); + if (retryIndex != index) + { + try + { + var reply = await InvokeAsync(retryClient, timeout, call).ConfigureAwait(false); + replyTo.Tell(decode(reply)); + return; + } + catch (Exception retryEx) + { + _logger.LogWarning(retryEx, + "Central control-plane call failed on both endpoints; surfacing as transient."); + replyTo.Tell(new AkkaStatus.Failure(retryEx)); + return; + } + } + + replyTo.Tell(new AkkaStatus.Failure(ex)); + } + catch (Exception ex) + { + // DeadlineExceeded / Internal / PermissionDenied / a PSK-resolution throw — do NOT + // retry across nodes (the call may have run). Surface as the transient failure the + // layer above already tolerates. + replyTo.Tell(new AkkaStatus.Failure(ex)); + } + } + + private static async Task InvokeAsync( + CentralControlService.CentralControlServiceClient client, + TimeSpan timeout, + Func> call) + { + var options = new CallOptions(deadline: DateTime.UtcNow.Add(timeout)); + using var asyncCall = call(client, options); + return await asyncCall.ResponseAsync.ConfigureAwait(false); + } + + /// + /// A failure that provably never reached a server — the only class safe to retry on the peer. + /// is deliberately excluded (the call may have run). + /// + /// + /// Two shapes qualify: a server-signalled (e.g. a node + /// that returns Unavailable while it is still starting), and a client-side failure to even + /// start the call — Grpc.Net surfaces a refused/failed connection as + /// "Error starting gRPC call" with the transport exception + /// attached, and there the request never left the client. Anything else — including a deadline, + /// a permission denial, or a generic server-side Internal after the call reached the server — + /// is NOT retried across nodes. + /// + private static bool IsConnectFailure(RpcException ex) + { + if (ex.StatusCode == StatusCode.Unavailable) + { + return true; + } + + // "Error starting gRPC call" is Grpc.Net's marker for a call that could not be sent — a + // refused/failed connection carrying an HttpRequestException. Provably unsent. + return ex.StatusCode == StatusCode.Internal + && (ex.Status.DebugException is HttpRequestException + || ex.Status.Detail.StartsWith("Error starting gRPC call", StringComparison.Ordinal)); + } + + private string CurrentEndpointSafe() + { + try + { + return _channels.CurrentEndpoint; + } + catch + { + return "(unknown)"; + } + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/StaticSitePskProvider.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/StaticSitePskProvider.cs new file mode 100644 index 00000000..f436ca35 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/Grpc/StaticSitePskProvider.cs @@ -0,0 +1,43 @@ +namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +/// +/// Site-side over a single fixed key — the one key a site node +/// presents on every control-plane call it makes to central (CommunicationOptions.GrpcPsk). +/// Central's provider resolves a key per site; a site has exactly one, so it ignores the +/// requested siteId and returns its own key. +/// +/// +/// Fail-closed. An empty key throws, matching the contract on +/// and the interceptor's own posture: a node shipped without a key must not degrade to an +/// unauthenticated dial. +/// +public sealed class StaticSitePskProvider : ISitePskProvider +{ + private readonly string _key; + + /// Creates the provider bound to a site's own preshared key. + /// The site's GrpcPsk. Empty is permitted at construction but throws on use. + public StaticSitePskProvider(string key) + { + _key = key ?? string.Empty; + } + + /// + public ValueTask GetAsync(string siteId, CancellationToken ct) + { + if (string.IsNullOrEmpty(_key)) + { + throw new InvalidOperationException( + "No gRPC preshared key is configured for this site (ScadaBridge:Communication:GrpcPsk). " + + "The control plane is fail-closed: an unauthenticated dial does not happen."); + } + + return new ValueTask(_key); + } + + /// + public void Invalidate(string siteId) + { + // A single static key never changes for the process lifetime; nothing to drop. + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs index 7d58cc63..3624b5ac 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Options; using ZB.MOM.WW.ScadaBridge.ClusterInfrastructure; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Communication.Actors; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; using ZB.MOM.WW.ScadaBridge.Host.Actors; using ZB.MOM.WW.ScadaBridge.SiteRuntime; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; @@ -832,13 +833,45 @@ akka {{ _logger, role: siteRole); var dmProxy = dm.Proxy; + // Select the site→central transport behind the coexistence flag (default Akka + // ClusterClient). When gRPC is chosen the site dials CentralControlService directly with + // a sticky-failover channel pair, presenting its own preshared key; the ClusterClient + // below is then not created at all. + ICentralTransport? centralTransport = null; + if (_communicationOptions.CentralTransport == CentralTransportMode.Grpc) + { + var loggerFactory = _serviceProvider.GetRequiredService(); + var channelProvider = new CentralChannelProvider( + _communicationOptions.CentralGrpcEndpoints, + new StaticSitePskProvider(_communicationOptions.GrpcPsk), + _nodeOptions.SiteId!, + _communicationOptions, + loggerFactory.CreateLogger()); + _trackedDisposables.Add(channelProvider); + centralTransport = new GrpcCentralTransport( + channelProvider, + _communicationOptions, + loggerFactory.CreateLogger()); + _logger.LogInformation( + "Site→central transport: gRPC to {Count} central endpoint(s) for site {SiteId}", + _communicationOptions.CentralGrpcEndpoints.Count, _nodeOptions.SiteId); + } + else + { + _logger.LogInformation( + "Site→central transport: Akka ClusterClient (default) for site {SiteId}", + _nodeOptions.SiteId); + } + // Create SiteCommunicationActor for receiving messages from central var siteCommActor = _actorSystem.ActorOf( Props.Create(() => new SiteCommunicationActor( _nodeOptions.SiteId!, _communicationOptions, dmProxy, - activeNodeCheck)), + activeNodeCheck, + null, + centralTransport)), "site-communication"); // Register local handlers with SiteCommunicationActor @@ -957,8 +990,12 @@ akka {{ "Site actors registered. DeploymentManager singleton scoped to role={SiteRole}, SiteCommunicationActor created.", siteRole); - // Create ClusterClient to central if contact points are configured - if (_communicationOptions.CentralContactPoints.Count > 0) + // Create ClusterClient to central if contact points are configured — but only on the Akka + // transport. On the gRPC transport the SiteCommunicationActor already holds a + // GrpcCentralTransport and never receives RegisterCentralClient, so a ClusterClient here + // would be dead weight (and keep an unwanted cross-cluster Akka association alive). + if (_communicationOptions.CentralTransport == CentralTransportMode.Akka + && _communicationOptions.CentralContactPoints.Count > 0) { var contacts = _communicationOptions.CentralContactPoints .Select(cp => ActorPath.Parse($"{cp}/system/receptionist")) diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/AkkaCentralTransportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/AkkaCentralTransportTests.cs new file mode 100644 index 00000000..6f73cbab --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/AkkaCentralTransportTests.cs @@ -0,0 +1,67 @@ +using Akka.Actor; +using Akka.Cluster.Tools.Client; +using Akka.TestKit.Xunit2; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Communication.Actors; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Actors; + +/// +/// T1A.3: the regression-prone bit of the Akka transport — that a forwarded +/// carries the caller's sender, so central's reply routes +/// straight back to the waiting Ask rather than to the site communication actor — plus the +/// no-ClusterClient-yet fallback that keeps the S&F layer treating the send as transient. +/// +public class AkkaCentralTransportTests : TestKit +{ + [Fact] + public void SubmitNotification_ForwardsToClusterClient_WithReplyToAsSender() + { + var transport = new AkkaCentralTransport(); + var clusterClient = CreateTestProbe(); + var replyTo = CreateTestProbe(); + transport.SetCentralClient(clusterClient.Ref); + + var submit = new NotificationSubmit( + "notif-1", "Operators", "S", "B", "site1", null, null, DateTimeOffset.UtcNow); + transport.SubmitNotification(submit, replyTo.Ref); + + // The ClusterClient receives a Send addressed to the central actor... + var send = clusterClient.ExpectMsg(); + Assert.Equal("/user/central-communication", send.Path); + Assert.IsType(send.Message); + + // ...and replying to it lands at replyTo, proving the sender was forwarded (not the + // transport / actor). This is the routing the waiting Ask relies on. + clusterClient.Reply(new NotificationSubmitAck("notif-1", Accepted: true, Error: null)); + replyTo.ExpectMsg(ack => ack.NotificationId == "notif-1" && ack.Accepted); + } + + [Fact] + public void SubmitNotification_WithNoClusterClient_RepliesNonAcceptedToReplyTo() + { + var transport = new AkkaCentralTransport(); + var replyTo = CreateTestProbe(); + + transport.SubmitNotification( + new NotificationSubmit("notif-2", "Operators", "S", "B", "site1", null, null, DateTimeOffset.UtcNow), + replyTo.Ref); + + replyTo.ExpectMsg(ack => ack.NotificationId == "notif-2" && !ack.Accepted); + } + + [Fact] + public void IngestAuditEvents_WithNoClusterClient_FaultsTheReplyTo() + { + // The audit drain treats a faulted Ask as transient and keeps rows Pending — so the + // no-client path must be a Status.Failure, not a silent drop. + var transport = new AkkaCentralTransport(); + var replyTo = CreateTestProbe(); + + transport.IngestAuditEvents( + new Commons.Messages.Audit.IngestAuditEventsCommand(new List()), + replyTo.Ref); + + replyTo.ExpectMsg(); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/SiteCommunicationActorTransportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/SiteCommunicationActorTransportTests.cs new file mode 100644 index 00000000..0432a702 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/Actors/SiteCommunicationActorTransportTests.cs @@ -0,0 +1,166 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using NSubstitute; +using ZB.MOM.WW.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; +using ZB.MOM.WW.ScadaBridge.Communication.Actors; + +namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Actors; + +/// +/// T1A.3: the site communication actor delegates each of the seven site→central sends to the +/// injected , preserving the current Sender as the reply +/// target — and a transport failure surfaces to that sender exactly as the S&F / audit / health +/// layers already expect, while a heartbeat transport fault never faults the actor. +/// +public class SiteCommunicationActorTransportTests : TestKit +{ + private readonly CommunicationOptions _options = new(); + + private (IActorRef actor, ICentralTransport transport) NewActor() + { + var transport = Substitute.For(); + var dmProbe = CreateTestProbe(); + var actor = Sys.ActorOf(Props.Create(() => + new SiteCommunicationActor("site1", _options, dmProbe.Ref, () => false, null, transport))); + return (actor, transport); + } + + [Fact] + public void NotificationSubmit_DelegatesToTransport_WithSenderAsReplyTo() + { + var (actor, transport) = NewActor(); + var submit = new NotificationSubmit( + "notif-1", "Operators", "Subj", "Body", "site1", "inst1", "alarmScript", DateTimeOffset.UtcNow); + + actor.Tell(submit, TestActor); + + AwaitAssert(() => transport.Received(1).SubmitNotification( + Arg.Is(m => m.NotificationId == "notif-1"), Arg.Is(TestActor))); + } + + [Fact] + public void NotificationStatusQuery_DelegatesToTransport_WithSenderAsReplyTo() + { + var (actor, transport) = NewActor(); + + actor.Tell(new NotificationStatusQuery("corr-1", "notif-1"), TestActor); + + AwaitAssert(() => transport.Received(1).QueryNotificationStatus( + Arg.Is(m => m.NotificationId == "notif-1"), Arg.Is(TestActor))); + } + + [Fact] + public void IngestAuditEvents_DelegatesToTransport_WithSenderAsReplyTo() + { + var (actor, transport) = NewActor(); + + actor.Tell(new IngestAuditEventsCommand(new List()), TestActor); + + AwaitAssert(() => transport.Received(1).IngestAuditEvents(Arg.Any(), Arg.Is(TestActor))); + } + + [Fact] + public void IngestCachedTelemetry_DelegatesToTransport_WithSenderAsReplyTo() + { + var (actor, transport) = NewActor(); + + actor.Tell(new IngestCachedTelemetryCommand(new List()), TestActor); + + AwaitAssert(() => transport.Received(1).IngestCachedTelemetry(Arg.Any(), Arg.Is(TestActor))); + } + + [Fact] + public void ReconcileSite_DelegatesToTransport_WithSenderAsReplyTo() + { + var (actor, transport) = NewActor(); + + actor.Tell( + new ReconcileSiteRequest("site1", "node-a", new Dictionary()), TestActor); + + AwaitAssert(() => transport.Received(1).ReconcileSite( + Arg.Is(m => m.NodeId == "node-a"), Arg.Is(TestActor))); + } + + [Fact] + public void ReportSiteHealth_DelegatesToTransport_WithSenderAsReplyTo() + { + var (actor, transport) = NewActor(); + var report = MinimalHealthReport(sequence: 7); + + actor.Tell(report, TestActor); + + AwaitAssert(() => transport.Received(1).ReportSiteHealth( + Arg.Is(m => m.SequenceNumber == 7), Arg.Is(TestActor))); + } + + [Fact] + public void TransportFailureReply_RoutesBackToTheWaitingSender() + { + // The seam preserves the reply-routing the S&F layer depends on: when the transport + // answers the captured replyTo with a Status.Failure (its transient-failure signal on a + // non-OK status), that failure reaches the original sender — here the test actor — so the + // waiting Ask faults, exactly as it did on the ClusterClient path. + var transport = Substitute.For(); + transport + .When(t => t.SubmitNotification(Arg.Any(), Arg.Any())) + .Do(ci => ci.Arg().Tell( + new Status.Failure(new InvalidOperationException("central unavailable")))); + + var dmProbe = CreateTestProbe(); + var actor = Sys.ActorOf(Props.Create(() => + new SiteCommunicationActor("site1", _options, dmProbe.Ref, () => false, null, transport))); + + actor.Tell(new NotificationSubmit( + "notif-x", "Operators", "S", "B", "site1", null, null, DateTimeOffset.UtcNow), TestActor); + + var failure = ExpectMsg(); + Assert.IsType(failure.Cause); + } + + [Fact] + public void HeartbeatTransportThrow_DoesNotFaultTheActor() + { + // A transport whose SendHeartbeat throws must not fault the actor — heartbeats are + // fire-and-forget and their failure is swallowed. Prove the actor still serves messages + // after a heartbeat that threw. + var transport = Substitute.For(); + transport + .When(t => t.SendHeartbeat(Arg.Any(), Arg.Any())) + .Do(_ => throw new InvalidOperationException("boom")); + + var dmProbe = CreateTestProbe(); + var actor = Sys.ActorOf(Props.Create(() => + new SiteCommunicationActor( + "site1", + new CommunicationOptions { ApplicationHeartbeatInterval = TimeSpan.FromMilliseconds(50) }, + dmProbe.Ref, () => true, null, transport))); + + // Let the heartbeat timer fire a few times (each throws inside the transport). + Thread.Sleep(250); + + // The actor is still alive and delegating: a subsequent send is handled normally. + actor.Tell(new NotificationSubmit( + "after-heartbeat", "Operators", "S", "B", "site1", null, null, DateTimeOffset.UtcNow), TestActor); + AwaitAssert(() => transport.Received(1).SubmitNotification( + Arg.Is(m => m.NotificationId == "after-heartbeat"), Arg.Is(TestActor))); + } + + private static SiteHealthReport MinimalHealthReport(long sequence) => new( + SiteId: "site1", + SequenceNumber: sequence, + ReportTimestamp: DateTimeOffset.UtcNow, + DataConnectionStatuses: new Dictionary(), + TagResolutionCounts: new Dictionary(), + ScriptErrorCount: 0, + AlarmEvaluationErrorCount: 0, + StoreAndForwardBufferDepths: new Dictionary(), + DeadLetterCount: 0, + DeployedInstanceCount: 0, + EnabledInstanceCount: 0, + DisabledInstanceCount: 0); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs index dafba565..441f62f9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/CommunicationOptionsValidatorTests.cs @@ -104,4 +104,57 @@ public class CommunicationOptionsValidatorTests Assert.True(result.Failed); Assert.Contains("LiveAlarmCachePublishCoalesce", result.FailureMessage); } + + // ── T1A.3: gRPC central transport endpoints (required only when selected) ──── + + [Fact] + public void GrpcTransport_WithNoEndpoints_IsRejected() + { + var result = Validate(new CommunicationOptions + { + CentralTransport = CentralTransportMode.Grpc, + CentralGrpcEndpoints = new List(), + }); + Assert.True(result.Failed); + Assert.Contains("CentralGrpcEndpoints", result.FailureMessage); + } + + [Fact] + public void GrpcTransport_WithBlankEndpoint_IsRejected() + { + var result = Validate(new CommunicationOptions + { + CentralTransport = CentralTransportMode.Grpc, + CentralGrpcEndpoints = new List { " " }, + }); + Assert.True(result.Failed); + Assert.Contains("CentralGrpcEndpoints", result.FailureMessage); + } + + [Fact] + public void GrpcTransport_WithEndpoints_IsValid() + { + var result = Validate(new CommunicationOptions + { + CentralTransport = CentralTransportMode.Grpc, + CentralGrpcEndpoints = new List + { + "http://scadabridge-central-a:8083", + "http://scadabridge-central-b:8083", + }, + }); + Assert.True(result.Succeeded, result.FailureMessage); + } + + [Fact] + public void AkkaTransport_IgnoresMissingGrpcEndpoints() + { + // The default transport must not be forced to declare gRPC endpoints it never dials. + var result = Validate(new CommunicationOptions + { + CentralTransport = CentralTransportMode.Akka, + CentralGrpcEndpoints = new List(), + }); + Assert.True(result.Succeeded, result.FailureMessage); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcCentralTransportTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcCentralTransportTests.cs new file mode 100644 index 00000000..733829c0 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/GrpcCentralTransportTests.cs @@ -0,0 +1,373 @@ +using System.Collections.Concurrent; +using Akka.Actor; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification; +using ZB.MOM.WW.ScadaBridge.Communication; +using ZB.MOM.WW.ScadaBridge.Communication.Grpc; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// T1A.3: + over a real +/// gRPC stack (two in-process central nodes, the real +/// and ). Proves +/// the sticky failover/failback policy, the PSK + site-header attachment, the per-call deadline, +/// and — the hard rule — no cross-node retry on DeadlineExceeded. +/// +/// +/// A "down" node is modelled by a that throws before reaching the +/// TestServer, so BOTH the unary call and the failback Heartbeat probe see it as +/// Unavailable — the honest shape of a refused connection, and the only class the transport +/// fails over on. Readiness is always set, so a node that is "up" answers everything. +/// +public class GrpcCentralTransportTests : IAsyncLifetime +{ + private const string SiteA = "site-a"; + private const string SiteAKey = "site-a-preshared-key"; + private const string EndpointA = "http://central-a/"; + private const string EndpointB = "http://central-b/"; + + private ActorSystem _system = null!; + private CentralNode _nodeA = null!; + private CentralNode _nodeB = null!; + + /// + public async Task InitializeAsync() + { + _system = ActorSystem.Create("grpc-central-transport-test"); + _nodeA = await CentralNode.StartAsync(_system, "A", SiteA, SiteAKey, repliesToSubmit: true); + _nodeB = await CentralNode.StartAsync(_system, "B", SiteA, SiteAKey, repliesToSubmit: true); + } + + /// + public async Task DisposeAsync() + { + await _nodeA.DisposeAsync(); + await _nodeB.DisposeAsync(); + await _system.Terminate(); + } + + private CentralChannelProvider NewProvider(string? pskKey = SiteAKey) => new( + new[] { EndpointA, EndpointB }, + new FixedPskProvider(pskKey), + SiteA, + new CommunicationOptions(), + NullLogger.Instance, + handlerFactory: HandlerFor, + probeDeadline: TimeSpan.FromSeconds(2), + backoffBase: TimeSpan.FromMilliseconds(50), + backoffCap: TimeSpan.FromMilliseconds(200)); + + private HttpMessageHandler HandlerFor(string endpoint) => endpoint == EndpointA + ? new ToggleHandler(_nodeA.Server.CreateHandler(), () => _nodeA.IsUp) + : new ToggleHandler(_nodeB.Server.CreateHandler(), () => _nodeB.IsUp); + + private GrpcCentralTransport NewTransport(CentralChannelProvider provider, CommunicationOptions? options = null) + => new(provider, options ?? new CommunicationOptions(), NullLogger.Instance); + + [Fact] + public async Task HappyPath_ReachesThePreferredNode_AndRoutesTheAckBack() + { + using var provider = NewProvider(); + var transport = NewTransport(provider); + var inbox = new Capture(_system); + + transport.SubmitNotification(NewSubmit("n1"), inbox.Ref); + + var ack = Assert.IsType(inbox.Receive(TimeSpan.FromSeconds(5))); + Assert.True(ack.Accepted); + Assert.Equal("n1", ack.NotificationId); + Assert.Equal(0, provider.CurrentIndex); // stayed on preferred + Assert.Equal(1, _nodeA.SubmitCount); + Assert.Equal(0, _nodeB.SubmitCount); + } + + [Fact] + public async Task Sticky_StaysOnThePreferredNode_WhileHealthy() + { + using var provider = NewProvider(); + var transport = NewTransport(provider); + + for (var i = 0; i < 4; i++) + { + var inbox = new Capture(_system); + transport.SubmitNotification(NewSubmit($"n{i}"), inbox.Ref); + Assert.IsType(inbox.Receive(TimeSpan.FromSeconds(5))); + } + + Assert.Equal(0, provider.CurrentIndex); + Assert.Equal(4, _nodeA.SubmitCount); + Assert.Equal(0, _nodeB.SubmitCount); + } + + [Fact] + public async Task Failover_FlipsToThePeer_WhenThePreferredIsUnavailable() + { + using var provider = NewProvider(); + var transport = NewTransport(provider); + _nodeA.IsUp = false; // preferred refuses connections + + var inbox = new Capture(_system); + transport.SubmitNotification(NewSubmit("n1"), inbox.Ref); + + var ack = Assert.IsType(inbox.Receive(TimeSpan.FromSeconds(5))); + Assert.True(ack.Accepted); + Assert.Equal(1, provider.CurrentIndex); // flipped to the peer + Assert.Equal(0, _nodeA.SubmitCount); + Assert.Equal(1, _nodeB.SubmitCount); + } + + [Fact] + public async Task Failback_ReturnsToThePreferred_OnceItIsReachableAgain() + { + using var provider = NewProvider(); + var transport = NewTransport(provider); + + // Take the preferred down and drive one call so we flip to the peer + arm the failback probe. + _nodeA.IsUp = false; + var inbox = new Capture(_system); + transport.SubmitNotification(NewSubmit("n1"), inbox.Ref); + Assert.IsType(inbox.Receive(TimeSpan.FromSeconds(5))); + Assert.Equal(1, provider.CurrentIndex); + + // Bring the preferred back; the background probe should fail us back within a few backoffs. + _nodeA.IsUp = true; + await WaitUntil(() => provider.CurrentIndex == 0, TimeSpan.FromSeconds(5)); + Assert.Equal(0, provider.CurrentIndex); + + // New calls resume on the preferred node. + var inbox2 = new Capture(_system); + transport.SubmitNotification(NewSubmit("n2"), inbox2.Ref); + Assert.IsType(inbox2.Receive(TimeSpan.FromSeconds(5))); + Assert.True(_nodeA.SubmitCount >= 1); + } + + [Fact] + public async Task PskAndSiteHeader_AreAttached_SoTheGatedCallReachesTheService() + { + // The service is gated by CentralControlAuthInterceptor; a call that reaches it (and gets + // Accepted) proves both the bearer PSK and the x-scadabridge-site header were attached. + using var provider = NewProvider(pskKey: SiteAKey); + var transport = NewTransport(provider); + var inbox = new Capture(_system); + + transport.SubmitNotification(NewSubmit("n1"), inbox.Ref); + + var ack = Assert.IsType(inbox.Receive(TimeSpan.FromSeconds(5))); + Assert.True(ack.Accepted); + } + + [Fact] + public async Task WrongPsk_IsRejected_AndNotRetriedOnThePeer() + { + // PermissionDenied is not a connect failure — the transport surfaces it as a transient + // Status.Failure without flipping to the peer. + using var provider = NewProvider(pskKey: "the-wrong-key"); + var transport = NewTransport(provider); + var inbox = new Capture(_system); + + transport.SubmitNotification(NewSubmit("n1"), inbox.Ref); + + Assert.IsType(inbox.Receive(TimeSpan.FromSeconds(5))); + Assert.Equal(0, provider.CurrentIndex); // no flip + Assert.Equal(0, _nodeB.SubmitCount); // peer never tried + } + + [Fact] + public async Task DeadlineExceeded_IsNotRetriedOnThePeer() + { + // THE hard rule. Node A is UP but never replies, so the call deadlines. The transport must + // surface Status.Failure and must NOT try node B (the call may already have executed). + _nodeA.SetBlackHole(); + var shortDeadline = new CommunicationOptions { NotificationForwardTimeout = TimeSpan.FromMilliseconds(300) }; + + using var provider = NewProvider(); + var transport = NewTransport(provider, shortDeadline); + var inbox = new Capture(_system); + + transport.SubmitNotification(NewSubmit("n1"), inbox.Ref); + + // A per-call deadline is applied (the call returns fast instead of hanging on the black hole). + Assert.IsType(inbox.Receive(TimeSpan.FromSeconds(5))); + Assert.Equal(0, provider.CurrentIndex); // no failover on a deadline + Assert.Equal(0, _nodeB.SubmitCount); // peer never tried + } + + private static NotificationSubmit NewSubmit(string id) => new( + NotificationId: id, + ListName: "ops", + Subject: "s", + Body: "b", + SourceSiteId: SiteA, + SourceInstanceId: null, + SourceScript: null, + SiteEnqueuedAt: DateTimeOffset.UtcNow); + + private static async Task WaitUntil(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return; + } + + await Task.Delay(25); + } + } + + /// + /// A raw message sink used as the transport's replyTo. Unlike Akka's Inbox, which + /// rethrows a 's cause on receive, this captures every message + /// verbatim so a test can assert on the itself. + /// + private sealed class Capture + { + private readonly BlockingCollection _messages = new(); + + public Capture(ActorSystem system) + { + Ref = system.ActorOf(Props.Create(() => new CaptureActor(_messages))); + } + + public IActorRef Ref { get; } + + public object Receive(TimeSpan timeout) + => _messages.TryTake(out var message, timeout) + ? message + : throw new TimeoutException("No message captured within the timeout."); + + private sealed class CaptureActor : ReceiveActor + { + public CaptureActor(BlockingCollection messages) => ReceiveAny(messages.Add); + } + } + + /// A gRPC channel handler that throws (a refused connection) while its node is "down". + private sealed class ToggleHandler : DelegatingHandler + { + private readonly Func _isUp; + + public ToggleHandler(HttpMessageHandler inner, Func isUp) + { + InnerHandler = inner; + _isUp = isUp; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (!_isUp()) + { + throw new HttpRequestException("simulated central node down"); + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + } + + private sealed class FixedPskProvider(string? key) : ISitePskProvider + { + public ValueTask GetAsync(string siteId, CancellationToken ct) + => key is null ? throw new InvalidOperationException("no key") : new ValueTask(key); + + public void Invalidate(string siteId) { } + } + + /// One in-process central node: TestServer + real service/interceptor + a stub actor. + private sealed class CentralNode : IAsyncDisposable + { + private IHost _host = null!; + private IActorRef _stub = null!; + private readonly StubCounters _counters = new(); + + public TestServer Server { get; private set; } = null!; + public volatile bool IsUp = true; + public int SubmitCount => _counters.Submits; + + public static async Task StartAsync( + ActorSystem system, string label, string site, string key, bool repliesToSubmit) + { + var node = new CentralNode(); + node._stub = system.ActorOf( + Props.Create(() => new StubCentralActor(node._counters, repliesToSubmit)), $"stub-{label}"); + + var service = new CentralControlGrpcService( + NullLogger.Instance, + Options.Create(new CommunicationOptions())); + service.SetReady(node._stub); + + var psk = new MapPskProvider(new Dictionary { [site] = key }); + + node._host = await new HostBuilder() + .ConfigureWebHost(web => web + .UseTestServer() + .ConfigureServices(services => + { + services.AddGrpc(o => o.Interceptors.Add()); + services.AddSingleton(psk); + services.AddSingleton(service); + }) + .Configure(app => + { + app.UseRouting(); + app.UseEndpoints(e => e.MapGrpcService()); + })) + .StartAsync(); + + node.Server = node._host.GetTestServer(); + return node; + } + + /// Switches the node's actor to a black hole that counts but never replies. + public void SetBlackHole() => _counters.BlackHole = true; + + public async ValueTask DisposeAsync() + { + await _host.StopAsync(); + _host.Dispose(); + } + + private sealed class StubCounters + { + private int _submits; + public int Submits => Volatile.Read(ref _submits); + public void IncrementSubmits() => Interlocked.Increment(ref _submits); + public volatile bool BlackHole; + } + + private sealed class StubCentralActor : ReceiveActor + { + public StubCentralActor(StubCounters counters, bool repliesToSubmit) + { + Receive(msg => + { + counters.IncrementSubmits(); + if (repliesToSubmit && !counters.BlackHole) + { + Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null)); + } + }); + + // Heartbeat lands here as a Tell (the failback probe); ignore it, no reply expected. + ReceiveAny(_ => { }); + } + } + + private sealed class MapPskProvider(IReadOnlyDictionary keys) : ISitePskProvider + { + public ValueTask GetAsync(string siteId, CancellationToken ct) + => keys.TryGetValue(siteId, out var key) + ? new ValueTask(key) + : throw new InvalidOperationException($"no key for '{siteId}'"); + + public void Invalidate(string siteId) { } + } + } +} From 0e162cb2507d09112c66b44a0992956d21569270 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 22 Jul 2026 19:50:32 -0400 Subject: [PATCH 4/5] fix(grpc): central Kestrel gRPC listener was dropping the :5000 HTTP surface (T1A.2 regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught on the Phase 1A rig proof: central-a logged only "Now listening on: http://[::]:8083" and nothing else. Central UI, the Management + Inbound API, and the /health/* endpoints Traefik + IActiveNodeGate depend on were all gone, with no startup error — the node booted, joined the cluster and served gRPC fine. Cause: calling options.ListenAnyIP in ConfigureKestrel puts Kestrel into explicit-endpoints mode, which SUPPRESSES the URLs from ASPNETCORE_URLS/--urls entirely — it is not additive, contrary to the comment T1A.2 shipped. Central's whole HTTP/1 surface lives on that URL (http://+:5000 on the rig, a different port in prod), so binding only the gRPC port silently deleted it. The site branch has the same shape but no ASPNETCORE_URLS surface to lose — it binds every port it needs explicitly. Fix: parse the port(s) from the configured URLs and re-declare them (Http1AndHttp2) alongside the gRPC port (Http2) in the one ConfigureKestrel call. New Program.ParseHttpBindPorts + CentralHttpBindPortsTests (14 cases: wildcard/ipv6/ hostname hosts, multi-URL, de-dupe, scheme-default, null/blank, unparseable-skipped). Why no test caught it originally: unit/E2E tests use TestServer, which never binds real Kestrel. Only a live node exposes the missing listener — which is exactly what the rig proof is for. --- src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 95 +++++++++++++++++-- .../CentralHttpBindPortsTests.cs | 56 +++++++++++ 2 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 2422f353..bd1a99b1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -98,17 +98,42 @@ try builder.Host.UseWindowsService(); // Explicit Kestrel h2c listener for the central-hosted gRPC control plane - // (CentralControlService). Mirrors the site branch's gRPC listener: HTTP/2-only, - // on its own port (default 8083, symmetric with the site GrpcPort — a node is - // either central or a site, never both). This is ADDITIVE to central's :5000 - // HTTP/1 surface (Central UI + Management/Inbound API from ASPNETCORE_URLS), - // which is untouched: gRPC does NOT go through Traefik (HTTP/1 only), sites reach - // this port by container name. T1A.2 hosts the server here; sites keep dialing over - // Akka ClusterClient until T1A.3 flips CentralTransport — this listener simply also - // accepts. + // (CentralControlService): HTTP/2-only, on its own port (default 8083, symmetric + // with the site GrpcPort — a node is either central or a site, never both). gRPC + // does NOT go through Traefik (HTTP/1 only); sites reach this port by container name. + // + // WARNING — the trap the rig caught (T1A.2 shipped it, fixed here): calling + // options.Listen*/ListenAnyIP puts Kestrel into EXPLICIT-ENDPOINTS mode, which + // SUPPRESSES the URLs from ASPNETCORE_URLS/--urls entirely — it is NOT additive. + // Central's ENTIRE HTTP/1 surface (Central UI, Management + Inbound API, and the + // /health/* endpoints Traefik + IActiveNodeGate depend on) lives on that URL + // (http://+:5000 on the rig, a different port in production). If we bind only the + // gRPC port, :5000 vanishes and central is reachable over gRPC while the UI, the + // management API and health checks are all dead — with no startup error. Unit tests + // use TestServer and never bind real Kestrel, so only a live node exposes this. + // The site branch has the same ConfigureKestrel shape but no ASPNETCORE_URLS surface + // to lose (it binds every port it needs — gRPC + metrics — explicitly). Central must + // therefore RE-BIND its HTTP port(s) here alongside the gRPC port. var centralGrpcPort = configuration.GetValue("ScadaBridge:Node:CentralGrpcPort", 8083); + // "urls" is WebHostDefaults.ServerUrlsKey — the host setting ASPNETCORE_URLS/--urls + // populate. Read it as a literal so this needs no extra Hosting using. + var httpUrls = configuration["ASPNETCORE_URLS"] + ?? builder.WebHost.GetSetting("urls") + ?? "http://+:5000"; + var httpPorts = ParseHttpBindPorts(httpUrls); builder.WebHost.ConfigureKestrel(options => { + // The HTTP/1.1 (+ HTTP/2) surface from the configured URLs, re-declared so it + // survives the explicit-endpoints switch above. + foreach (var httpPort in httpPorts) + { + options.ListenAnyIP(httpPort, listenOptions => + { + listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http1AndHttp2; + }); + } + + // The gRPC control plane, HTTP/2 h2c only, on its own port. options.ListenAnyIP(centralGrpcPort, listenOptions => { listenOptions.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2; @@ -645,4 +670,56 @@ finally /// /// Exposes the auto-generated Program class for test infrastructure (e.g. WebApplicationFactory). /// -public partial class Program { } +public partial class Program +{ + /// + /// Extracts the distinct TCP ports from an ASP.NET Core server-URLs string (the + /// ASPNETCORE_URLS / --urls value, e.g. "http://+:5000" or a + /// semicolon-separated list). Used to re-declare central's HTTP surface after the gRPC + /// listener switches Kestrel into explicit-endpoints mode — see the call site's warning. + /// + /// + /// Bind hosts (+, *, 0.0.0.0, [::], a hostname) are irrelevant + /// here because the caller re-binds via ListenAnyIP; only the port matters. A URL + /// with no explicit port falls back to the scheme default (80/443). Unparseable entries + /// are skipped rather than throwing — a bad URL should not take the node down at boot. + /// + /// The server-URLs string; may be null/empty. + /// The distinct ports, in first-seen order. + internal static IReadOnlyList ParseHttpBindPorts(string? serverUrls) + { + var ports = new List(); + if (string.IsNullOrWhiteSpace(serverUrls)) + { + return ports; + } + + foreach (var raw in serverUrls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + int port; + // Uri can't parse the wildcard hosts Kestrel accepts (+, *), so normalize them + // to a placeholder host before parsing; the host is discarded anyway. + var normalized = raw.Replace("://+", "://placeholder").Replace("://*", "://placeholder"); + if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri)) + { + port = uri.Port; // Uri fills the scheme default (80/443) when none is given. + } + else + { + // Last-ditch: pull the port after the final ':' (handles odd inputs Uri rejects). + var colon = raw.LastIndexOf(':'); + if (colon < 0 || !int.TryParse(raw.AsSpan(colon + 1), out port)) + { + continue; + } + } + + if (port is > 0 and <= 65535 && !ports.Contains(port)) + { + ports.Add(port); + } + } + + return ports; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs new file mode 100644 index 00000000..dd2d4336 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CentralHttpBindPortsTests.cs @@ -0,0 +1,56 @@ +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.Host.Tests; + +/// +/// Pins , which re-declares central's HTTP surface +/// after the gRPC listener switches Kestrel into explicit-endpoints mode. +/// +/// +/// This exists because the regression it guards is invisible to every other test: a central +/// node that binds only the gRPC port boots clean, joins the cluster and serves gRPC, while +/// the Central UI, Management/Inbound API and /health/* are silently dead. TestServer +/// never binds real Kestrel, so the parser is the one seam that can be asserted in-process. +/// The rig caught the original defect; this keeps it caught. +/// +public class CentralHttpBindPortsTests +{ + [Theory] + [InlineData("http://+:5000", new[] { 5000 })] + [InlineData("http://0.0.0.0:5000", new[] { 5000 })] + [InlineData("http://[::]:5000", new[] { 5000 })] + [InlineData("http://localhost:5000", new[] { 5000 })] + [InlineData("http://*:8085", new[] { 8085 })] + [InlineData("http://+:5000;http://+:5001", new[] { 5000, 5001 })] + [InlineData("http://+:5000 ; http://+:5001", new[] { 5000, 5001 })] + [InlineData("http://+:5000;http://+:5000", new[] { 5000 })] // de-duped + public void ParsesPortsFromServerUrls(string urls, int[] expected) + { + Assert.Equal(expected, Program.ParseHttpBindPorts(urls)); + } + + [Theory] + [InlineData("https://+:443", 443)] // scheme default when no explicit port + [InlineData("http://+:80", 80)] + public void HandlesSchemeDefaultAndExplicitPort(string url, int expected) + { + Assert.Equal(new[] { expected }, Program.ParseHttpBindPorts(url)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void EmptyOrNull_YieldsNoPorts(string? urls) + { + Assert.Empty(Program.ParseHttpBindPorts(urls)); + } + + [Fact] + public void UnparseableEntry_IsSkipped_NotThrown() + { + // A malformed URL must not take the node down at boot; the good one still binds. + var ports = Program.ParseHttpBindPorts("not-a-url;http://+:5000"); + Assert.Equal(new[] { 5000 }, ports); + } +} From f7c781194039c6ced08d77c2de0edc3e4cab7b59 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 22 Jul 2026 19:55:09 -0400 Subject: [PATCH 5/5] =?UTF-8?q?docs(grpc):=20Phase=201A=20live=20gate=20?= =?UTF-8?q?=E2=80=94=20PASS=20(3=20RPCs=20+=20restart-reconcile=20+=20coex?= =?UTF-8?q?istence);=20records=20the=20Kestrel-drop=20defect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...6-07-22-clusterclient-to-grpc-live-gate.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md b/docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md index a5fcc17a..387c87e7 100644 --- a/docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md +++ b/docs/plans/2026-07-22-clusterclient-to-grpc-live-gate.md @@ -141,3 +141,58 @@ reaches the page HTML** — has not executed since. Fix is a valid 32-hex SID in the same code path — but a live `SubscribeInstance` under load is untested here. - Key **rotation** on a live pair. - `docker-env2` was updated with its own key but not redeployed or gated. + +--- + +## Phase 1A — central control plane (site→central over gRPC) — **PASS** (2026-07-22) + +Branch `feat/grpc-central-control` @ `0e162cb2`. Rig rebuilt; **site-a flipped to +`CentralTransport=Grpc`** with `CentralGrpcEndpoints=[central-a:8083, central-b:8083]`, +**site-b/c left on Akka** (default) to prove coexistence. The site-a flag flip was a +DoD-test-only rig edit — reverted from the branch, never committed (the plan keeps the default +`Akka` until Phase 4). + +### The defect this gate caught (T1A.2 shipped it; fixed in `0e162cb2`) + +**First rebuild: central's entire HTTP surface was gone.** central-a logged only +`Now listening on: http://[::]:8083` — no `:5000`. Central UI, the Management + Inbound API, +and every `/health/*` endpoint (Traefik routing + `IActiveNodeGate` both depend on them) were +dead. The node booted, joined the cluster and served gRPC fine; **no startup error.** + +Cause: `builder.WebHost.ConfigureKestrel(o => o.ListenAnyIP(8083, Http2))` puts Kestrel into +explicit-endpoints mode, which **suppresses the URLs from `ASPNETCORE_URLS`/`--urls`** — it is +not additive, contrary to the comment T1A.2 shipped. Central's whole HTTP/1 surface lives on +that URL (`http://+:5000` on the rig; a different port in production). The site branch has the +same `ConfigureKestrel` shape but nothing on `ASPNETCORE_URLS` to lose — it binds every port it +needs explicitly — which is why the pattern looked safe. + +Every unit + E2E test uses `TestServer`, which never binds real Kestrel, so the whole suite +(6,872) stayed green. Only a live node exposes a missing listener. Fix: parse the port(s) from +the configured URLs and re-declare them (`Http1AndHttp2`) alongside the gRPC port (`Http2`) in +the one `ConfigureKestrel` call — `Program.ParseHttpBindPorts` + `CentralHttpBindPortsTests` +(14 cases). After the fix: `Now listening on: http://[::]:5000` **and** `:8083`; 9001 ready +`200`/active Healthy, 9002 standby, Traefik LB `200`, CLI over the LB works. + +### Checks (post-fix rebuild) + +| # | Check | Result | +|---|---|---| +| 1 | site-a rides authenticated gRPC to `CentralControlService` | **PASS** — Heartbeat, `ReportSiteHealth`, `ReconcileSite` all HTTP/2 → 200; **0** auth failures on either central | +| 2 | Heartbeat drives the active flag | **PASS** — 144+ heartbeats over gRPC; site-a `online=True` at central | +| 3 | Health page live | **PASS** — `ReportSiteHealth` lands; central shows site-a `online=True`, sequence advancing, alongside Akka site-b/c | +| 4 | Reconcile works after site restart | **PASS** — restarted `site-a-a`; it logged `Site→central transport: gRPC to 2 central endpoint(s)`, then `Reconcile pass … complete: 0 fetched, 0 failed, 0 orphan(s)` | +| 5 | Coexistence | **PASS** — site-b/c log `Created ClusterClient to central`; both `online=True` — Akka and gRPC sites side by side | +| 6 | Central HTTP surface intact under the new gRPC listener | **PASS** — after the fix (see above) | + +### Not independently exercised on this gate + +- **Notification e2e (`SubmitNotification`/`QueryNotificationStatus`) and audit ingest + (`IngestAuditEvents`/`IngestCachedTelemetry`)** were NOT driven live: the rig has templates + but **no deployed instance**, so nothing emits site→central notifications or audit rows on its + own. These four RPCs traverse the identical `CentralControlGrpcService` → + `CentralCommunicationActor` Ask path that checks 1–3 proved live under real auth, and their + payload mappers carry 32 round-trip goldens — but the payloads themselves were not put over + the wire here. **Phase 2's central-kill S&F soak is where they get their live workout;** flag + for a fuller 1A proof if a deployed-instance rig is set up before then. +- Cross-node failover/failback of the site→central channel under a central-node kill (unit-proven + via TestServer; not exercised on the rig at 1A).