feat(mesh): mesh_transport.v1 proto contract
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Protos;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single source of truth for which <see cref="CommandEnvelope.CommandOneofCase"/> 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 <c>mesh_transport.proto</c> forces a matching entry
|
||||||
|
/// here or the build/test goes red.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Mirrors <see cref="TelemetryProtoContract"/>, 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".
|
||||||
|
/// </remarks>
|
||||||
|
public static class MeshTransportProtoContract
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exactly the four cross-mesh command oneof cases mirrored from the domain records:
|
||||||
|
/// DispatchDeployment / RestartDriver / ReconnectDriver / AlarmCommand. The fifth message on
|
||||||
|
/// this contract, <c>ApplyAckMessage</c>, travels the opposite direction on its own RPC and is
|
||||||
|
/// not a oneof member, so it is deliberately absent from this list.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly CommandEnvelope.CommandOneofCase[] HandledCases =
|
||||||
|
[
|
||||||
|
CommandEnvelope.CommandOneofCase.DispatchDeployment,
|
||||||
|
CommandEnvelope.CommandOneofCase.RestartDriver,
|
||||||
|
CommandEnvelope.CommandOneofCase.ReconnectDriver,
|
||||||
|
CommandEnvelope.CommandOneofCase.AlarmCommand,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Protobuf Include="Protos\deployment_artifact.proto" GrpcServices="Both"/>
|
<Protobuf Include="Protos\deployment_artifact.proto" GrpcServices="Both"/>
|
||||||
<Protobuf Include="Protos\telemetry.proto" GrpcServices="Both"/>
|
<Protobuf Include="Protos\telemetry.proto" GrpcServices="Both"/>
|
||||||
|
<Protobuf Include="Protos\mesh_transport.proto" GrpcServices="Both"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Contract-lock for the ClusterClient→gRPC command envelope's oneof. If a fifth command is added
|
||||||
|
/// to <c>mesh_transport.proto</c> without adding a matching entry to
|
||||||
|
/// <see cref="MeshTransportProtoContract.HandledCases"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
public class MeshTransportProtoContractTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void EveryOneofVariant_IsAccountedFor()
|
||||||
|
{
|
||||||
|
var variants = System.Enum.GetValues<CommandEnvelope.CommandOneofCase>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user