feat(docker): seed + verify scripts for the MES-reportable alarm demo — rig live gate PASS
Task 13/14. seed-mes-alarm-demo.sh builds a template whose three computed alarms differ in how they acquire the flag (template --mes-reportable, nothing, instance override over a template false), a site Call script projecting them through Alarms.CurrentAsync() / MesReportableAsync(), and two inbound methods + a scoped API key. Idempotent by name lookup. verify-mes-alarm-demo.sh drives the real inbound HTTP surface: PASS 14/14 on the docker rig (migration 20260911135501_AddAlarmMesReportable applied at boot). Playwright AlarmOverride_MesReportable_RoundTrips passed. Results + the facts the gate established are in design doc section 8. Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Live gate for the MES-REPORTABLE ALARM flag (design
|
||||
# docs/plans/2026-09-11-mes-reportable-alarm-flag-design.md, Task 14). Drives the objects seeded by
|
||||
# docker/seed-mes-alarm-demo.sh through the REAL inbound HTTP surface (Traefik -> active central ->
|
||||
# site script -> Instance Actor alarm snapshot), so every assertion below is end-to-end.
|
||||
#
|
||||
# Steps:
|
||||
# A 96 -> AlarmStatus : only HighTemp, IsFlaggedForMES true (template flag)
|
||||
# B 100 -> AlarmStatus : HighTemp(true) + TempSpike(false) unfiltered;
|
||||
# FlaggedOnly=true drops TempSpike and keeps HighTemp
|
||||
# C -1 -> SimpleAlarmStatus : SensorFault true (INSTANCE override beats template false),
|
||||
# HighTemp absent (-1 trips no high band)
|
||||
# D -1 -> AlarmStatus MinSeverity : 600 leaves SensorFault (700) only
|
||||
# E 20 -> AlarmStatus : WasSuccessful true, zero alarms
|
||||
# F DelmiaSim01 : machine without the script -> "not supported on machine"
|
||||
# G NoSuchMachine : unknown machine -> "not found"
|
||||
#
|
||||
# Usage: MES_ALARM_DEMO_KEY=<token from the seed> bash docker/verify-mes-alarm-demo.sh
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
|
||||
URL="${SCADABRIDGE_URL:-http://localhost:9000}"
|
||||
A=(--url "$URL" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
|
||||
KEY="${MES_ALARM_DEMO_KEY:?set MES_ALARM_DEMO_KEY to the token printed by docker/seed-mes-alarm-demo.sh}"
|
||||
INSTANCE_NAME="${INSTANCE_NAME:-MesAlarmDemo01}"
|
||||
# A deployed instance that does NOT have an AlarmStatus script (docker/seed-delmia-sim.sh).
|
||||
OTHER_INSTANCE="${OTHER_INSTANCE:-DelmiaSim01}"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
pass() { PASS=$((PASS+1)); echo " PASS $*"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo " FAIL $*"; }
|
||||
|
||||
find_id_opt() {
|
||||
python3 -c 'import json,sys
|
||||
k,v=sys.argv[1],sys.argv[2]
|
||||
print(next((str(x["id"]) for x in json.load(sys.stdin) if str(x.get(k))==v), ""))' "$1" "$2"
|
||||
}
|
||||
|
||||
INSTANCE_ID=$("$CLI" instance list "${A[@]}" | find_id_opt uniqueName "$INSTANCE_NAME")
|
||||
[ -n "$INSTANCE_ID" ] || { echo "instance $INSTANCE_NAME not found — run docker/seed-mes-alarm-demo.sh first"; exit 1; }
|
||||
echo "instance $INSTANCE_NAME id=$INSTANCE_ID"
|
||||
|
||||
trip() { # $1 = Temperature value; the write trips the computed alarms synchronously at the site
|
||||
"$CLI" flow-debug run-value-change "${A[@]}" --instance-id "$INSTANCE_ID" --attribute Temperature --value="$1" > /dev/null
|
||||
sleep 2
|
||||
}
|
||||
|
||||
call() { # $1 = method name, $2 = JSON body -> response body on stdout
|
||||
curl -sS -X POST "$URL/api/$1" -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d "$2"
|
||||
}
|
||||
|
||||
# $1 = response JSON, $2.. = python assertion expressions over `d` (the parsed response) and
|
||||
# `alarms` (a {name: row} map); prints OK or the offending value.
|
||||
assert() { # $1 = label, $2 = response, $3 = python expression
|
||||
local out
|
||||
out=$(python3 -c '
|
||||
import json,sys
|
||||
d=json.loads(sys.argv[1])
|
||||
body=d.get("data", d) if isinstance(d, dict) else d
|
||||
alarms={a["Name"]: a for a in (body.get("Alarms") or [])}
|
||||
names=sorted(alarms)
|
||||
ok=eval(sys.argv[2])
|
||||
print("OK" if ok else "got WasSuccessful=%r names=%r rows=%s" % (body.get("WasSuccessful"), names, json.dumps([alarms[n] for n in names])[:600]))
|
||||
' "$2" "$3" 2>&1) || { fail "$1 (assertion error: $out)"; return; }
|
||||
if [ "$out" = "OK" ]; then pass "$1"; else fail "$1 — $out"; fi
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "=== A: Temperature 96 -> HighTemp only, flagged by the TEMPLATE ==="
|
||||
trip 96
|
||||
RA=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}")
|
||||
assert "A1 WasSuccessful true, exactly 1 alarm, named HighTemp" "$RA" \
|
||||
'body.get("WasSuccessful") is True and names==["HighTemp"]'
|
||||
# StatusCode is "Triggered.Acked", not "Triggered": AlarmConditionStateFactory.ForComputed reports
|
||||
# Acknowledged == true for every computed alarm (there is no ack-back for them), and AckDT is null.
|
||||
assert "A2 HighTemp IsFlaggedForMES true, Severity 500, StatusCode Triggered.Acked, AckDT null" "$RA" \
|
||||
'alarms["HighTemp"]["IsFlaggedForMES"] is True and alarms["HighTemp"]["Severity"]==500 and alarms["HighTemp"]["StatusCode"]=="Triggered.Acked" and alarms["HighTemp"]["AckDT"] is None'
|
||||
assert "A3 HierarchicalName is <machine>.<alarm>" "$RA" \
|
||||
'alarms["HighTemp"]["HierarchicalName"]=="'"$INSTANCE_NAME"'.HighTemp"'
|
||||
# H (design §6 step 7): cross-check the API answer against the site's own view of the same moment.
|
||||
# NOTE the snapshot's own `isMesReportable` is always false here and that is BY DESIGN — the flag is
|
||||
# deliberately not on the gRPC protos in this slice (design §2.2), so central never sees it; only the
|
||||
# site-local script snapshot is stamped. The cross-check is therefore about alarm ACTIVITY.
|
||||
SNAP=$("$CLI" debug snapshot "${A[@]}" --id "$INSTANCE_ID" 2>/dev/null || echo '{}')
|
||||
HSTATE=$(echo "$SNAP" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
rows=(d.get("data") or d).get("alarmStates") or []
|
||||
r=next((r for r in rows if r.get("alarmName")=="HighTemp"), None)
|
||||
# AlarmState: Active=0, Normal=1; condition.active is the same fact spelled out.
|
||||
print("MISSING" if r is None else ("Active" if (r.get("condition") or {}).get("active") or r.get("state")==0 else "Normal"))' 2>/dev/null || echo "MISSING")
|
||||
if [ "$HSTATE" = "Active" ]; then pass "H debug snapshot shows HighTemp Active"; else fail "H debug snapshot HighTemp state=$HSTATE"; fi
|
||||
|
||||
echo ""
|
||||
echo "=== B: Temperature 100 -> HighTemp(HiHi) + TempSpike; FlaggedOnly drops TempSpike ==="
|
||||
trip 100
|
||||
RB=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\",\"AlarmFilter\":{\"FlaggedOnly\":false}}")
|
||||
assert "B1 unfiltered: HighTemp + TempSpike" "$RB" \
|
||||
'body.get("WasSuccessful") is True and names==["HighTemp","TempSpike"]'
|
||||
assert "B2 HighTemp flagged true, TempSpike flagged false" "$RB" \
|
||||
'alarms["HighTemp"]["IsFlaggedForMES"] is True and alarms["TempSpike"]["IsFlaggedForMES"] is False'
|
||||
RB2=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\",\"AlarmFilter\":{\"FlaggedOnly\":true}}")
|
||||
assert "B3 FlaggedOnly=true: HighTemp present, TempSpike absent" "$RB2" \
|
||||
'body.get("WasSuccessful") is True and names==["HighTemp"]'
|
||||
|
||||
echo ""
|
||||
echo "=== C: Temperature -1 -> SensorFault reportable by INSTANCE OVERRIDE ==="
|
||||
trip -1
|
||||
RC=$(call SimpleAlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}")
|
||||
assert "C1 SimpleAlarmStatus returns SensorFault only (flagged-only)" "$RC" \
|
||||
'body.get("WasSuccessful") is True and names==["SensorFault"]'
|
||||
assert "C2 SensorFault IsFlaggedForMES true (override beats template false), Severity 700" "$RC" \
|
||||
'alarms["SensorFault"]["IsFlaggedForMES"] is True and alarms["SensorFault"]["Severity"]==700'
|
||||
RC2=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}")
|
||||
assert "C3 unfiltered at -1: HighTemp absent, SensorFault present" "$RC2" \
|
||||
'names==["SensorFault"]'
|
||||
|
||||
echo ""
|
||||
echo "=== D: MinSeverity 600 keeps SensorFault (700) only ==="
|
||||
RD=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\",\"AlarmFilter\":{\"MinSeverity\":600}}")
|
||||
assert "D1 MinSeverity=600 -> SensorFault only" "$RD" \
|
||||
'body.get("WasSuccessful") is True and names==["SensorFault"] and alarms["SensorFault"]["Severity"]>=600'
|
||||
|
||||
echo ""
|
||||
echo "=== E: Temperature 20 -> everything clear ==="
|
||||
trip 20
|
||||
RE=$(call AlarmStatus "{\"MachineCode\":\"$INSTANCE_NAME\"}")
|
||||
assert "E1 WasSuccessful true with zero alarms" "$RE" \
|
||||
'body.get("WasSuccessful") is True and names==[]'
|
||||
|
||||
echo ""
|
||||
echo "=== F: a machine without the AlarmStatus script ==="
|
||||
OTHER_ID=$("$CLI" instance list "${A[@]}" | find_id_opt uniqueName "$OTHER_INSTANCE")
|
||||
if [ -z "$OTHER_ID" ]; then
|
||||
echo " SKIP $OTHER_INSTANCE is not on this rig (docker/seed-delmia-sim.sh not run) — step F skipped"
|
||||
else
|
||||
RF=$(call AlarmStatus "{\"MachineCode\":\"$OTHER_INSTANCE\"}")
|
||||
assert "F1 WasSuccessful false, ErrorText says not supported on machine" "$RF" \
|
||||
'body.get("WasSuccessful") is False and "not supported on machine" in (body.get("ErrorText") or "")'
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== G: an unknown machine ==="
|
||||
RG=$(call AlarmStatus '{"MachineCode":"NoSuchMachine"}')
|
||||
assert "G1 WasSuccessful false, ErrorText contains 'not found'" "$RG" \
|
||||
'body.get("WasSuccessful") is False and "not found" in (body.get("ErrorText") or "")'
|
||||
|
||||
echo ""
|
||||
TOTAL=$((PASS+FAIL))
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
echo "PASS $PASS/$TOTAL"
|
||||
else
|
||||
echo "FAIL $FAIL of $TOTAL (passed $PASS/$TOTAL)"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user