Phase 0 — mechanical rename ZB.MOM.WW.LmxOpcUa.* → ZB.MOM.WW.OtOpcUa.*

Renames all 11 projects (5 src + 6 tests), the .slnx solution file, all source-file namespaces, all axaml namespace references, and all v1 documentation references in CLAUDE.md and docs/*.md (excluding docs/v2/ which is already in OtOpcUa form). Also updates the TopShelf service registration name from "LmxOpcUa" to "OtOpcUa" per Phase 0 Task 0.6.

Preserves runtime identifiers per Phase 0 Out-of-Scope rules to avoid breaking v1/v2 client trust during coexistence: OPC UA `ApplicationUri` defaults (`urn:{GalaxyName}:LmxOpcUa`), server `EndpointPath` (`/LmxOpcUa`), `ServerName` default (feeds cert subject CN), `MxAccessConfiguration.ClientName` default (defensive — stays "LmxOpcUa" for MxAccess audit-trail consistency), client OPC UA identifiers (`ApplicationName = "LmxOpcUaClient"`, `ApplicationUri = "urn:localhost:LmxOpcUaClient"`, cert directory `%LocalAppData%\LmxOpcUaClient\pki\`), and the `LmxOpcUaServer` class name (class rename out of Phase 0 scope per Task 0.5 sed pattern; happens in Phase 1 alongside `LmxNodeManager → GenericDriverNodeManager` Core extraction). 23 LmxOpcUa references retained, all enumerated and justified in `docs/v2/implementation/exit-gate-phase-0.md`.

Build clean: 0 errors, 30 warnings (lower than baseline 167). Tests at strict improvement over baseline: 821 passing / 1 failing vs baseline 820 / 2 (one flaky pre-existing failure passed this run; the other still fails — both pre-existing and unrelated to the rename). `Client.UI.Tests`, `Historian.Aveva.Tests`, `Client.Shared.Tests`, `IntegrationTests` all match baseline exactly. Exit gate compliance results recorded in `docs/v2/implementation/exit-gate-phase-0.md` with all 7 checks PASS or DEFERRED-to-PR-review (#7 service install verification needs Windows service permissions on the reviewer's box).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-17 13:57:47 -04:00
parent 5b8d708c58
commit 3b2defd94f
293 changed files with 841 additions and 722 deletions

View File

@@ -0,0 +1,180 @@
using System;
using System.IO;
using System.Reflection;
using Serilog;
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
namespace ZB.MOM.WW.OtOpcUa.Host.Historian
{
/// <summary>
/// Result of the most recent historian plugin load attempt.
/// </summary>
public enum HistorianPluginStatus
{
/// <summary>Historian.Enabled is false; TryLoad was not called.</summary>
Disabled,
/// <summary>Plugin DLL was not present in the Historian/ subfolder.</summary>
NotFound,
/// <summary>Plugin file exists but could not be loaded or instantiated.</summary>
LoadFailed,
/// <summary>Plugin loaded and an IHistorianDataSource was constructed.</summary>
Loaded
}
/// <summary>
/// Structured outcome of a <see cref="HistorianPluginLoader.TryLoad"/> or
/// <see cref="HistorianPluginLoader.MarkDisabled"/> call, used by the status dashboard.
/// </summary>
public sealed class HistorianPluginOutcome
{
public HistorianPluginOutcome(HistorianPluginStatus status, string pluginPath, string? error)
{
Status = status;
PluginPath = pluginPath;
Error = error;
}
public HistorianPluginStatus Status { get; }
public string PluginPath { get; }
public string? Error { get; }
}
/// <summary>
/// Loads the Wonderware historian plugin assembly from the Historian/ subfolder next to
/// the host executable. Used so the aahClientManaged SDK is not needed on hosts that run
/// with Historian.Enabled=false.
/// </summary>
public static class HistorianPluginLoader
{
private const string PluginSubfolder = "Historian";
private const string PluginAssemblyName = "ZB.MOM.WW.OtOpcUa.Historian.Aveva";
private const string PluginEntryType = "ZB.MOM.WW.OtOpcUa.Historian.Aveva.AvevaHistorianPluginEntry";
private const string PluginEntryMethod = "Create";
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(HistorianPluginLoader));
private static readonly object ResolverGate = new object();
private static bool _resolverInstalled;
private static string? _resolvedProbeDirectory;
/// <summary>
/// Gets the outcome of the most recent load attempt (or <see cref="HistorianPluginStatus.Disabled"/>
/// if the loader has never been invoked). The dashboard reads this to distinguish "disabled",
/// "plugin missing", and "plugin crashed".
/// </summary>
public static HistorianPluginOutcome LastOutcome { get; private set; }
= new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
/// <summary>
/// Records that the historian plugin is disabled by configuration. Called by
/// <c>OpcUaService</c> when <c>Historian.Enabled=false</c> so the status dashboard can
/// report the exact reason history is unavailable.
/// </summary>
public static void MarkDisabled()
{
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Disabled, string.Empty, null);
}
/// <summary>
/// Attempts to load the historian plugin and construct an <see cref="IHistorianDataSource"/>.
/// Returns null on any failure so the server can continue with history unsupported. The
/// specific reason is published on <see cref="LastOutcome"/>.
/// </summary>
public static IHistorianDataSource? TryLoad(HistorianConfiguration config)
{
var pluginDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, PluginSubfolder);
var pluginPath = Path.Combine(pluginDirectory, PluginAssemblyName + ".dll");
if (!File.Exists(pluginPath))
{
Log.Warning(
"Historian plugin not found at {PluginPath} — history read operations will return BadHistoryOperationUnsupported",
pluginPath);
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.NotFound, pluginPath, null);
return null;
}
EnsureAssemblyResolverInstalled(pluginDirectory);
try
{
var assembly = Assembly.LoadFrom(pluginPath);
var entryType = assembly.GetType(PluginEntryType, throwOnError: false);
if (entryType == null)
{
Log.Warning("Historian plugin {PluginPath} does not expose {EntryType}", pluginPath, PluginEntryType);
LastOutcome = new HistorianPluginOutcome(
HistorianPluginStatus.LoadFailed, pluginPath,
$"Plugin assembly does not expose entry type {PluginEntryType}");
return null;
}
var create = entryType.GetMethod(PluginEntryMethod, BindingFlags.Public | BindingFlags.Static);
if (create == null)
{
Log.Warning("Historian plugin entry type {EntryType} missing static {Method}", PluginEntryType, PluginEntryMethod);
LastOutcome = new HistorianPluginOutcome(
HistorianPluginStatus.LoadFailed, pluginPath,
$"Plugin entry type {PluginEntryType} is missing a public static {PluginEntryMethod} method");
return null;
}
var result = create.Invoke(null, new object[] { config });
if (result is IHistorianDataSource dataSource)
{
Log.Information("Historian plugin loaded from {PluginPath}", pluginPath);
LastOutcome = new HistorianPluginOutcome(HistorianPluginStatus.Loaded, pluginPath, null);
return dataSource;
}
Log.Warning("Historian plugin {PluginPath} returned an object that does not implement IHistorianDataSource", pluginPath);
LastOutcome = new HistorianPluginOutcome(
HistorianPluginStatus.LoadFailed, pluginPath,
"Plugin entry method returned an object that does not implement IHistorianDataSource");
return null;
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to load historian plugin from {PluginPath} — history disabled", pluginPath);
LastOutcome = new HistorianPluginOutcome(
HistorianPluginStatus.LoadFailed, pluginPath,
ex.GetBaseException().Message);
return null;
}
}
private static void EnsureAssemblyResolverInstalled(string pluginDirectory)
{
lock (ResolverGate)
{
_resolvedProbeDirectory = pluginDirectory;
if (_resolverInstalled)
return;
AppDomain.CurrentDomain.AssemblyResolve += ResolveFromPluginDirectory;
_resolverInstalled = true;
}
}
private static Assembly? ResolveFromPluginDirectory(object? sender, ResolveEventArgs args)
{
var probeDirectory = _resolvedProbeDirectory;
if (string.IsNullOrEmpty(probeDirectory))
return null;
var requested = new AssemblyName(args.Name);
var candidate = Path.Combine(probeDirectory!, requested.Name + ".dll");
if (!File.Exists(candidate))
return null;
try
{
return Assembly.LoadFrom(candidate);
}
catch (Exception ex)
{
Log.Debug(ex, "Historian plugin resolver failed to load {Candidate}", candidate);
return null;
}
}
}
}