Files
ScadaBridge/docker/verify-mes-test-machine.sh
T
Joseph Doherty b3021e7c5f 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).
2026-09-28 10:36:42 -04:00

173 lines
11 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
# Live gate for docker/seed-mes-test-machine.sh — drives the REAL inbound methods (MesMoveIn, MesMoveOut,
# DelmiaRecipeDownload, SimpleAlarmStatusRequest, AlarmStatusRequest) through `flow-debug run-method`
# (the same inbound executor an HTTP call reaches, minus the API-key check — no key needed) against the
# 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 \
# 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.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
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
CLI, URL = os.environ["SB_CLI"], os.environ["SB_URL"]
AUTH = ["--url", URL, "--format=json", "--username", os.environ["SB_USER"], "--password", os.environ["SB_PASS"]]
def cli(*args, timeout=180):
r = subprocess.run([CLI, *args, *AUTH], capture_output=True, text=True, timeout=timeout)
if r.returncode != 0:
raise RuntimeError(f"{' '.join(args[:3])}: {r.stderr.strip() or r.stdout.strip()}")
return json.loads(r.stdout)
methods = {m["name"]: m["id"] for m in cli("api-method", "list")}
instances = {i["uniqueName"]: i["id"] for i in cli("instance", "list")}
def call(method, params):
"""Run an inbound method via the Flow Debugger and return its response body (dict)."""
d = cli("flow-debug", "run-method", "--method-id", str(methods[method]), "--params", json.dumps(params),
"--timeout-seconds", "90", timeout=150)
ev = next(e for e in d["events"] if e["action"] == "ApiInbound.InboundRequest")
det = json.loads(ev["detailsJson"])
if det.get("status") != "Delivered":
raise RuntimeError(f"{method} not delivered: {det.get('status')} {det.get('errorMessage')}")
return json.loads(det["responseSummary"])
def write(inst, attr, value):
subprocess.run([CLI, "flow-debug", "run-value-change", "--instance-id", str(instances[inst]), "--attribute", attr,
"--value", str(value), "--wait-seconds", "1", *AUTH], capture_output=True, text=True, timeout=90)
passed = failed = 0
def check(label, cond, detail=""):
global passed, failed
if cond: passed += 1; print(f" PASS {label}")
else: failed += 1; print(f" FAIL {label} {detail}")
WO = [{"WorkOrderNumber": "WO-1001", "PartNumber": "PN-2001"}]
def movein(sap, wo=WO): return call("MesMoveIn", {"SAPID": sap, "ContainerNumber": "CN-1", "JobSequenceNumber": "10", "NumberWorkOrders": len(wo), "WorkOrders": wo, "OperatorName": "sb-test"})
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 [])
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.")}
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)
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)
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)
PY