2026-09-11 13:40:14 -04:00
|
|
|
#!/usr/bin/env bash
|
2026-09-18 14:30:20 -04:00
|
|
|
# Verify a CloudFront SPA deploy from deploy-web.yaml.
|
2026-09-11 13:40:14 -04:00
|
|
|
#
|
2026-09-18 14:30:20 -04:00
|
|
|
# Fail fast when any origin_path is still non-empty. Poll while the
|
|
|
|
|
# distribution is InProgress or the served index.html hash does not match
|
|
|
|
|
# the build. Then smoke-check caching headers, hashed assets, the baked
|
|
|
|
|
# API URL, and CORS against the target API.
|
2026-09-11 13:40:14 -04:00
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
|
|
|
|
|
EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}"
|
|
|
|
|
SITE_URL="${SITE_URL:-}"
|
|
|
|
|
API_URL="${API_URL:-https://api.dev.seahaven.com/api}"
|
|
|
|
|
BUDGET="${BUDGET:-40}"
|
|
|
|
|
INTERVAL="${INTERVAL:-15}"
|
|
|
|
|
|
2026-09-18 14:30:20 -04:00
|
|
|
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" ]]; then
|
|
|
|
|
echo "Usage: DISTRIBUTION_ID EXPECTED_INDEX_SHA256 SITE_URL must be set." >&2
|
2026-09-11 13:40:14 -04:00
|
|
|
exit 2
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
SITE_URL="${SITE_URL%/}"
|
2026-09-18 14:30:20 -04:00
|
|
|
API_URL="${API_URL%/}"
|
2026-09-11 13:40:14 -04:00
|
|
|
|
|
|
|
|
sha256_of() {
|
|
|
|
|
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
read_distribution_json() {
|
|
|
|
|
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parse_distribution() {
|
|
|
|
|
python3 -c '
|
2026-09-18 14:30:20 -04:00
|
|
|
import json, sys
|
2026-09-11 13:40:14 -04:00
|
|
|
payload = json.load(sys.stdin)
|
|
|
|
|
dist = payload.get("Distribution") or payload
|
|
|
|
|
status = dist.get("Status") or "Unknown"
|
|
|
|
|
config = dist.get("DistributionConfig") or {}
|
|
|
|
|
origins = ((config.get("Origins") or {}).get("Items")) or []
|
|
|
|
|
paths = [origin.get("OriginPath") or "" for origin in origins]
|
2026-09-18 14:30:20 -04:00
|
|
|
nonempty = [path for path in paths if path]
|
2026-09-11 13:40:14 -04:00
|
|
|
print(status)
|
|
|
|
|
print("\x1f".join(paths))
|
2026-09-18 14:30:20 -04:00
|
|
|
print("yes" if nonempty else "no")
|
2026-09-11 13:40:14 -04:00
|
|
|
'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
last_status="Unknown"
|
|
|
|
|
last_paths="Unknown"
|
|
|
|
|
last_hash="Unknown"
|
2026-09-18 14:30:20 -04:00
|
|
|
last_path_nonempty="no"
|
2026-09-11 13:40:14 -04:00
|
|
|
|
|
|
|
|
observe() {
|
|
|
|
|
local parsed
|
2026-09-18 14:30:20 -04:00
|
|
|
parsed="$(read_distribution_json | parse_distribution)"
|
2026-09-11 13:40:14 -04:00
|
|
|
last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')"
|
|
|
|
|
last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')"
|
2026-09-18 14:30:20 -04:00
|
|
|
last_path_nonempty="$(printf '%s\n' "${parsed}" | sed -n '3p')"
|
2026-09-11 17:10:51 -04:00
|
|
|
local hash
|
|
|
|
|
if hash="$(curl -fsS --max-time 30 "${SITE_URL}/" | sha256_of)" && [[ -n "${hash}" ]]; then
|
|
|
|
|
last_hash="${hash}"
|
2026-09-11 13:40:14 -04:00
|
|
|
else
|
|
|
|
|
last_hash="unreachable"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
report_state() {
|
2026-09-18 14:30:20 -04:00
|
|
|
echo "last observed: status=${last_status} origins=${last_paths} served_sha256=${last_hash}"
|
2026-09-11 13:40:14 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-18 14:30:20 -04:00
|
|
|
fail_fast_if_origin_path() {
|
|
|
|
|
if [[ "${last_path_nonempty}" == "yes" ]]; then
|
|
|
|
|
echo "FAIL: live origin_path values are '${last_paths}'; expected empty bucket-root origins." >&2
|
2026-09-11 13:40:14 -04:00
|
|
|
report_state >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
observe
|
2026-09-18 14:30:20 -04:00
|
|
|
fail_fast_if_origin_path
|
2026-09-11 13:40:14 -04:00
|
|
|
|
|
|
|
|
attempt=0
|
|
|
|
|
while [[ "${attempt}" -lt "${BUDGET}" ]]; do
|
|
|
|
|
attempt=$((attempt + 1))
|
|
|
|
|
echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}"
|
2026-09-18 14:30:20 -04:00
|
|
|
fail_fast_if_origin_path
|
2026-09-11 13:40:14 -04:00
|
|
|
if [[ "${last_status}" == "Deployed" && "${last_hash}" == "${EXPECTED_INDEX_SHA256}" ]]; then
|
|
|
|
|
break
|
|
|
|
|
fi
|
|
|
|
|
sleep "${INTERVAL}"
|
|
|
|
|
observe
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
if [[ "${last_status}" != "Deployed" || "${last_hash}" != "${EXPECTED_INDEX_SHA256}" ]]; then
|
|
|
|
|
echo "FAIL: release did not converge within the budget." >&2
|
|
|
|
|
report_state >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
2026-09-11 15:38:07 -04:00
|
|
|
write_asset_paths() {
|
|
|
|
|
python3 -c '
|
|
|
|
|
import re, sys
|
|
|
|
|
html = open(sys.argv[1], encoding="utf-8").read()
|
|
|
|
|
seen = []
|
|
|
|
|
for path in re.findall(r"(?:src|href)=\"(/assets/[^\"]+\.(?:js|css))\"", html):
|
|
|
|
|
if path not in seen:
|
|
|
|
|
seen.append(path)
|
|
|
|
|
print(path)
|
|
|
|
|
' "$1"
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-18 14:30:20 -04:00
|
|
|
api_host() {
|
|
|
|
|
python3 -c 'import os,urllib.parse; print(urllib.parse.urlparse(os.environ["API_URL"]).hostname or "")'
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 15:38:07 -04:00
|
|
|
assert_baked_api_url() {
|
|
|
|
|
local tmp="$1"
|
|
|
|
|
if [[ ! -s "${tmp}/asset-paths.txt" ]]; then
|
|
|
|
|
echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
: > "${tmp}/assets.txt"
|
|
|
|
|
local immutable_ok="no"
|
|
|
|
|
local asset_path
|
|
|
|
|
while IFS= read -r asset_path; do
|
|
|
|
|
curl -fsS --max-time 30 "${SITE_URL}${asset_path}" \
|
|
|
|
|
-o "${tmp}/asset-body" -D "${tmp}/asset.headers"
|
|
|
|
|
cat "${tmp}/asset-body" >> "${tmp}/assets.txt"
|
|
|
|
|
if [[ "${asset_path}" == *.js && "${immutable_ok}" == "no" ]]; then
|
|
|
|
|
if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then
|
|
|
|
|
echo "FAIL: hashed asset is missing Cache-Control immutable." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
immutable_ok="yes"
|
|
|
|
|
fi
|
|
|
|
|
done < "${tmp}/asset-paths.txt"
|
|
|
|
|
if [[ "${immutable_ok}" != "yes" ]]; then
|
|
|
|
|
echo "FAIL: served index.html has no hashed JS asset to check immutable caching." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
cat "${tmp}/index.html" "${tmp}/assets.txt" > "${tmp}/served.txt"
|
2026-09-18 14:30:20 -04:00
|
|
|
local host
|
|
|
|
|
host="$(api_host)"
|
|
|
|
|
local forbidden=""
|
|
|
|
|
case "${host}" in
|
|
|
|
|
api.dev.seahaven.com) forbidden="api.staging.seahaven.com" ;;
|
|
|
|
|
api.staging.seahaven.com) forbidden="api.dev.seahaven.com" ;;
|
|
|
|
|
*)
|
|
|
|
|
echo "FAIL: API_URL host '${host}' is not a known SHOC API." >&2
|
2026-09-11 15:38:07 -04:00
|
|
|
exit 1
|
2026-09-18 14:30:20 -04:00
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
if grep -Fq "${forbidden}" "${tmp}/served.txt"; then
|
|
|
|
|
echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
if grep -Fq "localhost:5141" "${tmp}/served.txt"; then
|
|
|
|
|
echo "FAIL: served assets contain forbidden URL localhost:5141." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
if ! grep -Fq "${host}" "${tmp}/served.txt"; then
|
|
|
|
|
echo "FAIL: served JS assets are missing the baked API URL ${host}." >&2
|
2026-09-11 15:38:07 -04:00
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-11 13:40:14 -04:00
|
|
|
tmp="$(mktemp -d)"
|
|
|
|
|
trap 'rm -rf "${tmp}"' EXIT
|
|
|
|
|
|
|
|
|
|
curl -fsS --max-time 30 "${SITE_URL}/" -o "${tmp}/index.html" -D "${tmp}/index.headers"
|
|
|
|
|
curl -fsS --max-time 30 "${SITE_URL}/login" -o "${tmp}/login.html"
|
|
|
|
|
curl -fsS --max-time 30 "${SITE_URL}/work-orders" -o "${tmp}/route.html"
|
|
|
|
|
if ! grep -qiE 'cache-control:.*no-store' "${tmp}/index.headers"; then
|
|
|
|
|
echo "FAIL: HTML Cache-Control is missing no-store." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
2026-09-11 15:38:07 -04:00
|
|
|
write_asset_paths "${tmp}/index.html" > "${tmp}/asset-paths.txt"
|
|
|
|
|
assert_baked_api_url "${tmp}"
|
2026-09-11 13:40:14 -04:00
|
|
|
|
|
|
|
|
cors_code="$(curl -sS --max-time 30 -o /dev/null -D "${tmp}/cors.headers" -w '%{http_code}' -X OPTIONS "${API_URL}" \
|
|
|
|
|
-H "Origin: ${SITE_URL}" \
|
|
|
|
|
-H "Access-Control-Request-Method: GET")"
|
|
|
|
|
if [[ "${cors_code}" != "200" && "${cors_code}" != "204" ]]; then
|
|
|
|
|
echo "FAIL: CORS preflight returned HTTP ${cors_code}." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
if ! grep -qi 'access-control-allow-origin' "${tmp}/cors.headers"; then
|
|
|
|
|
echo "FAIL: CORS preflight is missing Access-Control-Allow-Origin." >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
2026-09-18 14:30:20 -04:00
|
|
|
echo "PASS: CloudFront release is Deployed, hash-matched, and smoke-clean."
|
2026-09-11 13:40:14 -04:00
|
|
|
report_state
|