Files
hyperframes/examples/gcp-cloud-run/scripts/smoke.sh
T
James RussoandClaude Opus 4.8 4da567df22 feat(gcp-cloud-run): Google Cloud Run + Workflows distributed render adapter (#1253)
* feat(gcp-cloud-run): add Google Cloud Run + Workflows distributed render adapter

Adds @hyperframes/gcp-cloud-run, the GCP counterpart to @hyperframes/aws-lambda
(issue #932). The OSS distributed primitives (plan, renderChunk x N, assemble)
are unchanged; this package is the storage/compute/orchestration glue.

Package: Cloud Run handler (one image, three actions), runs under bun; GCS
transport; in-image chrome-headless-shell resolver; client SDK
(renderToCloudRun, getRenderProgress, deploySite, computeRenderCost); Dockerfile;
Cloud Workflows definition; Terraform module; CLI cloudrun
deploy|sites|render|render-batch|progress|destroy with --output-resolution and
--strict-variables; 62 unit tests + docs + live smoke script.

Shared extraction (removes ~640 lines of adapter duplication): move the
cloud-agnostic config validator + content-hash into producer/distributed; both
adapters import them. Validated end-to-end on GCP at 37.4 dB PSNR vs baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): resolve @hyperframes/gcp-cloud-run in the CLI build + root build

The CLI bundle (esbuild) couldn't resolve `@hyperframes/gcp-cloud-run/sdk`,
failing Build/Typecheck/CLI-smoke (and the perf/windows/regression jobs that
build first). Mirror the aws-lambda handling: mark the gcp adapter + its /sdk
subpath external in tsup.config.ts with a source alias, and add gcp-cloud-run
to the root `build` filter so its dist exists for publish + runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): copy gcp-cloud-run manifest in Dockerfile.test for frozen install

The regression test image runs `bun install --frozen-lockfile` after copying
each workspace package.json individually. The CLI now depends on
@hyperframes/gcp-cloud-run (workspace:*), so the frozen install fails to
resolve it unless its manifest is present. Add the COPY line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): add machine-sizing flags to `cloudrun deploy`

Closes the parity gap with `lambda deploy` (which exposes --memory etc.).
`cloudrun deploy` now threads --cpu, --memory, --max-instances, and --timeout
into the Terraform apply; omitted flags keep the module defaults
(4 vCPU / 16Gi / 100 instances / 3600s). For finer control, apply the module
directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gcp-cloud-run): address PR review (security, waste, limits, alerts)

- server.ts: bucket-allowlist guard no longer fails open silently. Unset env
  logs a one-time WARNING; "*" is an explicit opt-out; otherwise it enforces.
- server.ts: stop double-shipping audio.aac. It already rides in the plan
  tarball every consumer downloads, so drop the redundant standalone upload
  (plan) + re-download/overwrite (assemble); assemble reads it from the untar,
  falling back to a supplied AudioGcsUri for compat.
- server.ts: chunk extension via path.extname() instead of slice(lastIndexOf).
- workflow.yaml: clamp parallel concurrency_limit to math.min(chunkCount, 20)
  — Cloud Workflows hard-caps concurrent iterations at 20.
- Dockerfile: pin bun (bun-v1.3.9) so an interop change can't silently break
  the image rebuild.
- terraform: add min_instances var (default 0); add a workflow-failure alert
  (finished_execution_count status=FAILED) alongside the request-count one.
- costAccounting: document that displayCost excludes GCS storage/egress.

Verified against the actual APIs: @google-cloud/workflows@4.4.0
ICreateExecutionRequest has no executionId (so the idempotency-token suggestion
isn't available in this client); Workflows concurrency cap is 20; failure
metric is workflows.googleapis.com/finished_execution_count (status label).
174 adapter tests pass, fallow/oxlint/oxfmt/terraform clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(gcp-cloud-run): address round-2 review — error code + CFR forwarding

- workflow.yaml: rename the zero-chunk failure code PLAN_TOO_LARGE →
  PLAN_PRODUCED_ZERO_CHUNKS. The old code implied a size-ceiling breach (the
  opposite cause), misleading anyone triaging the alert.
- workflow.yaml: forward Config.cfr to the assemble step
  (`Cfr: ${("cfr" in config) and config.cfr}`). It was read by the handler
  but never sent, so exact-CFR was silently off for every Cloud Run render.
  Uses the same `in`-operator guard already proven in the retryable predicate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(release): include gcp-cloud-run in set-version PACKAGES list

set-version.ts (driven by release:prepare) bumps an explicit package list to
the shared version on each release. gcp-cloud-run was wired into the build +
publish.yml but missing here, so a release would leave it at a stale version
and publish.yml would push the wrong version. Add it so the new package
version-bumps + publishes in lockstep with the others.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:43:38 -07:00

240 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Real-GCP smoke + benchmark for the HyperFrames Cloud Run adapter.
#
# Run from a workstation with `gcloud` credentials. Builds the render
# container, pushes it to Artifact Registry, applies the Terraform module at
# packages/gcp-cloud-run/terraform to your project, renders a fixture
# composition through the Cloud Workflows definition, PSNR-compares the
# output against the in-process baseline, and tears the stack down.
#
# Usage:
# ./smoke.sh --project <gcp-project>
# ./smoke.sh --project p --fixture mp4-h264-sdr --chunk-sizes 15,30
# ./smoke.sh --project p --keep-stack
#
# Required tools on PATH:
# - gcloud (authenticated; the target project must have billing enabled)
# - terraform (>= 1.5)
# - docker
# - ffmpeg (PSNR computation)
# - jq
#
# Inputs (flags or env vars):
# --project <id> (required; or $GCP_PROJECT)
# --region <region> (default: us-central1)
# --fixture <name> (default: mp4-h264-sdr — under packages/producer/tests/distributed/)
# --chunk-sizes <list> (default: from the fixture meta; CSV of chunkSize overrides)
# --psnr-threshold <db> (default: 35)
# --repo <ar-repo> (Artifact Registry repo name, default: hyperframes)
# --keep-stack (skip `terraform destroy` at the end)
# --skip-build (reuse the last-pushed image tag in ./gcp-smoke-artifacts/image.txt)
#
# Outputs:
# ./gcp-smoke-artifacts/results.json (chunkSize x wallClockMs x psnrAvgDb)
# ./gcp-smoke-artifacts/renders/c<N>-output.mp4
# ./gcp-smoke-artifacts/renders/c<N>-execution.json
#
# Exit codes:
# 0 all good 1 arg/pre-flight 2 build/push 3 terraform apply
# 4 a render failed 5 PSNR below threshold
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
TF_DIR="$REPO_ROOT/packages/gcp-cloud-run/terraform"
# ── Defaults ──────────────────────────────────────────────────────────────
PROJECT="${GCP_PROJECT:-}"
REGION="${GCP_REGION:-us-central1}"
FIXTURE="${FIXTURE:-mp4-h264-sdr}"
CHUNK_SIZES="${CHUNK_SIZES:-}"
PSNR_THRESHOLD="${PSNR_THRESHOLD:-35}"
AR_REPO="${AR_REPO:-hyperframes}"
KEEP_STACK=0
SKIP_BUILD=0
while [ $# -gt 0 ]; do
case "$1" in
--project) PROJECT="$2"; shift 2 ;;
--region) REGION="$2"; shift 2 ;;
--fixture) FIXTURE="$2"; shift 2 ;;
--chunk-sizes) CHUNK_SIZES="$2"; shift 2 ;;
--psnr-threshold) PSNR_THRESHOLD="$2"; shift 2 ;;
--repo) AR_REPO="$2"; shift 2 ;;
--keep-stack) KEEP_STACK=1; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 1 ;;
esac
done
[ -n "$PROJECT" ] || { echo "ERROR: --project (or \$GCP_PROJECT) is required" >&2; exit 1; }
for tool in gcloud terraform docker ffmpeg jq; do
command -v "$tool" >/dev/null || { echo "ERROR: $tool not on PATH" >&2; exit 1; }
done
FIXTURE_DIR="$REPO_ROOT/packages/producer/tests/distributed/$FIXTURE"
FIXTURE_META="$FIXTURE_DIR/meta.json"
BASELINE_MP4="$FIXTURE_DIR/output/output.mp4"
[ -d "$FIXTURE_DIR/src" ] || { echo "ERROR: fixture src missing: $FIXTURE_DIR/src" >&2; exit 1; }
[ -f "$BASELINE_MP4" ] || { echo "ERROR: baseline mp4 missing: $BASELINE_MP4" >&2; exit 1; }
ARTIFACT_DIR="$SCRIPT_DIR/gcp-smoke-artifacts"
mkdir -p "$ARTIFACT_DIR/renders"
echo "→ Project: $PROJECT Region: $REGION Fixture: $FIXTURE"
# ── 1. Enable APIs ──────────────────────────────────────────────────────────
echo "→ Enabling required APIs (idempotent)"
gcloud services enable \
run.googleapis.com workflows.googleapis.com workflowexecutions.googleapis.com \
artifactregistry.googleapis.com cloudbuild.googleapis.com monitoring.googleapis.com \
--project "$PROJECT" >/dev/null
# ── 2. Build + push the render image ────────────────────────────────────────
IMAGE_TXT="$ARTIFACT_DIR/image.txt"
if [ "$SKIP_BUILD" -eq 1 ] && [ -f "$IMAGE_TXT" ]; then
IMAGE="$(cat "$IMAGE_TXT")"
echo "→ Reusing image $IMAGE"
else
gcloud artifacts repositories describe "$AR_REPO" --location "$REGION" --project "$PROJECT" >/dev/null 2>&1 || \
gcloud artifacts repositories create "$AR_REPO" --repository-format docker \
--location "$REGION" --project "$PROJECT" >/dev/null
TAG="$(date +%Y%m%d-%H%M%S)"
IMAGE="$REGION-docker.pkg.dev/$PROJECT/$AR_REPO/hyperframes-render:$TAG"
echo "→ Building + pushing $IMAGE via Cloud Build"
# The Dockerfile lives at packages/gcp-cloud-run/Dockerfile, not the repo
# root, so we drive the build with an inline cloudbuild config rather than
# `--tag` (which assumes a root Dockerfile).
CB_CONFIG="$ARTIFACT_DIR/cloudbuild.yaml"
cat > "$CB_CONFIG" <<EOF
steps:
- name: gcr.io/cloud-builders/docker
args: ["build","-f","packages/gcp-cloud-run/Dockerfile","-t","$IMAGE","."]
images: ["$IMAGE"]
timeout: 3600s
options:
machineType: E2_HIGHCPU_8
EOF
gcloud builds submit "$REPO_ROOT" --project "$PROJECT" --config "$CB_CONFIG" \
|| { echo "ERROR: image build/push failed" >&2; exit 2; }
echo "$IMAGE" > "$IMAGE_TXT"
fi
# ── 3. terraform apply ──────────────────────────────────────────────────────
# The google provider authenticates via Application Default Credentials. If
# ADC isn't configured (common on a box set up with only `gcloud auth login`),
# fall back to a short-lived access token from the active gcloud account.
if ! gcloud auth application-default print-access-token >/dev/null 2>&1; then
echo "→ ADC not configured; using a gcloud access token for Terraform"
export GOOGLE_OAUTH_ACCESS_TOKEN="$(gcloud auth print-access-token)"
export GOOGLE_PROJECT="$PROJECT"
fi
echo "→ terraform apply"
terraform -chdir="$TF_DIR" init -input=false >/dev/null
terraform -chdir="$TF_DIR" apply -input=false -auto-approve \
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \
|| { echo "ERROR: terraform apply failed" >&2; exit 3; }
BUCKET="$(terraform -chdir="$TF_DIR" output -raw render_bucket_name)"
SERVICE_URL="$(terraform -chdir="$TF_DIR" output -raw service_url)"
WORKFLOW="$(terraform -chdir="$TF_DIR" output -raw workflow_name)"
echo " bucket=$BUCKET service=$SERVICE_URL workflow=$WORKFLOW"
cleanup() {
if [ "$KEEP_STACK" -eq 0 ]; then
echo "→ terraform destroy"
# Apply force_destroy=true into state FIRST. Terraform reads the bucket's
# force_destroy from prior state during the destroy step, so a destroy
# alone can't flip it; a quick apply updates the attribute, then destroy
# can empty + remove the (scratch) bucket.
terraform -chdir="$TF_DIR" apply -input=false -auto-approve \
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \
-var "bucket_force_destroy=true" >/dev/null 2>&1 || true
terraform -chdir="$TF_DIR" destroy -input=false -auto-approve \
-var "project_id=$PROJECT" -var "region=$REGION" -var "image=$IMAGE" \
-var "bucket_force_destroy=true" || true
else
echo "→ --keep-stack set; leaving the stack up. Destroy with:"
echo " terraform -chdir=$TF_DIR destroy -var project_id=$PROJECT -var region=$REGION -var image=$IMAGE -var bucket_force_destroy=true"
fi
}
trap cleanup EXIT
# ── 4. Upload the fixture as a project tarball ──────────────────────────────
SITE_TAR="$ARTIFACT_DIR/project.tar.gz"
tar -czf "$SITE_TAR" -C "$FIXTURE_DIR/src" .
PROJECT_GCS="gs://$BUCKET/sites/$FIXTURE/project.tar.gz"
gcloud storage cp "$SITE_TAR" "$PROJECT_GCS" --project "$PROJECT" >/dev/null
echo "→ Uploaded fixture to $PROJECT_GCS"
BASE_FPS=$(jq -r '.renderConfig.fps // 30' "$FIXTURE_META")
META_CHUNK=$(jq -r '.renderConfig.chunkSize // empty' "$FIXTURE_META")
[ -n "$CHUNK_SIZES" ] || CHUNK_SIZES="${META_CHUNK:-15}"
echo "[]" > "$ARTIFACT_DIR/results.json"
OVERALL_RC=0
IFS=',' read -ra SIZES <<< "$CHUNK_SIZES"
for CS in "${SIZES[@]}"; do
RENDER_ID="hf-smoke-c${CS}-$(date +%s)"
OUT_GCS="gs://$BUCKET/renders/$RENDER_ID/output.mp4"
ARG=$(jq -n \
--arg svc "$SERVICE_URL" \
--arg proj "$PROJECT_GCS" \
--arg prefix "gs://$BUCKET/renders/$RENDER_ID/" \
--arg out "$OUT_GCS" \
--argjson fps "$BASE_FPS" \
--argjson cs "$CS" \
'{ServiceUrl:$svc, ProjectGcsUri:$proj, PlanOutputGcsPrefix:$prefix, OutputGcsUri:$out,
Config:{fps:$fps, width:640, height:360, format:"mp4", chunkSize:$cs}}')
echo "→ Render chunkSize=$CS (renderId=$RENDER_ID)"
START_MS=$(date +%s%3N)
EXEC=$(gcloud workflows execute "$WORKFLOW" --location "$REGION" --project "$PROJECT" \
--data "$ARG" --format='value(name)')
# Poll until terminal.
STATE="ACTIVE"
while [ "$STATE" = "ACTIVE" ] || [ "$STATE" = "QUEUED" ]; do
sleep 5
STATE=$(gcloud workflows executions describe "$EXEC" --location "$REGION" \
--project "$PROJECT" --format='value(state)')
done
END_MS=$(date +%s%3N)
WALL=$((END_MS - START_MS))
gcloud workflows executions describe "$EXEC" --location "$REGION" --project "$PROJECT" \
--format=json > "$ARTIFACT_DIR/renders/c$CS-execution.json"
if [ "$STATE" != "SUCCEEDED" ]; then
echo " ✗ execution state=$STATE"
jq -r '.error.payload // empty' "$ARTIFACT_DIR/renders/c$CS-execution.json" | head -c 800
OVERALL_RC=4
continue
fi
OUT_LOCAL="$ARTIFACT_DIR/renders/c$CS-output.mp4"
gcloud storage cp "$OUT_GCS" "$OUT_LOCAL" --project "$PROJECT" >/dev/null
# PSNR vs the in-process baseline.
PSNR_LOG="$ARTIFACT_DIR/renders/c$CS-psnr.log"
ffmpeg -y -i "$OUT_LOCAL" -i "$BASELINE_MP4" \
-lavfi "psnr=stats_file=$PSNR_LOG" -f null - 2>/dev/null || true
PSNR_AVG=$(awk -F'psnr_avg:' '/psnr_avg:/{split($2,a," "); s+=a[1]; n++} END{if(n>0) printf "%.2f", s/n; else print "0"}' "$PSNR_LOG" 2>/dev/null || echo "0")
echo " ✓ state=SUCCEEDED wall=${WALL}ms psnr_avg=${PSNR_AVG}dB"
jq --argjson cs "$CS" --argjson wall "$WALL" --arg psnr "$PSNR_AVG" \
'. += [{chunkSize:$cs, wallClockMs:$wall, psnrAvgDb:($psnr|tonumber)}]' \
"$ARTIFACT_DIR/results.json" > "$ARTIFACT_DIR/results.json.tmp" && \
mv "$ARTIFACT_DIR/results.json.tmp" "$ARTIFACT_DIR/results.json"
if awk "BEGIN{exit !($PSNR_AVG < $PSNR_THRESHOLD)}"; then
echo " ✗ PSNR ${PSNR_AVG}dB below threshold ${PSNR_THRESHOLD}dB"
OVERALL_RC=5
fi
done
echo "→ Results:"; cat "$ARTIFACT_DIR/results.json" | jq .
exit $OVERALL_RC