From ce8576bd6e7eb5d38a108ccd9dcca9251167f5b5 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 21 Jun 2026 19:12:42 -0400 Subject: [PATCH] =?UTF-8?q?M3=20R3.1=20capture:=20read-only=20gRPC=20conne?= =?UTF-8?q?ct=20scenario=20=E2=80=94=20LIVE-VERIFIED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added the `connect` scenario to the 2023 R2 capture harness and ran it read-only against the live server. The native mixed-mode client connects end-to-end over gRPC from this box: OpenConnection -> True (ErrorCode=Success) ConnectedToServer = True ConnectedToServerStorage = True <-- native client HAS the storage-engine session ConnectedToStoreForward = False Connection args that work (HistorianConnectionArgs): ServerName, TcpPort=32565, ConnectionMode=Historian (gRPC), ConnectionType=Process, ReadOnly=true, IntegratedSecurity=false, UserName/Password (explicit), AllowUnTrustedConnection=true, SecurityInfo=CertificateInfo{ SecurityMode=TransportCertificate, CertificateName=WONDER-SQL-VD03 } (the https:// host over the loopback tunnel). Creds from HISTORIAN_USER/HISTORIAN_PASSWORD. Significance: ConnectedToServerStorage=True means the native client establishes the storage session the pure-managed SDK couldn't — so a write driven through it should route AddNonStreamValues with a live storage session, and the cache-gate mitigation (read-first) is promising. Next: IL-rewrite Archestra.Historian.GrpcClient.dll + a write-enabled run to capture the RegisterTags btTagInfos + AddNonStreamValues btInput (prod write; per-action auth). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../Program.cs | 180 +++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/tools/AVEVA.Historian.Grpc2023CaptureHarness/Program.cs b/tools/AVEVA.Historian.Grpc2023CaptureHarness/Program.cs index e64de42..92c1b02 100644 --- a/tools/AVEVA.Historian.Grpc2023CaptureHarness/Program.cs +++ b/tools/AVEVA.Historian.Grpc2023CaptureHarness/Program.cs @@ -60,12 +60,126 @@ namespace AVEVA.Historian.Grpc2023CaptureHarness { case "load-check": return LoadCheck(managedDll, probeDirs); + case "connect": + return Connect(managedDll, args); default: - Console.Error.WriteLine($"Unknown scenario '{scenario}'. Supported: load-check."); + Console.Error.WriteLine($"Unknown scenario '{scenario}'. Supported: load-check, connect."); return 1; } } + /// + /// Read-only gRPC connect probe: opens a 2023 R2 Historian (mode=Historian) connection via the + /// native client and reports the resulting connection status. Proves the mixed-mode client can + /// reach the live server over gRPC from this box — the foundation for the write-capture step. + /// Reads creds from HISTORIAN_USER / HISTORIAN_PASSWORD (explicit) or uses IntegratedSecurity. + /// Usage: connect --server WONDER-SQL-VD03 [--port 32565] [--cert WONDER-SQL-VD03] [--integrated] + /// + private static int Connect(string managedDll, string[] args) + { + Assembly asm = Assembly.LoadFrom(managedDll); + Type accessType = Req(asm, "ArchestrA.HistorianAccess"); + Type connArgsType = Req(asm, "ArchestrA.HistorianConnectionArgs"); + Type connModeType = Req(asm, "ArchestrA.HistorianConnectionMode"); + Type connTypeType = Req(asm, "ArchestrA.HistorianConnectionType"); + Type errorType = Req(asm, "ArchestrA.HistorianAccessError"); + Type statusType = Req(asm, "ArchestrA.HistorianConnectionStatus"); + Type certInfoType = Req(asm, "ArchestrA.CertificateInfo"); + Type secModeType = Req(asm, "ArchestrA.HistorianSecurityMode"); + + string server = GetOption(args, "--server") ?? "WONDER-SQL-VD03"; + int port = int.TryParse(GetOption(args, "--port"), out int p) ? p : 32565; + string certName = GetOption(args, "--cert") ?? server; + bool integrated = args.Contains("--integrated"); + string? user = Environment.GetEnvironmentVariable("HISTORIAN_USER"); + string? password = Environment.GetEnvironmentVariable("HISTORIAN_PASSWORD"); + if (!integrated && string.IsNullOrEmpty(user)) + { + Console.Error.WriteLine("Set HISTORIAN_USER/HISTORIAN_PASSWORD or pass --integrated."); + return 1; + } + + object connArgs = Activator.CreateInstance(connArgsType)!; + SetProp(connArgs, "ServerName", server); + SetProp(connArgs, "TcpPort", checked((ushort)port)); + SetProp(connArgs, "ConnectionMode", Enum.Parse(connModeType, "Historian")); // 2 = gRPC + SetProp(connArgs, "ConnectionType", Enum.Parse(connTypeType, "Process")); + SetProp(connArgs, "ReadOnly", true); + SetProp(connArgs, "IntegratedSecurity", integrated); + SetProp(connArgs, "AllowUnTrustedConnection", true); + if (!integrated) + { + SetProp(connArgs, "UserName", user!); + SetProp(connArgs, "Password", password ?? string.Empty); + } + + // TLS transport: SecurityInfo = CertificateInfo { SecurityMode=TransportCertificate, + // CertificateName= }. AllowUnTrustedConnection + // skips chain validation (the box reaches the server cert CN over the loopback tunnel). + object certInfo = Activator.CreateInstance(certInfoType)!; + TrySetProp(certInfo, "CertificateName", certName); + TrySetProp(certInfo, "SecurityMode", Enum.Parse(secModeType, "TransportCertificate")); + TrySetProp(connArgs, "SecurityInfo", certInfo); + + object access = Activator.CreateInstance(accessType)!; + object error = Activator.CreateInstance(errorType)!; + MethodInfo open = accessType.GetMethod("OpenConnection", new[] { connArgsType, errorType.MakeByRefType() }) + ?? throw new MissingMethodException("OpenConnection"); + Console.WriteLine($"OpenConnection: server={server} port={port} mode=Historian cert={certName} integrated={integrated} readonly=true"); + object?[] openArgs = { connArgs, error }; + bool ok; + try + { + ok = (bool)open.Invoke(access, openArgs)!; + } + catch (TargetInvocationException tie) + { + Console.Error.WriteLine($"OpenConnection threw: {tie.InnerException?.GetType().Name}: {tie.InnerException?.Message}"); + return 2; + } + error = openArgs[1]!; + Console.WriteLine($"OpenConnection returned: {ok}"); + Console.WriteLine($" error: {DescribeError(error)}"); + + // Poll connection status for a few seconds. + MethodInfo getStatus = accessType.GetMethod("GetConnectionStatus", new[] { statusType.MakeByRefType() }) + ?? accessType.GetMethods().First(m => m.Name == "GetConnectionStatus" && m.GetParameters().Length == 1); + object? status = null; + for (int i = 0; i < 10; i++) + { + object?[] sArgs = { null }; + getStatus.Invoke(access, sArgs); + status = sArgs[0]; + bool connected = ReadBoolProp(status, "ConnectedToServer"); + bool pending = ReadBoolProp(status, "Pending"); + if (connected || !pending) + { + break; + } + System.Threading.Thread.Sleep(500); + } + + Console.WriteLine("ConnectionStatus:"); + DumpProps(status, " "); + + bool connectedToServer = ReadBoolProp(status, "ConnectedToServer"); + + // Always close cleanly. + try + { + MethodInfo? close = accessType.GetMethod("CloseConnection", new[] { errorType.MakeByRefType() }); + if (close != null) + { + object?[] cArgs = { Activator.CreateInstance(errorType) }; + close.Invoke(access, cArgs); + } + } + catch { /* close best-effort */ } + + Console.WriteLine(connectedToServer ? "CONNECT: PASS (ConnectedToServer)" : "CONNECT: FAIL (not connected)"); + return connectedToServer ? 0 : 3; + } + private static int LoadCheck(string managedDll, string[] probeDirs) { Console.WriteLine($"Process: {(Environment.Is64BitProcess ? "x64" : "x86")}, CLR {Environment.Version}"); @@ -133,6 +247,70 @@ namespace AVEVA.Historian.Grpc2023CaptureHarness return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; } + private static Type Req(Assembly asm, string name) => + asm.GetType(name) ?? throw new TypeLoadException($"Type not found: {name}"); + + private static void SetProp(object target, string name, object value) + { + PropertyInfo prop = target.GetType().GetProperty(name) + ?? throw new MissingMemberException(target.GetType().FullName, name); + prop.SetValue(target, value); + } + + private static void TrySetProp(object target, string name, object value) + { + try + { + PropertyInfo? prop = target.GetType().GetProperty(name); + if (prop != null && prop.CanWrite) + { + prop.SetValue(target, value); + } + } + catch (Exception ex) + { + Console.WriteLine($" (note: could not set {name}: {ex.GetType().Name})"); + } + } + + private static bool ReadBoolProp(object? target, string name) + { + if (target == null) return false; + PropertyInfo? prop = target.GetType().GetProperty(name); + return prop != null && prop.PropertyType == typeof(bool) && (bool)(prop.GetValue(target) ?? false); + } + + private static void DumpProps(object? target, string indent) + { + if (target == null) { Console.WriteLine($"{indent}"); return; } + foreach (PropertyInfo prop in target.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.GetIndexParameters().Length != 0) continue; + object? val; + try { val = prop.GetValue(target); } + catch { val = ""; } + Console.WriteLine($"{indent}{prop.Name} = {val}"); + } + } + + private static string DescribeError(object? error) + { + if (error == null) return ""; + var sb = new System.Text.StringBuilder(); + foreach (PropertyInfo prop in error.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.GetIndexParameters().Length != 0) continue; + object? val; + try { val = prop.GetValue(error); } + catch { continue; } + if (val != null && !string.IsNullOrEmpty(val.ToString())) + { + sb.Append($"{prop.Name}={val} "); + } + } + return sb.Length == 0 ? "" : sb.ToString().Trim(); + } + private static string FindRepoRoot() { string dir = AppDomain.CurrentDomain.BaseDirectory;