#!/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 "\t\t\t" 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