Compare commits
3 Commits
0af0672597
...
2aede140e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2aede140e2 | |||
| 5cc09b08ef | |||
| b3021e7c5f |
@@ -231,7 +231,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
|
||||
### Akka.NET Conventions
|
||||
- Tell for hot-path internal communication; Ask reserved for system boundaries.
|
||||
- Cross-cluster communication is gRPC (per-site PSK-authenticated): site→central `CentralControlService`, central→site `SiteCommandService`, plus the `SiteStreamService` data stream. ClusterClient/ClusterClientReceptionist were removed in the migration's Phase 4 — service discovery is by dialling configured endpoints, not the receptionist. (Akka.Cluster.Tools remains for ClusterSingleton.)
|
||||
- Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network). The trust boundary is centralized in the Script Analysis component (#25) — `ScriptTrustPolicy` is the single source of truth; all four call sites (Template Engine, Site Runtime, Inbound API, Central UI) delegate to `ScriptTrustValidator`. The design-time deploy gate in Template Engine is authoritative (real semantic compile), not advisory. Every Roslyn script-compile surface must also attach the shared `CachingScriptMetadataResolver.Instance` (ScriptAnalysis) via `ScriptOptions.WithMetadataResolver` — the four today are Site Runtime `ScriptCompilationService`, Inbound API `InboundScriptExecutor`, Central UI `ScriptAnalysisService`, and ScriptAnalysis `RoslynScriptCompiler`; a new surface that omits it silently reintroduces a per-compile native metadata leak (see Component-ScriptAnalysis.md).
|
||||
- Script trust model: forbidden APIs (System.IO, Process, Threading, Reflection, raw network). **One host-mediated exception (2026-09-28):** site scripts get a read-only `Files` global (`ReadTextAsync`/`ReadLinesAsync`/`ExistsAsync`) that reads only beneath `ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots` — **off by default** (empty ⇒ every call throws `ScriptFileAccessException(Disabled)`), canonical-path directory-boundary match, no symlinks/junctions below a root, `MaxFileBytes` cap (1 MiB). The script still never names a `System.IO` type; errors are the Commons `ScriptFileAccessException`, and `Commons.Types.Scripts` is now a default script import on every surface. Not on inbound (central) scripts; the Test Run sandbox binds it but refuses. Design: `docs/plans/2026-09-28-script-file-access-design.md`. The trust boundary is centralized in the Script Analysis component (#25) — `ScriptTrustPolicy` is the single source of truth; all four call sites (Template Engine, Site Runtime, Inbound API, Central UI) delegate to `ScriptTrustValidator`. The design-time deploy gate in Template Engine is authoritative (real semantic compile), not advisory. Every Roslyn script-compile surface must also attach the shared `CachingScriptMetadataResolver.Instance` (ScriptAnalysis) via `ScriptOptions.WithMetadataResolver` — the four today are Site Runtime `ScriptCompilationService`, Inbound API `InboundScriptExecutor`, Central UI `ScriptAnalysisService`, and ScriptAnalysis `RoslynScriptCompiler`; a new surface that omits it silently reintroduces a per-compile native metadata leak (see Component-ScriptAnalysis.md).
|
||||
- Application-level correlation IDs on all request/response messages.
|
||||
|
||||
## Tool Usage
|
||||
|
||||
@@ -238,6 +238,7 @@ services:
|
||||
- "9023:8083" # gRPC streaming
|
||||
volumes:
|
||||
- ./site-a-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||
- ./recipes:/app/recipes:ro # script file access (Files global) — sample Delmia recipes
|
||||
- site-a-node-a-data:/app/data
|
||||
# Same named volume, mounted a second time OUTSIDE the content root (/app).
|
||||
# Secrets 0.6.2 refuses a store path under the content root; this mount is
|
||||
@@ -274,6 +275,7 @@ services:
|
||||
- "9024:8083" # gRPC streaming
|
||||
volumes:
|
||||
- ./site-a-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||
- ./recipes:/app/recipes:ro # script file access (Files global) — sample Delmia recipes
|
||||
- site-a-node-b-data:/app/data
|
||||
# Same named volume, mounted a second time OUTSIDE the content root (/app).
|
||||
# Secrets 0.6.2 refuses a store path under the content root; this mount is
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Delmia recipe download - sample for the SBTEST test machines (docker/seed-mes-test-machine.sh)
|
||||
# One value per line: RecipeValueName,RecipeValue
|
||||
# The value may be an int, a double (invariant culture, '.' decimal), a bool (true/false) or text.
|
||||
# Blank lines and lines starting with '#' are ignored. Everything after the FIRST comma is the value.
|
||||
RecipeName,Aluminide Coat R100
|
||||
RecipeRevision,7
|
||||
CoatingType,Aluminide
|
||||
ReactorTempSP,1050.5
|
||||
PotTempSP,980
|
||||
ArgonFlowSP,40.25
|
||||
HydrogenFlowSP,25
|
||||
ChlorineFlowSP,12.5
|
||||
VacuumSP,0.5
|
||||
TargetRuntime,480
|
||||
PurgeEnabled,true
|
||||
OperatorNote,Load parts cold, verify fixture ID
|
||||
@@ -0,0 +1,13 @@
|
||||
# Second valid recipe - different values/types so a re-download visibly changes every tag
|
||||
RecipeName,Chromide Coat R200
|
||||
RecipeRevision,12
|
||||
CoatingType,Chromide
|
||||
ReactorTempSP,1082
|
||||
PotTempSP,1001
|
||||
ArgonFlowSP,38
|
||||
HydrogenFlowSP,27
|
||||
ChlorineFlowSP,11.75
|
||||
VacuumSP,0.45
|
||||
TargetRuntime,540
|
||||
PurgeEnabled,false
|
||||
OperatorNote,"Quoted text, commas kept"
|
||||
@@ -0,0 +1,6 @@
|
||||
# Deliberately INVALID - the receiver must reject it and load nothing:
|
||||
# line 4: no comma; line 5: unknown RecipeValueName; line 6: PotTempSP must be an int
|
||||
RecipeName,Broken Recipe
|
||||
ReactorTempSP 1050
|
||||
MysterySetting,42
|
||||
PotTempSP,980.5
|
||||
@@ -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;
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"GrpcPort": 8083,
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
// Script file access (design 2026-09-28): the Files global reads only beneath these
|
||||
// roots. ./recipes is bind-mounted read-only at /app/recipes on site-a (both nodes, so
|
||||
// a failover keeps the same view); site-b/site-c stay unconfigured = Files disabled.
|
||||
"SiteRuntime": {
|
||||
"ScriptFileAccess": {
|
||||
"AllowedRoots": [ "/app/recipes" ]
|
||||
}
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
"akka.tcp://scadabridge@scadabridge-site-a-a:8082",
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"GrpcPort": 8083,
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
// Script file access (design 2026-09-28): the Files global reads only beneath these
|
||||
// roots. ./recipes is bind-mounted read-only at /app/recipes on site-a (both nodes, so
|
||||
// a failover keeps the same view); site-b/site-c stay unconfigured = Files disabled.
|
||||
"SiteRuntime": {
|
||||
"ScriptFileAccess": {
|
||||
"AllowedRoots": [ "/app/recipes" ]
|
||||
}
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
"akka.tcp://scadabridge@scadabridge-site-a-b:8082",
|
||||
|
||||
@@ -7,7 +7,13 @@ set -euo pipefail
|
||||
# SBTEST01 / SBTEST02 test machines, and trips/clears the computed alarms by writing their static source
|
||||
# attributes. Needs the BTDB rows SBTEST01=990001 / SBTEST02=990002 (vd03 only).
|
||||
#
|
||||
# SCADABRIDGE_URL=http://wonder-app-vd03.zmr.zimmer.com:8085 bash docker/verify-mes-test-machine.sh
|
||||
# SCADABRIDGE_URL=http://wonder-app-vd03.zmr.zimmer.com:8085 \
|
||||
# RECIPE_ROOT='E:\ApiInstall\ScadaBridge\recipes' RECIPE_OUTSIDE='E:\ApiInstall\ScadaBridge\app\appsettings.Site.json' \
|
||||
# bash docker/verify-mes-test-machine.sh
|
||||
#
|
||||
# Group A reads the docker/recipes/ sample files through the Files global: RECIPE_ROOT is where they live
|
||||
# ON THE SITE NODE (default /app/recipes, the rig's site-a mount) and must be inside the site's
|
||||
# ScriptFileAccess:AllowedRoots; RECIPE_OUTSIDE is an existing file OUTSIDE the roots (proves the allow-list).
|
||||
#
|
||||
# Leaves both machines as it found them: alarm sources reset to their defaults, no batch moved in.
|
||||
|
||||
@@ -16,6 +22,8 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
export SB_CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
|
||||
export SB_URL="${SCADABRIDGE_URL:-http://localhost:9000}"
|
||||
export SB_USER="${SCADABRIDGE_USERNAME:-multi-role}" SB_PASS="${SCADABRIDGE_PASSWORD:-password}"
|
||||
export VERIFY_GROUPS="${VERIFY_GROUPS:-ABCD}" # the rig has no BTDB: VERIFY_GROUPS=AD there
|
||||
export RECIPE_ROOT="${RECIPE_ROOT:-/app/recipes}" RECIPE_OUTSIDE="${RECIPE_OUTSIDE:-/app/appsettings.Site.json}"
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, os, subprocess, sys
|
||||
@@ -57,68 +65,108 @@ def movein(sap, wo=WO): return call("MesMoveIn", {"SAPID": sap, "ContainerNumb
|
||||
def moveout(sap, wo=WO): return call("MesMoveOut", {"SAPID": sap, "ContainerNumber": "CN-1", "NumberWorkOrders": len(wo), "WorkOrders": wo, "OperatorName": "sb-test"})
|
||||
def names(r): return sorted(a["Name"] for a in r.get("Alarms") or [])
|
||||
|
||||
print("A. DelmiaRecipeDownload (routes by MachineCode)")
|
||||
r = call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": r"\\share\recipes\r1.xml", "WorkOrderNumber": "WO-1001", "PartNumber": "PN-2001", "JobStepNumber": "20", "Username": "sb-test"})
|
||||
check("A1 accepted", r.get("Result") is True and "accepted" in r.get("ResultText", ""), r)
|
||||
r = call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": r"\\share\recipes\r1.bad", "WorkOrderNumber": "WO-1001"})
|
||||
check("A2 .bad path rejected", r.get("Result") is False and "reject" in r.get("ResultText", "").lower(), r)
|
||||
def group_A():
|
||||
print("A. DelmiaRecipeDownload -> recipe file read + parsed + loaded (Files global)")
|
||||
ROOT, OUTSIDE = os.environ["RECIPE_ROOT"], os.environ["RECIPE_OUTSIDE"]
|
||||
SEP = "\\" if "\\" in ROOT else "/"
|
||||
def recipe(name): return ROOT.rstrip("/\\") + SEP + name
|
||||
def download(path, wo="WO-1001"):
|
||||
return call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": path, "WorkOrderNumber": wo,
|
||||
"PartNumber": "PN-2001", "JobStepNumber": "20", "Username": "sb-test"})
|
||||
def recv_tags():
|
||||
snap = cli("debug", "snapshot", "--id", str(instances["SBTEST01"]))
|
||||
return {a["attributeName"].split(".", 1)[1]: a["value"] for a in snap["attributeValues"]
|
||||
if a["attributeName"].startswith("DelmiaReceiver.")}
|
||||
|
||||
print("B. MesMoveIn / MesMoveOut (SAPID -> BTDB -> SBTEST01)")
|
||||
r = movein("990001_A")
|
||||
batch = r.get("BatchID", 0)
|
||||
check("B1 MoveIn left succeeds with a batch id", r.get("WasSuccessful") is True and batch >= 5000, r)
|
||||
r = moveout("990001_A")
|
||||
check("B2 MoveOut left returns the same batch", r.get("WasSuccessful") is True and r.get("BatchID") == batch, r)
|
||||
check("B3 MoveOut Run returns 16 cycle rows", len(r.get("Data") or []) == 16 and r["Data"][0].get("ValueName") == "TargetRuntime", r.get("Data"))
|
||||
r = moveout("990001_A")
|
||||
check("B4 second MoveOut rejected (nothing moved in)", r.get("WasSuccessful") is False and "No batch" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001_A", [{"WorkOrderNumber": "WO-FAIL-1", "PartNumber": "PN-2001"}])
|
||||
check("B5 FAIL work order rejected", r.get("WasSuccessful") is False and "FAIL" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001")
|
||||
check("B6 missing side suffix", r.get("WasSuccessful") is False and "suffix" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001_B_LT"); lt = r.get("BatchID", 0)
|
||||
check("B7 leak-test MoveIn right", r.get("WasSuccessful") is True and lt > 0, r)
|
||||
r = moveout("990001_B_LT")
|
||||
check("B8 leak-test MoveOut returns 6 LT rows", r.get("WasSuccessful") is True and len(r.get("Data") or []) == 6 and r["Data"][0]["ValueName"] == "LTType", r)
|
||||
r = movein("999999_A")
|
||||
check("B9 unknown SAPID", r.get("WasSuccessful") is False and "No machine found" in r.get("ErrorText", ""), r)
|
||||
r = download(recipe("SBTEST-R100.txt"))
|
||||
check("A1 R100 accepted, 12 values", r.get("Result") is True and r.get("ResultText") == "Loaded 12 recipe values from SBTEST-R100.txt for WO-1001", r)
|
||||
t = recv_tags()
|
||||
check("A2 R100 values loaded with their types",
|
||||
t.get("RecipeName") == "Aluminide Coat R100" and t.get("RecipeRevision") == "7" and t.get("ReactorTempSP") == "1050.5"
|
||||
and t.get("PotTempSP") == "980" and t.get("PurgeEnabled", "").lower() == "true" and t.get("RecipeValueCount") == "12"
|
||||
and t.get("OperatorNote") == "Load parts cold, verify fixture ID", t)
|
||||
r = download(recipe("SBTEST-R200.txt"))
|
||||
t = recv_tags()
|
||||
check("A3 R200 re-download replaces every value (quoted text unwrapped)", r.get("Result") is True
|
||||
and t.get("CoatingType") == "Chromide" and t.get("ChlorineFlowSP") == "11.75" and t.get("PurgeEnabled", "").lower() == "false"
|
||||
and t.get("OperatorNote") == "Quoted text, commas kept", (r, t))
|
||||
r = download(recipe("SBTEST-R900-invalid.txt"))
|
||||
txt = r.get("ResultText", "")
|
||||
check("A4 invalid recipe rejected with line numbers", r.get("Result") is False and "line 4" in txt and "line 5: unknown recipe value 'MysterySetting'" in txt
|
||||
and "line 6: PotTempSP expects Int32" in txt, r)
|
||||
check("A5 rejected download loaded nothing (still R200)", recv_tags().get("RecipeName") == "Chromide Coat R200")
|
||||
r = download(recipe("SBTEST-NOPE.txt"))
|
||||
check("A6 missing file -> NotFound", r.get("Result") is False and "(NotFound)" in r.get("ResultText", ""), r)
|
||||
r = download(OUTSIDE)
|
||||
check("A7 file outside AllowedRoots -> refused", r.get("Result") is False and "(OutsideAllowedRoots)" in r.get("ResultText", ""), r)
|
||||
r = download(recipe("SBTEST-R100.txt"), wo="")
|
||||
check("A8 blank work order rejected", r.get("Result") is False and "work order" in r.get("ResultText", ""), r)
|
||||
|
||||
print("C. Alarm APIs")
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
|
||||
check("C1 no alarms active -> success, empty", r.get("WasSuccessful") is True and names(r) == [], r)
|
||||
for inst in ("SBTEST01", "SBTEST02"):
|
||||
write(inst, "VacuumPressure", 12); write(inst, "DoorOpen", "true"); write(inst, "SensorStatus", -1)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
|
||||
check("C2 SBTEST01 simple = flagged only (HighVacuumFault)", names(r) == ["HighVacuumFault"], names(r))
|
||||
a = (r.get("Alarms") or [{}])[0]
|
||||
check("C3 row shape", a.get("HierarchicalName") == "SBTEST01.HighVacuumFault" and a.get("IsFlaggedForMES") is True
|
||||
and a.get("Severity") == 800 and a.get("StatusCode") == "Triggered.Acked" and a.get("TriggeredDT"), a)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990002"})
|
||||
check("C4 SBTEST02 simple includes the overridden SensorFault", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}})
|
||||
check("C5 full, no filter = all 3 active", names(r) == ["DoorOpenAlarm", "HighVacuumFault", "SensorFault"], names(r))
|
||||
flags = {x["Name"]: x["IsFlaggedForMES"] for x in r.get("Alarms") or []}
|
||||
check("C6 IsFlaggedForMES per alarm", flags == {"DoorOpenAlarm": False, "HighVacuumFault": True, "SensorFault": False}, flags)
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "990001_A"}, "AlarmFilter": {"MinSeverity": 500}})
|
||||
check("C7 SAPID w/ suffix + MinSeverity 500", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"ZTag": "SBTEST01"}, "AlarmFilter": {"NameFilter": "door"}})
|
||||
check("C8 ZTag + NameFilter", names(r) == ["DoorOpenAlarm"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"MachineID": 204}, "AlarmFilter": {"FlaggedOnly": True, "MaxSeverity": 750}})
|
||||
check("C9 MachineID + FlaggedOnly + MaxSeverity", names(r) == ["SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}, "AlarmFilter": {"IncludeAcked": False}})
|
||||
check("C10 IncludeAcked=false drops computed (always-acked) alarms", r.get("WasSuccessful") is True and names(r) == [], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "123"}})
|
||||
check("C11 unknown SAPID error text", r.get("WasSuccessful") is False and "Failed to find machine with SAPID '123'" in (r.get("ErrorText") or ""), r)
|
||||
r = call("AlarmStatusRequest", {})
|
||||
check("C12 empty filter error", r.get("WasSuccessful") is False and "machine filter" in (r.get("ErrorText") or ""), r)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "103559"})
|
||||
check("C13 machine without AlarmStatus script -> not supported", r.get("WasSuccessful") is False and "not supported" in (r.get("ErrorText") or ""), r)
|
||||
def group_B():
|
||||
print("B. MesMoveIn / MesMoveOut (SAPID -> BTDB -> SBTEST01)")
|
||||
r = movein("990001_A")
|
||||
batch = r.get("BatchID", 0)
|
||||
check("B1 MoveIn left succeeds with a batch id", r.get("WasSuccessful") is True and batch >= 5000, r)
|
||||
r = moveout("990001_A")
|
||||
check("B2 MoveOut left returns the same batch", r.get("WasSuccessful") is True and r.get("BatchID") == batch, r)
|
||||
check("B3 MoveOut Run returns 16 cycle rows", len(r.get("Data") or []) == 16 and r["Data"][0].get("ValueName") == "TargetRuntime", r.get("Data"))
|
||||
r = moveout("990001_A")
|
||||
check("B4 second MoveOut rejected (nothing moved in)", r.get("WasSuccessful") is False and "No batch" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001_A", [{"WorkOrderNumber": "WO-FAIL-1", "PartNumber": "PN-2001"}])
|
||||
check("B5 FAIL work order rejected", r.get("WasSuccessful") is False and "FAIL" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001")
|
||||
check("B6 missing side suffix", r.get("WasSuccessful") is False and "suffix" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001_B_LT"); lt = r.get("BatchID", 0)
|
||||
check("B7 leak-test MoveIn right", r.get("WasSuccessful") is True and lt > 0, r)
|
||||
r = moveout("990001_B_LT")
|
||||
check("B8 leak-test MoveOut returns 6 LT rows", r.get("WasSuccessful") is True and len(r.get("Data") or []) == 6 and r["Data"][0]["ValueName"] == "LTType", r)
|
||||
r = movein("999999_A")
|
||||
check("B9 unknown SAPID", r.get("WasSuccessful") is False and "No machine found" in r.get("ErrorText", ""), r)
|
||||
|
||||
print("D. Reset alarm sources")
|
||||
for inst in ("SBTEST01", "SBTEST02"):
|
||||
write(inst, "VacuumPressure", 0.5); write(inst, "DoorOpen", "false"); write(inst, "SensorStatus", 0)
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST02"}})
|
||||
check("D1 cleared", r.get("WasSuccessful") is True and names(r) == [], names(r))
|
||||
def group_C():
|
||||
print("C. Alarm APIs")
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
|
||||
check("C1 no alarms active -> success, empty", r.get("WasSuccessful") is True and names(r) == [], r)
|
||||
for inst in ("SBTEST01", "SBTEST02"):
|
||||
write(inst, "VacuumPressure", 12); write(inst, "DoorOpen", "true"); write(inst, "SensorStatus", -1)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
|
||||
check("C2 SBTEST01 simple = flagged only (HighVacuumFault)", names(r) == ["HighVacuumFault"], names(r))
|
||||
a = (r.get("Alarms") or [{}])[0]
|
||||
check("C3 row shape", a.get("HierarchicalName") == "SBTEST01.HighVacuumFault" and a.get("IsFlaggedForMES") is True
|
||||
and a.get("Severity") == 800 and a.get("StatusCode") == "Triggered.Acked" and a.get("TriggeredDT"), a)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990002"})
|
||||
check("C4 SBTEST02 simple includes the overridden SensorFault", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}})
|
||||
check("C5 full, no filter = all 3 active", names(r) == ["DoorOpenAlarm", "HighVacuumFault", "SensorFault"], names(r))
|
||||
flags = {x["Name"]: x["IsFlaggedForMES"] for x in r.get("Alarms") or []}
|
||||
check("C6 IsFlaggedForMES per alarm", flags == {"DoorOpenAlarm": False, "HighVacuumFault": True, "SensorFault": False}, flags)
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "990001_A"}, "AlarmFilter": {"MinSeverity": 500}})
|
||||
check("C7 SAPID w/ suffix + MinSeverity 500", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"ZTag": "SBTEST01"}, "AlarmFilter": {"NameFilter": "door"}})
|
||||
check("C8 ZTag + NameFilter", names(r) == ["DoorOpenAlarm"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"MachineID": 204}, "AlarmFilter": {"FlaggedOnly": True, "MaxSeverity": 750}})
|
||||
check("C9 MachineID + FlaggedOnly + MaxSeverity", names(r) == ["SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}, "AlarmFilter": {"IncludeAcked": False}})
|
||||
check("C10 IncludeAcked=false drops computed (always-acked) alarms", r.get("WasSuccessful") is True and names(r) == [], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "123"}})
|
||||
check("C11 unknown SAPID error text", r.get("WasSuccessful") is False and "Failed to find machine with SAPID '123'" in (r.get("ErrorText") or ""), r)
|
||||
r = call("AlarmStatusRequest", {})
|
||||
check("C12 empty filter error", r.get("WasSuccessful") is False and "machine filter" in (r.get("ErrorText") or ""), r)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "103559"})
|
||||
check("C13 machine without AlarmStatus script -> not supported", r.get("WasSuccessful") is False and "not supported" in (r.get("ErrorText") or ""), r)
|
||||
|
||||
def group_D():
|
||||
print("D. Reset alarm sources")
|
||||
for inst in ("SBTEST01", "SBTEST02"):
|
||||
write(inst, "VacuumPressure", 0.5); write(inst, "DoorOpen", "false"); write(inst, "SensorStatus", 0)
|
||||
if "C" in os.environ["VERIFY_GROUPS"].upper(): # the check reads through BTDB, like group C
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST02"}})
|
||||
check("D1 cleared", r.get("WasSuccessful") is True and names(r) == [], names(r))
|
||||
|
||||
GROUPS = os.environ["VERIFY_GROUPS"].upper()
|
||||
for g in "ABCD":
|
||||
if g in GROUPS:
|
||||
globals()["group_" + g]()
|
||||
|
||||
print(f"\n{'PASS' if failed == 0 else 'FAIL'} {passed}/{passed + failed}")
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
@@ -124,3 +124,52 @@ method 9 (`return new { WasSuccessful = true, Alarms = Array.Empty<Object>() };`
|
||||
Staging left on the box under `E:\psrem-stage\fe89363b\` (zip, unpacked tree, migration SQL, prep + swap
|
||||
scripts, `.reg` exports). The `_appsettings-keep-20260928T095846` copy and `*.bak.20260928T095846`
|
||||
directories can go once the build has soaked.
|
||||
|
||||
---
|
||||
|
||||
# Second window, same day — `fe89363b` → `b3021e7c` (script file access + recipe-parsing receiver)
|
||||
|
||||
**Build:** `main` @ `b3021e7c` (ProductVersion `1.0.0+b3021e7c5ff68ba20abc510f644045c7dbbccd86`; zip
|
||||
125 585 217 B, SHA-256 `5b7d7c68…2d66df`). **Delta:** the read-only, allow-listed `Files` script global
|
||||
(design `docs/plans/2026-09-28-script-file-access-design.md`) and the TestMesMachine Delmia simulator now
|
||||
reading + parsing the downloaded recipe file. **No migrations** (57 before and after — asserted, nothing applied).
|
||||
|
||||
**Config (the only change):** `appsettings.Site.json` gained
|
||||
`ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots = ["E:\\ApiInstall\\ScadaBridge\\recipes"]` (+ a
|
||||
`_comment_SiteRuntime`), edited on the Mac and pushed over SMB **before** the window so the swap script's
|
||||
keep/restore carried it through (new SHA-256 `3E7AD9A4…`); original saved as
|
||||
`E:\psrem-stage\b3021e7c\appsettings.Site.json.pre-b3021e7c`. The three sample recipes
|
||||
(`docker/recipes/SBTEST-R100.txt`, `SBTEST-R200.txt`, `SBTEST-R900-invalid.txt`) were pushed to
|
||||
`E:\ApiInstall\ScadaBridge\recipes\`. Central's appsettings unchanged.
|
||||
|
||||
**Window:** prep 10:41:47 (COPY_ONLY backup `ScadaBridge_pre-b3021e7c_20260928T104147.bak`, 140 064 768 B,
|
||||
VERIFYONLY valid); swap 10:42:08 → site ready 10:42:57 (**~50 s**); the swap script asserted the recipes root
|
||||
(3 files) and logged `site AllowedRoots = E:\ApiInstall\ScadaBridge\recipes`; boot validation accepted it.
|
||||
Backups: `app.bak.20260928T104207` / `cli.bak.20260928T104207`.
|
||||
|
||||
**Re-seed + gate:** `seed-mes-test-machine.sh` added the 14 recipe attributes to `TestDelmiaReceiver` and
|
||||
updated all nine scripts; `verify-mes-test-machine.sh` (with `RECIPE_ROOT='E:\ApiInstall\ScadaBridge\recipes'`,
|
||||
`RECIPE_OUTSIDE='E:\ApiInstall\ScadaBridge\app\appsettings.Site.json'`) **PASS 31/31** — group A now proves
|
||||
accept (12 typed values loaded), re-download, invalid-line rejection with line numbers and nothing loaded,
|
||||
`NotFound`, and a file **outside** the root refused (`OutsideAllowedRoots`). Real HTTP with a key:
|
||||
|
||||
```
|
||||
DelmiaRecipeDownload R100 -> {"Result":true,"ResultText":"Loaded 12 recipe values from SBTEST-R100.txt for WO-1001"}
|
||||
DelmiaRecipeDownload R900 -> {"Result":false,"ResultText":"Recipe rejected: line 4: expected RecipeValueName,RecipeValue;
|
||||
line 5: unknown recipe value 'MysterySetting'; line 6: PotTempSP expects Int32, got '980.5'"}
|
||||
```
|
||||
|
||||
**Recipe file format** (`RecipeValueName,RecipeValue` per line; value = int / double (invariant `.`) / bool /
|
||||
text; `#` comments and blank lines ignored; everything after the first comma is the value; surrounding double
|
||||
quotes unwrapped). Loaded all-or-nothing into `TestDelmiaReceiver`'s typed recipe tags (`RecipeName`,
|
||||
`RecipeRevision`, `CoatingType`, `ReactorTempSP`, `PotTempSP`, `ArgonFlowSP`, `HydrogenFlowSP`,
|
||||
`ChlorineFlowSP`, `VacuumSP`, `TargetRuntime`, `PurgeEnabled`, `OperatorNote`) plus `RecipeValueCount` /
|
||||
`RecipeLoadedFile`. To test your own file: drop it in `E:\ApiInstall\ScadaBridge\recipes\` (SMB) and call
|
||||
`DelmiaRecipeDownload` with that full path.
|
||||
|
||||
**Rollback:** stop Site + Central, rename `app` → `app.failed`, `app.bak.20260928T104207` → `app` (same for
|
||||
`cli`), restore `appsettings.Site.json.pre-b3021e7c` if the key must go (the old build ignores it), start
|
||||
Central then Site. No DB restore needed (no schema change).
|
||||
|
||||
**Rig:** `docker/deploy.sh` from `b3021e7c` (site-a both nodes mount `docker/recipes` read-only at
|
||||
`/app/recipes`, `AllowedRoots ["/app/recipes"]`); seeded on site-a; `VERIFY_GROUPS=AD` **PASS 8/8**.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Deployment record — wonder-app-vq03 + wonder-app-vq04 (QA) — 2026-09-28 — `2b9a7c3c` → `b3021e7c`
|
||||
|
||||
Point-in-time record of a ScadaBridge binary + schema upgrade of the **QA two-host topology**
|
||||
(central pair `central-a` = vq03 `10.220.158.2` / `central-b` = vq04 `10.220.158.3` on :8085, site-a
|
||||
pair `node-a`/`node-b` on the same boxes, gRPC :8083, health :8084, LocalDb replication ON; services
|
||||
`ScadaBridge-Central` / `ScadaBridge-Site` out of `E:\ApiInstall\ScadaBridge`; shared ConfigDb
|
||||
`ScadaBridge` on `wonder-sql-vq03`). Same package as DEV (`2026-09-28-wonder-app-vd03-fe89363b.md`,
|
||||
second window) — read that for the build delta. Performed from the Mac with the `winrm` CLI
|
||||
(deployment-only scope: zip over SMB, one prep script per box, seven window calls), after the user
|
||||
confirmed boxes + build. Closes register row 126.
|
||||
|
||||
## 1. Binary + schema upgrade
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Build deployed | `main` @ **`b3021e7c`** (from `2b9a7c3c`, 2026-09-10) — all four nodes. Host + CLI ProductVersion `1.0.0+b3021e7c5ff68ba20abc510f644045c7dbbccd86` on both boxes. |
|
||||
| Headline change | Computed-alarm `IsMesReportable` flag, site artifact staleness + *Pending updates* indicator, Gitea #36 reset-aware handshake, and the read-only allow-listed script `Files` global. |
|
||||
| Artifact | `E:\psrem-stage\b3021e7c\scadabridge.zip` on both boxes, 125 585 217 B, SHA-256 `5B7D7C68…2D66DF` (re-verified on box; identical to vd03's); unpacked to `…\unpacked` (app 1029 files, cli 223). |
|
||||
| EF migrations applied | 2 (55 → 57) via `sb-mig-55to57.sql` (from→to, NOT idempotent, identical to vd03's) with `sqlcmd -C -b -I` on vq03, after asserting 55 / `20260909203046_…`: `20260911135501_AddAlarmMesReportable`, `20260914173147_AddSiteArtifactRevision`. Both additive `AddColumn`s — no data upgrade; a binary-only rollback to `2b9a7c3c` would tolerate the extra columns, but the DB restore is the clean path. |
|
||||
| New required config | **None.** The three `appsettings*.json` per box were kept and hash-matched after the swap (vq03 `F94000B1`/`D1320FA8`/`4CBAF66C`; vq04 `F94000B1`/`2FD3EC4C`/`D0C1AF05` — vq03's Central file changed since 2026-09-10, user-held, restored as-is). Service registry env untouched (`reg export` of both keys to `E:\psrem-stage\b3021e7c\_envbak-*.reg` on each box). **Script file access left OFF** on QA (no `SiteRuntime:ScriptFileAccess:AllowedRoots`). |
|
||||
| DB backup | `F:\MSSQL\BACKUP\ScadaBridge_pre-b3021e7c_20260928T110651.bak` on wonder-sql-vq03 (COPY_ONLY, CHECKSUM, COMPRESSION; `RESTORE VERIFYONLY … WITH CHECKSUM` valid), taken 11:06:55 with everything running. |
|
||||
| Binary backup | `app.bak.20260928T110907` / `cli.bak.…` on vq03, `app.bak.20260928T110841` / `cli.bak.…` on vq04 (old `2b9a7c3c` binaries); `_appsettings-keep-<ts>` alongside. |
|
||||
| Notes on box | `E:\ApiInstall\ScadaBridge\_DEPLOY-NOTES-b3021e7c.txt` on each box. |
|
||||
|
||||
### Sequence (maintenance window)
|
||||
|
||||
| Step | Local time |
|
||||
|---|---|
|
||||
| Stop Site vq04 (**window opens**) | 11:07:34 → 11:08:04 |
|
||||
| Stop Site vq03 | 11:08:10 → 11:08:21 |
|
||||
| Stop Central vq04 + swap vq04 | 11:08:28 → 11:08:47 |
|
||||
| Stop Central vq03, migrate 55 → 57, swap vq03 | 11:08:54 → 11:09:13 |
|
||||
| Start Central vq03 (was active) → ready | 11:09:13 → 11:09:27 |
|
||||
| Start Central vq04 → ready | 11:09:35 → 11:09:43 |
|
||||
| Start Site vq03 → ready | 11:09:52 → 11:10:00 |
|
||||
| Start Site vq04 → ready (**window closes**) | 11:10:08 → 11:10:12 |
|
||||
|
||||
**Downtime (first stop → last site ready): 2 min 38 s.** Pairs stopped and started together.
|
||||
|
||||
### Verification (from the Mac, HTTP + CLI)
|
||||
|
||||
- `:8085 /health/ready` 200 on both centrals — database Healthy, akka-cluster memberCount 2 /
|
||||
unreachable 0, required-singletons Healthy; `/health/active` 200 vq03 / 503 vq04 (same active
|
||||
node as before). Ready also proves the production-mode pending-migration check passed (57/57).
|
||||
- `:8084 /health/ready` 200 on both sites — memberCount 2, `localdb` `replicationConnected:true`;
|
||||
`/health/active` 200 vq03 / 503 vq04.
|
||||
- `POST /management` no-auth → 401 (unchanged).
|
||||
- Snapshots: Z28061 / Z28062 244 attrs, 244 Good before (11:07) and after (11:11).
|
||||
- **Deploy Artifacts** (last step, per row 126): deployment `999dee70…`, site-a success;
|
||||
`Sites.ArtifactRevisionHash` = `sha256:d79ba1a3…`, `ArtifactsDeployedAt` 15:11:20Z — Sites page
|
||||
reads *Current*, not *Never deployed*.
|
||||
|
||||
## 2. Inbound method copied from DEV
|
||||
|
||||
`SimpleAlarmStatusRequest` (QA id 9) was a stub returning `WasSuccessful=true, Alarms=[]`. Replaced
|
||||
via `api-method update` with vd03's definition (id 9): script (BTDB `dbo.Machine` SAPID → code,
|
||||
trailing A/B stripped → `Route.To(instance).Call("AlarmStatus", FlaggedOnly/IncludeAcked)`),
|
||||
return definition (now `required: [WasSuccessful, Alarms]`), parameters `{SAPID}`, timeout 30 s —
|
||||
field-for-field identical to vd03 afterwards. Key-less smoke via `flow-debug run-method`:
|
||||
`SAPID=000000` → `Failed to find machine with SAPID '000000'` (BTDB query ran, 113 ms);
|
||||
`SAPID=""` → same shape. **No QA instance carries an `AlarmStatus` script yet**, so a real SAPID
|
||||
answers `Alarm status is not supported on machine '<code>'` until the MES templates on QA gain it.
|
||||
|
||||
## 3. Rollback
|
||||
|
||||
1. Stop Site vq04 → vq03, Central vq04 → vq03; confirm no `ZB.MOM.WW.ScadaBridge.Host` process.
|
||||
2. From vq03: `RESTORE DATABASE [ScadaBridge] FROM DISK = 'F:\MSSQL\BACKUP\ScadaBridge_pre-b3021e7c_20260928T110651.bak' WITH REPLACE`
|
||||
(reverts the two columns and the method-9 update).
|
||||
3. Each box: `app` → `app.failed`, `app.bak.<ts>` → `app`; same for `cli`.
|
||||
4. Start Central vq03 → vq04, then Site vq03 → vq04.
|
||||
|
||||
## 4. Outstanding
|
||||
|
||||
- `ApiKeyStore:Provider` still unset on the QA pair (per-node SQLite inbound keys) — unchanged.
|
||||
- Housekeeping after soak: the 2026-09-10 and this deploy's `app.bak.*`/`cli.bak.*`/`_appsettings-keep-*`
|
||||
and `E:\psrem-stage\{2b9a7c3c,b3021e7c}` on both boxes.
|
||||
@@ -119,7 +119,7 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
|
||||
| 123 | **Rail nav scroll pane (`.rail-scroll` + the `#theme-rail > .side-rail { height: 100% }` fill rule) lives in app-local `site.css`/`NavMenu.razor`, not the `ZB.MOM.WW.Theme` kit** *(Low)*. Every other kit consumer (OtOpcUa, MxGateway, HistorianGateway, ignitionoee) has the same unbounded-nav-pushes-the-footer-off-screen shape and would need the identical fix copy-pasted. | `docs/plans/2026-09-14-pending-updates-design.md` §3.5 | Scoped to ScadaBridge for this change; upstreaming into the shared kit is a separate cross-repo release, not a one-app CSS tweak. | A second kit consumer hits the same long-nav clipping and copies the fix instead of consuming it from the kit. |
|
||||
| 124 | **No template-graph-watermark short-circuit on the pending-updates fleet flatten** *(Low)*. `PendingUpdatesEvaluator.EvaluateAsync` re-flattens every deployed instance through `DeploymentService.GetInstanceStalenessAsync` on every cache-TTL expiry (`PendingUpdatesCacheTtl`, default 60 s) while any Deployer/Admin circuit is open, even when nothing in the template/instance/artifact graph has changed since the last evaluation. | `docs/plans/2026-09-14-pending-updates-design.md` §3.2 ("Not in scope") | The single-flight TTL cache already bounds the cost to one fleet flatten per TTL fleet-wide (not per viewer); a watermark short-circuit is a further optimization, not a correctness need, and needs a cheap "has anything changed" signal that does not exist yet. | The fleet flatten's cost (instance count x template depth) becomes measurable against `PendingUpdatesCacheTtl` on a large deployment. |
|
||||
| 125 | **Pending-updates counts have no Health-dashboard tile or CLI surface** *(Low)*. The aggregate (`PendingUpdatesSnapshot.StaleInstanceCount` / `StaleArtifactSiteCount`) is consumed only by the Central UI Sites page and rail indicator; there is no Health dashboard KPI tile and no `deploy`/`site` CLI command surfacing it — `site list` shows only the raw `artifactRevisionHash`/`artifactsDeployedAt` fields on each site, not whether they are current. | `docs/plans/2026-09-14-pending-updates-design.md` §3.2 ("Not in scope") | Out of scope for the UI-focused ask; the CLI/Health surfaces are a separate consumer of the same `IPendingUpdatesCache`. | An operator wants the pending-updates counts without opening the Central UI (headless monitoring, CLI scripting). |
|
||||
| 126 | **TODO — roll the two outstanding migrations to the QA pair vq03/vq04** *(deployment, open)*. **DEV vd03 DONE 2026-09-28** (`fe89363b`, 55 → 57, record `docs/deployment/deployment-records/2026-09-28-wonder-app-vd03-fe89363b.md` — the `sb-mig-55to57.sql` there is the same script QA needs). QA still runs `2b9a7c3c` with the DB at migration 55 (`20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration`); `main` (`ac833f5a`, pushed 2026-09-14) carries two more: **56** `20260911135501_AddAlarmMesReportable` (computed-alarm `IsMesReportable` flag) and **57** `20260914173147_AddSiteArtifactRevision` (`Sites.ArtifactRevisionHash`/`ArtifactsDeployedAt`, both nullable). Both are additive `AddColumn`s — no data upgrade, no coordinated step like the 2026-09-10 protocol-config encryption. Recipe (same as the 55 rollout): generate ONE from→to delta script `dotnet ef migrations script EnforceGlobalInstanceNamesAndProtectProtocolConfiguration AddSiteArtifactRevision --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase --startup-project src/ZB.MOM.WW.ScadaBridge.Host -o sb-mig-55to57.sql` (NOT idempotent, identical for vd03 and the QA pair), take the pre-upgrade DB backup, stop the pair TOGETHER, apply via `sqlcmd -C -b -I` on the SQL host, publish/swap the `ac833f5a` binaries, start the pair, confirm the Central boot log shows 0 pending migrations and no "Applying migration" (the script already applied them). After upgrade every site reads *Artifacts never deployed* until one *Deploy Artifacts to All Sites* — run it as the last step. Write the deployment record `docs/deployment/deployment-records/<date>-wonder-app-{vd03,vq03-vq04}-ac833f5a.md`. | Pending-updates finish (2026-09-14) | ~~Blocked by the 2026-09-11 remote-access hold~~ — **unblocked 2026-09-28**: `winrm` is re-enabled for deployment activities (CLAUDE.md). vd03 done; QA waits only for the user to schedule its window. | The user asks for the QA (vq03/vq04) window. |
|
||||
| 126 | ~~**TODO — roll the two outstanding migrations to the QA pair vq03/vq04**~~ **DONE 2026-09-28** — QA vq03/vq04 upgraded `2b9a7c3c` → `b3021e7c`, 55 → 57, artifacts deployed (record `docs/deployment/deployment-records/2026-09-28-wonder-app-vq03-vq04-b3021e7c.md`). *(deployment, closed)*. **DEV vd03 DONE 2026-09-28** (`fe89363b`, 55 → 57, record `docs/deployment/deployment-records/2026-09-28-wonder-app-vd03-fe89363b.md` — the `sb-mig-55to57.sql` there is the same script QA needs). QA still runs `2b9a7c3c` with the DB at migration 55 (`20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration`); `main` (`ac833f5a`, pushed 2026-09-14) carries two more: **56** `20260911135501_AddAlarmMesReportable` (computed-alarm `IsMesReportable` flag) and **57** `20260914173147_AddSiteArtifactRevision` (`Sites.ArtifactRevisionHash`/`ArtifactsDeployedAt`, both nullable). Both are additive `AddColumn`s — no data upgrade, no coordinated step like the 2026-09-10 protocol-config encryption. Recipe (same as the 55 rollout): generate ONE from→to delta script `dotnet ef migrations script EnforceGlobalInstanceNamesAndProtectProtocolConfiguration AddSiteArtifactRevision --project src/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase --startup-project src/ZB.MOM.WW.ScadaBridge.Host -o sb-mig-55to57.sql` (NOT idempotent, identical for vd03 and the QA pair), take the pre-upgrade DB backup, stop the pair TOGETHER, apply via `sqlcmd -C -b -I` on the SQL host, publish/swap the `ac833f5a` binaries, start the pair, confirm the Central boot log shows 0 pending migrations and no "Applying migration" (the script already applied them). After upgrade every site reads *Artifacts never deployed* until one *Deploy Artifacts to All Sites* — run it as the last step. Write the deployment record `docs/deployment/deployment-records/<date>-wonder-app-{vd03,vq03-vq04}-ac833f5a.md`. | Pending-updates finish (2026-09-14) | ~~Blocked by the 2026-09-11 remote-access hold~~ — **unblocked 2026-09-28**: `winrm` is re-enabled for deployment activities (CLAUDE.md). vd03 done; QA done 2026-09-28. | Closed 2026-09-28. |
|
||||
|
||||
## Resolved (verified against the code 2026-07-10)
|
||||
Rows removed from the Deferred table above once confirmed shipped. Kept here for traceability.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Script file access — the `Files` global (2026-09-28)
|
||||
|
||||
## Problem
|
||||
|
||||
A Delmia recipe download hands the machine a `DownloadPath`; in production the Galaxy `DelmiaReceiver`
|
||||
object's own script opens that text file, parses it and loads the values into its tags. ScadaBridge scripts
|
||||
cannot do the same thing: the trust policy forbids `System.IO` outright, and the deploy gate refuses any
|
||||
script that names a `System.IO` type. So an in-memory test machine (and any future ScadaBridge-side
|
||||
receiver) could exercise the handshake but never the file parse.
|
||||
|
||||
## Options considered
|
||||
|
||||
1. **Leave parsing in the Galaxy.** No change; test machines never parse.
|
||||
2. **A small file-reader HTTP service reached through `ExternalSystem.Call`.** No trust-model change, but a
|
||||
new service to deploy and secure on every site.
|
||||
3. **A host-mediated, allow-listed platform API.** *Chosen* (user decision). A deliberate, narrow exception
|
||||
to the trust boundary: the script never touches `System.IO`; the host reads on its behalf, only beneath
|
||||
directories an administrator lists.
|
||||
|
||||
Rejected outright: reading through SQL Server (`OPENROWSET(BULK …)` via `Database.Connection`) — needs
|
||||
elevated SQL rights, reads the SQL host's filesystem, and sidesteps the trust policy.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Surface.** `Files.ReadTextAsync(path)`, `Files.ReadLinesAsync(path)`, `Files.ExistsAsync(path)` on the
|
||||
site `ScriptGlobals` (+ `Instance.Files`), each taking an optional `CancellationToken`. Read-only — no
|
||||
write, list, delete or directory enumeration; add one only with its own use case.
|
||||
- **Off by default.** `ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots` (absolute local/UNC dirs,
|
||||
default empty) and `MaxFileBytes` (default 1 MiB, 1…64 MiB). No roots ⇒ every call throws `Disabled`, so
|
||||
upgrading a site changes nothing until an operator opts in. Validated at boot.
|
||||
- **Guard (host side, `ScriptFileReader`).** Fully-qualified path; no device paths or alternate data
|
||||
streams; canonicalize, then a directory-boundary prefix match against each root; no reparse point on the
|
||||
file or any directory between root and file; size cap enforced before and during the read.
|
||||
- **One exception type in Commons** — `ScriptFileAccessException` + `ScriptFileAccessFailure` — because
|
||||
scripts cannot name `System.IO` exceptions. `Commons.Types.Scripts` joins the default script imports on
|
||||
every surface so it binds by short name.
|
||||
- **Site scripts only.** Inbound API scripts run on central and have no `Files`. The Test Run sandbox
|
||||
mirrors the member (so scripts bind) and throws `Disabled` at run time. `ScriptCompileSurface.CompileFiles`
|
||||
mirrors it for the deploy gate; parity is pinned by `CompileSurfaceParityTests`.
|
||||
- **Not audited.** File reads are not one of the audit channels; refusals log a node Warning.
|
||||
|
||||
## Accepted residuals
|
||||
|
||||
- Time-of-check/time-of-use between the link check and the open: a writer inside an allowed root could swap
|
||||
a file for a link in between. Bounded by the allow-list (only administrator-chosen directories) and by the
|
||||
API being read-only.
|
||||
- The node's service account permissions still apply; on a site pair both nodes need the same roots and the
|
||||
same access (a script can run on either node after failover).
|
||||
|
||||
## First consumer
|
||||
|
||||
`docker/seed-mes-test-machine.sh`: the `SimDelmiaRecipe` simulator on `TestMesMachine` reads the downloaded
|
||||
recipe file (`RecipeValueName,RecipeValue` per line), types each value (int → double → bool → string) and
|
||||
loads it into the matching `TestDelmiaReceiver` recipe attributes, rejecting unknown names and malformed
|
||||
lines. Sample files: `docker/recipes/`.
|
||||
@@ -32,7 +32,7 @@ The following namespace/type prefixes are forbidden in all scripts:
|
||||
|
||||
| Scope | Rationale |
|
||||
|-------|-----------|
|
||||
| `System.IO` | File system access — forbidden entirely |
|
||||
| `System.IO` | File system access — forbidden entirely. The only file access a script has is the host-mediated, allow-listed, read-only `Files` global on site scripts (2026-09-28, `Component-SiteRuntime.md` → *File Access*) — the script never touches a `System.IO` type, so this scope is unchanged |
|
||||
| `System.Diagnostics.Process` | Process spawning — forbidden; `Stopwatch`, `Debug`, `Activity`, and other `System.Diagnostics` types are **allowed** |
|
||||
| `System.Threading` | Raw thread manipulation — forbidden, with the exceptions below |
|
||||
| `System.Reflection` | Reflection — forbidden entirely |
|
||||
@@ -92,7 +92,7 @@ The identifiers `dynamic` and `Activator` are forbidden at any scope, as they pr
|
||||
- For each identifier in the syntax tree, resolves the underlying symbol to its fully qualified containing namespace and type name.
|
||||
- Flags any symbol whose containing namespace or type matches a forbidden scope in `ScriptTrustPolicy.ForbiddenScopes`, taking `AllowedExceptions` into account.
|
||||
- Correctly handles aliases (`using X = System.IO.File`), `using static`, and `global::` prefixes — the resolved symbol is checked, not the spelling.
|
||||
- The validation compilation applies the **same default namespace imports** (`ScriptTrustPolicy.DefaultImports` — `using System;`, `System.Collections.Generic`, `System.Linq`, `System.Threading.Tasks`) that `RoslynScriptCompiler` compiles the real script with. Without them the semantic pass under-resolves **bare** identifiers the running script sees fully bound — e.g. `Environment.Exit(0)` or `GC.Collect()`, whose `Environment`/`GC` resolve to the forbidden `System.Environment`/`System.GC` types only when `using System;` is in scope. Matching the runtime import set closes that blind spot; it can only make more identifiers resolve to their true (forbidden) namespace, never produce a false allow.
|
||||
- The validation compilation applies the **same default namespace imports** (`ScriptTrustPolicy.DefaultImports` — `using System;`, `System.Collections.Generic`, `System.Linq`, `System.Threading.Tasks`, and since 2026-09-28 `ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts`, so script-facing Commons types such as `ScriptFileAccessException` bind by short name) that `RoslynScriptCompiler` compiles the real script with. Without them the semantic pass under-resolves **bare** identifiers the running script sees fully bound — e.g. `Environment.Exit(0)` or `GC.Collect()`, whose `Environment`/`GC` resolve to the forbidden `System.Environment`/`System.GC` types only when `using System;` is in scope. Matching the runtime import set closes that blind spot; it can only make more identifiers resolve to their true (forbidden) namespace, never produce a false allow.
|
||||
- Because the full reference set is loaded, this pass also catches a forbidden type accessed inside an otherwise-allowed namespace (e.g., bare `Process` after `using System.Diagnostics;`).
|
||||
|
||||
##### `AnalysisReferences` vs `DefaultReferences`
|
||||
|
||||
@@ -520,6 +520,17 @@ Available to all script runs and alarm on-trigger runs:
|
||||
- The type lives in Commons so the runtime accessor and the Script Analysis compile-only surface (`ScriptCompileSurface.CompileAlarmsAccessor`) project to the **same** type — a script binding at the design-time gate binds identically at the site. Parity is pinned by `CompileSurfaceParityTests`.
|
||||
- Design record: `docs/plans/2026-06-30-mes-alarm-status-api.md` §5.2.
|
||||
|
||||
### File Access (`Files`, read-only, allow-listed — 2026-09-28)
|
||||
- `Files.ReadTextAsync(path)` → `string`, `Files.ReadLinesAsync(path)` → `IReadOnlyList<string>` (`\r\n`/`\n`/`\r`; a trailing line break adds no empty last line), `Files.ExistsAsync(path)` → `bool`. Also reachable as `Instance.Files`. The use case that motivated it: a recipe/NC file whose path a Delmia/MES call hands to the machine, which a script parses and loads into attributes.
|
||||
- **The one sanctioned exception to the `System.IO` ban, and it is host-mediated.** Scripts still cannot name any `System.IO` type — the trust policy is unchanged. The runtime `FilesAccessor` delegates to the site-singleton `ScriptFileReader` (`SiteRuntime/Scripts/`), which does every check on the host side.
|
||||
- **Off by default.** `ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots` (list of absolute local/UNC directories, default empty) enables it; with no roots every call throws `ScriptFileAccessException(Disabled)`, so an unconfigured site behaves exactly as before. `ScriptFileAccess:MaxFileBytes` (default 1 MiB, validated 1…64 MiB) caps one read. Both validated at boot (`SiteRuntimeOptionsValidator` — a relative root is rejected). Read per call through `IOptionsMonitor`.
|
||||
- **Guard, in order:** roots configured → path non-empty, fully qualified, not a device path (`\\?\`, `\\.\`), no NTFS alternate data stream → canonical path (`Path.GetFullPath`, so `..` is resolved first) **strictly inside** a root with a directory-boundary match (root `C:\recipes` does not admit `C:\recipes2\x`, and the root itself is not a file) → no symbolic link / junction / reparse point on the file **or on any directory between the root and the file** (the root itself may be a link — an administrator chose it) → size under the cap, re-checked while reading in case the file grows. Files are opened `FileShare.ReadWrite | Delete` so a file still being written by its producer can be read; text is BOM-detected, UTF-8 by default.
|
||||
- **Errors are one Commons type.** Every refusal and I/O failure is `ScriptFileAccessException` (`Commons.Types.Scripts`) with a `Failure` of `Disabled`, `InvalidPath`, `OutsideAllowedRoots`, `LinkNotAllowed`, `NotFound`, `TooLarge` or `IoError` — scripts cannot name `System.IO` exception types, so this is what they `catch`. `Commons.Types.Scripts` is now one of the **default script imports** on every surface (runtime, deploy gate, Test Run editor), so the short name binds.
|
||||
- **Bounded by the run.** Reads are cancelled by the script's own execution deadline as well as any token the script passes. Refusals (outside roots, links) log a Warning on the node; successful reads log at Debug. There is no audit row — file reads are not an audited channel (the `AuditLog` scope is outbound API/DB, notifications and inbound API).
|
||||
- **The node's service account still needs read permission** on the path; the allow-list only ever narrows access. A time-of-check/time-of-use window exists between the link check and the open (a writer inside an allowed root could swap a file for a link in between) — acceptable because only the directories an administrator lists are reachable at all, and read-only.
|
||||
- **Site scripts only.** Inbound API method scripts (central) have no `Files` global. The Central UI Test Run sandbox mirrors the member so scripts bind, but every call throws `ScriptFileAccessException(Disabled)` — central cannot reach a site's filesystem — so a script's own error handling runs as it would on an unconfigured site. The design-time `ScriptCompileSurface.CompileFiles` mirror is pinned by `CompileSurfaceParityTests`.
|
||||
- Design record: `docs/plans/2026-09-28-script-file-access-design.md`.
|
||||
|
||||
### Logging (`Log`)
|
||||
- `Log.Debug(message, exception?)` / `Log.Info` / `Log.Warn` / `Log.Error` — leveled, per-run logging available to **every** script, alongside the `Alarms` accessor above (design `docs/plans/2026-09-09-flow-debugger-step-logs-design.md` D4/D5). Synchronous, `void`, and it **never throws into the script**: a failing sink is swallowed, because a debug observer must not fail a production run.
|
||||
- **Every line always reaches the node's `ILogger`** (structured `Instance` / `Script` / `ExecutionId` / message), traced or not — an untraced run is an ordinary logging surface and pays nothing else. A `ScriptTrace` / `ScriptLog` **audit row** is emitted **only when the run is traced** (a Flow Debugger Live run, or a real call to a method with `TraceAllCalls`), so a timer or value-change script logging at high rate costs no audit volume. There is no site-event-log sink for `Log.*`.
|
||||
|
||||
@@ -96,6 +96,46 @@ public class SandboxScriptHost
|
||||
/// <c>Log</c> and <c>Instance.Log</c> are the same object here too.
|
||||
/// </summary>
|
||||
public SandboxLog Log => Instance.Log;
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox mirror of the site runtime's <c>Files</c> global (design 2026-09-28), so a script
|
||||
/// that reads a file BINDS in the Test Run editor. The central sandbox has no access to a
|
||||
/// site node's filesystem, so every call throws the same
|
||||
/// <see cref="ScriptFileAccessException"/> (<see cref="ScriptFileAccessFailure.Disabled"/>) a
|
||||
/// site without configured roots throws — a script's own error handling runs as it would there.
|
||||
/// </summary>
|
||||
public SandboxFilesAccessor Files => Instance.Files;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox mirror of the SiteRuntime <c>FilesAccessor</c>: binds identically, refuses at run time.
|
||||
/// </summary>
|
||||
public class SandboxFilesAccessor
|
||||
{
|
||||
private const string Message =
|
||||
"Files.* reads the SITE node's filesystem (under ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots), " +
|
||||
"which the central Test Run sandbox cannot reach — deploy to a site to exercise file-reading scripts.";
|
||||
|
||||
/// <summary>Mirrors <c>FilesAccessor.ReadTextAsync</c>; always refused in the sandbox.</summary>
|
||||
/// <param name="path">The file path.</param>
|
||||
/// <param name="cancellationToken">Unused in the sandbox.</param>
|
||||
/// <returns>Never returns.</returns>
|
||||
public Task<string> ReadTextAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message);
|
||||
|
||||
/// <summary>Mirrors <c>FilesAccessor.ReadLinesAsync</c>; always refused in the sandbox.</summary>
|
||||
/// <param name="path">The file path.</param>
|
||||
/// <param name="cancellationToken">Unused in the sandbox.</param>
|
||||
/// <returns>Never returns.</returns>
|
||||
public Task<IReadOnlyList<string>> ReadLinesAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message);
|
||||
|
||||
/// <summary>Mirrors <c>FilesAccessor.ExistsAsync</c>; always refused in the sandbox.</summary>
|
||||
/// <param name="path">The file path.</param>
|
||||
/// <param name="cancellationToken">Unused in the sandbox.</param>
|
||||
/// <returns>Never returns.</returns>
|
||||
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled, Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -263,6 +303,9 @@ public class SandboxInstanceContext
|
||||
/// </summary>
|
||||
public SandboxLog Log { get; } = new();
|
||||
|
||||
/// <summary>Sandbox mirror of <c>ScriptRuntimeContext.Files</c> (design 2026-09-28); always refuses.</summary>
|
||||
public SandboxFilesAccessor Files { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the SandboxInstanceContext.
|
||||
/// </summary>
|
||||
|
||||
@@ -71,7 +71,9 @@ public class ScriptAnalysisService
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Text",
|
||||
"System.Threading.Tasks");
|
||||
"System.Threading.Tasks",
|
||||
// Mirrors the site runtime's imports (design 2026-09-28).
|
||||
"ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts");
|
||||
|
||||
/// <summary>
|
||||
/// Options for a sandbox run — <see cref="DefaultOptions"/> plus the sandbox host assembly
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Why a script's <c>Files.*</c> call was refused or failed (design 2026-09-28).
|
||||
/// </summary>
|
||||
public enum ScriptFileAccessFailure
|
||||
{
|
||||
/// <summary>No allowed roots are configured on this node (the default), or the surface has no file access at all (Test Run sandbox).</summary>
|
||||
Disabled,
|
||||
|
||||
/// <summary>The path is empty, relative, a device/alternate-stream path, or otherwise malformed.</summary>
|
||||
InvalidPath,
|
||||
|
||||
/// <summary>The canonical path is not under any configured allowed root.</summary>
|
||||
OutsideAllowedRoots,
|
||||
|
||||
/// <summary>The file, or a directory between the allowed root and the file, is a symbolic link / junction / reparse point.</summary>
|
||||
LinkNotAllowed,
|
||||
|
||||
/// <summary>The file does not exist (or the path names a directory).</summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>The file is larger than the configured size cap.</summary>
|
||||
TooLarge,
|
||||
|
||||
/// <summary>The operating system refused or failed the read (permissions, sharing violation, I/O error).</summary>
|
||||
IoError,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single exception type a script's <c>Files.*</c> calls throw. Scripts cannot name
|
||||
/// <c>System.IO</c> exception types (the trust policy forbids the namespace), so every refusal
|
||||
/// and every I/O failure is surfaced as this Commons type, which a script can catch by name and
|
||||
/// branch on via <see cref="Failure"/>.
|
||||
/// </summary>
|
||||
public sealed class ScriptFileAccessException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance.</summary>
|
||||
/// <param name="failure">The failure category.</param>
|
||||
/// <param name="message">A message naming the path and the reason.</param>
|
||||
/// <param name="innerException">The underlying exception, if any.</param>
|
||||
public ScriptFileAccessException(ScriptFileAccessFailure failure, string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
Failure = failure;
|
||||
}
|
||||
|
||||
/// <summary>Why the call was refused or failed.</summary>
|
||||
public ScriptFileAccessFailure Failure { get; }
|
||||
}
|
||||
@@ -70,6 +70,9 @@ public sealed class ScriptCompileSurface
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Log</c>.</summary>
|
||||
public CompileLog Log => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptGlobals.Files</c>.</summary>
|
||||
public CompileFiles Files => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext</c> (the <c>Instance</c> global).</summary>
|
||||
public sealed class CompileInstance
|
||||
{
|
||||
@@ -110,6 +113,37 @@ public sealed class ScriptCompileSurface
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.Log</c>.</summary>
|
||||
public CompileLog Log => throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>ScriptRuntimeContext.Files</c>.</summary>
|
||||
public CompileFiles Files => throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compile-only mirror of the SiteRuntime <c>FilesAccessor</c> (design 2026-09-28) — the
|
||||
/// <c>Files</c> global's read-only, allow-listed file access.
|
||||
/// </summary>
|
||||
public sealed class CompileFiles
|
||||
{
|
||||
/// <summary>Mirrors <c>FilesAccessor.ReadTextAsync</c>.</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The file's text.</returns>
|
||||
public Task<string> ReadTextAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>FilesAccessor.ReadLinesAsync</c>.</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The file's lines.</returns>
|
||||
public Task<IReadOnlyList<string>> ReadLinesAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
|
||||
/// <summary>Mirrors <c>FilesAccessor.ExistsAsync</c>.</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the check.</param>
|
||||
/// <returns>Whether the file exists.</returns>
|
||||
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException(CompileOnly);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -342,5 +342,8 @@ public static class ScriptTrustPolicy
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks",
|
||||
// Script-facing Commons types (ScriptAlarm, ScriptFileAccessException, …) — mirrors the
|
||||
// site runtime's imports so the deploy gate binds the same short names (design 2026-09-28).
|
||||
"ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>Files</c> script global (design 2026-09-28): READ-ONLY access to files under the site
|
||||
/// node's configured <c>ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots</c> — e.g. a
|
||||
/// recipe file whose path a MES/Delmia call handed to the machine. Off by default. Every
|
||||
/// refusal or failure throws <see cref="ScriptFileAccessException"/>; reads are bounded by the
|
||||
/// script's own execution deadline as well as the caller's token.
|
||||
/// <code>
|
||||
/// foreach (var line in await Files.ReadLinesAsync(path)) { … }
|
||||
/// </code>
|
||||
/// </summary>
|
||||
public sealed class FilesAccessor
|
||||
{
|
||||
private readonly IScriptFileReader? _reader;
|
||||
private readonly CancellationToken _scriptToken;
|
||||
|
||||
/// <summary>Initializes a new instance.</summary>
|
||||
/// <param name="reader">The host reader; null when the node has none wired (then every call throws Disabled).</param>
|
||||
/// <param name="scriptToken">The running script's execution-timeout token.</param>
|
||||
public FilesAccessor(IScriptFileReader? reader, CancellationToken scriptToken)
|
||||
{
|
||||
_reader = reader;
|
||||
_scriptToken = scriptToken;
|
||||
}
|
||||
|
||||
/// <summary>Reads a whole text file (BOM-detected, UTF-8 by default).</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The file's text.</returns>
|
||||
public async Task<string> ReadTextAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_scriptToken, cancellationToken);
|
||||
return await Reader.ReadTextAsync(path, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a text file as lines (<c>\r\n</c>, <c>\n</c> or <c>\r</c>); a trailing line break does
|
||||
/// not produce a final empty line.
|
||||
/// </summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The file's lines.</returns>
|
||||
public async Task<IReadOnlyList<string>> ReadLinesAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var text = await ReadTextAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return SplitLines(text);
|
||||
}
|
||||
|
||||
/// <summary>True when an existing file is at the path; false when nothing is there. Still refuses paths outside the allowed roots.</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the check.</param>
|
||||
/// <returns>Whether the file exists.</returns>
|
||||
public async Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(_scriptToken, cancellationToken);
|
||||
return await Reader.ExistsAsync(path, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private IScriptFileReader Reader => _reader
|
||||
?? throw new ScriptFileAccessException(ScriptFileAccessFailure.Disabled,
|
||||
"Script file access is not available on this node.");
|
||||
|
||||
internal static IReadOnlyList<string> SplitLines(string text)
|
||||
{
|
||||
if (text.Length == 0)
|
||||
return Array.Empty<string>();
|
||||
var lines = text.Split(["\r\n", "\n", "\r"], StringSplitOptions.None).ToList();
|
||||
if (lines.Count > 0 && lines[^1].Length == 0)
|
||||
lines.RemoveAt(lines.Count - 1);
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,10 @@ public class ScriptCompilationService
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
"System.Linq",
|
||||
"System.Threading.Tasks");
|
||||
"System.Threading.Tasks",
|
||||
// Script-facing Commons types (ScriptAlarm, ScriptFileAccessException, …) — lets a
|
||||
// script `catch (ScriptFileAccessException)` by its short name (design 2026-09-28).
|
||||
"ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts");
|
||||
|
||||
/// <summary>
|
||||
/// Compiles a script into a reusable delegate that takes a ScriptRuntimeContext
|
||||
@@ -347,6 +350,14 @@ public class ScriptGlobals
|
||||
/// <summary>Leveled per-run logging (design 2026-09-09). Usage: <c>Log.Info("…")</c>, <c>Log.Warn("…", ex)</c>. Always reaches the node log; becomes Flow Debugger trace rows only for a traced run.</summary>
|
||||
public ScriptLogger Log => Instance.Log;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only file access under the node's configured allowed roots (design 2026-09-28).
|
||||
/// Usage: <c>var lines = await Files.ReadLinesAsync(path);</c>. Off by default — every call
|
||||
/// throws <c>ScriptFileAccessException(Disabled)</c> until
|
||||
/// <c>ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots</c> is configured.
|
||||
/// </summary>
|
||||
public FilesAccessor Files => Instance.Files;
|
||||
|
||||
/// <summary>
|
||||
/// Parent composition (null when this script is on a root-level template).
|
||||
/// <c>Parent.Attributes["SpeedRPM"]</c> reaches the parent's attribute;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// <c>ScadaBridge:SiteRuntime:ScriptFileAccess</c> — the read-only file access a site script gets
|
||||
/// through the <c>Files</c> global (design 2026-09-28). The script trust policy still forbids
|
||||
/// <c>System.IO</c> outright; this is the one sanctioned, host-mediated exception, and it is
|
||||
/// <b>off by default</b>: with no <see cref="AllowedRoots"/> every <c>Files.*</c> call throws
|
||||
/// <c>ScriptFileAccessException(Disabled)</c>.
|
||||
/// </summary>
|
||||
public sealed class ScriptFileAccessOptions
|
||||
{
|
||||
/// <summary>Largest value accepted for <see cref="MaxFileBytes"/> (64 MiB).</summary>
|
||||
public const long MaxFileBytesCeiling = 64L * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Absolute directories (local or UNC) a script may read beneath. A path is allowed only when
|
||||
/// its canonical form (after <c>..</c> resolution) is strictly inside one of these roots, and
|
||||
/// no file or directory below the root is a symbolic link / junction. Empty (the default)
|
||||
/// disables script file access on this node. The node's service account still needs NTFS/share
|
||||
/// read permission — the allow-list narrows access, it never widens it.
|
||||
/// </summary>
|
||||
public List<string> AllowedRoots { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Per-read size cap in bytes (default 1 MiB, at most <see cref="MaxFileBytesCeiling"/>). A
|
||||
/// larger file throws <c>ScriptFileAccessException(TooLarge)</c> instead of being loaded into
|
||||
/// the script's memory.
|
||||
/// </summary>
|
||||
public long MaxFileBytes { get; set; } = 1024 * 1024;
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Host-side, read-only file access behind the script <c>Files</c> global (design 2026-09-28).
|
||||
/// Scripts never touch <c>System.IO</c> themselves; every path they pass is validated here
|
||||
/// against <see cref="ScriptFileAccessOptions.AllowedRoots"/>.
|
||||
/// </summary>
|
||||
public interface IScriptFileReader
|
||||
{
|
||||
/// <summary>Reads the whole file as text (BOM-detected, UTF-8 by default).</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The file's text.</returns>
|
||||
Task<string> ReadTextAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>True when the path names an existing file under an allowed root; false when it does not exist.</summary>
|
||||
/// <param name="path">Absolute path under an allowed root.</param>
|
||||
/// <param name="cancellationToken">Cancels the check.</param>
|
||||
/// <returns>Whether the file exists.</returns>
|
||||
Task<bool> ExistsAsync(string path, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IScriptFileReader"/>. The guard, in order: access enabled (at least one
|
||||
/// root) → path well-formed and fully qualified (no device <c>\\?\</c>/<c>\\.\</c> paths, no
|
||||
/// alternate data streams) → canonical path strictly inside an allowed root (directory-boundary
|
||||
/// match, so root <c>C:\recipes</c> does not admit <c>C:\recipes2\x</c>) → no symbolic link,
|
||||
/// junction or other reparse point on the file or any directory between the root and the file
|
||||
/// (so a link planted inside a root cannot point outside it) → size under
|
||||
/// <see cref="ScriptFileAccessOptions.MaxFileBytes"/>, enforced again while reading in case the
|
||||
/// file grows. Every refusal and I/O failure is a <see cref="ScriptFileAccessException"/>.
|
||||
/// </summary>
|
||||
public sealed class ScriptFileReader : IScriptFileReader
|
||||
{
|
||||
private readonly IOptionsMonitor<SiteRuntimeOptions> _options;
|
||||
private readonly ILogger<ScriptFileReader> _logger;
|
||||
|
||||
/// <summary>Initializes a new instance.</summary>
|
||||
/// <param name="options">Site runtime options (read per call, so a config reload applies).</param>
|
||||
/// <param name="logger">Logger for refusals and reads.</param>
|
||||
public ScriptFileReader(IOptionsMonitor<SiteRuntimeOptions> options, ILogger<ScriptFileReader> logger)
|
||||
{
|
||||
_options = options;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private static StringComparison PathComparison =>
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> ReadTextAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var (full, root, settings) = Resolve(path);
|
||||
var info = new FileInfo(full);
|
||||
if (!info.Exists)
|
||||
throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.");
|
||||
EnsureNoLinks(info, root);
|
||||
if (info.Length > settings.MaxFileBytes)
|
||||
throw Fail(ScriptFileAccessFailure.TooLarge,
|
||||
$"File '{full}' is {info.Length} bytes; the limit is {settings.MaxFileBytes} (ScriptFileAccess:MaxFileBytes).");
|
||||
|
||||
try
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
await using (var stream = new FileStream(full, FileMode.Open, FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete, bufferSize: 16 * 1024, useAsync: true))
|
||||
{
|
||||
var chunk = new byte[16 * 1024];
|
||||
int n;
|
||||
while ((n = await stream.ReadAsync(chunk, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
if (buffer.Length + n > settings.MaxFileBytes)
|
||||
throw Fail(ScriptFileAccessFailure.TooLarge,
|
||||
$"File '{full}' grew past the {settings.MaxFileBytes}-byte limit while it was being read.");
|
||||
buffer.Write(chunk, 0, n);
|
||||
}
|
||||
}
|
||||
buffer.Position = 0;
|
||||
using var reader = new StreamReader(buffer, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
var text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogDebug("Script file read {Path} ({Bytes} bytes)", full, buffer.Length);
|
||||
return text;
|
||||
}
|
||||
catch (ScriptFileAccessException) { throw; }
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (FileNotFoundException ex)
|
||||
{
|
||||
throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.", ex);
|
||||
}
|
||||
catch (DirectoryNotFoundException ex)
|
||||
{
|
||||
throw Fail(ScriptFileAccessFailure.NotFound, $"File not found: '{full}'.", ex);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
throw Fail(ScriptFileAccessFailure.IoError, $"Could not read '{full}': {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var (full, root, _) = Resolve(path);
|
||||
var info = new FileInfo(full);
|
||||
if (!info.Exists)
|
||||
return Task.FromResult(false);
|
||||
EnsureNoLinks(info, root);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
private (string Full, string Root, ScriptFileAccessOptions Settings) Resolve(string path)
|
||||
{
|
||||
var settings = _options.CurrentValue.ScriptFileAccess ?? new ScriptFileAccessOptions();
|
||||
var roots = settings.AllowedRoots.Where(r => !string.IsNullOrWhiteSpace(r)).ToList();
|
||||
if (roots.Count == 0)
|
||||
throw Fail(ScriptFileAccessFailure.Disabled,
|
||||
"Script file access is disabled on this node (ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots is empty).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path) || path.IndexOf('\0') >= 0)
|
||||
throw Fail(ScriptFileAccessFailure.InvalidPath, "A file path is required.");
|
||||
path = path.Trim();
|
||||
if (path.StartsWith(@"\\?\", StringComparison.Ordinal) || path.StartsWith(@"\\.\", StringComparison.Ordinal)
|
||||
|| path.StartsWith("//?/", StringComparison.Ordinal) || path.StartsWith("//./", StringComparison.Ordinal))
|
||||
throw Fail(ScriptFileAccessFailure.InvalidPath, $"Device paths are not allowed: '{path}'.");
|
||||
if (!Path.IsPathFullyQualified(path))
|
||||
throw Fail(ScriptFileAccessFailure.InvalidPath, $"The path must be absolute: '{path}'.");
|
||||
|
||||
string full;
|
||||
try
|
||||
{
|
||||
full = Path.GetFullPath(path);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
throw Fail(ScriptFileAccessFailure.InvalidPath, $"Invalid path '{path}': {ex.Message}", ex);
|
||||
}
|
||||
// NTFS alternate data streams ("file.txt:hidden") — only the drive-letter colon is legal.
|
||||
if (OperatingSystem.IsWindows() && full.IndexOf(':', 2) >= 0)
|
||||
throw Fail(ScriptFileAccessFailure.InvalidPath, $"Alternate data streams are not allowed: '{path}'.");
|
||||
|
||||
foreach (var configured in roots)
|
||||
{
|
||||
var root = NormalizeRoot(configured);
|
||||
if (full.Length > root.Length && full.StartsWith(root, PathComparison))
|
||||
return (full, root, settings);
|
||||
}
|
||||
|
||||
_logger.LogWarning("Script file access refused: '{Path}' is outside the allowed roots ({Roots})",
|
||||
full, string.Join(", ", roots));
|
||||
throw Fail(ScriptFileAccessFailure.OutsideAllowedRoots,
|
||||
$"'{full}' is outside the allowed roots (ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots).");
|
||||
}
|
||||
|
||||
/// <summary>Canonical root with exactly one trailing separator, for a directory-boundary prefix match.</summary>
|
||||
private static string NormalizeRoot(string configured)
|
||||
{
|
||||
var root = Path.GetFullPath(configured.Trim());
|
||||
return Path.EndsInDirectorySeparator(root) ? root : root + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses a reparse point (symlink / junction / mount) on the file or on any directory strictly
|
||||
/// below the root: a link inside an allowed root could otherwise point anywhere. The root itself
|
||||
/// may be a link — an administrator configured it.
|
||||
/// </summary>
|
||||
private void EnsureNoLinks(FileInfo file, string root)
|
||||
{
|
||||
if (IsLink(file))
|
||||
throw LinkRefused(file.FullName);
|
||||
for (var dir = file.Directory; dir != null; dir = dir.Parent)
|
||||
{
|
||||
var withSep = Path.EndsInDirectorySeparator(dir.FullName) ? dir.FullName : dir.FullName + Path.DirectorySeparatorChar;
|
||||
if (withSep.Length <= root.Length)
|
||||
break;
|
||||
if (IsLink(dir))
|
||||
throw LinkRefused(dir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsLink(FileSystemInfo info) =>
|
||||
info.LinkTarget != null || (info.Exists && info.Attributes.HasFlag(FileAttributes.ReparsePoint));
|
||||
|
||||
private ScriptFileAccessException LinkRefused(string at)
|
||||
{
|
||||
_logger.LogWarning("Script file access refused: '{Path}' is a symbolic link / reparse point", at);
|
||||
return Fail(ScriptFileAccessFailure.LinkNotAllowed,
|
||||
$"'{at}' is a symbolic link, junction or reparse point; links below an allowed root are not followed.");
|
||||
}
|
||||
|
||||
private static ScriptFileAccessException Fail(ScriptFileAccessFailure failure, string message, Exception? inner = null)
|
||||
=> new(failure, message, inner);
|
||||
}
|
||||
@@ -263,6 +263,7 @@ internal static class ScriptRunLauncher
|
||||
IAuditWriter? auditWriter = null;
|
||||
var siteId = string.Empty;
|
||||
string? sourceNode = null;
|
||||
IScriptFileReader? fileReader = null;
|
||||
// ISiteEventLogger is a singleton; resolve from the root provider so
|
||||
// it is available to the catch blocks regardless of scope state.
|
||||
var siteEventLogger = serviceProvider?.GetService<ISiteEventLogger>();
|
||||
@@ -346,6 +347,7 @@ internal static class ScriptRunLauncher
|
||||
operationTrackingStore = serviceScope.ServiceProvider.GetService<IOperationTrackingStore>();
|
||||
cachedForwarder = serviceScope.ServiceProvider.GetService<ICachedCallTelemetryForwarder>();
|
||||
sourceNode = serviceScope.ServiceProvider.GetService<INodeIdentityProvider>()?.NodeName;
|
||||
fileReader = serviceScope.ServiceProvider.GetService<IScriptFileReader>();
|
||||
}
|
||||
|
||||
context = new ScriptRuntimeContext(
|
||||
@@ -407,7 +409,9 @@ internal static class ScriptRunLauncher
|
||||
// effectiveOptions (the IOptionsMonitor snapshot taken at the top of this run),
|
||||
// so an operator's live edit applies from the NEXT run, like the event-policy
|
||||
// flags above.
|
||||
maxLogLinesPerRun: effectiveOptions.MaxLogLinesPerRun);
|
||||
maxLogLinesPerRun: effectiveOptions.MaxLogLinesPerRun,
|
||||
// Script file access (design 2026-09-28): the Files global's host reader.
|
||||
fileReader: fileReader);
|
||||
|
||||
var globals = new ScriptGlobals
|
||||
{
|
||||
@@ -737,7 +741,9 @@ internal static class ScriptRunLauncher
|
||||
// script's per-script execution-timeout token so a
|
||||
// Attributes.WaitAsync inside an on-trigger script is bounded
|
||||
// by the same script deadline.
|
||||
scriptTimeoutToken: cts.Token);
|
||||
scriptTimeoutToken: cts.Token,
|
||||
// Script file access (design 2026-09-28): the Files global's host reader.
|
||||
fileReader: services?.GetService<IScriptFileReader>());
|
||||
|
||||
var globals = new ScriptGlobals
|
||||
{
|
||||
|
||||
@@ -335,7 +335,8 @@ public class ScriptRuntimeContext
|
||||
bool isTraced = false,
|
||||
FlowTraceCollector? traceCollector = null,
|
||||
int maxLogLinesPerRun = 200,
|
||||
ScriptLogger? sharedLog = null)
|
||||
ScriptLogger? sharedLog = null,
|
||||
IScriptFileReader? fileReader = null)
|
||||
{
|
||||
_instanceActor = instanceActor;
|
||||
_sharedScriptLibrary = sharedScriptLibrary;
|
||||
@@ -368,6 +369,8 @@ public class ScriptRuntimeContext
|
||||
// WaitForAttribute (spec §4.3): default(CancellationToken) == None when
|
||||
// not threaded in — the WaitAsync Ask is then bounded only by its own timeout.
|
||||
_scriptTimeoutToken = scriptTimeoutToken;
|
||||
// Script file access (design 2026-09-28): null when the host wired no reader — Files.* then throws Disabled.
|
||||
_fileReader = fileReader;
|
||||
_isTraced = isTraced;
|
||||
_traceCollector = traceCollector;
|
||||
_traceSink = traceCollector is null ? null : Collect;
|
||||
@@ -503,7 +506,9 @@ public class ScriptRuntimeContext
|
||||
// Step logs (design §5): the inline shared script is its own execution node for step
|
||||
// rows, but it SHARES this run's Log — one line cap and one anchor for the whole
|
||||
// inline chain, so its lines carry this run's ExecutionId.
|
||||
sharedLog: Log);
|
||||
sharedLog: Log,
|
||||
// Script file access (design 2026-09-28): the inline shared script reads through the same host reader.
|
||||
fileReader: _fileReader);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1248,6 +1253,15 @@ public class ScriptRuntimeContext
|
||||
/// </summary>
|
||||
public AlarmsAccessor Alarms => _alarms ??= new(this);
|
||||
|
||||
private readonly IScriptFileReader? _fileReader;
|
||||
private FilesAccessor? _files;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only access to files under the node's configured allowed roots (design 2026-09-28).
|
||||
/// Bounded by this run's execution deadline. Off by default.
|
||||
/// </summary>
|
||||
public FilesAccessor Files => _files ??= new(_fileReader, _scriptTimeoutToken);
|
||||
|
||||
/// <summary>
|
||||
/// Backing read for <see cref="AlarmsAccessor.CurrentAsync"/>: Asks THIS instance's
|
||||
/// Instance Actor for its retained alarm rows and projects them to
|
||||
|
||||
@@ -73,6 +73,11 @@ public static class ServiceCollectionExtensions
|
||||
// construction, which is what runs SiteLocalDbSetup.OnReady.
|
||||
services.AddSingleton<Commons.Interfaces.IOperationTrackingStore, Tracking.OperationTrackingStore>();
|
||||
|
||||
// Script file access (design 2026-09-28): the host reader behind the Files global.
|
||||
// Always registered — with no ScriptFileAccess:AllowedRoots configured it refuses every
|
||||
// call (Disabled), so an unconfigured site behaves exactly as before.
|
||||
services.AddSingleton<Scripts.IScriptFileReader, Scripts.ScriptFileReader>();
|
||||
|
||||
// Site-local repository implementations backed by SQLite.
|
||||
// WP2.6a: ExternalSystemDefinitionCache is a singleton shared by every scoped
|
||||
// SiteExternalSystemRepository instance (one per script-execution DI scope), so
|
||||
|
||||
@@ -257,4 +257,10 @@ public class SiteRuntimeOptions
|
||||
/// Must be greater than 0. Default: 200.</para>
|
||||
/// </summary>
|
||||
public int MaxLogLinesPerRun { get; set; } = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only script file access through the <c>Files</c> global (design 2026-09-28) — the
|
||||
/// allow-listed roots and per-read size cap. Off by default (no roots).
|
||||
/// </summary>
|
||||
public Scripts.ScriptFileAccessOptions ScriptFileAccess { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -119,5 +119,17 @@ public sealed class SiteRuntimeOptionsValidator : OptionsValidatorBase<SiteRunti
|
||||
$"(was {options.MaxLogLinesPerRun}); it is the per-run cap on Flow Debugger ScriptLog " +
|
||||
"rows a script's Log.* calls may produce (design 2026-09-09 D7), and a run that may " +
|
||||
"emit no lines at all cannot report its own truncation.");
|
||||
|
||||
var files = options.ScriptFileAccess ?? new Scripts.ScriptFileAccessOptions();
|
||||
builder.RequireThat(files.MaxFileBytes > 0 && files.MaxFileBytes <= Scripts.ScriptFileAccessOptions.MaxFileBytesCeiling,
|
||||
$"ScadaBridge:SiteRuntime:ScriptFileAccess:MaxFileBytes must be between 1 and " +
|
||||
$"{Scripts.ScriptFileAccessOptions.MaxFileBytesCeiling} (was {files.MaxFileBytes}); it caps one Files.* read.");
|
||||
for (var i = 0; i < files.AllowedRoots.Count; i++)
|
||||
{
|
||||
var root = files.AllowedRoots[i];
|
||||
builder.RequireThat(!string.IsNullOrWhiteSpace(root) && System.IO.Path.IsPathFullyQualified(root),
|
||||
$"ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots:{i} must be an absolute directory path " +
|
||||
$"(was '{root}'); a relative root would resolve against the service's working directory.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -819,6 +819,8 @@ public class ScriptAnalysisServiceTests
|
||||
" AckDT = a.AckTime,\n" +
|
||||
" AckComment = a.OperatorComment })\n" +
|
||||
" .ToList();\n" +
|
||||
"var recipe = await Files.ReadLinesAsync(@\"C:\\recipes\\r.txt\");\n" +
|
||||
"var recipeThere = await Instance.Files.ExistsAsync(@\"C:\\recipes\\r.txt\");\n" +
|
||||
"var r = await Alarms.MesReportableAsync();\n" +
|
||||
"var f = r.Where(a => a.IsMesReportable).ToList();\n" +
|
||||
"return new { WasSuccessful = true, Alarms = rows, Flagged = f.Count };";
|
||||
|
||||
@@ -58,12 +58,37 @@ public class RoslynScriptCompilerTests
|
||||
var viaInstance = await Instance.Alarms.CurrentAsync();
|
||||
var mesReportable = await Alarms.MesReportableAsync();
|
||||
var flaggedAlarms = mesReportable.Where(a => a.IsMesReportable).ToList();
|
||||
var recipeText = await Files.ReadTextAsync(@"C:\recipes\r.txt");
|
||||
var recipeLines = await Instance.Files.ReadLinesAsync(@"C:\recipes\r.txt");
|
||||
var recipeThere = await Files.ExistsAsync(@"C:\recipes\r.txt");
|
||||
""";
|
||||
|
||||
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
||||
Assert.Empty(diagnostics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_Empty_ForARecipeFileParsingScript()
|
||||
{
|
||||
// Script file access (design 2026-09-28): the Files global + catching the Commons
|
||||
// ScriptFileAccessException by name must bind at design time — scripts cannot name the
|
||||
// System.IO exception types.
|
||||
const string code = """
|
||||
var path = (string)Parameters["DownloadPath"];
|
||||
try {
|
||||
foreach (var line in await Files.ReadLinesAsync(path, CancellationToken)) {
|
||||
var parts = line.Split(',', 2);
|
||||
if (parts.Length == 2 && double.TryParse(parts[1], out var d)) { }
|
||||
}
|
||||
return true;
|
||||
} catch (ScriptFileAccessException ex) when (ex.Failure == ScriptFileAccessFailure.NotFound) {
|
||||
return false;
|
||||
}
|
||||
""";
|
||||
|
||||
Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compile_Empty_ForTheMesAlarmStatusScriptShape()
|
||||
{
|
||||
|
||||
@@ -110,6 +110,9 @@ public class CompileSurfaceParityTests
|
||||
// future read method (e.g. an ActiveAsync convenience) cannot ship on the
|
||||
// runtime accessor while the design-time gate stays blind to it.
|
||||
new object[] { typeof(AlarmsAccessor), typeof(ScriptCompileSurface.CompileAlarmsAccessor) },
|
||||
// Script file access (design 2026-09-28): a new Files.* read method cannot ship on the
|
||||
// runtime accessor without its design-time mirror.
|
||||
new object[] { typeof(FilesAccessor), typeof(ScriptCompileSurface.CompileFiles) },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Text;
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
||||
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
||||
|
||||
/// <summary>
|
||||
/// Script file access (design 2026-09-28): the host-side guard behind the <c>Files</c> global —
|
||||
/// off by default, allow-listed roots with a directory-boundary match, no traversal, no links,
|
||||
/// a size cap — plus the script-facing accessor and a real compile-and-run through
|
||||
/// <see cref="ScriptCompilationService"/> (so the trust validator is proven to accept it).
|
||||
/// </summary>
|
||||
public sealed class ScriptFileReaderTests : IDisposable
|
||||
{
|
||||
private readonly string _base;
|
||||
private readonly string _root;
|
||||
|
||||
public ScriptFileReaderTests()
|
||||
{
|
||||
_base = Path.Combine(Path.GetTempPath(), "sb-files-" + Guid.NewGuid().ToString("N"));
|
||||
_root = Path.Combine(_base, "recipes");
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_base, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private static IOptionsMonitor<SiteRuntimeOptions> Monitor(ScriptFileAccessOptions files) =>
|
||||
new StaticMonitor(new SiteRuntimeOptions { ScriptFileAccess = files });
|
||||
|
||||
private ScriptFileReader Reader(long maxBytes = 1024 * 1024, params string[] roots) =>
|
||||
new(Monitor(new ScriptFileAccessOptions
|
||||
{
|
||||
AllowedRoots = roots.Length == 0 ? [_root] : roots.ToList(),
|
||||
MaxFileBytes = maxBytes,
|
||||
}), NullLogger<ScriptFileReader>.Instance);
|
||||
|
||||
private string Write(string relative, string content)
|
||||
{
|
||||
var path = Path.Combine(_root, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, content);
|
||||
return path;
|
||||
}
|
||||
|
||||
private static async Task<ScriptFileAccessFailure> FailureOf(Func<Task> act) =>
|
||||
(await Assert.ThrowsAsync<ScriptFileAccessException>(act)).Failure;
|
||||
|
||||
[Fact]
|
||||
public async Task NoRoots_IsDisabled_ByDefault()
|
||||
{
|
||||
var reader = new ScriptFileReader(Monitor(new ScriptFileAccessOptions()), NullLogger<ScriptFileReader>.Instance);
|
||||
var path = Write("a.txt", "x");
|
||||
|
||||
Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => reader.ReadTextAsync(path, default)));
|
||||
Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => reader.ExistsAsync(path, default)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadsText_UnderRoot_AndDetectsBom()
|
||||
{
|
||||
var path = Path.Combine(_root, "bom.txt");
|
||||
File.WriteAllText(path, "ReactorTempSP,1050\n", new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
|
||||
|
||||
var text = await Reader().ReadTextAsync(path, default);
|
||||
|
||||
Assert.Equal("ReactorTempSP,1050\n", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadsFromNestedDirectory()
|
||||
{
|
||||
var path = Write(Path.Combine("wo", "1001", "r.txt"), "a,1");
|
||||
Assert.Equal("a,1", await Reader().ReadTextAsync(path, default));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("relative/r.txt")]
|
||||
public async Task RejectsEmptyOrRelative(string path)
|
||||
{
|
||||
Assert.Equal(ScriptFileAccessFailure.InvalidPath, await FailureOf(() => Reader().ReadTextAsync(path, default)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectsPathOutsideRoot()
|
||||
{
|
||||
var outside = Path.Combine(_base, "secret.txt");
|
||||
File.WriteAllText(outside, "nope");
|
||||
|
||||
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
|
||||
await FailureOf(() => Reader().ReadTextAsync(outside, default)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RejectsDotDotTraversalOutOfRoot()
|
||||
{
|
||||
File.WriteAllText(Path.Combine(_base, "secret.txt"), "nope");
|
||||
var sneaky = _root + Path.DirectorySeparatorChar + ".." + Path.DirectorySeparatorChar + "secret.txt";
|
||||
|
||||
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
|
||||
await FailureOf(() => Reader().ReadTextAsync(sneaky, default)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SiblingDirectorySharingThePrefix_IsNotInsideTheRoot()
|
||||
{
|
||||
// Root ".../recipes" must not admit ".../recipes2/x" — a directory-boundary match, not a string prefix.
|
||||
var sibling = Path.Combine(_base, "recipes2");
|
||||
Directory.CreateDirectory(sibling);
|
||||
var path = Path.Combine(sibling, "x.txt");
|
||||
File.WriteAllText(path, "nope");
|
||||
|
||||
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
|
||||
await FailureOf(() => Reader().ReadTextAsync(path, default)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheRootItself_IsNotAFile()
|
||||
{
|
||||
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
|
||||
await FailureOf(() => Reader().ReadTextAsync(_root, default)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SymlinkedFileInsideRoot_IsRefused()
|
||||
{
|
||||
var outside = Path.Combine(_base, "secret.txt");
|
||||
File.WriteAllText(outside, "nope");
|
||||
var link = Path.Combine(_root, "link.txt");
|
||||
File.CreateSymbolicLink(link, outside);
|
||||
|
||||
Assert.Equal(ScriptFileAccessFailure.LinkNotAllowed,
|
||||
await FailureOf(() => Reader().ReadTextAsync(link, default)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SymlinkedDirectoryInsideRoot_IsRefused()
|
||||
{
|
||||
var outsideDir = Path.Combine(_base, "elsewhere");
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
File.WriteAllText(Path.Combine(outsideDir, "secret.txt"), "nope");
|
||||
Directory.CreateSymbolicLink(Path.Combine(_root, "linked"), outsideDir);
|
||||
|
||||
Assert.Equal(ScriptFileAccessFailure.LinkNotAllowed,
|
||||
await FailureOf(() => Reader().ReadTextAsync(Path.Combine(_root, "linked", "secret.txt"), default)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingFile_IsNotFound_AndExistsIsFalse()
|
||||
{
|
||||
var path = Path.Combine(_root, "missing.txt");
|
||||
|
||||
Assert.Equal(ScriptFileAccessFailure.NotFound, await FailureOf(() => Reader().ReadTextAsync(path, default)));
|
||||
Assert.False(await Reader().ExistsAsync(path, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Exists_TrueForFile_ButStillEnforcesRoots()
|
||||
{
|
||||
var path = Write("r.txt", "x");
|
||||
Assert.True(await Reader().ExistsAsync(path, default));
|
||||
Assert.Equal(ScriptFileAccessFailure.OutsideAllowedRoots,
|
||||
await FailureOf(() => Reader().ExistsAsync(Path.Combine(_base, "whatever.txt"), default)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FileOverTheCap_IsTooLarge()
|
||||
{
|
||||
var path = Write("big.txt", new string('x', 2048));
|
||||
|
||||
Assert.Equal(ScriptFileAccessFailure.TooLarge, await FailureOf(() => Reader(maxBytes: 1024).ReadTextAsync(path, default)));
|
||||
Assert.Equal(2048, (await Reader(maxBytes: 2048).ReadTextAsync(path, default)).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SecondRoot_IsHonoured()
|
||||
{
|
||||
var other = Path.Combine(_base, "other");
|
||||
Directory.CreateDirectory(other);
|
||||
var path = Path.Combine(other, "o.txt");
|
||||
File.WriteAllText(path, "ok");
|
||||
|
||||
Assert.Equal("ok", await Reader(1024 * 1024, _root, other).ReadTextAsync(path, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RootConfiguredWithTrailingSeparator_Works()
|
||||
{
|
||||
var path = Write("t.txt", "ok");
|
||||
Assert.Equal("ok", await Reader(1024 * 1024, _root + Path.DirectorySeparatorChar).ReadTextAsync(path, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SplitLines_HandlesMixedEndings_AndDropsTrailingEmptyLine()
|
||||
{
|
||||
Assert.Equal(["a", "b", "c", "", "d"], FilesAccessor.SplitLines("a\r\nb\nc\r\rd\n"));
|
||||
Assert.Empty(FilesAccessor.SplitLines(""));
|
||||
Assert.Equal([""], FilesAccessor.SplitLines("\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accessor_WithoutReader_ThrowsDisabled()
|
||||
{
|
||||
var files = new FilesAccessor(reader: null, CancellationToken.None);
|
||||
Assert.Equal(ScriptFileAccessFailure.Disabled, await FailureOf(() => files.ReadTextAsync("C:/x.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accessor_HonoursTheScriptDeadline()
|
||||
{
|
||||
var path = Write("r.txt", "x");
|
||||
using var expired = new CancellationTokenSource();
|
||||
await expired.CancelAsync();
|
||||
var files = new FilesAccessor(Reader(), expired.Token);
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => files.ReadTextAsync(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RealScript_CompilesThroughTheTrustGate_AndParsesARecipe()
|
||||
{
|
||||
// End-to-end at the script boundary: the runtime compiler (trust validator included)
|
||||
// accepts a script that uses Files + catches ScriptFileAccessException by name, and the
|
||||
// compiled script reads + parses a recipe through the real ScriptRuntimeContext wiring.
|
||||
var path = Write("recipe.txt", "# comment\nReactorTempSP,1050\nCoatingType,Aluminide\n\nTargetRuntime,480\n");
|
||||
const string code = """
|
||||
var path = (string)Parameters["Path"];
|
||||
try {
|
||||
var values = new System.Collections.Generic.Dictionary<string, string>();
|
||||
foreach (var line in await Files.ReadLinesAsync(path)) {
|
||||
if (line.Trim().Length == 0 || line.TrimStart().StartsWith("#")) continue;
|
||||
var i = line.IndexOf(',');
|
||||
values[line.Substring(0, i).Trim()] = line.Substring(i + 1).Trim();
|
||||
}
|
||||
var exists = await Instance.Files.ExistsAsync(path);
|
||||
return values.Count + "|" + values["CoatingType"] + "|" + exists;
|
||||
} catch (ScriptFileAccessException ex) when (ex.Failure == ScriptFileAccessFailure.NotFound) {
|
||||
return "missing";
|
||||
}
|
||||
""";
|
||||
var compiled = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance).Compile("RecipeParse", code);
|
||||
Assert.True(compiled.IsSuccess, string.Join("; ", compiled.Errors));
|
||||
|
||||
var ctx = new ScriptRuntimeContext(
|
||||
ActorRefs.Nobody, sharedScriptLibrary: null!, currentCallDepth: 0, maxCallDepth: 10,
|
||||
askTimeout: TimeSpan.FromSeconds(5), instanceName: "SBTEST01",
|
||||
logger: NullLogger<ScriptRuntimeContext>.Instance, fileReader: Reader());
|
||||
var run = async (string p) => (await compiled.CompiledScript!.RunAsync(new ScriptGlobals
|
||||
{
|
||||
Instance = ctx,
|
||||
Parameters = new ScriptParameters(new Dictionary<string, object?> { ["Path"] = p }),
|
||||
})).ReturnValue;
|
||||
|
||||
Assert.Equal("3|Aluminide|True", await run(path));
|
||||
Assert.Equal("missing", await run(Path.Combine(_root, "nope.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawSystemIo_IsStillRejectedByTheTrustGate()
|
||||
{
|
||||
// The Files global is the ONLY sanctioned door — System.IO stays forbidden.
|
||||
var compiled = new ScriptCompilationService(NullLogger<ScriptCompilationService>.Instance)
|
||||
.Compile("Raw", "return System.IO.File.ReadAllText(\"C:/x.txt\");");
|
||||
Assert.False(compiled.IsSuccess);
|
||||
}
|
||||
|
||||
private sealed class StaticMonitor(SiteRuntimeOptions value) : IOptionsMonitor<SiteRuntimeOptions>
|
||||
{
|
||||
public SiteRuntimeOptions CurrentValue => value;
|
||||
public SiteRuntimeOptions Get(string? name) => value;
|
||||
public IDisposable? OnChange(Action<SiteRuntimeOptions, string?> listener) => null;
|
||||
}
|
||||
}
|
||||
@@ -201,4 +201,46 @@ public class SiteRuntimeOptionsValidatorTests
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("TagSubscribeInFlightCeilingMs", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptFileAccess_DefaultsToDisabled_AndIsValid()
|
||||
{
|
||||
var options = new SiteRuntimeOptions();
|
||||
Assert.Empty(options.ScriptFileAccess.AllowedRoots);
|
||||
Assert.True(Validate(options).Succeeded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptFileAccess_RelativeRoot_IsRejected()
|
||||
{
|
||||
var options = new SiteRuntimeOptions();
|
||||
options.ScriptFileAccess.AllowedRoots.Add("recipes");
|
||||
|
||||
var result = Validate(options);
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("ScriptFileAccess:AllowedRoots:0", result.FailureMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScriptFileAccess_AbsoluteRoot_IsAccepted()
|
||||
{
|
||||
var options = new SiteRuntimeOptions();
|
||||
options.ScriptFileAccess.AllowedRoots.Add(Path.GetTempPath());
|
||||
Assert.True(Validate(options).Succeeded);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L)]
|
||||
[InlineData(64L * 1024 * 1024 + 1)]
|
||||
public void ScriptFileAccess_MaxFileBytesOutOfRange_IsRejected(long max)
|
||||
{
|
||||
var options = new SiteRuntimeOptions();
|
||||
options.ScriptFileAccess.MaxFileBytes = max;
|
||||
|
||||
var result = Validate(options);
|
||||
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains("ScriptFileAccess:MaxFileBytes", result.FailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user