namespace ZB.MOM.WW.OtOpcUa.Core.Scripting; /// /// Where compiled scripts run (Scripting:Runner, lmxopcua#780). is the default /// and the documented rollback: it is today's path, unchanged. /// public enum ScriptRunnerMode { /// Compiled scripts run in the host process under (the default). InProcess = 0, /// /// Run in process — that result is the one published — AND mirror each evaluation of the selected seams to the /// worker pool, comparing the two (design P2, ). A time-boxed diagnostic: it /// doubles script CPU and keeps the in-process risk. Nothing on the shadow side reaches a published value or /// readiness. /// Shadow = 1, /// /// Compiled scripts of the selected seams run in the pre-emptible ScriptWorker process pool. The /// runner fails closed: a worker that cannot start makes the script Bad /// (BadResourceUnavailable), never an in-process run. /// OutOfProcess = 2, } /// The three host seams that run operator-authored C# (Scripting:Runner:Seams). public enum ScriptSeam { /// Virtual tags (RoslynVirtualTagEvaluator). VirtualTag = 0, /// Calculation tags (CalculationEvaluator). Calculation = 1, /// /// Scripted-alarm predicates (ScriptedAlarmEngine). Last in the rollout (design §14 #10): out of process only /// when Scripting:Runner:Seams lists it — an unset list does not select it. /// ScriptedAlarm = 2, } /// /// The out-of-process worker pool's knobs (the Scripting:Worker section). Every value is clamped by /// , which reports each out-of-range key as a startup Warning, like the rest of the /// Scripting section. Only consulted when is not /// . /// public sealed class ScriptWorkerOptions { /// Default : 2 (design §14 #1). public const int DefaultShards = 2; /// Upper bound of . public const int MaxShards = 8; /// Default : 512 MiB. public const long DefaultHeapLimitBytes = 512L * 1024 * 1024; /// Lower bound of : 64 MiB (a worker below this cannot host Roslyn). public const long MinHeapLimitBytes = 64L * 1024 * 1024; /// Default : 2. public const int DefaultQuarantineAfterKills = 2; /// Default : 5 s. public static readonly TimeSpan DefaultUnavailableGrace = TimeSpan.FromSeconds(5); /// Lower bound of an explicit : 250 ms. public static readonly TimeSpan MinHardGrace = TimeSpan.FromMilliseconds(250); /// Upper bound of an explicit : 30 s. public static readonly TimeSpan MaxHardGrace = TimeSpan.FromSeconds(30); /// Upper bound of : 60 s. public static readonly TimeSpan MaxUnavailableGrace = TimeSpan.FromSeconds(60); /// Default : 20. public const int DefaultIsolationProbationRuns = 20; /// Default : 10 min (design §3.3). public static readonly TimeSpan DefaultIsolationIdleTimeout = TimeSpan.FromMinutes(10); /// Default : 2 min (design §5.3.6). public static readonly TimeSpan DefaultCircuitOpenAfter = TimeSpan.FromMinutes(2); /// Shard workers per node; a script is placed by a stable hash of its key. Clamped to 1–8. public int Shards { get; set; } = DefaultShards; /// /// Grace after the soft deadline T before the worker running the script is killed. Null (the default) /// means max(1 s, T) per run (design §14 #2); an explicit value is clamped to 250 ms – 30 s. /// public TimeSpan? HardGrace { get; set; } /// The worker's DOTNET_GCHeapHardLimit. Default 512 MiB, minimum 64 MiB. public long HeapLimitBytes { get; set; } = DefaultHeapLimitBytes; /// Hard-deadline kills (within one hour) after which a script is quarantined. Default 2; 1–100. public int QuarantineAfterKills { get; set; } = DefaultQuarantineAfterKills; /// /// How long an evaluation waits for its shard to become ready (a restart, a backoff) before it reports /// script runner unavailable. Default 5 s; 0–60 s. /// public TimeSpan UnavailableGrace { get; set; } = DefaultUnavailableGrace; /// /// Successful runs a suspect script must complete in the isolation worker before it returns to its shard. /// Default 20; 1–10 000. /// public int IsolationProbationRuns { get; set; } = DefaultIsolationProbationRuns; /// /// How long the isolation worker may sit with nothing to run before it is stopped (it is started again by the /// next suspect). Default 10 min; 1 s – 24 h. /// public TimeSpan IsolationIdleTimeout { get; set; } = DefaultIsolationIdleTimeout; /// /// The crash-loop circuit (design §5.3.6): when every shard worker has been down for this long, the runner /// reports Unavailable, logs one Error and answers evaluations at once (no /// wait) while it keeps retrying at the backoff cap. Default 2 min; 1 s – 1 h. /// public TimeSpan CircuitOpenAfter { get; set; } = DefaultCircuitOpenAfter; /// /// Directory of the node-local compiled-PE cache (design §7): HMAC-authenticated PE images, so a worker restart /// does not re-enter Roslyn. Must be absolute and outside the content root when set; startup fails otherwise /// (the family's fail-closed path rule). Null (the default) runs without a cache: every worker start recompiles. /// public string? PeCacheDir { get; set; } /// /// Path of the worker entry assembly (ZB.MOM.WW.OtOpcUa.ScriptWorker.dll). Null (the default) means /// script-worker/ under the host's base directory, where the Host build places it. /// public string? ExecutablePath { get; set; } /// /// The worker's identity on Windows (design §6, owner decision §14 #5): NT SERVICE\<name> — the virtual /// account scripts/install/Install-ScriptWorkerIsolation.ps1 registers, by default /// NT SERVICE\OtOpcUaScriptWorker — or a SID string. When set, each worker starts under a restricted token /// derived from the host's (see ): every privilege removed, and every access check /// must ALSO pass for this identity, Everyone, BUILTIN\Users or RESTRICTED. The pipe ACL then /// admits the host account and this identity only. Null (the default) keeps the host's own token. Windows only: /// startup refuses it elsewhere. /// public string? Identity { get; set; } /// The hard grace that applies to a run with soft budget . /// The run's soft deadline T. /// when set, otherwise max(1 s, T). public TimeSpan EffectiveHardGrace(TimeSpan softTimeout) => HardGrace ?? (softTimeout > TimeSpan.FromSeconds(1) ? softTimeout : TimeSpan.FromSeconds(1)); /// Returns a clamped copy; one line per out-of-range key in . /// The clamps applied, naming each key. /// A new, clamped instance. public ScriptWorkerOptions Sanitized(out IReadOnlyList adjustments) { const string prefix = ScriptingOptions.SectionName + ":Worker:"; var notes = new List(); var shards = Shards; if (shards < 1 || shards > MaxShards) { var clamped = Math.Clamp(shards, 1, MaxShards); notes.Add($"{prefix}{nameof(Shards)} {shards} is outside 1–{MaxShards}; clamped to {clamped}."); shards = clamped; } var grace = HardGrace; if (grace is { } g && (g < MinHardGrace || g > MaxHardGrace)) { var clamped = g < MinHardGrace ? MinHardGrace : MaxHardGrace; notes.Add($"{prefix}{nameof(HardGrace)} {g} is outside {MinHardGrace}–{MaxHardGrace}; clamped to {clamped}."); grace = clamped; } var heap = HeapLimitBytes; if (heap < MinHeapLimitBytes) { notes.Add($"{prefix}{nameof(HeapLimitBytes)} {heap} is below the minimum; clamped to {MinHeapLimitBytes}."); heap = MinHeapLimitBytes; } var kills = QuarantineAfterKills; if (kills < 1 || kills > 100) { var clamped = Math.Clamp(kills, 1, 100); notes.Add($"{prefix}{nameof(QuarantineAfterKills)} {kills} is outside 1–100; clamped to {clamped}."); kills = clamped; } var unavailable = UnavailableGrace; if (unavailable < TimeSpan.Zero || unavailable > MaxUnavailableGrace) { var clamped = unavailable < TimeSpan.Zero ? TimeSpan.Zero : MaxUnavailableGrace; notes.Add($"{prefix}{nameof(UnavailableGrace)} {unavailable} is outside 0–{MaxUnavailableGrace}; clamped to {clamped}."); unavailable = clamped; } var probation = IsolationProbationRuns; if (probation < 1 || probation > 10_000) { var clamped = Math.Clamp(probation, 1, 10_000); notes.Add($"{prefix}{nameof(IsolationProbationRuns)} {probation} is outside 1–10000; clamped to {clamped}."); probation = clamped; } var idle = IsolationIdleTimeout; if (idle < TimeSpan.FromSeconds(1) || idle > TimeSpan.FromHours(24)) { var clamped = idle < TimeSpan.FromSeconds(1) ? TimeSpan.FromSeconds(1) : TimeSpan.FromHours(24); notes.Add($"{prefix}{nameof(IsolationIdleTimeout)} {idle} is outside 00:00:01–1.00:00:00; clamped to {clamped}."); idle = clamped; } var circuit = CircuitOpenAfter; if (circuit < TimeSpan.FromSeconds(1) || circuit > TimeSpan.FromHours(1)) { var clamped = circuit < TimeSpan.FromSeconds(1) ? TimeSpan.FromSeconds(1) : TimeSpan.FromHours(1); notes.Add($"{prefix}{nameof(CircuitOpenAfter)} {circuit} is outside 00:00:01–01:00:00; clamped to {clamped}."); circuit = clamped; } adjustments = notes; return new ScriptWorkerOptions { IsolationIdleTimeout = idle, CircuitOpenAfter = circuit, Shards = shards, HardGrace = grace, HeapLimitBytes = heap, QuarantineAfterKills = kills, UnavailableGrace = unavailable, IsolationProbationRuns = probation, PeCacheDir = PeCacheDir, ExecutablePath = ExecutablePath, Identity = Identity, }; } /// /// Fail-closed path validation (design §7): , when set, must be absolute and must not /// sit inside . Same rule as the family's Secrets:SqlitePath. /// /// The host's content root. /// The violation, or null when the options are acceptable. public string? ValidatePaths(string contentRootPath) { if (string.IsNullOrWhiteSpace(PeCacheDir)) return null; const string key = ScriptingOptions.SectionName + ":Worker:" + nameof(PeCacheDir); if (!Path.IsPathFullyQualified(PeCacheDir)) return $"{key} '{PeCacheDir}' must be an absolute path."; var dir = Path.TrimEndingDirectorySeparator(Path.GetFullPath(PeCacheDir)); var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(contentRootPath)); var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; if (string.Equals(dir, root, comparison) || dir.StartsWith(root + Path.DirectorySeparatorChar, comparison) || dir.StartsWith(root + Path.AltDirectorySeparatorChar, comparison)) return $"{key} '{PeCacheDir}' is inside the content root '{contentRootPath}'; it must live outside it."; return null; } /// /// Fail-closed identity validation: , when set, must be NT SERVICE\<name> or a SID /// string, and the host must run on Windows. /// /// Whether the host runs on Windows (callers pass ). /// The violation, or null when the identity is unset or acceptable. public string? ValidateIdentity(bool isWindows) { if (string.IsNullOrWhiteSpace(Identity)) return null; const string key = ScriptingOptions.SectionName + ":Worker:" + nameof(Identity); if (!isWindows) return $"{key} '{Identity}' is only supported on Windows (it is a restricted token); unset it on this host."; return ScriptWorkerIdentity.TryResolveSid(Identity, out _, out var error) ? null : $"{key} '{Identity}': {error}"; } } /// /// The shadow comparison's knobs (the Scripting:Shadow section, lmxopcua#780 P2). Only consulted when /// is . /// public sealed class ScriptShadowOptions { /// Default : 256. public const int DefaultMaxInFlight = 256; /// Default : 20. public const int DefaultMismatchLogsPerMinute = 20; /// /// Shadow runs outstanding at once (all seams). An evaluation that would exceed it is not mirrored and is counted /// as dropped (reason = behind): the shadow is best effort and never queues without bound. Default 256; /// 1–100 000. /// public int MaxInFlight { get; set; } = DefaultMaxInFlight; /// /// Mismatch Warning lines per minute (each script logs once per mismatch reason until its seam redeploys; this /// caps a burst of many scripts). Default 20; 0 (counted, never logged) – 10 000. /// public int MismatchLogsPerMinute { get; set; } = DefaultMismatchLogsPerMinute; /// Returns a clamped copy; one line per out-of-range key in . /// The clamps applied, naming each key. /// A new, clamped instance. public ScriptShadowOptions Sanitized(out IReadOnlyList adjustments) { const string prefix = ScriptingOptions.SectionName + ":Shadow:"; var notes = new List(); var inFlight = MaxInFlight; if (inFlight < 1 || inFlight > 100_000) { var clamped = Math.Clamp(inFlight, 1, 100_000); notes.Add($"{prefix}{nameof(MaxInFlight)} {inFlight} is outside 1–100000; clamped to {clamped}."); inFlight = clamped; } var logs = MismatchLogsPerMinute; if (logs < 0 || logs > 10_000) { var clamped = Math.Clamp(logs, 0, 10_000); notes.Add($"{prefix}{nameof(MismatchLogsPerMinute)} {logs} is outside 0–10000; clamped to {clamped}."); logs = clamped; } adjustments = notes; return new ScriptShadowOptions { MaxInFlight = inFlight, MismatchLogsPerMinute = logs }; } }