using Mbproxy.Bcd;
using Mbproxy.Options;
namespace Mbproxy.Configuration;
///
/// Validates an incoming snapshot before any state mutation
/// is attempted. All cross-PLC checks (uniqueness, port collisions) live here.
/// Per-PLC tag-list well-formedness is delegated to .
///
/// Usage:
///
/// if (!ReloadValidator.Validate(next, out var errors))
/// // log errors and abort reload
///
///
internal static class ReloadValidator
{
///
/// Validates . Returns true when valid.
///
/// Checks performed (in order):
///
/// - All PLC names are non-empty and unique (ordinal comparison).
/// - All ListenPort values are in [1, 65535] and unique.
/// - AdminPort is in [1, 65535] and does not collide with any ListenPort.
/// - For each PLC, reports no errors.
///
///
public static bool Validate(MbproxyOptions next, out IReadOnlyList errors)
{
var errs = new List();
// ── 1. PLC name uniqueness ────────────────────────────────────────────
var seenNames = new HashSet(StringComparer.Ordinal);
for (int i = 0; i < next.Plcs.Count; i++)
{
var plc = next.Plcs[i];
if (string.IsNullOrWhiteSpace(plc.Name))
{
errs.Add($"Plcs[{i}]: Name must be non-empty.");
}
else if (!seenNames.Add(plc.Name))
{
errs.Add($"Plcs[{i}]: Duplicate PLC name '{plc.Name}'.");
}
}
// ── 2. ListenPort uniqueness and range ────────────────────────────────
var seenPorts = new Dictionary(next.Plcs.Count); // port → PLC name
foreach (var plc in next.Plcs)
{
if (plc.ListenPort is < 1 or > 65535)
{
errs.Add($"Plc '{plc.Name}': ListenPort {plc.ListenPort} is out of range [1, 65535].");
}
else if (!seenPorts.TryAdd(plc.ListenPort, plc.Name))
{
errs.Add($"Plc '{plc.Name}': Duplicate ListenPort {plc.ListenPort} " +
$"(already used by '{seenPorts[plc.ListenPort]}').");
}
}
// ── 3. AdminPort range and collision ─────────────────────────────────
int adminPort = next.AdminPort;
if (adminPort is < 1 or > 65535)
{
errs.Add($"AdminPort {adminPort} is out of range [1, 65535].");
}
else if (seenPorts.TryGetValue(adminPort, out string? clashPlc))
{
errs.Add($"AdminPort {adminPort} collides with ListenPort of PLC '{clashPlc}'.");
}
// ── 4. Per-PLC tag-map build ──────────────────────────────────────────
// BcdTagMapBuilder.Build is the single source of truth for tag-list
// well-formedness; we must not duplicate its validation logic here.
// Also re-check the RESOLVED per-tag CacheTtlMs against AllowLongTtl. The raw-
// input check at section 5 covers explicit per-tag and per-PLC-default values,
// but defensively re-validating the post-fold values catches any future fold
// logic that produces a value above the gate.
bool allowLongTtlForResolved = next.Cache.AllowLongTtl;
foreach (var plc in next.Plcs)
{
var result = BcdTagMapBuilder.Build(next.BcdTags, plc.BcdTags, plc.DefaultCacheTtlMs);
foreach (var err in result.Errors)
errs.Add($"Plc '{plc.Name}': BCD tag map error ({err.Kind}): {err.Message}");
if (!allowLongTtlForResolved)
{
foreach (var tag in result.Map.All)
{
if (tag.CacheTtlMs > 60_000)
errs.Add(
$"Plc '{plc.Name}': resolved CacheTtlMs for Address {tag.Address} is " +
$"{tag.CacheTtlMs} ms (exceeds 60_000) without Cache.AllowLongTtl=true.");
}
}
}
// ── 5. Cache TTL bounds ───────────────────────────────────────────────
// The MbproxyOptionsValidator catches these at schema time too, but ReloadValidator
// is the gate that the hot-reload path consults directly so re-checking here keeps
// both paths internally consistent (and the validator runs against tag-map-resolved
// BcdTag.CacheTtlMs values too).
bool allowLongTtl = next.Cache.AllowLongTtl;
foreach (var tag in next.BcdTags.Global)
{
CheckTtl(errs, $"BcdTags.Global Address {tag.Address}", tag.CacheTtlMs, allowLongTtl);
}
foreach (var plc in next.Plcs)
{
if (plc.DefaultCacheTtlMs > 60_000 && !allowLongTtl)
errs.Add(
$"Plc '{plc.Name}': DefaultCacheTtlMs={plc.DefaultCacheTtlMs} exceeds 60_000 ms " +
"without Cache.AllowLongTtl=true.");
else if (plc.DefaultCacheTtlMs < 0)
errs.Add($"Plc '{plc.Name}': DefaultCacheTtlMs must be >= 0; got {plc.DefaultCacheTtlMs}.");
if (plc.BcdTags?.Add is { } addList)
{
foreach (var tag in addList)
CheckTtl(errs, $"Plc '{plc.Name}' BcdTags.Add Address {tag.Address}",
tag.CacheTtlMs, allowLongTtl);
}
}
if (next.Cache.MaxEntriesPerPlc < 0)
errs.Add($"Cache.MaxEntriesPerPlc must be >= 0; got {next.Cache.MaxEntriesPerPlc}.");
if (next.Cache.EvictionIntervalMs < 0)
errs.Add($"Cache.EvictionIntervalMs must be >= 0; got {next.Cache.EvictionIntervalMs}.");
// Connection timeouts must be > 0. A reload that sets any of these to 0 or
// negative would break the runtime; reject the reload as a whole.
if (next.Connection.BackendConnectTimeoutMs <= 0)
errs.Add(
$"Connection.BackendConnectTimeoutMs must be > 0; got {next.Connection.BackendConnectTimeoutMs}.");
if (next.Connection.BackendRequestTimeoutMs <= 0)
errs.Add(
$"Connection.BackendRequestTimeoutMs must be > 0; got {next.Connection.BackendRequestTimeoutMs}.");
if (next.Connection.GracefulShutdownTimeoutMs <= 0)
errs.Add(
$"Connection.GracefulShutdownTimeoutMs must be > 0; got {next.Connection.GracefulShutdownTimeoutMs}.");
// ── 6. Keepalive section ──────────────────────────────────────────────
// Schema bounds are also checked in MbproxyOptionsValidator; re-checking here keeps
// the hot-reload gate self-contained. The cross-field rule (heartbeat interval must
// sit above the request timeout, or it would fire continuously) lives only here.
var ka = next.Connection.Keepalive;
if (ka.TcpIdleTimeMs <= 0)
errs.Add($"Connection.Keepalive.TcpIdleTimeMs must be > 0; got {ka.TcpIdleTimeMs}.");
if (ka.TcpProbeIntervalMs <= 0)
errs.Add($"Connection.Keepalive.TcpProbeIntervalMs must be > 0; got {ka.TcpProbeIntervalMs}.");
if (ka.TcpProbeCount <= 0)
errs.Add($"Connection.Keepalive.TcpProbeCount must be > 0; got {ka.TcpProbeCount}.");
if (ka.BackendHeartbeatProbeAddress is < 0 or > 65535)
errs.Add(
$"Connection.Keepalive.BackendHeartbeatProbeAddress must be in [0, 65535]; " +
$"got {ka.BackendHeartbeatProbeAddress}.");
if (ka.BackendHeartbeatIdleMs <= next.Connection.BackendRequestTimeoutMs)
errs.Add(
$"Connection.Keepalive.BackendHeartbeatIdleMs ({ka.BackendHeartbeatIdleMs}) must be greater " +
$"than Connection.BackendRequestTimeoutMs ({next.Connection.BackendRequestTimeoutMs}); " +
"a heartbeat interval at or below the request timeout would fire continuously.");
errors = errs;
return errs.Count == 0;
}
private static void CheckTtl(List errs, string context, int? ttl, bool allowLongTtl)
{
if (ttl is null) return;
int v = ttl.Value;
if (v < 0)
errs.Add($"{context}: CacheTtlMs must be >= 0; got {v}.");
else if (v > 60_000 && !allowLongTtl)
errs.Add(
$"{context}: CacheTtlMs={v} exceeds 60_000 ms without Cache.AllowLongTtl=true.");
}
}