Files
ScadaBridge/docker/verify-pending-updates.sh
T
Joseph Doherty a672f91d90 test(rig): verify-pending-updates.sh + Playwright smoke for the indicator and Sites badge
Rig rebuilt from this branch (image built at d8979664; migration
20260914173147_AddSiteArtifactRevision applied by central-b on boot).

verify-pending-updates.sh drives the CLI end to end: the site list carries the
artifact-revision fields, a fleet-wide deploy stamps every site with a
sha256:<64 hex> hash and a fresh timestamp, creating a shared script leaves the
stamp alone until the next deploy and then moves EVERY site's hash, and deleting
it again returns every hash byte-for-byte to its pre-create value — so the hash
is content-addressed, not deploy-addressed. PASS 13 / FAIL 0.

Step A asserts the SHAPE rather than key presence: the management API serializer
omits null properties, so on a never-deployed site the two keys are absent, not
present-and-null. Step E is a soft reachability check only; the badge text and
the rail indicator land after the Blazor circuit's post-render load.

PendingUpdatesTests covers that browser half — the rail footer settles off
"Checking for updates…", the Sites page carries an Artifacts badge per site card
plus a working Re-check control, and the rail nav's scroll pane computes
overflow-y: auto at lg+. 3/3 passed against the rig (rail read "Up to date",
3 badges over 3 cards, all "Artifacts current").

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:10:24 -04:00

213 lines
9.5 KiB
Bash
Executable File

#!/bin/bash
set -uo pipefail
# Live gate for the PER-SITE ARTIFACT REVISION that backs the pending-updates indicator
# (design docs/plans/2026-09-14-pending-updates-design.md, plan task 10). Drives the real
# CLI -> Traefik -> active central -> ArtifactDeploymentService path, so every assertion
# below is end-to-end against the running rig.
#
# Steps:
# A site list carries artifactRevisionHash / artifactsDeployedAt (the columns are live)
# CAVEAT: the management API serializer omits null properties, so on a site that has
# never had an artifact deploy the two keys are ABSENT rather than present-and-null.
# A therefore asserts the shape (absent, or a well-formed sha256) and B is the proof.
# B deploy artifacts fleet-wide -> every site stamped sha256:<64 hex>, deployed-at is now
# C create a throwaway shared script -> hashes UNCHANGED until a deploy, then ALL CHANGED
# D delete it, redeploy -> every hash is byte-identical to B again (determinism)
# E Central UI /admin/sites answers 200 and serves the Artifacts badge markup (soft)
#
# NOTE on step E: the Sites page badge and the rail-footer indicator are Blazor Server
# interactive content — the badge text only lands after the circuit's post-render
# LoadUpdatesAsync, which curl never sees. E is therefore a reachability + static-markup
# smoke only; the real browser coverage is
# tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/PendingUpdatesTests.cs.
#
# Usage: bash docker/verify-pending-updates.sh
# SCADABRIDGE_URL=http://localhost:9000 bash docker/verify-pending-updates.sh
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="${SCADABRIDGE_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}")
PROBE_NAME="PendingUpdatesProbe_$(date +%s)"
PASS=0; FAIL=0
pass() { PASS=$((PASS+1)); echo " PASS $*"; }
fail() { FAIL=$((FAIL+1)); echo " FAIL $*"; }
[ -x "$CLI" ] || { echo "CLI not found at $CLI — build it: dotnet build src/ZB.MOM.WW.ScadaBridge.CLI"; exit 1; }
# The CLI's JSON envelope varies by command (bare array vs {"data": [...]}); normalise once.
UNWRAP='
import json,sys
d=json.load(sys.stdin)
if isinstance(d, dict):
d = d.get("data", d)
'
# Prints one "<siteId>\t<identifier>\t<hash|->\t<deployedAt|->" line per site, id-sorted.
site_rows() {
"$CLI" site list "${A[@]}" 2>/dev/null | python3 -c "$UNWRAP"'
for s in sorted(d, key=lambda x: x["id"]):
print("%s\t%s\t%s\t%s" % (s["id"], s.get("siteIdentifier"),
s.get("artifactRevisionHash") or "-",
s.get("artifactsDeployedAt") or "-"))
'
}
deploy_all() {
"$CLI" site deploy-artifacts "${A[@]}" > /tmp/pu-deploy.$$ 2>&1
local rc=$?
if [ $rc -ne 0 ]; then
echo " deploy-artifacts exited $rc:"; sed 's/^/ /' /tmp/pu-deploy.$$
fi
rm -f /tmp/pu-deploy.$$
return $rc
}
echo "=== ScadaBridge pending-updates live gate ==="
echo "management url: $URL"
echo ""
echo "=== A: site list exposes the artifact-revision columns ==="
RAW=$("$CLI" site list "${A[@]}" 2>&1)
OUT=$(printf '%s' "$RAW" | python3 -c "$UNWRAP"'
import re
bad=[]
stamped=0
for s in d:
h=s.get("artifactRevisionHash")
at=s.get("artifactsDeployedAt")
if h is None and at is None:
continue # never deployed: both keys omitted together
if h is not None and at is not None and re.fullmatch(r"sha256:[0-9a-f]{64}", h):
stamped+=1
continue
bad.append("%s(hash=%r,at=%r)" % (s.get("siteIdentifier"), h, at))
print("EMPTY" if not d else
("OK %d site(s), %d already stamped" % (len(d), stamped) if not bad
else "malformed on " + ",".join(bad)))
' 2>&1)
case "$OUT" in
OK*) pass "A ${OUT#OK }: artifact-revision fields present and well-formed (or absent = never deployed)" ;;
EMPTY) fail "A site list returned no sites — run docker/seed-sites.sh"; echo "$RAW" | head -3; exit 1 ;;
*) fail "A $OUT"; echo "$RAW" | head -5; exit 1 ;;
esac
SITE_COUNT=$(site_rows | wc -l | tr -d ' ')
echo " $SITE_COUNT sites"
echo ""
echo "=== B: fleet-wide deploy stamps every site ==="
deploy_all && pass "B0 site deploy-artifacts (fleet-wide) succeeded" \
|| fail "B0 site deploy-artifacts (fleet-wide) failed"
B_ROWS=$(site_rows)
OUT=$(printf '%s\n' "$B_ROWS" | python3 -c '
import sys,re,datetime
bad_hash=[]; bad_time=[]
now=datetime.datetime.now(datetime.timezone.utc)
rows=[l.rstrip("\n").split("\t") for l in sys.stdin if l.strip()]
for sid,ident,h,at in rows:
if not re.fullmatch(r"sha256:[0-9a-f]{64}", h or ""):
bad_hash.append("%s=%r" % (ident, h))
try:
t=datetime.datetime.fromisoformat((at or "").replace("Z","+00:00"))
if t.tzinfo is None: t=t.replace(tzinfo=datetime.timezone.utc)
age=(now-t).total_seconds()
if not (-60 <= age <= 300): bad_time.append("%s=%.0fs old" % (ident, age))
except Exception:
bad_time.append("%s=%r" % (ident, at))
print("HASH " + ("OK" if not bad_hash else ",".join(bad_hash)))
print("TIME " + ("OK" if not bad_time else ",".join(bad_time)))
')
HASH_LINE=$(printf '%s\n' "$OUT" | sed -n 's/^HASH //p')
TIME_LINE=$(printf '%s\n' "$OUT" | sed -n 's/^TIME //p')
[ "$HASH_LINE" = "OK" ] && pass "B1 every site hash is sha256:<64 hex> (71 chars)" \
|| fail "B1 bad hash(es): $HASH_LINE"
[ "$TIME_LINE" = "OK" ] && pass "B2 every artifactsDeployedAt is within the last 5 minutes" \
|| fail "B2 stale/unparseable deployed-at: $TIME_LINE"
echo ""
echo "=== C: a new shared script moves the hash — but only on deploy ==="
CREATE=$("$CLI" shared-script create "${A[@]}" --name "$PROBE_NAME" \
--code 'var pendingUpdatesProbe = 1; return pendingUpdatesProbe;' 2>&1)
# `shared-script create` echoes the created entity, so the id comes straight back.
PROBE_ID=$(printf '%s' "$CREATE" | python3 -c "$UNWRAP"'
print(d.get("id","") if isinstance(d, dict) else "")
' 2>/dev/null)
if [ -n "$PROBE_ID" ]; then
pass "C0 created shared script $PROBE_NAME (id=$PROBE_ID)"
else
fail "C0 shared-script create failed: $(printf '%s' "$CREATE" | head -2)"
exit 1
fi
# Cleanup even if a later assertion aborts the run: the rig must not keep the probe.
cleanup() {
if [ -n "${PROBE_ID:-}" ]; then
"$CLI" shared-script delete "${A[@]}" --id "$PROBE_ID" >/dev/null 2>&1 && PROBE_ID=""
fi
}
trap cleanup EXIT
C_BEFORE=$(site_rows)
# Compare hash columns only — artifactsDeployedAt is untouched here by definition.
if [ "$(printf '%s\n' "$C_BEFORE" | cut -f1,3)" = "$(printf '%s\n' "$B_ROWS" | cut -f1,3)" ]; then
pass "C1 hashes UNCHANGED by the create (the stamp only moves on deploy)"
else
fail "C1 a hash moved without a deploy"
diff <(printf '%s\n' "$B_ROWS" | cut -f1,3) <(printf '%s\n' "$C_BEFORE" | cut -f1,3) | sed 's/^/ /'
fi
deploy_all && pass "C2 redeploy after the create succeeded" || fail "C2 redeploy after the create failed"
C_ROWS=$(site_rows)
UNCHANGED=$(diff <(printf '%s\n' "$B_ROWS" | cut -f1,3) <(printf '%s\n' "$C_ROWS" | cut -f1,3) >/dev/null && echo yes || echo no)
if [ "$UNCHANGED" = "no" ]; then
# Every site must have moved, not just some.
SAME=$(join -t$'\t' <(printf '%s\n' "$B_ROWS" | cut -f1,2,3) <(printf '%s\n' "$C_ROWS" | cut -f1,3) \
| awk -F'\t' '$3==$4 {print $2}')
[ -z "$SAME" ] && pass "C3 EVERY site's hash changed after deploying the new shared script" \
|| fail "C3 hash did not move for: $(echo $SAME)"
else
fail "C3 no site's hash changed after deploying the new shared script"
fi
echo ""
echo "=== D: deleting it again restores the exact B hashes (determinism) ==="
"$CLI" shared-script delete "${A[@]}" --id "$PROBE_ID" >/dev/null 2>&1 \
&& { pass "D0 deleted shared script $PROBE_NAME"; PROBE_ID=""; } \
|| fail "D0 shared-script delete failed"
deploy_all && pass "D1 redeploy after the delete succeeded" || fail "D1 redeploy after the delete failed"
D_ROWS=$(site_rows)
if diff <(printf '%s\n' "$B_ROWS" | cut -f1,3) <(printf '%s\n' "$D_ROWS" | cut -f1,3) >/dev/null; then
pass "D2 every hash is byte-identical to B — the hash is content-addressed, not deploy-addressed"
else
fail "D2 hashes did not return to their B values"
diff <(printf '%s\n' "$B_ROWS" | cut -f1,3) <(printf '%s\n' "$D_ROWS" | cut -f1,3) | sed 's/^/ /'
fi
echo ""
echo "=== E: Central UI Sites page reachability (soft — see the header note) ==="
BODY=$(curl -sS -w '\n%{http_code}' "$URL/admin/sites" 2>/dev/null)
CODE=$(printf '%s' "$BODY" | tail -1)
if [ "$CODE" = "200" ]; then
pass "E1 GET /admin/sites -> 200"
if printf '%s' "$BODY" | grep -q 'Re-check pending updates'; then
pass "E2 prerendered markup carries the Re-check pending updates control"
else
echo " NOTE E2 the Re-check control is not in the prerendered HTML (interactive render) — covered by PendingUpdatesTests"
fi
else
echo " NOTE E1 GET /admin/sites -> $CODE (login enabled on this rig?) — covered by PendingUpdatesTests"
fi
echo ""
echo "=== Summary ==="
printf '%-4s %-14s %-14s %-14s %-14s\n' "id" "site" "B hash" "C hash" "D hash"
join -t$'\t' <(join -t$'\t' <(printf '%s\n' "$B_ROWS" | cut -f1,2,3) <(printf '%s\n' "$C_ROWS" | cut -f1,3)) \
<(printf '%s\n' "$D_ROWS" | cut -f1,3) \
| awk -F'\t' '{printf "%-4s %-14s %-14s %-14s %-14s\n", $1, $2, substr($3,8,12), substr($4,8,12), substr($5,8,12)}'
echo ""
echo "PASS $PASS / FAIL $FAIL"
[ "$FAIL" -eq 0 ] || exit 1