#!/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 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" } 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" local forbidden for forbidden in api.staging.seahaven.com localhost:5141; do if grep -Fq "${forbidden}" "${tmp}/served.txt"; then echo "FAIL: served assets contain forbidden URL ${forbidden}." >&2 exit 1 fi done if ! grep -Fq "api.dev.seahaven.com" "${tmp}/served.txt"; then echo "FAIL: served JS assets are missing the baked dev API URL." >&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 write_asset_paths "${tmp}/index.html" > "${tmp}/asset-paths.txt" assert_baked_api_url "${tmp}" 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