feat(scripts): read-only, allow-listed Files global for site scripts; Delmia test receiver parses recipe files

Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync /
ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton
ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a
System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call
throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully
qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no
reparse point on the file or any directory below the root, size cap before and during the read.
Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts
joins the default script imports on every surface so it binds by short name. Mirrored on
ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled).
Inbound (central) scripts do not get it.

Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted
read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine
SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's
type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh
group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS
selects groups (rig: AD).
This commit is contained in:
Joseph Doherty
2026-09-28 10:36:42 -04:00
parent 0af0672597
commit b3021e7c5f
31 changed files with 1157 additions and 77 deletions
+80 -8
View File
@@ -24,11 +24,17 @@ set -euo pipefail
# handshakes are ported to per-attribute SetAsync + WaitAsync.
# * Galaxy simulators SimLeft/RightMoveIn, SimLeft/RightMoveOut, SimDelmiaRecipe (ValueChange on the
# trigger flags) — they play the machine side and complete each handshake.
# SimDelmiaRecipe READS + PARSES the downloaded recipe file via the Files global
# and loads it into the TestDelmiaReceiver recipe tags (see docker/recipes/).
# - Instances SBTEST01 and SBTEST02 (SBTEST02 overrides SensorFault to MES-reportable), deployed.
# - Inbound methods SimpleAlarmStatusRequest (updated in place) and AlarmStatusRequest (legacy
# /mes/simplealarmstatus + /mes/alarmstatus shapes). Both resolve the machine through BTDB.dbo.Machine,
# so they need the "BTDB" database connection — present on vd03, absent on the docker rig.
#
# Recipe files: the site needs ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots covering the folder the
# DownloadPath points into (rig: docker/recipes bind-mounted at /app/recipes on site-a; vd03:
# E:\ApiInstall\ScadaBridge\recipes). Without it every download is rejected "recipe file not readable (Disabled)".
#
# The MES methods resolve SAPID -> BTDB.dbo.Machine.Code, so each instance needs a BTDB row
# (Code = instance name). On vd03 those were inserted 2026-09-28: SBTEST01 = SAPID 990001 (MachineID 203),
# SBTEST02 = SAPID 990002 (MachineID 204), LastUpdateBy 'scadabridge-test-seed'.
@@ -128,7 +134,7 @@ done
ensure_attr "$RX_ID" MoveInJobSequenceNumber --data-type String --description "MoveIn payload"
ensure_attr "$RX_ID" ScanStateCmd --data-type Boolean --value false --description "Unused by the simulator (prod parity)"
echo "2/7 Template TestDelmiaReceiver (10 static attributes)..."
echo "2/7 Template TestDelmiaReceiver (10 handshake + 14 recipe static attributes)..."
DR_ID=$(ensure_template TestDelmiaReceiver "In-memory stand-in for the Galaxy DelmiaReceiver object (prod attribute names, static)")
ensure_attr "$DR_ID" ReadyFlag --data-type Boolean --value true --description "Precondition; true = receiver accepts downloads"
ensure_attr "$DR_ID" RecipeDownloadFlag --data-type Boolean --value false --description "Trigger: set true after the payload is written"
@@ -137,6 +143,22 @@ ensure_attr "$DR_ID" RecipeProcessResult --data-type Boolean --value false --des
for s in DownloadPath WorkOrderNumber PartNumber JobStepNumber Username RecipeProcessResultText; do
ensure_attr "$DR_ID" "$s" --data-type String --description "Recipe-download payload / outcome text"
done
# Recipe tags the simulator loads from the downloaded file (the Galaxy object's recipe schema stand-in).
# The type here is what each file value is converted to — keep it in step with RECIPE_SCHEMA in SimDelmiaRecipe.
ensure_attr "$DR_ID" RecipeName --data-type String --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" RecipeRevision --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" CoatingType --data-type String --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" ReactorTempSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" PotTempSP --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" ArgonFlowSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" HydrogenFlowSP --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" ChlorineFlowSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" VacuumSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" TargetRuntime --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" PurgeEnabled --data-type Boolean --value false --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" OperatorNote --data-type String --description "Recipe tag (loaded from the recipe file)"
ensure_attr "$DR_ID" RecipeValueCount --data-type Int32 --value 0 --description "How many values the last accepted download loaded"
ensure_attr "$DR_ID" RecipeLoadedFile --data-type String --description "Path of the last accepted recipe file"
echo "3/7 Template TestMesMachine (standalone base) — compositions, knobs, alarm tags, alarms..."
M_ID=$(ensure_template TestMesMachine "In-memory MES/Delmia/alarm-API test machine: Left/Right MES receivers + Delmia receiver, simulated Galaxy side (docker/seed-mes-test-machine.sh)")
@@ -379,19 +401,69 @@ return null;
CS
}
read -r -d '' SIM_RECIPE_CODE <<'CS' || true
// Plays the Galaxy DelmiaReceiver: accepts unless SimRecipeReject is set, DownloadPath is blank or ends in
// ".bad", or the work order is blank. SimNoResponse = never answer.
// Plays the Galaxy DelmiaReceiver. On the rising edge of RecipeDownloadFlag it READS the downloaded recipe
// file through the Files global (script file access, design 2026-09-28 — only beneath the site's configured
// ScriptFileAccess:AllowedRoots), parses "RecipeValueName,RecipeValue" lines, converts each value to its
// recipe tag's type and loads them. All-or-nothing: any bad line rejects the download and loads nothing.
// Blank lines and '#' comments are ignored; everything after the FIRST comma is the value; a value wrapped
// in double quotes is unwrapped. SimRecipeReject / SimNoResponse knobs still apply.
System.Func<object?,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
var recv = Children["DelmiaReceiver"];
if (!IsTrue(await recv.Attributes.GetAsync("RecipeDownloadFlag"))) return null;
if (IsTrue(await Attributes.GetAsync("SimNoResponse"))) return null;
var path = (await recv.Attributes.GetAsync("DownloadPath"))?.ToString() ?? "";
var path = (await recv.Attributes.GetAsync("DownloadPath"))?.ToString()?.Trim() ?? "";
var wo = (await recv.Attributes.GetAsync("WorkOrderNumber"))?.ToString() ?? "";
var forced = IsTrue(await Attributes.GetAsync("SimRecipeReject"));
var ok = !forced && path.Length > 0 && wo.Length > 0 && !path.EndsWith(".bad", System.StringComparison.OrdinalIgnoreCase);
// RECIPE_SCHEMA — recipe tag name -> target type (keep in step with the TestDelmiaReceiver attributes).
var schema = new System.Collections.Generic.Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) {
["RecipeName"] = "String", ["RecipeRevision"] = "Int32", ["CoatingType"] = "String",
["ReactorTempSP"] = "Double", ["PotTempSP"] = "Int32", ["ArgonFlowSP"] = "Double",
["HydrogenFlowSP"] = "Int32", ["ChlorineFlowSP"] = "Double", ["VacuumSP"] = "Double",
["TargetRuntime"] = "Int32", ["PurgeEnabled"] = "Boolean", ["OperatorNote"] = "String" };
var inv = System.Globalization.CultureInfo.InvariantCulture;
var errors = new System.Collections.Generic.List<string>();
var values = new System.Collections.Generic.List<(string Name, object Value)>();
if (IsTrue(await Attributes.GetAsync("SimRecipeReject"))) errors.Add("simulated reject (SimRecipeReject)");
else if (wo.Length == 0) errors.Add("missing work order number");
else if (path.Length == 0) errors.Add("missing download path");
else {
IReadOnlyList<string>? lines = null;
try { lines = await Files.ReadLinesAsync(path); }
catch (ScriptFileAccessException ex) { errors.Add($"recipe file not readable ({ex.Failure}): {ex.Message}"); }
for (var i = 0; lines != null && i < lines.Count; i++) {
var line = lines[i].Trim();
if (line.Length == 0 || line.StartsWith("#")) continue;
var comma = line.IndexOf(',');
if (comma <= 0) { errors.Add($"line {i + 1}: expected RecipeValueName,RecipeValue"); continue; }
var name = line.Substring(0, comma).Trim();
var raw = line.Substring(comma + 1).Trim();
if (raw.Length >= 2 && raw[0] == '"' && raw[raw.Length - 1] == '"') raw = raw.Substring(1, raw.Length - 2);
var key = schema.Keys.FirstOrDefault(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase));
if (key == null) { errors.Add($"line {i + 1}: unknown recipe value '{name}'"); continue; }
if (values.Any(v => v.Name == key)) { errors.Add($"line {i + 1}: '{key}' appears more than once"); continue; }
object? typed = null;
switch (schema[key]) {
case "Int32": if (int.TryParse(raw, System.Globalization.NumberStyles.Integer, inv, out var n)) typed = n; break;
case "Double": if (double.TryParse(raw, System.Globalization.NumberStyles.Float, inv, out var d)) typed = d; break;
case "Boolean": if (bool.TryParse(raw, out var bv)) typed = bv; break;
default: typed = raw; break;
}
if (typed == null) { errors.Add($"line {i + 1}: {key} expects {schema[key]}, got '{raw}'"); continue; }
values.Add((key, typed));
}
if (lines != null && errors.Count == 0 && values.Count == 0) errors.Add("recipe file contains no values");
}
var ok = errors.Count == 0;
if (ok) {
foreach (var (name, value) in values) await recv.Attributes.SetAsync(name, value);
await recv.Attributes.SetAsync("RecipeValueCount", values.Count);
await recv.Attributes.SetAsync("RecipeLoadedFile", path);
}
var file = path.Substring(path.LastIndexOfAny(new[] { '\\', '/' }) + 1);
var text = ok ? $"Loaded {values.Count} recipe values from {file} for {wo}"
: "Recipe rejected: " + string.Join("; ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} more)" : "");
if (!ok) Log.Warn(text);
await recv.Attributes.SetAsync("RecipeProcessResult", ok);
await recv.Attributes.SetAsync("RecipeProcessResultText", ok ? "Simulated download accepted for " + wo
: forced ? "Simulated reject (SimRecipeReject)" : "Simulated reject: bad path or missing work order");
await recv.Attributes.SetAsync("RecipeProcessResultText", text);
await recv.Attributes.SetAsync("RecipeDownloadFlag", false);
await recv.Attributes.SetAsync("RecipeProcessedFlag", true);
return null;