diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/MeshTransportProtoContract.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/MeshTransportProtoContract.cs new file mode 100644 index 00000000..d49ca315 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/MeshTransportProtoContract.cs @@ -0,0 +1,33 @@ +using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Protos; + +/// +/// The single source of truth for which variants +/// the cross-mesh command transport actually handles. Both the contract-lock test (which proves +/// this list stays in lock-step with the generated oneof) and the oneof↔domain mapper reference +/// this array, so adding a fifth command to mesh_transport.proto forces a matching entry +/// here or the build/test goes red. +/// +/// +/// Mirrors , and for a sharper reason: a telemetry variant +/// nobody handles loses an observation, whereas a command variant nobody handles loses a +/// deployment, a driver restart or an alarm acknowledgement — silently, since the sender's ack +/// only ever meant "received". +/// +public static class MeshTransportProtoContract +{ + /// + /// Exactly the four cross-mesh command oneof cases mirrored from the domain records: + /// DispatchDeployment / RestartDriver / ReconnectDriver / AlarmCommand. The fifth message on + /// this contract, ApplyAckMessage, travels the opposite direction on its own RPC and is + /// not a oneof member, so it is deliberately absent from this list. + /// + public static readonly CommandEnvelope.CommandOneofCase[] HandledCases = + [ + CommandEnvelope.CommandOneofCase.DispatchDeployment, + CommandEnvelope.CommandOneofCase.RestartDriver, + CommandEnvelope.CommandOneofCase.ReconnectDriver, + CommandEnvelope.CommandOneofCase.AlarmCommand, + ]; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/mesh_transport.proto b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/mesh_transport.proto new file mode 100644 index 00000000..d91c52f8 --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Protos/mesh_transport.proto @@ -0,0 +1,118 @@ +syntax = "proto3"; + +package mesh_transport.v1; + +option csharp_namespace = "ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1"; + +import "google/protobuf/timestamp.proto"; + +// ClusterClient→gRPC migration Phase 0: the cross-mesh command plane. The five messages that cross +// an application-Cluster boundary today ride an Akka ClusterClient receptionist; this contract is +// the replacement carrier. Four are central→node commands, one is the node→central deployment ack. +// +// The C# namespace deliberately says "MeshTransport", never "MeshCommand" — the latter is already +// the CLR envelope record ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh.MeshCommand, and a generated type +// of the same name would collide on every file that touches both sides of the mapper. +// +// Additive-only field evolution: never renumber or reuse a tag; a pre-field peer must read a new +// field's proto3 default correctly (which is why nullable domain members are modelled with `optional` +// for explicit presence, and why every enum reserves 0 as UNSPECIFIED). The oneof is locked by +// MeshTransportProtoContract + its test in the Commons test project. + +// Central → node. Delivered to the node's NodeCommunicationActor, which re-emits the decoded message +// on the node's EventStream. ONE-WAY semantics, preserved verbatim from the ClusterClient transport: +// the ack means "received and re-emitted", nothing more — not applied, not acknowledged by any +// subscriber. No retry is layered on a failure; a failed Execute is logged per node and dropped, +// which is an honest upgrade over the receptionist's silent drop rather than a semantic change. +service NodeCommandService { + rpc Execute (CommandEnvelope) returns (ExecuteAck); +} + +// Node → central. Delivered to central's CentralCommunicationActor, which forwards to the +// ConfigPublishCoordinator singleton proxy — so an ack landing on either node of the central pair +// routes identically, exactly as the receptionist made arrival-node-agnostic. ONE-WAY semantics: the +// ack means "handed to the coordinator", not "the deployment is complete". +service CentralCommandService { + rpc ReportApply (ApplyAckMessage) returns (ReportAck); +} + +message CommandEnvelope { + // Transport-level correlation, carried for every command including AlarmCommandMessage, which is + // the one CLR record with no correlation member of its own. The other three ALSO carry their own + // domain correlation field-for-field below; the duplication is deliberate, so each command stays + // independently round-trippable through the mapper and the envelope stays loggable on its own. + string correlation_id = 1; + oneof command { + DispatchDeploymentCommand dispatch_deployment = 2; // <- Messages.Deploy.DispatchDeployment + RestartDriverCommand restart_driver = 3; // <- Messages.Admin.RestartDriver + ReconnectDriverCommand reconnect_driver = 4; // <- Messages.Admin.ReconnectDriver + AlarmCommandMessage alarm_command = 5; // <- OpcUa.AlarmCommand + } +} + +// Deliberately empty. A non-OK gRPC status is the ONLY failure signal on this call — putting a +// success flag in the body would invite a caller to read OK+false as a distinct outcome the +// fire-and-forget contract does not have. Extensible later without a version bump. +message ExecuteAck { +} + +// Deliberately empty, for the same reason as ExecuteAck. +message ReportAck { +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.DispatchDeployment. +message DispatchDeploymentCommand { + string deployment_id = 1; // DeploymentId (Guid) in its "N" hex form + string revision_hash = 2; // RevisionHash — lowercase 64-char SHA-256 hex + string correlation_id = 3; // CorrelationId (Guid) in its "N" hex form +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin.RestartDriver. +message RestartDriverCommand { + string cluster_id = 1; + string driver_instance_id = 2; + string actor_by_user_name = 3; + string correlation_id = 4; // Guid in the record; "N" hex form +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin.ReconnectDriver. +message ReconnectDriverCommand { + string cluster_id = 1; + string driver_instance_id = 2; + string actor_by_user_name = 3; + string correlation_id = 4; // Guid in the record; "N" hex form +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Commons.OpcUa.AlarmCommand. +message AlarmCommandMessage { + string alarm_id = 1; + // The Part 9 operation (Acknowledge / Confirm / OneShotShelve / TimedShelve / Unshelve / Enable / + // Disable / AddComment). A `string` in the CLR record, and kept as one here — the same call the + // telemetry contract makes for transition_kind / state / level. Promoting it to a proto enum would + // put a lossy translation in the mapper for a set the CLR side does not constrain: an operation the + // enum did not know would decode to UNSPECIFIED and the command would be dropped, which is the + // exact silent-drop class this migration exists to remove. + string operation = 2; + string user = 3; + optional string comment = 4; // nullable in the record — presence distinguishes null from "" + google.protobuf.Timestamp unshelve_at_utc = 5; // DateTime? — absent Timestamp encodes null +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAck. +message ApplyAckMessage { + string deployment_id = 1; // DeploymentId (Guid) in its "N" hex form + string node_id = 2; // NodeId — a logical host name, already a string + ApplyOutcome outcome = 3; + optional string failure_reason = 4; // nullable in the record — presence distinguishes null from "" + string correlation_id = 5; // CorrelationId (Guid) in its "N" hex form +} + +// Mirrors ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy.ApplyAckOutcome, shifted by one so 0 stays +// meaningless. proto3 decodes an absent enum field as 0; if 0 were Applied, an ack that lost the +// field would report a deployment applied when the node said nothing at all — and the deploy path +// seals on exactly that signal. +enum ApplyOutcome { + APPLY_OUTCOME_UNSPECIFIED = 0; + APPLY_OUTCOME_APPLIED = 1; + APPLY_OUTCOME_FAILED = 2; +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj index 5e55544f..bd560b43 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/ZB.MOM.WW.OtOpcUa.Commons.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportProtoContractTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportProtoContractTests.cs new file mode 100644 index 00000000..a7c33c08 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/MeshTransportProtoContractTests.cs @@ -0,0 +1,49 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Protos; +using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests; + +/// +/// Contract-lock for the ClusterClient→gRPC command envelope's oneof. If a fifth command is added +/// to mesh_transport.proto without adding a matching entry to +/// , this test goes red — the single guard +/// that keeps the wire contract and the handled-case set from silently drifting apart. A drift +/// here is exactly the failure the whole transport exists to avoid: a command that crosses the +/// mesh boundary, decodes to a case nobody handles, and is dropped without a trace. +/// +public class MeshTransportProtoContractTests +{ + [Fact] + public void EveryOneofVariant_IsAccountedFor() + { + var variants = System.Enum.GetValues() + .Where(c => c != CommandEnvelope.CommandOneofCase.None).ToArray(); + + variants.ShouldBe(MeshTransportProtoContract.HandledCases, ignoreOrder: true); + } + + [Fact] + public void The_service_base_and_client_types_generate() + { + // Referencing these types is the whole assertion — GrpcServices="Both" must emit both halves + // of both services: central hosts CentralCommandService and dials NodeCommandService, and a + // driver node does the mirror image, so neither service can ship client-only or server-only. + typeof(NodeCommandService.NodeCommandServiceBase).ShouldNotBeNull(); + typeof(NodeCommandService.NodeCommandServiceClient).ShouldNotBeNull(); + typeof(CentralCommandService.CentralCommandServiceBase).ShouldNotBeNull(); + typeof(CentralCommandService.CentralCommandServiceClient).ShouldNotBeNull(); + } + + [Fact] + public void The_apply_outcome_enum_reserves_zero_as_unspecified() + { + // proto3 decodes an absent enum field as 0. If 0 meant "Applied", a message that lost the + // field in transit — or was written by a peer that predates it — would report a deployment + // as successfully applied when nothing was said at all. 0 must stay meaningless. + ((int)ApplyOutcome.Unspecified).ShouldBe(0); + ((int)ApplyOutcome.Applied).ShouldNotBe(0); + ((int)ApplyOutcome.Failed).ShouldNotBe(0); + } +}