shoc-frontend-new/scripts/verify-cloudfront-release.sh
Adam Moussa 69c24c1c2c
feat(terraform): ship dev content CD through Terraform (SH-300) (#180)
* feat(terraform): ship dev content CD through Terraform (SH-300)

GitHub uploads immutable release prefixes; Terraform owns live publish.
Push-to-dev stays off until TERRAFORM_CONTENT_CD_ENABLED is set.

* fix(terraform): align release-plan guard flags and CloudFront verify IAM (SH-300)
2026-09-11 13:40:14 -04:00

177 lines
5.6 KiB
Bash
Executable file

#!/usr/bin/env bash
# Verify a CloudFront content release or rollback.
#
# Fail fast when origin_path or .release/current is the wrong label.
# Poll while the distribution is InProgress or the served index.html hash
# still matches the previous release. On timeout, print last observed state.
set -euo pipefail
DISTRIBUTION_ID="${DISTRIBUTION_ID:-}"
EXPECTED_LABEL="${EXPECTED_LABEL:-}"
EXPECTED_INDEX_SHA256="${EXPECTED_INDEX_SHA256:-}"
SITE_URL="${SITE_URL:-}"
SITE_BUCKET="${SITE_BUCKET:-}"
PREVIOUS_INDEX_SHA256="${PREVIOUS_INDEX_SHA256:-}"
API_URL="${API_URL:-https://api.dev.seahaven.com/api}"
BUDGET="${BUDGET:-40}"
INTERVAL="${INTERVAL:-15}"
if [[ -z "${DISTRIBUTION_ID}" || -z "${EXPECTED_INDEX_SHA256}" || -z "${SITE_URL}" || -z "${SITE_BUCKET}" ]]; then
echo "Usage: DISTRIBUTION_ID EXPECTED_LABEL EXPECTED_INDEX_SHA256 SITE_URL SITE_BUCKET must be set." >&2
exit 2
fi
SITE_URL="${SITE_URL%/}"
if [[ -n "${EXPECTED_LABEL}" ]]; then
EXPECTED_PATH="/releases/${EXPECTED_LABEL}"
else
EXPECTED_PATH=""
fi
sha256_of() {
python3 -c "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())"
}
read_pointer() {
aws s3 cp "s3://${SITE_BUCKET}/.release/current" - --only-show-errors 2>/dev/null || true
}
read_distribution_json() {
aws cloudfront get-distribution --id "${DISTRIBUTION_ID}" --output json
}
parse_distribution() {
python3 -c '
import json, os, sys
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]
expected = os.environ["EXPECTED_PATH"]
print(status)
print("\x1f".join(paths))
print("yes" if expected in paths else "no")
'
}
pointer_current() {
POINTER_BODY="$1" python3 -c '
import json, os
raw = os.environ.get("POINTER_BODY", "").strip()
if not raw:
print("")
raise SystemExit
print(json.loads(raw).get("current") or "")
'
}
last_status="Unknown"
last_paths="Unknown"
last_pointer="Unknown"
last_hash="Unknown"
last_path_ok="no"
observe() {
last_pointer="$(read_pointer)"
local parsed
parsed="$(read_distribution_json | EXPECTED_PATH="${EXPECTED_PATH}" parse_distribution)"
last_status="$(printf '%s\n' "${parsed}" | sed -n '1p')"
last_paths="$(printf '%s\n' "${parsed}" | sed -n '2p' | tr '\037' ' ')"
last_path_ok="$(printf '%s\n' "${parsed}" | sed -n '3p')"
local body
body="$(curl -fsS --max-time 30 "${SITE_URL}/" || true)"
if [[ -n "${body}" ]]; then
last_hash="$(printf '%s' "${body}" | sha256_of)"
else
last_hash="unreachable"
fi
}
report_state() {
echo "last observed: status=${last_status} pointer=${last_pointer} origins=${last_paths} served_sha256=${last_hash}"
}
fail_fast_if_misconfigured() {
local current
current="$(pointer_current "${last_pointer}")"
if [[ "${current}" != "${EXPECTED_LABEL}" ]]; then
echo "FAIL: live pointer current is '${current}'; expected '${EXPECTED_LABEL}'." >&2
report_state >&2
exit 1
fi
if [[ "${last_path_ok}" != "yes" ]]; then
echo "FAIL: live origin_path values are '${last_paths}'; expected '${EXPECTED_PATH}'." >&2
report_state >&2
exit 1
fi
}
observe
fail_fast_if_misconfigured
attempt=0
while [[ "${attempt}" -lt "${BUDGET}" ]]; do
attempt=$((attempt + 1))
echo "poll ${attempt}/${BUDGET}: status=${last_status} served_sha256=${last_hash}"
fail_fast_if_misconfigured
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
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
for forbidden in api.staging.seahaven.com localhost:5141; do
if grep -Fq "${forbidden}" "${tmp}/index.html"; then
echo "FAIL: served index contains forbidden URL ${forbidden}." >&2
exit 1
fi
done
if ! grep -Fq "api.dev.seahaven.com" "${tmp}/index.html"; then
echo "FAIL: served index is missing the dev API URL." >&2
exit 1
fi
asset_path="$(python3 -c 'import re,sys; html=open(sys.argv[1],encoding="utf-8").read(); m=re.search(r"(/assets/[^\"'\'']+)", html); print(m.group(1) if m else "")' "${tmp}/index.html")"
if [[ -z "${asset_path}" ]]; then
echo "FAIL: served index.html has no /assets/ URL to check immutable caching." >&2
exit 1
fi
curl -fsS --max-time 30 "${SITE_URL}${asset_path}" -o /dev/null -D "${tmp}/asset.headers"
if ! grep -qiE 'cache-control:.*immutable' "${tmp}/asset.headers"; then
echo "FAIL: hashed asset is missing Cache-Control immutable." >&2
exit 1
fi
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
echo "PASS: CloudFront release ${EXPECTED_LABEL} is Deployed, hash-matched, and smoke-clean."
report_state