diff --git a/docs/reverse-engineering/grpc-interface-versions.md b/docs/reverse-engineering/grpc-interface-versions.md new file mode 100644 index 0000000..5bf1b89 --- /dev/null +++ b/docs/reverse-engineering/grpc-interface-versions.md @@ -0,0 +1,44 @@ +# 2023 R2 gRPC Interface-Version Integers (C3a) + +**Captured:** 2026-06-25 +**Transport:** 2023 R2 gRPC (h2c, unauthenticated `GetInterfaceVersion` RPCs — no credentials required) +**Status:** LIVE — integers captured from a real AVEVA Historian 2023 R2 server. + +## Captured Values + +| Service | UiVersion / Version | UiError / Error | Notes | +|---------------|---------------------|-----------------|----------------------------------------------------| +| History | 12 | 0 | Matches `HistoryInterfaceVersionGrpc2023R2 = 12` | +| Retrieval | 4 | 0 | Matches `RetrievalInterfaceVersion = 4` (unchanged from 2020) | +| Transaction | 2 | 0 | Matches `TransactionInterfaceVersion = 2` (unchanged from 2020) | +| Status | 4 | 0 | Reachability-only; version integer is not version-gated (see note) | + +> **Field-name note:** The History, Retrieval, and Status proto responses use `UiError`/`UiVersion` fields. +> The Transaction response uses `Error`/`Version` (different naming convention in the proto). Both are +> captured correctly; the table uses a unified column header for readability. + +> **Status note:** `StatusService.GetStatusInterfaceVersion` returned UiVersion=4, UiError=0 on the live +> 2023 R2 server. Status is classified as reachability-only — its version integer carries no semantic +> meaning for the SDK's byte serializers — so its UiVersion is not gated and not asserted in tests. + +## Evidence Test + +`tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs` — +`GrpcInterfaceVersions_LiveServer_MatchAcceptedSet` reads these four RPCs live and asserts: +- `history.UiError == 0` and `history.UiVersion ∈ {11, 12}` +- `retrieval.UiError == 0` and `retrieval.UiVersion == 4` +- `transaction.Error == 0` and `transaction.Version == 2` +- `status.UiError == 0` (version not asserted) + +The test skips silently when `HISTORIAN_GRPC_HOST` is absent. + +## Gap Closed + +This document closes the **C3a** gap: "2023 R2 gRPC server-version integers not yet captured." +Prior to this capture, the `HistorianServerVersionGate` accepted History=12, Retrieval=4, and +Transaction=2 on the basis that they were inferred/expected-to-be-unchanged. All four integers are +now confirmed from a live 2023 R2 server over the gRPC transport; no widening of `AcceptedVersions` +is required (all captured values were already accepted). + +The 2020 WCF baseline (History=11, Retrieval=4, Transaction=2) was captured earlier via the +`wcf-probe` command and is documented in `wcf-probe-remote-latest.json` and `wcf-contract-evidence.md`. diff --git a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs index 96930be..cc69813 100644 --- a/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs +++ b/src/AVEVA.Historian.Client/HistorianServerVersionGate.cs @@ -48,8 +48,12 @@ internal static class HistorianServerVersionGate /// The 2023 R2 gRPC HistoryService reports interface version 12. It is buffer-compatible with /// the 2020 version 11 — the OpenConnection3 v6 / token / DataQueryRequest / row buffers are /// byte-identical — confirmed by a live end-to-end gRPC read against a real 2023 R2 server - /// (2026-06-21). So both 11 and 12 are accepted for History. (Retrieval reported 4, matching - /// the 2020 value, so it needs no widening.) + /// (2026-06-21). So both 11 and 12 are accepted for History. + /// + /// Retrieval=4, Transaction=2, and Status UiError=0 are now confirmed captured live over the + /// 2023 R2 gRPC transport (2026-06-25, unauthenticated GetInterfaceVersion RPCs); see + /// docs/reverse-engineering/grpc-interface-versions.md. All captured values were already + /// accepted — no widening of was required. /// public const uint HistoryInterfaceVersionGrpc2023R2 = 12; diff --git a/tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs b/tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs new file mode 100644 index 0000000..2d0d854 --- /dev/null +++ b/tests/AVEVA.Historian.Client.Tests/GrpcInterfaceVersionEvidenceTests.cs @@ -0,0 +1,102 @@ +using AVEVA.Historian.Client.Grpc; +using GrpcHistory = ArchestrA.Grpc.Contract.History; +using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval; +using GrpcStatus = ArchestrA.Grpc.Contract.Status; +using GrpcTransaction = ArchestrA.Grpc.Contract.Transaction; +using Xunit.Abstractions; + +namespace AVEVA.Historian.Client.Tests; + +/// +/// Live evidence test (C3a): reads the four unauthenticated GetInterfaceVersion RPCs from a +/// real 2023 R2 Historian over gRPC and asserts the accepted version set. These RPCs run before any +/// credential exchange, so no HISTORIAN_USER / HISTORIAN_PASSWORD is required — only a reachable host. +/// +/// Skips silently when HISTORIAN_GRPC_HOST is absent (offline / CI). The captured integers +/// are recorded in docs/reverse-engineering/grpc-interface-versions.md and close the C3a +/// "2023 R2 gRPC server-version integers not yet captured" gap. +/// +public sealed class GrpcInterfaceVersionEvidenceTests +{ + private readonly ITestOutputHelper _output; + + public GrpcInterfaceVersionEvidenceTests(ITestOutputHelper output) => _output = output; + + [Fact] + public void GrpcInterfaceVersions_LiveServer_MatchAcceptedSet() + { + string host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST") ?? ""; + if (string.IsNullOrWhiteSpace(host)) + { + _output.WriteLine("SKIP: HISTORIAN_GRPC_HOST is not set — no live historian available."); + return; + } + + HistorianClientOptions options = BuildOptions(host); + + using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(options); + DateTime deadline = DateTime.UtcNow.Add(TimeSpan.FromSeconds(10)); + + GrpcHistory.GetInterfaceVersionResponse history = + new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel) + .GetInterfaceVersion(new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, deadline, default); + + GrpcRetrieval.GetRetrievalInterfaceVersionResponse retrieval = + new GrpcRetrieval.RetrievalService.RetrievalServiceClient(connection.Channel) + .GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, deadline, default); + + GrpcStatus.GetStatusInterfaceVersionResponse status = + new GrpcStatus.StatusService.StatusServiceClient(connection.Channel) + .GetStatusInterfaceVersion(new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, deadline, default); + + GrpcTransaction.GetTransactionInterfaceVersionResponse transaction = + new GrpcTransaction.TransactionService.TransactionServiceClient(connection.Channel) + .GetTransactionInterfaceVersion(new GrpcTransaction.GetTransactionInterfaceVersionRequest(), connection.Metadata, deadline, default); + + _output.WriteLine($"History UiVersion={history.UiVersion} UiError={history.UiError}"); + _output.WriteLine($"Retrieval UiVersion={retrieval.UiVersion} UiError={retrieval.UiError}"); + _output.WriteLine($"Status UiVersion={status.UiVersion} UiError={status.UiError}"); + // Note: Transaction response fields are named Error/Version (not UiError/UiVersion) per the proto. + _output.WriteLine($"Transaction Version={transaction.Version} Error={transaction.Error}"); + + // History: accepted set is {11 (2020 WCF), 12 (2023 R2 gRPC)}. + Assert.Equal(0u, history.UiError); + Assert.Contains(history.UiVersion, new uint[] { 11u, 12u }); + + // Retrieval: 4 on both 2020 WCF and 2023 R2 gRPC. + Assert.Equal(0u, retrieval.UiError); + Assert.Equal(4u, retrieval.UiVersion); + + // Transaction: 2 (confirmed by live gRPC capture — see grpc-interface-versions.md). + // NOTE: the Transaction response proto uses Error/Version (not UiError/UiVersion). + Assert.Equal(0u, transaction.Error); + Assert.Equal(2u, transaction.Version); + + // Status: reachability-only — UiError must be 0; UiVersion is intentionally 0 by design. + Assert.Equal(0u, status.UiError); + } + + 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, + }; + } +} diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs index cbc7539..61051ae 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianServerVersionGateTests.cs @@ -71,8 +71,9 @@ public sealed class HistorianServerVersionGateTests [Fact] public void Validate_VerificationDisabled_NeverThrows() { - // A wildly wrong version is tolerated when the operator opts out (e.g. bringing up a - // 2023 R2 gRPC server whose reported integers have not yet been captured). + // A wildly wrong version is tolerated when the operator opts out. The 2023 R2 gRPC + // integers are now captured live (see docs/reverse-engineering/grpc-interface-versions.md) + // and all accepted; this opt-out is a safety valve for some future, not-yet-captured value. HistorianServerVersionGate.Validate(HistorianServiceInterface.History, 999u, Options(verify: false)); HistorianServerVersionGate.Validate(HistorianServiceInterface.Retrieval, 0u, Options(verify: false)); }