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
This commit is contained in:
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user