f50b29abae
v2-ci / unit-tests (push) Failing after 1h12m10s
Second P1 slice of docs/plans/2026-11-09-out-of-process-script-runner-design.md; still opt-in behind Scripting:Runner=OutOfProcess (InProcess unchanged). - Compiled-PE cache (design §7, §14 #8): Scripting:Worker:PeCacheDir holds <dir>/pe/<id>.pe entries authenticated with HMAC-SHA256 under a per-node random key (<dir>/pe-cache.key, created by the host, 0600/0700 on Unix), handed to the worker in Hello. Entry id = SHA-256(compile fingerprint + kind + source); the fingerprint covers the Roslyn version, the Core.Scripting build (analyzer, wrapper, stack guard), the sandbox reference set and the runtime. Edited, truncated, other-key or renamed entries are rejected, deleted and recompiled; the worker never loads unverified bytes. Pruned by recency at startup (30 d / 50 000). ScriptEvaluator splits into CompileToImage + FromImage. Measured on 40 scripts, restart to every first result: macOS 820 ms no cache / 840 ms empty / 85 ms warm; windev (Windows) 9.5 s / 9.4 s / 1.0 s. - Quarantine race: WorkerConnection runs exit attribution synchronously BEFORE completing the evaluations/loads it held (and before Exited), and the shard passes it straight to the runner — a kill strike is in place before the killed key's slot is released. - Supervision: a dead worker restarts by itself after its backoff; Unavailable outcomes carry ScriptRunOutcome.Recovery (-> VirtualTagEvalResult.RetryWhen) and the VT actor / Calculation driver re-evaluate once against current inputs when it completes. Isolation worker stops after Scripting:Worker:IsolationIdleTimeout (10 min). Keys compiling when a worker dies are suspects too (isolation finds a poison compile and quarantines it). Startup orphan sweep by launch marker: kills only a process whose marker's host is gone and whose pid + start time + image match. - Crash-loop circuit (Scripting:Worker:CircuitOpenAfter, 2 min): runner Unavailable, one Error, evaluations answered at once while shards keep retrying at the cap. - Observability (§10): otopcua.script.runner.state, worker.up/inflight/queue_depth/heap_bytes (shard), ipc.roundtrip + eval.duration (kind), compile{cache}, isolation.suspects, exits reason=idle. `script-runner` health check on the ready tier, at worst Degraded (§14 #12: degraded but still ready). Left on #780: self-contained worker publish, installer (account, pipe ACL, firewall), docker-dev live drill, scripted-alarm seam, the §4.5 per-seam batch submit, the §8 perf harness. Tests: SupervisionTests, PeCacheStoreTests/PeCacheWorkerTests (real workers), ScriptRunnerHealthCheckTests, VT + Calc recovery seam tests; 14 mutations each turned their test red. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
176 lines
9.8 KiB
C#
176 lines
9.8 KiB
C#
using Microsoft.CodeAnalysis;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
/// <summary>
|
|
/// Factory for the compile-time sandbox every user script is built against.
|
|
/// Enforces a read-only sandbox by whitelisting only the
|
|
/// assemblies + namespaces the script API needs; no <c>System.IO</c>, no
|
|
/// <c>System.Net</c>, no <c>System.Diagnostics.Process</c>, no
|
|
/// <c>System.Reflection</c>. Attempts to reference those types in a script fail at
|
|
/// compile with a compiler error that points at the exact span — the operator sees
|
|
/// the rejection before publish, not at evaluation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Roslyn would otherwise pull in every type in the BCL transitively via
|
|
/// <c>mscorlib</c> / <c>System.Runtime</c> — this class overrides that with an
|
|
/// explicit minimal allow-list. The list is the same regardless of whether
|
|
/// <see cref="ScriptEvaluator{TContext, TResult}"/> uses the legacy
|
|
/// <c>CSharpScript</c> path or the collectible-<c>AssemblyLoadContext</c> path:
|
|
/// both go through <see cref="Build"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Namespaces pre-imported so scripts don't have to write <c>using</c> clauses:
|
|
/// <c>System</c>, <c>System.Math</c>-style statics are reachable via
|
|
/// <see cref="Math"/>, and <c>ZB.MOM.WW.OtOpcUa.Core.Abstractions</c> so scripts
|
|
/// can name <see cref="DataValueSnapshot"/> directly.
|
|
/// </para>
|
|
/// <para>
|
|
/// The sandbox cannot prevent a script from allocating unbounded memory or
|
|
/// spinning in a tight loop — those are budget concerns, handled by the
|
|
/// per-evaluation timeout (Stream A.4) + the test-harness (Stream F.4) that lets
|
|
/// operators preview output before publishing.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class ScriptSandbox
|
|
{
|
|
/// <summary>
|
|
/// Build the sandbox configuration used for every virtual-tag / alarm script.
|
|
/// <paramref name="contextType"/> is the concrete <see cref="ScriptContext"/>
|
|
/// subclass the script's <c>ctx</c> will be of — the compiler uses its assembly
|
|
/// to resolve <c>ctx.GetTag(...)</c> calls.
|
|
/// </summary>
|
|
/// <param name="contextType">The concrete script context type to use for compilation.</param>
|
|
/// <returns>The sandbox configuration (assembly references + default imports) to compile the script against.</returns>
|
|
public static SandboxConfig Build(Type contextType)
|
|
{
|
|
if (contextType is null) throw new ArgumentNullException(nameof(contextType));
|
|
if (!typeof(ScriptContext).IsAssignableFrom(contextType))
|
|
throw new ArgumentException(
|
|
$"Script context type must derive from {nameof(ScriptContext)}", nameof(contextType));
|
|
|
|
// OtOpcUa-owned assemblies — pinned by typeof(...) so they survive a rename.
|
|
var pinnedAssemblies = new HashSet<System.Reflection.Assembly>
|
|
{
|
|
// Core.Abstractions — DataValueSnapshot + DriverDataType so scripts can name
|
|
// the types they receive from ctx.GetTag.
|
|
typeof(DataValueSnapshot).Assembly,
|
|
// Core.Scripting.Abstractions — ScriptContext base class + Deadband static.
|
|
// Intentionally NOT Core.Scripting (which holds ScriptEvaluator/ScriptSandbox + Roslyn):
|
|
// keeping it out of the sandbox pin keeps Roslyn out of the globalsType assembly closure.
|
|
typeof(ScriptContext).Assembly,
|
|
// Serilog.ILogger — script-side logger type.
|
|
typeof(Serilog.ILogger).Assembly,
|
|
// Concrete context type's assembly — production contexts subclass
|
|
// ScriptContext in Core.VirtualTags / Core.ScriptedAlarms; tests use their
|
|
// own subclass. The globals wrapper is generic on this type so Roslyn must
|
|
// be able to resolve it during compilation.
|
|
contextType.Assembly,
|
|
};
|
|
|
|
// BCL references. We list the trusted-platform-assemblies set restricted to
|
|
// System.* and netstandard so the synthesized wrapper can reference every BCL
|
|
// type by FQN — including the ones we forbid (HttpClient, File, Process,
|
|
// Registry, etc.). Letting those types resolve at compile is intentional: the
|
|
// hard security gate is ForbiddenTypeAnalyzer in step 3 of the compile pipeline
|
|
// (the analyzer must be the sole gate because type forwarding makes any
|
|
// references-list-only restriction porous).
|
|
// The references list now serves only as scoping hygiene — out-of-band BCL
|
|
// surface (operator-authored hosting helpers, third-party packages, app code)
|
|
// is not on the list and stays unreachable.
|
|
var references = new List<MetadataReference>();
|
|
// both halves are cached. CreateFromFile reads + maps the DLL, and the BCL half is ~170
|
|
// assemblies, so doing it per compile was the dominant fixed cost of every ScriptEvaluator build.
|
|
// MetadataReference is immutable and thread-safe, so sharing instances across compilations is safe.
|
|
foreach (var asm in pinnedAssemblies)
|
|
references.Add(PinnedReferences.GetOrAdd(asm, static a => MetadataReference.CreateFromFile(a.Location)));
|
|
references.AddRange(BclReferences.Value);
|
|
|
|
var imports = new[]
|
|
{
|
|
"System",
|
|
"System.Linq",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Abstractions",
|
|
"ZB.MOM.WW.OtOpcUa.Core.Scripting",
|
|
};
|
|
|
|
return new SandboxConfig(references, imports);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A stable textual identity of the reference set <see cref="Build"/> compiles against for
|
|
/// <paramref name="contextType"/> — each pinned assembly's name and module version id, then each BCL path — without
|
|
/// opening any of them. Part of <see cref="ScriptEvaluator{TContext, TResult}.CacheFingerprint"/> (lmxopcua#780 §7).
|
|
/// </summary>
|
|
/// <param name="contextType">The concrete script context type.</param>
|
|
/// <returns>One line per reference, in a stable order.</returns>
|
|
public static IEnumerable<string> ReferenceIdentities(Type contextType)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(contextType);
|
|
var pinned = new[] { typeof(DataValueSnapshot).Assembly, typeof(ScriptContext).Assembly, typeof(Serilog.ILogger).Assembly, contextType.Assembly }
|
|
.Distinct()
|
|
.Select(static a => $"{a.FullName}|{a.ManifestModule.ModuleVersionId:N}")
|
|
.Order(StringComparer.Ordinal);
|
|
return pinned.Concat(EnumerateBclAssemblyPaths().Order(StringComparer.Ordinal)).ToArray();
|
|
}
|
|
|
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<System.Reflection.Assembly, MetadataReference> PinnedReferences = new();
|
|
|
|
// The trusted-platform set is process-constant, so the BCL reference list is context-independent.
|
|
private static readonly Lazy<IReadOnlyList<MetadataReference>> BclReferences = new(
|
|
static () => EnumerateBclAssemblyPaths().Select(static p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToArray(),
|
|
LazyThreadSafetyMode.ExecutionAndPublication);
|
|
|
|
private static IEnumerable<string> EnumerateBclAssemblyPaths()
|
|
{
|
|
// The .NET host advertises the resolved runtime-shared-framework + BCL DLL set
|
|
// via the TRUSTED_PLATFORM_ASSEMBLIES AppContext data slot. This is what the
|
|
// ALC fallback uses when resolving assemblies, so anything in here is already
|
|
// loadable by the host process. We restrict to System.* and netstandard to keep
|
|
// the script's reachable surface to the BCL — anything else (Microsoft.*,
|
|
// application code, third-party packages happening to be in the runtime store)
|
|
// would expand the analyzer's deny-list job unnecessarily.
|
|
var raw = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES");
|
|
if (string.IsNullOrEmpty(raw))
|
|
yield break;
|
|
|
|
var separator = OperatingSystem.IsWindows() ? ';' : ':';
|
|
foreach (var path in raw.Split(separator, StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
var name = System.IO.Path.GetFileName(path);
|
|
if (name.StartsWith("System.", StringComparison.Ordinal) ||
|
|
string.Equals(name, "netstandard.dll", StringComparison.Ordinal) ||
|
|
string.Equals(name, "mscorlib.dll", StringComparison.Ordinal) ||
|
|
// Microsoft.Win32.Registry isn't a System.* DLL but the analyzer's
|
|
// Microsoft.Win32 deny-list relies on the type being resolvable so it
|
|
// can identify + reject it. Add the one
|
|
// DLL we need rather than broadening to Microsoft.* (which would also
|
|
// pull in compilers, build tooling, etc.).
|
|
string.Equals(name, "Microsoft.Win32.Registry.dll", StringComparison.Ordinal))
|
|
{
|
|
yield return path;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compile-time sandbox configuration. Returned by <see cref="ScriptSandbox.Build"/>;
|
|
/// consumed by <see cref="ScriptEvaluator{TContext, TResult}"/>'s manual
|
|
/// <c>CSharpCompilation</c> path.
|
|
/// </summary>
|
|
/// <param name="References">
|
|
/// Metadata references (allow-listed assemblies) the script compilation is built
|
|
/// against. Anything not in this set is unresolved at compile, which is the sandbox's
|
|
/// first line of defense — <see cref="ForbiddenTypeAnalyzer"/> is the second.
|
|
/// </param>
|
|
/// <param name="Imports">
|
|
/// Namespaces pre-imported into the wrapper compilation as <c>using</c> directives
|
|
/// so scripts can write <c>Math.Abs</c> rather than <c>System.Math.Abs</c>.
|
|
/// </param>
|
|
public sealed record SandboxConfig(
|
|
IReadOnlyList<MetadataReference> References,
|
|
IReadOnlyList<string> Imports);
|