Add 2023 R2 gRPC transport (RemoteGrpc) reusing native byte payloads
Stands up HistorianTransport.RemoteGrpc end-to-end for the read path, built on the recovered 2023 R2 gRPC contract (gRPC-Web/HTTP-1.1, port 32565, gzip). The opaque protobuf `bytes` fields carry the SAME native binary payloads as the 2020 WCF/MDAS path, so the proven serializers and parsers are reused unchanged. - Grpc/Protos/*.proto: 6 protoc-validated contracts recovered from embedded FileDescriptors (authoritative, not guessed). - Grpc/HistorianGrpcChannelFactory: GrpcWebHandler/HTTP-1.1 channel, ResolvePort/ResolveAddress, optional TLS + gzip. - Grpc/HistorianGrpcReadOrchestrator: mirrors the WCF read chain over gRPC; auth uses HistoryService.ExchangeKey (the gRPC ValCl op). - Wcf/HistorianNativeHandshake: transport-agnostic Open2 request builder + SSPI/Negotiate token loop + response decode, shared by WCF and gRPC. - Op map (2020 -> gRPC): ValCl->ExchangeKey, Open2->OpenConnection, StartQuery2->StartQuery, GetNextQueryResultBuffer2->GetNextQueryResultBuffer. - HistorianClientOptions: DefaultGrpcPort=32565, GrpcUseTls. - csproj: Google.Protobuf, Grpc.Net.Client(.Web), Grpc.Tools codegen. Not yet live-verified against a 2023 R2 server: ExchangeKey is the first thing to revisit if a live server rejects the handshake; the inner byte payloads are the proven 2020 protocol. Gated live test via HISTORIAN_GRPC_HOST. 188 unit tests green; build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
using AVEVA.Historian.Client.Models;
|
||||
|
||||
namespace AVEVA.Historian.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Live integration tests for the 2023 R2 RemoteGrpc transport. Gated on a dedicated
|
||||
/// <c>HISTORIAN_GRPC_HOST</c> env var (plus <c>HISTORIAN_TEST_TAG</c>) so they skip cleanly until
|
||||
/// a 2023 R2 Historian is available. Optional:
|
||||
/// HISTORIAN_GRPC_PORT (default 32565), HISTORIAN_GRPC_TLS (true/false),
|
||||
/// HISTORIAN_USER / HISTORIAN_PASSWORD (explicit creds; otherwise IntegratedSecurity),
|
||||
/// HISTORIAN_GRPC_DNSID (server certificate name when connecting by IP over TLS).
|
||||
/// </summary>
|
||||
public sealed class HistorianGrpcIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_OverGrpc_ReturnsAtLeastOneRow()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(BuildOptions(host));
|
||||
|
||||
DateTime endUtc = DateTime.UtcNow;
|
||||
DateTime startUtc = endUtc - TimeSpan.FromDays(7);
|
||||
|
||||
List<HistorianSample> samples = [];
|
||||
await foreach (HistorianSample sample in client.ReadRawAsync(testTag, startUtc, endUtc, maxValues: 8, CancellationToken.None))
|
||||
{
|
||||
samples.Add(sample);
|
||||
}
|
||||
|
||||
Assert.NotEmpty(samples);
|
||||
Assert.All(samples, s => Assert.Equal(testTag, s.TagName));
|
||||
}
|
||||
|
||||
private static HistorianClientOptions BuildOptions(string host)
|
||||
{
|
||||
string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER");
|
||||
string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD");
|
||||
bool explicitCreds = !string.IsNullOrEmpty(user);
|
||||
int port = int.TryParse(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_PORT"), out int parsed)
|
||||
? parsed
|
||||
: HistorianClientOptions.DefaultGrpcPort;
|
||||
bool tls = string.Equals(Environment.GetEnvironmentVariable("HISTORIAN_GRPC_TLS"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Transport = HistorianTransport.RemoteGrpc,
|
||||
GrpcUseTls = tls,
|
||||
AllowUntrustedServerCertificate = tls,
|
||||
ServerDnsIdentity = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_DNSID"),
|
||||
IntegratedSecurity = !explicitCreds,
|
||||
UserName = user ?? string.Empty,
|
||||
Password = password ?? string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using AVEVA.Historian.Client.Grpc;
|
||||
using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using Google.Protobuf;
|
||||
using ArchestrA.Grpc.Contract.Retrieval;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
|
||||
namespace AVEVA.Historian.Client.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for the 2023 R2 RemoteGrpc transport — the parts that do not require a live
|
||||
/// server: channel address/port resolution, metadata, transport routing, and the invariant that
|
||||
/// gRPC request messages carry the same native byte buffers the WCF path uses.
|
||||
/// </summary>
|
||||
public sealed class HistorianGrpcTransportTests
|
||||
{
|
||||
private static HistorianClientOptions Options(
|
||||
string host = "histserver",
|
||||
int port = HistorianClientOptions.DefaultPort,
|
||||
bool tls = false,
|
||||
string? dnsIdentity = null,
|
||||
bool compression = false) => new()
|
||||
{
|
||||
Host = host,
|
||||
Port = port,
|
||||
Transport = HistorianTransport.RemoteGrpc,
|
||||
GrpcUseTls = tls,
|
||||
ServerDnsIdentity = dnsIdentity,
|
||||
Compression = compression,
|
||||
IntegratedSecurity = true
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ResolvePort_DefaultWcfPort_SubstitutesGrpcDefault()
|
||||
{
|
||||
Assert.Equal(HistorianClientOptions.DefaultGrpcPort, HistorianGrpcChannelFactory.ResolvePort(Options(port: HistorianClientOptions.DefaultPort)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePort_ExplicitPort_IsHonoured()
|
||||
{
|
||||
Assert.Equal(443, HistorianGrpcChannelFactory.ResolvePort(Options(port: 443)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAddress_Plaintext_UsesHttpAndHost()
|
||||
{
|
||||
Assert.Equal("http://histserver:32565", HistorianGrpcChannelFactory.ResolveAddress(Options()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAddress_Tls_UsesHttpsAndHostWhenNoDnsIdentity()
|
||||
{
|
||||
Assert.Equal("https://histserver:32565", HistorianGrpcChannelFactory.ResolveAddress(Options(tls: true)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAddress_Tls_PrefersDnsIdentityForCertMatch()
|
||||
{
|
||||
string address = HistorianGrpcChannelFactory.ResolveAddress(Options(host: "10.0.0.5", tls: true, dnsIdentity: "localhost"));
|
||||
Assert.Equal("https://localhost:32565", address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_CompressionDisabled_EmitsNoEncodingHeader()
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(Options(compression: false));
|
||||
Assert.DoesNotContain(connection.Metadata, e => e.Key == "grpc-internal-encoding-request");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_CompressionEnabled_AdvertisesGzipRequestEncoding()
|
||||
{
|
||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(Options(compression: true));
|
||||
global::Grpc.Core.Metadata.Entry entry = Assert.Single(connection.Metadata, e => e.Key == "grpc-internal-encoding-request");
|
||||
Assert.Equal("gzip", entry.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StartQueryRequest_CarriesNativeDataQueryBufferUnchanged()
|
||||
{
|
||||
// The gRPC envelope must wrap the exact bytes the WCF StartQuery2 path sends, so the
|
||||
// already-reverse-engineered DataQueryRequest serializer is reused verbatim.
|
||||
HistorianDataQueryRequest request = HistorianWcfReadOrchestrator.BuildDataQueryRequest(
|
||||
"Tag.Counter", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), 100);
|
||||
byte[] nativeBuffer = HistorianDataQueryProtocol.SerializeFullHistoryRequest(request);
|
||||
|
||||
var message = new StartQueryRequest
|
||||
{
|
||||
UiHandle = 7,
|
||||
UiQueryRequestType = HistorianDataQueryProtocol.QueryRequestTypeData,
|
||||
BtRequestBuffer = ByteString.CopyFrom(nativeBuffer)
|
||||
};
|
||||
|
||||
// Round-trip through protobuf and confirm the native buffer survives byte-for-byte.
|
||||
byte[] wire = message.ToByteArray();
|
||||
var decoded = StartQueryRequest.Parser.ParseFrom(wire);
|
||||
Assert.Equal(nativeBuffer, decoded.BtRequestBuffer.ToByteArray());
|
||||
Assert.Equal(7u, decoded.UiHandle);
|
||||
Assert.Equal((uint)HistorianDataQueryProtocol.QueryRequestTypeData, decoded.UiQueryRequestType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenConnectionRequest_CarriesNativeOpen2BufferUnchanged()
|
||||
{
|
||||
byte[] open2 = HistorianNativeHandshake.BuildOpenConnection3Request(
|
||||
"histserver", Guid.NewGuid(), HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode);
|
||||
|
||||
var message = new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2) };
|
||||
var decoded = GrpcHistory.OpenConnectionRequest.Parser.ParseFrom(message.ToByteArray());
|
||||
|
||||
Assert.Equal(open2, decoded.BtConnectionRequest.ToByteArray());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user