Replace direct SQL queries against Historian Runtime database with the Wonderware Historian managed SDK (ArchestrA.HistorianAccess). Add HistoryServerCapabilities node, AggregateFunctions folder, continuation points, ReadAtTime interpolation, ReturnBounds, ReadModified rejection, HistoricalDataConfiguration per node, historical event access, and client-side StandardDeviation aggregate support. Remove screenshot tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
214 lines
11 KiB
C#
214 lines
11 KiB
C#
using System;
|
|
using System.Linq;
|
|
using Opc.Ua;
|
|
using Serilog;
|
|
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.LmxOpcUa.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;
|
|
}
|
|
|
|
// 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);
|
|
|
|
if (string.IsNullOrWhiteSpace(config.MxAccess.ClientName))
|
|
{
|
|
Log.Error("MxAccess.ClientName must not be empty");
|
|
valid = false;
|
|
}
|
|
|
|
// Galaxy Repository
|
|
Log.Information(
|
|
"GalaxyRepository.ConnectionString={ConnectionString}, ChangeDetectionInterval={ChangeInterval}s, CommandTimeout={CmdTimeout}s, ExtendedAttributes={ExtendedAttributes}",
|
|
config.GalaxyRepository.ConnectionString, config.GalaxyRepository.ChangeDetectionIntervalSeconds,
|
|
config.GalaxyRepository.CommandTimeoutSeconds, config.GalaxyRepository.ExtendedAttributes);
|
|
|
|
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
|
|
Log.Information("Historian.Enabled={Enabled}, ServerName={ServerName}, IntegratedSecurity={IntegratedSecurity}, Port={Port}",
|
|
config.Historian.Enabled, config.Historian.ServerName, config.Historian.IntegratedSecurity,
|
|
config.Historian.Port);
|
|
Log.Information("Historian.CommandTimeoutSeconds={Timeout}, MaxValuesPerRead={MaxValues}",
|
|
config.Historian.CommandTimeoutSeconds, config.Historian.MaxValuesPerRead);
|
|
|
|
if (config.Historian.Enabled)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(config.Historian.ServerName))
|
|
{
|
|
Log.Error("Historian.ServerName must not be empty when Historian is enabled");
|
|
valid = false;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
} |