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."); } }