a672f91d90
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
213 lines
9.5 KiB
Bash
Executable File
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
|