feat(grpc): central_control.proto + DTO mapper for the 7 site→central control RPCs (T1A.1)
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 <Protobuf> 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.
This commit is contained in:
+75
-49
@@ -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 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path("$CSPROJ")
|
||||
src = p.read_text()
|
||||
# Find the commented Protobuf block and unwrap it.
|
||||
new = re.sub(
|
||||
r"<!--\s*\n(\s*<ItemGroup>\s*\n\s*<Protobuf [^>]*/>\s*\n\s*</ItemGroup>)\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 </Project>. 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' <Protobuf Include="Protos\\{p}" GrpcServices="Both" />' for p in protos)
|
||||
block = f" <ItemGroup>\n{items}\n </ItemGroup>\n\n</Project>"
|
||||
|
||||
src = csproj.read_text()
|
||||
if "</Project>" not in src:
|
||||
raise SystemExit("Couldn't find </Project> to inject the Protobuf ItemGroup before.")
|
||||
csproj.write_text(src.replace("</Project>", 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 - <<PY
|
||||
import re, pathlib
|
||||
p = pathlib.Path("$CSPROJ")
|
||||
src = p.read_text()
|
||||
new = re.sub(
|
||||
r"(\s*<ItemGroup>\s*\n\s*<Protobuf [^>]*/>\s*\n\s*</ItemGroup>)",
|
||||
r"\n <!--\1\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"
|
||||
|
||||
Reference in New Issue
Block a user