feat(mesh): DTO mapper with round-trip goldens

This commit is contained in:
Joseph Doherty
2026-08-06 03:19:11 -04:00
parent 09db9e47de
commit 545413a835
2 changed files with 842 additions and 0 deletions
@@ -0,0 +1,391 @@
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Protos.MeshTransport.V1;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
namespace ZB.MOM.WW.OtOpcUa.Commons.Messages.Mesh;
/// <summary>
/// The one translation between the five cross-mesh command records and their
/// <c>mesh_transport.v1</c> wire shapes (ClusterClient→gRPC migration Phase 0). Both directions live
/// here so a field can only be added to one side without the other by making this file
/// visibly asymmetric.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing here guesses.</b> The transport it replaces — an Akka ClusterClient receptionist —
/// dropped an undeliverable command silently, and the entire point of the migration is to stop
/// doing that. So every decode that cannot produce a faithful record throws: an unset oneof, an
/// <c>APPLY_OUTCOME_UNSPECIFIED</c> ack, an enum value from a newer peer. A loud failure on one
/// command is recoverable; a deployment reported applied because an absent enum field decoded to
/// 0 is not.
/// </para>
/// <para>
/// <b>Presence, not emptiness.</b> The two nullable members that cross this contract —
/// <see cref="AlarmCommand.Comment"/> and <see cref="ApplyAck.FailureReason"/> — are
/// <c>optional string</c> on the wire, so <c>null</c> maps to an absent field and <c>""</c> to a
/// present-but-empty one. Collapsing them would report an operator's deliberately blank comment
/// as "no comment supplied", and a blank failure reason as "no reason recorded".
/// <see cref="AlarmCommand.UnshelveAtUtc"/> uses a message field, whose own absence carries the
/// null.
/// </para>
/// <para>
/// <b>Correlation is written twice and read once.</b> Encode stamps the envelope's
/// <c>correlation_id</c> and the command's own from the same source. Decode takes the command's
/// own for the three records that declare one — that member is domain state the record must
/// carry back verbatim — and leaves the envelope's to the transport, which is the only place
/// <see cref="AlarmCommand"/>'s correlation can live at all since the record has no member for it.
/// </para>
/// </remarks>
public static class MeshTransportDtoMapper
{
// ---------------------------------------------------------------- DispatchDeployment
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain dispatch command.</param>
/// <returns>The wire <see cref="DispatchDeploymentCommand"/>.</returns>
public static DispatchDeploymentCommand ToProto(DispatchDeployment message)
{
ArgumentNullException.ThrowIfNull(message);
return new DispatchDeploymentCommand
{
DeploymentId = message.DeploymentId.ToString(),
RevisionHash = message.RevisionHash.Value ?? "",
CorrelationId = message.CorrelationId.ToString(),
};
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire dispatch command.</param>
/// <returns>The domain <see cref="DispatchDeployment"/>.</returns>
/// <exception cref="FormatException">An id field is not a hyphenless GUID.</exception>
/// <exception cref="ArgumentException">The revision hash is absent or blank.</exception>
public static DispatchDeployment FromProto(DispatchDeploymentCommand proto)
{
ArgumentNullException.ThrowIfNull(proto);
return new DispatchDeployment(
DeploymentId.Parse(proto.DeploymentId),
RevisionHash.Parse(proto.RevisionHash),
CorrelationId.Parse(proto.CorrelationId));
}
// ---------------------------------------------------------------- RestartDriver
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain restart command.</param>
/// <returns>The wire <see cref="RestartDriverCommand"/>.</returns>
public static RestartDriverCommand ToProto(RestartDriver message)
{
ArgumentNullException.ThrowIfNull(message);
// Required (non-optional) proto string setters throw on null and the record's nullable-ref
// annotations are compile-time only, so a null reaching here is already a producer bug. Coalesce
// rather than throw out of the mapper: a command with a blank field is diagnosable downstream,
// whereas a throw here is the silent drop this transport exists to remove.
return new RestartDriverCommand
{
ClusterId = message.ClusterId ?? "",
DriverInstanceId = message.DriverInstanceId ?? "",
ActorByUserName = message.ActorByUserName ?? "",
CorrelationId = message.CorrelationId.ToString("N"),
};
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire restart command.</param>
/// <returns>The domain <see cref="RestartDriver"/>.</returns>
/// <exception cref="FormatException">The correlation id is not a hyphenless GUID.</exception>
public static RestartDriver FromProto(RestartDriverCommand proto)
{
ArgumentNullException.ThrowIfNull(proto);
return new RestartDriver(
proto.ClusterId,
proto.DriverInstanceId,
proto.ActorByUserName,
Guid.ParseExact(proto.CorrelationId, "N"));
}
// ---------------------------------------------------------------- ReconnectDriver
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain reconnect command.</param>
/// <returns>The wire <see cref="ReconnectDriverCommand"/>.</returns>
public static ReconnectDriverCommand ToProto(ReconnectDriver message)
{
ArgumentNullException.ThrowIfNull(message);
return new ReconnectDriverCommand
{
ClusterId = message.ClusterId ?? "",
DriverInstanceId = message.DriverInstanceId ?? "",
ActorByUserName = message.ActorByUserName ?? "",
CorrelationId = message.CorrelationId.ToString("N"),
};
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire reconnect command.</param>
/// <returns>The domain <see cref="ReconnectDriver"/>.</returns>
/// <exception cref="FormatException">The correlation id is not a hyphenless GUID.</exception>
public static ReconnectDriver FromProto(ReconnectDriverCommand proto)
{
ArgumentNullException.ThrowIfNull(proto);
return new ReconnectDriver(
proto.ClusterId,
proto.DriverInstanceId,
proto.ActorByUserName,
Guid.ParseExact(proto.CorrelationId, "N"));
}
// ---------------------------------------------------------------- AlarmCommand
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain alarm command.</param>
/// <returns>The wire <see cref="AlarmCommandMessage"/>.</returns>
/// <remarks>
/// <see cref="AlarmCommand.Operation"/> is copied verbatim. It is an unconstrained string in the
/// record and stays one on the wire, so an operation this build does not recognise reaches the
/// far side intact instead of collapsing onto an enum's UNSPECIFIED and being dropped.
/// </remarks>
public static AlarmCommandMessage ToProto(AlarmCommand message)
{
ArgumentNullException.ThrowIfNull(message);
var proto = new AlarmCommandMessage
{
AlarmId = message.AlarmId ?? "",
Operation = message.Operation ?? "",
User = message.User ?? "",
};
if (message.Comment is not null)
proto.Comment = message.Comment;
if (message.UnshelveAtUtc is { } unshelveAt)
proto.UnshelveAtUtc = ToUtcTimestamp(unshelveAt);
return proto;
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire alarm command.</param>
/// <returns>The domain <see cref="AlarmCommand"/>.</returns>
public static AlarmCommand FromProto(AlarmCommandMessage proto)
{
ArgumentNullException.ThrowIfNull(proto);
return new AlarmCommand(
proto.AlarmId,
proto.Operation,
proto.User,
proto.HasComment ? proto.Comment : null,
proto.UnshelveAtUtc?.ToDateTime());
}
// ---------------------------------------------------------------- ApplyAck
/// <summary>Projects <paramref name="message"/> onto its wire form.</summary>
/// <param name="message">The domain per-node apply acknowledgment.</param>
/// <returns>The wire <see cref="ApplyAckMessage"/>.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <see cref="ApplyAck.Outcome"/> is not a declared <see cref="ApplyAckOutcome"/>.
/// </exception>
public static ApplyAckMessage ToProto(ApplyAck message)
{
ArgumentNullException.ThrowIfNull(message);
var proto = new ApplyAckMessage
{
DeploymentId = message.DeploymentId.ToString(),
NodeId = message.NodeId.Value ?? "",
Outcome = message.Outcome switch
{
ApplyAckOutcome.Applied => ApplyOutcome.Applied,
ApplyAckOutcome.Failed => ApplyOutcome.Failed,
_ => throw new ArgumentOutOfRangeException(
nameof(message), message.Outcome, "Unknown ApplyAckOutcome"),
},
CorrelationId = message.CorrelationId.ToString(),
};
if (message.FailureReason is not null)
proto.FailureReason = message.FailureReason;
return proto;
}
/// <summary>Rebuilds the domain record from its wire form.</summary>
/// <param name="proto">The wire apply acknowledgment.</param>
/// <returns>The domain <see cref="ApplyAck"/>.</returns>
/// <exception cref="InvalidOperationException">
/// The outcome is <c>APPLY_OUTCOME_UNSPECIFIED</c> or a value this build does not know.
/// </exception>
/// <exception cref="FormatException">An id field is not a hyphenless GUID.</exception>
/// <exception cref="ArgumentException">The node id is absent or blank.</exception>
public static ApplyAck FromProto(ApplyAckMessage proto)
{
ArgumentNullException.ThrowIfNull(proto);
// proto3 decodes an absent enum field as 0, and the deploy path seals a revision on Applied.
// ApplyAckOutcome has no Unknown member to land on, so an unspecified or unrecognised outcome
// must refuse rather than pick one of the two real answers — either choice would be a lie about
// what the node said, and the "Applied" lie seals a deployment nobody applied.
var outcome = proto.Outcome switch
{
ApplyOutcome.Applied => ApplyAckOutcome.Applied,
ApplyOutcome.Failed => ApplyAckOutcome.Failed,
_ => throw new InvalidOperationException(
$"ApplyAckMessage carries outcome '{proto.Outcome}', which has no ApplyAckOutcome " +
"equivalent. Refusing to decode rather than report an unsaid deployment result."),
};
return new ApplyAck(
DeploymentId.Parse(proto.DeploymentId),
NodeId.Parse(proto.NodeId),
outcome,
proto.HasFailureReason ? proto.FailureReason : null,
CorrelationId.Parse(proto.CorrelationId));
}
// ---------------------------------------------------------------- Envelope
/// <summary>Wraps a dispatch command in its transport envelope.</summary>
/// <param name="message">The domain dispatch command.</param>
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
public static CommandEnvelope ToEnvelope(DispatchDeployment message)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = message.CorrelationId.ToString(),
DispatchDeployment = ToProto(message),
};
}
/// <summary>Wraps a restart command in its transport envelope.</summary>
/// <param name="message">The domain restart command.</param>
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
public static CommandEnvelope ToEnvelope(RestartDriver message)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = message.CorrelationId.ToString("N"),
RestartDriver = ToProto(message),
};
}
/// <summary>Wraps a reconnect command in its transport envelope.</summary>
/// <param name="message">The domain reconnect command.</param>
/// <returns>The envelope, correlated from the record's own <c>CorrelationId</c>.</returns>
public static CommandEnvelope ToEnvelope(ReconnectDriver message)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = message.CorrelationId.ToString("N"),
ReconnectDriver = ToProto(message),
};
}
/// <summary>Wraps an alarm command in its transport envelope.</summary>
/// <param name="message">The domain alarm command.</param>
/// <param name="correlationId">
/// Transport correlation. Explicit because <see cref="AlarmCommand"/> is the one record with no
/// correlation member of its own, so the envelope is the only place it can be carried.
/// </param>
/// <returns>The envelope.</returns>
public static CommandEnvelope ToEnvelope(AlarmCommand message, CorrelationId correlationId)
{
ArgumentNullException.ThrowIfNull(message);
return new CommandEnvelope
{
CorrelationId = correlationId.ToString(),
AlarmCommand = ToProto(message),
};
}
/// <summary>
/// Untyped dispatch for the transport router, which holds its payload as
/// <see cref="MeshCommand.Message"/> (an <see cref="object"/>).
/// </summary>
/// <param name="message">One of the four central→node command records.</param>
/// <param name="alarmCorrelationId">
/// Consulted <b>only</b> when <paramref name="message"/> is an <see cref="AlarmCommand"/>. The
/// other three carry their own correlation and it is authoritative for them, so passing a
/// different value here cannot make the envelope disagree with the command inside it.
/// </param>
/// <returns>The envelope produced by the matching typed overload.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="message"/> is not one of the four handled command records — the transport
/// would otherwise carry a payload the far side cannot decode.
/// </exception>
public static CommandEnvelope ToEnvelope(object message, CorrelationId alarmCorrelationId)
{
ArgumentNullException.ThrowIfNull(message);
return message switch
{
DispatchDeployment m => ToEnvelope(m),
RestartDriver m => ToEnvelope(m),
ReconnectDriver m => ToEnvelope(m),
AlarmCommand m => ToEnvelope(m, alarmCorrelationId),
_ => throw new ArgumentOutOfRangeException(
nameof(message), message.GetType().FullName, "Not a cross-mesh command record"),
};
}
/// <summary>Unwraps a transport envelope into the domain record it carries.</summary>
/// <param name="envelope">The received envelope.</param>
/// <returns>
/// One of <see cref="DispatchDeployment"/>, <see cref="RestartDriver"/>,
/// <see cref="ReconnectDriver"/> or <see cref="AlarmCommand"/>, boxed — the node-side handlers
/// match on the concrete type exactly as they do under the DPS transport.
/// </returns>
/// <exception cref="InvalidOperationException">
/// The oneof is unset or names a case this build does not handle. Kept unreachable by
/// <c>MeshTransportProtoContract</c> and its contract-lock test; it throws anyway because the
/// alternative is a command that crosses the mesh and disappears.
/// </exception>
public static object FromEnvelope(CommandEnvelope envelope)
{
ArgumentNullException.ThrowIfNull(envelope);
return envelope.CommandCase switch
{
CommandEnvelope.CommandOneofCase.DispatchDeployment => FromProto(envelope.DispatchDeployment),
CommandEnvelope.CommandOneofCase.RestartDriver => FromProto(envelope.RestartDriver),
CommandEnvelope.CommandOneofCase.ReconnectDriver => FromProto(envelope.ReconnectDriver),
CommandEnvelope.CommandOneofCase.AlarmCommand => FromProto(envelope.AlarmCommand),
_ => throw new InvalidOperationException(
$"CommandEnvelope (correlation '{envelope.CorrelationId}') carries oneof case " +
$"'{envelope.CommandCase}', which this build does not handle."),
};
}
/// <summary>
/// Converts a domain <see cref="DateTime"/> to a proto <see cref="Timestamp"/> without ever
/// throwing on Kind: Utc is used as-is, Local is converted, and Unspecified is assumed
/// already-UTC (the producer contract for every <c>…Utc</c> member here).
/// </summary>
private static Timestamp ToUtcTimestamp(DateTime dt)
{
var utc = dt.Kind switch
{
DateTimeKind.Utc => dt,
DateTimeKind.Local => dt.ToUniversalTime(),
_ => DateTime.SpecifyKind(dt, DateTimeKind.Utc),
};
return Timestamp.FromDateTime(utc);
}
}