feat(scripts): read-only, allow-listed Files global for site scripts; Delmia test receiver parses recipe files
Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync / ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no reparse point on the file or any directory below the root, size cap before and during the read. Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts joins the default script imports on every surface so it binds by short name. Mirrored on ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled). Inbound (central) scripts do not get it. Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS selects groups (rig: AD).
This commit is contained in:
@@ -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,107 @@ 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)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user