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>
|
||||
<Protobuf Include="Protos\deployment_artifact.proto" GrpcServices="Both"/>
|
||||
<Protobuf Include="Protos\telemetry.proto" GrpcServices="Both"/>
|
||||
<Protobuf Include="Protos\mesh_transport.proto" GrpcServices="Both"/>
|
||||
</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