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>
314 lines
16 KiB
C#
314 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Data.SqlClient;
|
|
using System.Linq;
|
|
using Opc.Ua;
|
|
using Serilog;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Configuration
|
|
{
|
|
/// <summary>
|
|
/// Validates and logs effective configuration at startup. (SVC-003, SVC-005)
|
|
/// </summary>
|
|
public static class ConfigurationValidator
|
|
{
|
|
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
|
|
|
|
/// <summary>
|
|
/// Validates the effective host configuration and writes the resolved values to the startup log before service
|
|
/// initialization continues.
|
|
/// </summary>
|
|
/// <param name="config">
|
|
/// The bound service configuration that drives OPC UA hosting, MXAccess connectivity, Galaxy queries,
|
|
/// and dashboard behavior.
|
|
/// </param>
|
|
/// <returns>
|
|
/// <see langword="true" /> when the required settings are present and within supported bounds; otherwise,
|
|
/// <see langword="false" />.
|
|
/// </returns>
|
|
public static bool ValidateAndLog(AppConfiguration config)
|
|
{
|
|
var valid = true;
|
|
|
|
Log.Information("=== Effective Configuration ===");
|
|
|
|
// OPC UA
|
|
Log.Information(
|
|
"OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
|
|
config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName,
|
|
config.OpcUa.GalaxyName);
|
|
Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}",
|
|
config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes);
|
|
|
|
if (config.OpcUa.Port < 1 || config.OpcUa.Port > 65535)
|
|
{
|
|
Log.Error("OpcUa.Port must be between 1 and 65535");
|
|
valid = false;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(config.OpcUa.GalaxyName))
|
|
{
|
|
Log.Error("OpcUa.GalaxyName must not be empty");
|
|
valid = false;
|
|
}
|
|
|
|
// Alarm filter
|
|
var alarmFilterCount = config.OpcUa.AlarmFilter?.ObjectFilters?.Count ?? 0;
|
|
Log.Information(
|
|
"OpcUa.AlarmTrackingEnabled={AlarmEnabled}, AlarmFilter.ObjectFilters=[{Filters}]",
|
|
config.OpcUa.AlarmTrackingEnabled,
|
|
alarmFilterCount == 0 ? "(none)" : string.Join(", ", config.OpcUa.AlarmFilter!.ObjectFilters));
|
|
if (alarmFilterCount > 0 && !config.OpcUa.AlarmTrackingEnabled)
|
|
Log.Warning(
|
|
"OpcUa.AlarmFilter.ObjectFilters has {Count} patterns but OpcUa.AlarmTrackingEnabled is false — filter will have no effect",
|
|
alarmFilterCount);
|
|
|
|
// MxAccess
|
|
Log.Information(
|
|
"MxAccess.ClientName={ClientName}, ReadTimeout={ReadTimeout}s, WriteTimeout={WriteTimeout}s, MaxConcurrent={MaxConcurrent}",
|
|
config.MxAccess.ClientName, config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds,
|
|
config.MxAccess.MaxConcurrentOperations);
|
|
Log.Information(
|
|
"MxAccess.MonitorInterval={MonitorInterval}s, AutoReconnect={AutoReconnect}, ProbeTag={ProbeTag}, ProbeStaleThreshold={ProbeStale}s",
|
|
config.MxAccess.MonitorIntervalSeconds, config.MxAccess.AutoReconnect,
|
|
config.MxAccess.ProbeTag ?? "(none)", config.MxAccess.ProbeStaleThresholdSeconds);
|
|
Log.Information(
|
|
"MxAccess.RuntimeStatusProbesEnabled={Enabled}, RuntimeStatusUnknownTimeoutSeconds={Timeout}s, RequestTimeoutSeconds={RequestTimeout}s",
|
|
config.MxAccess.RuntimeStatusProbesEnabled, config.MxAccess.RuntimeStatusUnknownTimeoutSeconds,
|
|
config.MxAccess.RequestTimeoutSeconds);
|
|
|
|
if (string.IsNullOrWhiteSpace(config.MxAccess.ClientName))
|
|
{
|
|
Log.Error("MxAccess.ClientName must not be empty");
|
|
valid = false;
|
|
}
|
|
|
|
if (config.MxAccess.RuntimeStatusUnknownTimeoutSeconds < 5)
|
|
Log.Warning(
|
|
"MxAccess.RuntimeStatusUnknownTimeoutSeconds={Timeout} is below the recommended floor of 5s; initial probe resolution may time out before MxAccess has delivered the first callback",
|
|
config.MxAccess.RuntimeStatusUnknownTimeoutSeconds);
|
|
|
|
if (config.MxAccess.RequestTimeoutSeconds < 1)
|
|
{
|
|
Log.Error("MxAccess.RequestTimeoutSeconds must be at least 1");
|
|
valid = false;
|
|
}
|
|
else if (config.MxAccess.RequestTimeoutSeconds <
|
|
Math.Max(config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds))
|
|
{
|
|
Log.Warning(
|
|
"MxAccess.RequestTimeoutSeconds={RequestTimeout} is below Read/Write inner timeouts ({Read}s/{Write}s); outer safety bound may fire before the inner client completes its own error path",
|
|
config.MxAccess.RequestTimeoutSeconds,
|
|
config.MxAccess.ReadTimeoutSeconds, config.MxAccess.WriteTimeoutSeconds);
|
|
}
|
|
|
|
// Galaxy Repository
|
|
Log.Information(
|
|
"GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}",
|
|
SanitizeConnectionString(config.GalaxyRepository.ConnectionString), config.GalaxyRepository.ChangeDetectionIntervalSeconds,
|
|
config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes);
|
|
|
|
var effectivePlatformName = string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName)
|
|
? Environment.MachineName
|
|
: config.GalaxyRepository.PlatformName;
|
|
Log.Information(
|
|
"GalaxyRepository.Scope={Scope}, PlatformName={PlatformName}",
|
|
config.GalaxyRepository.Scope,
|
|
config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform
|
|
? effectivePlatformName
|
|
: "(n/a)");
|
|
|
|
if (config.GalaxyRepository.Scope == GalaxyScope.LocalPlatform &&
|
|
string.IsNullOrWhiteSpace(config.GalaxyRepository.PlatformName))
|
|
Log.Information(
|
|
"GalaxyRepository.PlatformName not set — using Environment.MachineName '{MachineName}'",
|
|
Environment.MachineName);
|
|
|
|
if (string.IsNullOrWhiteSpace(config.GalaxyRepository.ConnectionString))
|
|
{
|
|
Log.Error("GalaxyRepository.ConnectionString must not be empty");
|
|
valid = false;
|
|
}
|
|
|
|
// Dashboard
|
|
Log.Information("Dashboard.Enabled={Enabled}, Port={Port}, RefreshInterval={Refresh}s",
|
|
config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds);
|
|
|
|
// Security
|
|
Log.Information(
|
|
"Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}",
|
|
string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates,
|
|
config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize);
|
|
|
|
Log.Information("Security.PkiRootPath={PkiRootPath}", config.Security.PkiRootPath ?? "(default)");
|
|
Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject ?? "(default)");
|
|
Log.Information("Security.CertificateLifetimeMonths={Months}", config.Security.CertificateLifetimeMonths);
|
|
|
|
var unknownProfiles = config.Security.Profiles
|
|
.Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, StringComparer.OrdinalIgnoreCase))
|
|
.ToList();
|
|
if (unknownProfiles.Count > 0)
|
|
Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}",
|
|
string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames));
|
|
|
|
if (config.Security.MinimumCertificateKeySize < 2048)
|
|
{
|
|
Log.Error("Security.MinimumCertificateKeySize must be at least 2048");
|
|
valid = false;
|
|
}
|
|
|
|
if (config.Security.AutoAcceptClientCertificates)
|
|
Log.Warning(
|
|
"Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production");
|
|
|
|
if (config.Security.Profiles.Count == 1 &&
|
|
config.Security.Profiles[0].Equals("None", StringComparison.OrdinalIgnoreCase))
|
|
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
|
|
|
|
// Historian
|
|
var clusterNodes = config.Historian.ServerNames ?? new List<string>();
|
|
var effectiveNodes = clusterNodes.Count > 0
|
|
? string.Join(",", clusterNodes)
|
|
: config.Historian.ServerName;
|
|
Log.Information(
|
|
"Historian.Enabled={Enabled}, Nodes=[{Nodes}], IntegratedSecurity={IntegratedSecurity}, Port={Port}",
|
|
config.Historian.Enabled, effectiveNodes, config.Historian.IntegratedSecurity,
|
|
config.Historian.Port);
|
|
Log.Information(
|
|
"Historian.CommandTimeoutSeconds={Timeout}, MaxValuesPerRead={MaxValues}, FailureCooldownSeconds={Cooldown}, RequestTimeoutSeconds={RequestTimeout}",
|
|
config.Historian.CommandTimeoutSeconds, config.Historian.MaxValuesPerRead,
|
|
config.Historian.FailureCooldownSeconds, config.Historian.RequestTimeoutSeconds);
|
|
|
|
if (config.Historian.Enabled)
|
|
{
|
|
if (clusterNodes.Count == 0 && string.IsNullOrWhiteSpace(config.Historian.ServerName))
|
|
{
|
|
Log.Error("Historian.ServerName (or ServerNames) must not be empty when Historian is enabled");
|
|
valid = false;
|
|
}
|
|
|
|
if (config.Historian.FailureCooldownSeconds < 0)
|
|
{
|
|
Log.Error("Historian.FailureCooldownSeconds must be zero or positive");
|
|
valid = false;
|
|
}
|
|
|
|
if (config.Historian.RequestTimeoutSeconds < 1)
|
|
{
|
|
Log.Error("Historian.RequestTimeoutSeconds must be at least 1");
|
|
valid = false;
|
|
}
|
|
else if (config.Historian.RequestTimeoutSeconds < config.Historian.CommandTimeoutSeconds)
|
|
{
|
|
Log.Warning(
|
|
"Historian.RequestTimeoutSeconds={RequestTimeout} is below CommandTimeoutSeconds={CmdTimeout}; outer safety bound may fire before the inner SDK completes its own error path",
|
|
config.Historian.RequestTimeoutSeconds, config.Historian.CommandTimeoutSeconds);
|
|
}
|
|
|
|
if (clusterNodes.Count > 0 && !string.IsNullOrWhiteSpace(config.Historian.ServerName)
|
|
&& config.Historian.ServerName != "localhost")
|
|
Log.Warning(
|
|
"Historian.ServerName='{ServerName}' is ignored because Historian.ServerNames has {Count} entries",
|
|
config.Historian.ServerName, clusterNodes.Count);
|
|
|
|
if (config.Historian.Port < 1 || config.Historian.Port > 65535)
|
|
{
|
|
Log.Error("Historian.Port must be between 1 and 65535");
|
|
valid = false;
|
|
}
|
|
|
|
if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.UserName))
|
|
{
|
|
Log.Error("Historian.UserName must not be empty when IntegratedSecurity is disabled");
|
|
valid = false;
|
|
}
|
|
|
|
if (!config.Historian.IntegratedSecurity && string.IsNullOrWhiteSpace(config.Historian.Password))
|
|
Log.Warning("Historian.Password is empty — authentication may fail");
|
|
}
|
|
|
|
// Authentication
|
|
Log.Information("Authentication.AllowAnonymous={AllowAnonymous}, AnonymousCanWrite={AnonymousCanWrite}",
|
|
config.Authentication.AllowAnonymous, config.Authentication.AnonymousCanWrite);
|
|
|
|
if (config.Authentication.Ldap.Enabled)
|
|
{
|
|
Log.Information("Authentication.Ldap.Enabled=true, Host={Host}, Port={Port}, BaseDN={BaseDN}",
|
|
config.Authentication.Ldap.Host, config.Authentication.Ldap.Port,
|
|
config.Authentication.Ldap.BaseDN);
|
|
Log.Information(
|
|
"Authentication.Ldap groups: ReadOnly={ReadOnly}, WriteOperate={WriteOperate}, WriteTune={WriteTune}, WriteConfigure={WriteConfigure}, AlarmAck={AlarmAck}",
|
|
config.Authentication.Ldap.ReadOnlyGroup, config.Authentication.Ldap.WriteOperateGroup,
|
|
config.Authentication.Ldap.WriteTuneGroup, config.Authentication.Ldap.WriteConfigureGroup,
|
|
config.Authentication.Ldap.AlarmAckGroup);
|
|
|
|
if (string.IsNullOrWhiteSpace(config.Authentication.Ldap.ServiceAccountDn))
|
|
Log.Warning("Authentication.Ldap.ServiceAccountDn is empty — group lookups will fail");
|
|
}
|
|
|
|
// Redundancy
|
|
if (config.OpcUa.ApplicationUri != null)
|
|
Log.Information("OpcUa.ApplicationUri={ApplicationUri}", config.OpcUa.ApplicationUri);
|
|
|
|
Log.Information(
|
|
"Redundancy.Enabled={Enabled}, Mode={Mode}, Role={Role}, ServiceLevelBase={ServiceLevelBase}",
|
|
config.Redundancy.Enabled, config.Redundancy.Mode, config.Redundancy.Role,
|
|
config.Redundancy.ServiceLevelBase);
|
|
|
|
if (config.Redundancy.ServerUris.Count > 0)
|
|
Log.Information("Redundancy.ServerUris=[{ServerUris}]",
|
|
string.Join(", ", config.Redundancy.ServerUris));
|
|
|
|
if (config.Redundancy.Enabled)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(config.OpcUa.ApplicationUri))
|
|
{
|
|
Log.Error(
|
|
"OpcUa.ApplicationUri must be set when redundancy is enabled — each instance needs a unique identity");
|
|
valid = false;
|
|
}
|
|
|
|
if (config.Redundancy.ServerUris.Count < 2)
|
|
Log.Warning(
|
|
"Redundancy.ServerUris contains fewer than 2 entries — a redundant set typically has at least 2 servers");
|
|
|
|
if (config.OpcUa.ApplicationUri != null &&
|
|
!config.Redundancy.ServerUris.Contains(config.OpcUa.ApplicationUri))
|
|
Log.Warning("Local OpcUa.ApplicationUri '{ApplicationUri}' is not listed in Redundancy.ServerUris",
|
|
config.OpcUa.ApplicationUri);
|
|
|
|
var mode = RedundancyModeResolver.Resolve(config.Redundancy.Mode, true);
|
|
if (mode == RedundancySupport.None)
|
|
Log.Warning("Redundancy is enabled but Mode '{Mode}' is not recognized — will fall back to None",
|
|
config.Redundancy.Mode);
|
|
}
|
|
|
|
if (config.Redundancy.ServiceLevelBase < 1 || config.Redundancy.ServiceLevelBase > 255)
|
|
{
|
|
Log.Error("Redundancy.ServiceLevelBase must be between 1 and 255");
|
|
valid = false;
|
|
}
|
|
|
|
Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID");
|
|
return valid;
|
|
}
|
|
|
|
private static string SanitizeConnectionString(string connectionString)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(connectionString))
|
|
return "(empty)";
|
|
try
|
|
{
|
|
var builder = new SqlConnectionStringBuilder(connectionString);
|
|
if (!string.IsNullOrEmpty(builder.Password))
|
|
builder.Password = "********";
|
|
return builder.ConnectionString;
|
|
}
|
|
catch
|
|
{
|
|
return "(unparseable)";
|
|
}
|
|
}
|
|
}
|
|
} |