#!/usr/bin/env bash # # setup.sh — GCP setup for Claude Gateway (walkthrough §1–7b). # # Provisions, in doc order: APIs (§1), service account + IAM (§2), the gateway # container image in Artifact Registry (§3), a Cloud SQL (PostgreSQL) backend # with PRIVATE IP only (§4), the JWT + postgres-url secrets (§5), the # gateway.yaml config secret (§6), and a Cloud Run deploy with Direct VPC # egress (§7b). # # Private IP is required because public IP is disallowed by the org-policy constraint # `constraints/sql.restrictPublicIp`. A Cloud SQL private IP is an address inside a VPC, # so §4 here also provisions the prerequisite VPC + Private Services Access — the # one-time, irreducible networking required for private IP. # # Section markers (§N) below map to the walkthrough: # https://code.claude.com/docs/en/claude-apps-gateway-on-gcp # # Covers here: APIs (§1) -> service account + IAM (§2) -> build & push image (§3) # -> VPC + Private Services Access -> Cloud SQL (private IP only) -> database # + user (§4) -> jwt + postgres-url secrets (§5) -> gateway-config # secret from gateway.yaml (§6) -> Cloud Run deploy (§7b). # Not covered: GKE track (§7a) — Cloud Run is the lower-friction path here. # # Idempotent: existing resources are detected and skipped, so it is safe to re-run. # Override any default below via environment variable, e.g. `REGION=us-east5 ./setup.sh`. set -euo pipefail # ---- configuration (env-overridable) ---------------------------------------- PROJECT_ID="${PROJECT_ID:-$(gcloud config get-value project 2>/dev/null)}" REGION="${REGION:-${CLOUDSDK_COMPUTE_REGION:-us-east5}}" # guide §1 uses us-east5 (Agent Platform model region) SA_NAME="${SA_NAME:-claude-gateway}" # §2 service account SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" # §3 image AR_REPO="${AR_REPO:-claude-gateway}" # Artifact Registry repository IMAGE_NAME="${IMAGE_NAME:-gateway}" RELEASES_URL="${RELEASES_URL:-https://downloads.claude.ai/claude-code-releases}" # public Claude Code release endpoint VERSION="${VERSION:-}" # Claude Code release to deploy; empty = latest release (resolved below) VERSION_FILE="${VERSION_FILE:-./.claude-version}" # pins the resolved release across re-runs; delete it (or set VERSION) to upgrade DOCKERFILE="${DOCKERFILE:-./Dockerfile}" CLAUDE_BINARY="${CLAUDE_BINARY:-./claude}" # linux-x64 Claude Code binary; downloaded from RELEASES_URL if missing CLAUDE_SHA256="${CLAUDE_SHA256:-}" # optional: out-of-band sha256 pin for the downloaded binary, checked in addition to the release manifest VPC_NETWORK="${VPC_NETWORK:-cc-gateway-vpc}" SUBNET="${SUBNET:-cc-gateway-subnet}" SUBNET_RANGE="${SUBNET_RANGE:-10.0.0.0/24}" PSA_RANGE_NAME="${PSA_RANGE_NAME:-google-managed-services-${VPC_NETWORK}}" PSA_PREFIX_LENGTH="${PSA_PREFIX_LENGTH:-16}" # /16 is GCP's recommendation; reserved, not consumed DB_INSTANCE="${DB_INSTANCE:-claude-gateway-db}" DB_VERSION="${DB_VERSION:-POSTGRES_16}" # PG14+ supported; 16 is the recommended default (§4) DB_TIER="${DB_TIER:-db-g1-small}" DB_NAME="${DB_NAME:-claude_gateway}" DB_USER="${DB_USER:-gateway}" SECRET_NAME="${SECRET_NAME:-gateway-postgres-url}" # §5 store.postgres_url JWT_SECRET_NAME="${JWT_SECRET_NAME:-gateway-jwt-secret}" # §5 session.jwt_secret GATEWAY_YAML="${GATEWAY_YAML:-./gateway.yaml}" # §6 config file CONFIG_SECRET="${CONFIG_SECRET:-gateway-config}" # §6 mounted at /etc/claude/gateway.yaml # §7 Cloud Run deploy SERVICE_NAME="${SERVICE_NAME:-claude-gateway}" OIDC_SECRET_NAME="${OIDC_SECRET_NAME:-gateway-oidc-client-secret}" # operator-created (Google OAuth client) DEPLOY="${DEPLOY:-1}" # set DEPLOY=0 to provision only, no Cloud Run deploy INGRESS="${INGRESS:-internal}" # internal (default; no public URL) | internal-and-cloud-load-balancing (only if you front it with your own internal ALB) MAX_INSTANCES="${MAX_INSTANCES:-8}" # keep MAX_INSTANCES × store.max_connections (default 5) below the DB tier's max_connections (~50 on db-g1-small); raise the tier before raising this # ---- helpers ---------------------------------------------------------------- log() { printf '\n==> %s\n' "$*"; } skip() { printf ' (exists) %s\n' "$*"; } curl_https() { curl --proto '=https' --proto-redir '=https' --tlsv1.2 "$@"; } # refuse plaintext/protocol-downgrade sha_of() { openssl dgst -sha256 "$1" | awk '{print $NF}'; } # openssl avoids shasum/sha256sum portability gaps if [[ -z "${PROJECT_ID}" ]]; then echo "ERROR: PROJECT_ID is not set and no gcloud default project is configured." >&2 echo " Set it with: export PROJECT_ID= (or 'gcloud config set project ...')" >&2 exit 1 fi # VERSION tags the image and selects the public Claude Code release to download. # The first resolved value is pinned to ${VERSION_FILE} so the documented # re-runs (fill gateway.yaml -> re-run; set public_url -> re-run) don't silently # build and deploy a newer release mid-bootstrap. if [[ -z "${VERSION}" && -f "${VERSION_FILE}" ]]; then VERSION="$(< "${VERSION_FILE}")" log "Using release pinned in ${VERSION_FILE}: ${VERSION} (delete the file or set VERSION to change it)" elif [[ -z "${VERSION}" ]]; then # /latest is the channel the official installer (claude.ai/install.sh) uses. VERSION="$(curl_https -fsSL "${RELEASES_URL}/latest" | tr -d '[:space:]' || true)" if [[ -z "${VERSION}" ]]; then echo "ERROR: could not resolve the latest release from ${RELEASES_URL}/latest." >&2 echo " Set VERSION to a Claude Code release version, e.g. export VERSION=2.1.195" >&2 exit 1 fi log "VERSION not set — using latest Claude Code release: ${VERSION}" fi # Reject non-version content (e.g. an HTML error page served with HTTP 200) # before it reaches the image tag and download URLs. if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then echo "ERROR: '${VERSION}' is not a release version (from VERSION, ${VERSION_FILE}, or ${RELEASES_URL}/latest)." >&2 exit 1 fi printf '%s' "${VERSION}" > "${VERSION_FILE}" IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${AR_REPO}/${IMAGE_NAME}:${VERSION}" # Claude Code only connects to a gateway whose hostname resolves to private # addresses (a client-side /login check), so public ingress can never serve # clients — mirror the terraform module's validation and refuse it up front. if [[ "${INGRESS}" != "internal" && "${INGRESS}" != "internal-and-cloud-load-balancing" ]]; then echo "ERROR: INGRESS must be 'internal' or 'internal-and-cloud-load-balancing' — Claude Code's" >&2 echo " /login only accepts gateway hosts on private addresses, so public ingress cannot serve clients." >&2 exit 1 fi log "Project: ${PROJECT_ID} Region: ${REGION} VPC: ${VPC_NETWORK}" # ---- 1 Project & API setup ------------------------------------------------ # walkthrough §1 list (aiplatform, artifactregistry, sqladmin, secretmanager, iamcredentials) # plus iam/compute/servicenetworking required for the SA + private-IP networking below. # container.googleapis.com is for the GKE track (§7a) — harmless if you stay on Cloud Run. # We pass --project on every call rather than mutating your gcloud config. log "Enabling required APIs (§1)" gcloud services enable \ aiplatform.googleapis.com \ artifactregistry.googleapis.com \ sqladmin.googleapis.com \ secretmanager.googleapis.com \ iamcredentials.googleapis.com \ iam.googleapis.com \ compute.googleapis.com \ container.googleapis.com \ servicenetworking.googleapis.com \ run.googleapis.com \ --project="${PROJECT_ID}" # ---- 2 Service account & IAM ---------------------------------------------- log "Creating service account ${SA_EMAIL} and granting project roles (§2)" if gcloud iam service-accounts describe "${SA_EMAIL}" --project="${PROJECT_ID}" >/dev/null 2>&1; then skip "service account ${SA_EMAIL}" else gcloud iam service-accounts create "${SA_NAME}" \ --display-name="Claude Gateway" --project="${PROJECT_ID}" fi # add-iam-policy-binding is idempotent (re-adding an existing binding is a no-op). # --condition=None avoids the interactive condition prompt in non-interactive runs. # # Only aiplatform.user is granted: the gateway reaches Cloud SQL over the VPC at # its PRIVATE IP with a password user (§4/§7b — direct TCP, not the Cloud SQL # Auth Proxy / connector), so it never calls cloudsql.instances.connect and no # roles/cloudsql.client grant is needed. Direct private-IP is used because the # gateway's store is a plain postgres_url — no proxy sidecar/socket plumbing, # one less moving part, and the connection string is portable across Cloud Run # and GKE. gcloud projects add-iam-policy-binding "${PROJECT_ID}" \ --member="serviceAccount:${SA_EMAIL}" \ --role="roles/aiplatform.user" --condition=None >/dev/null # Agent Platform inference (§2) # ---- 3 Build & push image to Artifact Registry ---------------------------- log "Ensuring Artifact Registry repo and image (§3)" if gcloud artifacts repositories describe "${AR_REPO}" \ --location="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then skip "Artifact Registry repo ${AR_REPO}" else gcloud artifacts repositories create "${AR_REPO}" \ --repository-format=docker --location="${REGION}" --project="${PROJECT_ID}" fi # Image is the expensive, already-done step: skip the build+push entirely if the # tag already exists in the registry. if gcloud artifacts docker images describe "${IMAGE}" >/dev/null 2>&1; then skip "image ${IMAGE}" else # The public Claude Code release includes the gateway subcommand, so the # binary comes straight from the release endpoint, verified against the # release manifest's sha256. A pre-existing ${CLAUDE_BINARY} (stale version, # interrupted download, hand-placed file) is verified the same way and # re-downloaded on mismatch, so an unverified binary can never reach the image. manifest="$(curl_https -fsSL "${RELEASES_URL}/${VERSION}/manifest.json" | tr -d '[:space:]' || true)" sha_re='"linux-x64"[^}]*"checksum":"([a-f0-9]{64})"' # structure-based: survives pretty-printed, minified, and one-line-per-platform manifests if [[ ! "${manifest}" =~ ${sha_re} ]]; then echo "ERROR: could not read the linux-x64 sha256 from ${RELEASES_URL}/${VERSION}/manifest.json — refusing to build." >&2 exit 1 fi expected_sha="${BASH_REMATCH[1]}" if [[ -f "${CLAUDE_BINARY}" && "$(sha_of "${CLAUDE_BINARY}")" == "${expected_sha}" ]]; then skip "binary ${CLAUDE_BINARY} (sha256 matches release ${VERSION})" else if [[ -f "${CLAUDE_BINARY}" ]]; then log "Existing ${CLAUDE_BINARY} does not match release ${VERSION} — re-downloading" else log "Downloading Claude Code ${VERSION} (linux-x64) from ${RELEASES_URL}" fi # Until verification passes, ANY exit (curl failure, set -e, signal, the # error exit below) removes the file, so a partial download can't be # silently picked up by a later run. trap 'rm -f "${CLAUDE_BINARY}"' EXIT INT TERM curl_https -fL -o "${CLAUDE_BINARY}" "${RELEASES_URL}/${VERSION}/linux-x64/claude" actual_sha="$(sha_of "${CLAUDE_BINARY}")" if [[ "${actual_sha}" != "${expected_sha}" ]]; then echo "ERROR: sha256 of ${CLAUDE_BINARY} is ${actual_sha} but the release manifest says ${expected_sha} — refusing to build." >&2 exit 1 fi trap - EXIT INT TERM log "Verified binary sha256 ${actual_sha}" fi # Optional out-of-band pin, checked even for a pre-existing binary: the # manifest shares an origin with the binary, so it can't defend against a # compromised endpoint — CLAUDE_SHA256 can. if [[ -n "${CLAUDE_SHA256}" && "$(sha_of "${CLAUDE_BINARY}")" != "${CLAUDE_SHA256}" ]]; then echo "ERROR: sha256 of ${CLAUDE_BINARY} does not match CLAUDE_SHA256 (${CLAUDE_SHA256}) — refusing to build." >&2 exit 1 fi chmod +x "${CLAUDE_BINARY}" log "Building and pushing ${IMAGE}" gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet # Cloud Run requires linux/amd64. --platform forces it (e.g. when building on an # Apple Silicon Mac), and --provenance=false keeps buildx from wrapping the result # in an OCI image index that Cloud Run rejects ("manifest ... must support amd64/linux"). docker build --platform=linux/amd64 --provenance=false \ -f "${DOCKERFILE}" --build-arg CLAUDE_BINARY="${CLAUDE_BINARY}" -t "${IMAGE}" . docker push "${IMAGE}" fi # ---- 4 VPC + Private Services Access (private-IP prerequisite) ------------- log "Creating VPC network and subnet" if gcloud compute networks describe "${VPC_NETWORK}" --project="${PROJECT_ID}" >/dev/null 2>&1; then skip "network ${VPC_NETWORK}" else gcloud compute networks create "${VPC_NETWORK}" \ --subnet-mode=custom --project="${PROJECT_ID}" fi if gcloud compute networks subnets describe "${SUBNET}" \ --region="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then skip "subnet ${SUBNET}" else gcloud compute networks subnets create "${SUBNET}" \ --network="${VPC_NETWORK}" --region="${REGION}" \ --range="${SUBNET_RANGE}" --project="${PROJECT_ID}" fi log "Configuring Private Services Access (allocated range + VPC peering)" if gcloud compute addresses describe "${PSA_RANGE_NAME}" \ --global --project="${PROJECT_ID}" >/dev/null 2>&1; then skip "allocated range ${PSA_RANGE_NAME}" else gcloud compute addresses create "${PSA_RANGE_NAME}" \ --global --purpose=VPC_PEERING --prefix-length="${PSA_PREFIX_LENGTH}" \ --network="${VPC_NETWORK}" --project="${PROJECT_ID}" fi if gcloud services vpc-peerings list --network="${VPC_NETWORK}" --project="${PROJECT_ID}" \ --format='value(peering)' 2>/dev/null | grep -q servicenetworking; then skip "servicenetworking VPC peering" else gcloud services vpc-peerings connect \ --service=servicenetworking.googleapis.com \ --ranges="${PSA_RANGE_NAME}" \ --network="${VPC_NETWORK}" --project="${PROJECT_ID}" fi # ---- 4 Cloud SQL instance (private IP only) ------------------------------- log "Creating Cloud SQL instance ${DB_INSTANCE} (private IP only)" if gcloud sql instances describe "${DB_INSTANCE}" --project="${PROJECT_ID}" >/dev/null 2>&1; then skip "instance ${DB_INSTANCE}" else gcloud sql instances create "${DB_INSTANCE}" \ --database-version="${DB_VERSION}" \ --tier="${DB_TIER}" \ --region="${REGION}" \ --network="projects/${PROJECT_ID}/global/networks/${VPC_NETWORK}" \ --no-assign-ip \ --project="${PROJECT_ID}" fi log "Creating database ${DB_NAME}" if gcloud sql databases describe "${DB_NAME}" \ --instance="${DB_INSTANCE}" --project="${PROJECT_ID}" >/dev/null 2>&1; then skip "database ${DB_NAME}" else gcloud sql databases create "${DB_NAME}" \ --instance="${DB_INSTANCE}" --project="${PROJECT_ID}" fi # hex (not base64) keeps the password URL-safe for the connection string below. log "Creating database user ${DB_USER}" DB_PASSWORD="" if gcloud sql users list --instance="${DB_INSTANCE}" --project="${PROJECT_ID}" \ --format='value(name)' 2>/dev/null | grep -qx "${DB_USER}"; then if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then skip "user ${DB_USER} (password unchanged; secret not rewritten)" else # Self-heal: a previous run died after creating the user but before writing # the connection-string secret, losing the only copy of the password. The # secret is the password's only consumer, so resetting it is safe and keeps # re-runs able to recover from any partial state. log "User ${DB_USER} exists but secret ${SECRET_NAME} is missing — resetting password" DB_PASSWORD="$(openssl rand -hex 24)" gcloud sql users set-password "${DB_USER}" \ --instance="${DB_INSTANCE}" --password="${DB_PASSWORD}" \ --project="${PROJECT_ID}" fi else DB_PASSWORD="$(openssl rand -hex 24)" gcloud sql users create "${DB_USER}" \ --instance="${DB_INSTANCE}" --password="${DB_PASSWORD}" \ --project="${PROJECT_ID}" fi # ---- 5 Connection string -> Secret Manager + secretAccessor --------------- PRIVATE_IP="$(gcloud sql instances describe "${DB_INSTANCE}" --project="${PROJECT_ID}" \ --format='value(ipAddresses[0].ipAddress)')" if [[ -n "${DB_PASSWORD}" ]]; then # direct private-IP form, ?sslmode=require (guide §4) CONN="postgres://${DB_USER}:${DB_PASSWORD}@${PRIVATE_IP}:5432/${DB_NAME}?sslmode=require" log "Storing connection string in Secret Manager secret ${SECRET_NAME}" if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then printf '%s' "${CONN}" | gcloud secrets versions add "${SECRET_NAME}" \ --data-file=- --project="${PROJECT_ID}" else printf '%s' "${CONN}" | gcloud secrets create "${SECRET_NAME}" \ --replication-policy=automatic --data-file=- --project="${PROJECT_ID}" fi else log "Skipping secret write (user already existed, password not available this run)" fi if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then log "Granting ${SA_EMAIL} secretAccessor on ${SECRET_NAME}" gcloud secrets add-iam-policy-binding "${SECRET_NAME}" \ --member="serviceAccount:${SA_EMAIL}" \ --role="roles/secretmanager.secretAccessor" \ --condition=None --project="${PROJECT_ID}" >/dev/null fi # JWT signing secret — generated once (re-runs do NOT rotate it). log "Ensuring JWT signing secret ${JWT_SECRET_NAME} (§5)" if gcloud secrets describe "${JWT_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then skip "secret ${JWT_SECRET_NAME}" else openssl rand -base64 32 | tr -d '\n' | gcloud secrets create "${JWT_SECRET_NAME}" \ --replication-policy=automatic --data-file=- --project="${PROJECT_ID}" fi gcloud secrets add-iam-policy-binding "${JWT_SECRET_NAME}" \ --member="serviceAccount:${SA_EMAIL}" \ --role="roles/secretmanager.secretAccessor" \ --condition=None --project="${PROJECT_ID}" >/dev/null # OIDC client secret — operator-created (the script can't generate it). Grant # accessor here once it exists so the deploy step doesn't fail on permission. if gcloud secrets describe "${OIDC_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then log "Granting ${SA_EMAIL} secretAccessor on ${OIDC_SECRET_NAME}" gcloud secrets add-iam-policy-binding "${OIDC_SECRET_NAME}" \ --member="serviceAccount:${SA_EMAIL}" \ --role="roles/secretmanager.secretAccessor" \ --condition=None --project="${PROJECT_ID}" >/dev/null fi # ---- 6 gateway.yaml -> Secret Manager (gateway-config) -------------------- # Published only when fully filled in: refuse to push a config that still has # REPLACE_ME placeholders (checked on non-comment lines so commented examples # and this file's header don't trip the guard). log "Publishing ${GATEWAY_YAML} as Secret Manager secret ${CONFIG_SECRET} (§6)" if [[ ! -f "${GATEWAY_YAML}" ]]; then echo " (skip) ${GATEWAY_YAML} not found — run 'cp gateway.yaml.example gateway.yaml', fill it in, then re-run (§6)." elif grep -vE '^[[:space:]]*#' "${GATEWAY_YAML}" | grep -q 'REPLACE_ME'; then echo " (skip) ${GATEWAY_YAML} still has REPLACE_ME placeholders to fill:" grep -nE 'REPLACE_ME' "${GATEWAY_YAML}" | grep -vE '^[0-9]+:[[:space:]]*#' | sed 's/^/ /' echo " Fill them in, then re-run to publish ${CONFIG_SECRET}." else if gcloud secrets describe "${CONFIG_SECRET}" --project="${PROJECT_ID}" >/dev/null 2>&1; then gcloud secrets versions add "${CONFIG_SECRET}" \ --data-file="${GATEWAY_YAML}" --project="${PROJECT_ID}" else gcloud secrets create "${CONFIG_SECRET}" --replication-policy=automatic \ --data-file="${GATEWAY_YAML}" --project="${PROJECT_ID}" fi gcloud secrets add-iam-policy-binding "${CONFIG_SECRET}" \ --member="serviceAccount:${SA_EMAIL}" \ --role="roles/secretmanager.secretAccessor" \ --condition=None --project="${PROJECT_ID}" >/dev/null fi # ---- 7 Cloud Run deploy (Direct VPC egress) ------------------------------- # Direct VPC egress (--network/--subnet/--vpc-egress) puts the service on the # VPC so it reaches the Cloud SQL PRIVATE IP directly — matching the private-IP # connection string in the postgres-url secret. private-ranges-only keeps public # egress (Agent Platform, accounts.google.com) off the VPC, so no Cloud NAT is needed. # We deliberately do NOT use --add-cloudsql-instances (that's the Auth Proxy / # socket path, which would need a different connection string). # # Secrets: gateway.yaml is mounted as a FILE at /etc/claude (alone in its dir). # The JWT / OIDC / Postgres secrets are injected as ENV VARS — Cloud Run cannot # mount multiple secrets into one directory, and gateway.yaml references them via # ${ENV_VAR}. (See the env-var names in gateway.yaml: GATEWAY_JWT_SECRET etc.) # # Self-gating: deploy only once its inputs exist (config secret published + the # operator-provided OIDC client secret). On a first run these are missing and it # cleanly skips. RUN_URL="" missing="" gcloud secrets describe "${CONFIG_SECRET}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${CONFIG_SECRET}" gcloud secrets describe "${OIDC_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${OIDC_SECRET_NAME}" # Also gate on the postgres-url secret (referenced by --set-secrets below): if it # is somehow absent, skip with a clear message rather than failing the deploy with # a raw Cloud Run missing-secret error. gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${SECRET_NAME}" if [[ "${DEPLOY}" != "1" ]]; then log "Skipping Cloud Run deploy (DEPLOY=${DEPLOY}) (§7)" elif [[ -n "${missing// }" ]]; then log "Skipping Cloud Run deploy — missing secret(s):${missing} (§7)" echo " Fill ${GATEWAY_YAML} and re-run to publish ${CONFIG_SECRET}; create ${OIDC_SECRET_NAME}" echo " from the Google OAuth client. Then re-run to deploy." else SECRET_MOUNTS="/etc/claude/gateway.yaml=${CONFIG_SECRET}:latest" # file mount (alone in /etc/claude) SECRET_MOUNTS="${SECRET_MOUNTS},GATEWAY_JWT_SECRET=${JWT_SECRET_NAME}:latest" # env var SECRET_MOUNTS="${SECRET_MOUNTS},OIDC_CLIENT_SECRET=${OIDC_SECRET_NAME}:latest" # env var SECRET_MOUNTS="${SECRET_MOUNTS},GATEWAY_POSTGRES_URL=${SECRET_NAME}:latest" # env var log "Deploying Cloud Run service ${SERVICE_NAME} (§7b, Direct VPC egress)" # Deploy private (--no-allow-unauthenticated avoids the interactive prompt and # keeps allUsers OUT of the deploy, so a Domain-Restricted-Sharing org doesn't # fail the deploy on the IAM step). Public access is attempted separately below. # # --ingress is passed EXPLICITLY because it is sticky across redeploys (omitting # it keeps the previous value). The default, internal, keeps the *.run.app URL # off the public internet — reachable only from this VPC, or from corp networks # with the PSC endpoint + private run.app DNS plumbing (see terraform/README.md # "Private access"). Public ingress cannot serve clients (see the INGRESS # guard at the top of this script), so the two-pass OAuth bootstrap has to be # completed from inside the VPC (or a PSC-connected corp network). Use # internal-and-cloud-load-balancing instead if you front the service with # your own internal ALB. # # --timeout=3600 raises Cloud Run's default 300s request timeout, which would # otherwise cut off long streaming /v1/messages responses mid-stream. # # --max-instances bounds the Postgres connection footprint: each instance # opens a pool of up to 5 connections (store.max_connections default) and # db-g1-small caps at ~50 max_connections, so the default ceiling of 100 # instances would crash-loop new instances under load. Keep # max-instances × 5 below the DB tier's max_connections; raise the DB tier # (or set store.max_connections lower) before raising this. gcloud run deploy "${SERVICE_NAME}" \ --image="${IMAGE}" \ --region="${REGION}" \ --service-account="${SA_EMAIL}" \ --min-instances=1 \ --max-instances="${MAX_INSTANCES}" \ --port=8080 \ --timeout=3600 \ --ingress="${INGRESS}" \ --network="${VPC_NETWORK}" \ --subnet="${SUBNET}" \ --vpc-egress=private-ranges-only \ --set-secrets="${SECRET_MOUNTS}" \ --no-allow-unauthenticated \ --project="${PROJECT_ID}" # The gateway runs its OWN OIDC, so the Cloud Run IAM layer must allow # unauthenticated. Attempt it separately and tolerate failure: Domain Restricted # Sharing (iam.allowedPolicyMemberDomains) blocks allUsers in hardened orgs. log "Granting public invoker (allUsers) — required for the gateway's OIDC login" if gcloud run services add-iam-policy-binding "${SERVICE_NAME}" \ --region="${REGION}" --member=allUsers --role=roles/run.invoker \ --project="${PROJECT_ID}" >/dev/null 2>&1; then echo " public invoker granted." else echo " WARN: allUsers rejected (likely Domain Restricted Sharing). The service is" echo " deployed but the invoker IAM check is still enabled, so requests 403" echo " before reaching the container. Preferred fix (where available):" echo " gcloud run services update ${SERVICE_NAME} --no-invoker-iam-check \\" echo " --region=${REGION} --project=${PROJECT_ID}" echo " Alternatively: request a DRS exception for ${SERVICE_NAME}, or use the GKE" echo " track, which exposes the gateway at the network layer with no allUsers" echo " binding. An LB is NOT a fix — it does not bypass the invoker IAM check." fi RUN_URL="$(gcloud run services describe "${SERVICE_NAME}" --region="${REGION}" \ --project="${PROJECT_ID}" --format='value(status.url)')" log "Cloud Run URL: ${RUN_URL}" # public_url is now required (config validation refuses a non-loopback bind # without it), so the template ships a placeholder for the first pass. Once we # know the real URL, warn on any mismatch so the operator doesn't leave the # placeholder — or a stale hostname — in place. Normalize quotes / inline # comments / a trailing slash so schema-equivalent spellings compare equal. # Only checked with internal ingress, where public_url should be the run.app # URL; behind an internal ALB it is the ALB hostname, which this script # cannot know. CFG_PUBLIC_URL="$(grep -E '^[[:space:]]*public_url:' "${GATEWAY_YAML}" 2>/dev/null \ | head -1 \ | sed -E 's/^[[:space:]]*public_url:[[:space:]]*//; s/[[:space:]]+#.*$//; s/[[:space:]]*$//' \ || true)" CFG_PUBLIC_URL="${CFG_PUBLIC_URL#[\'\"]}"; CFG_PUBLIC_URL="${CFG_PUBLIC_URL%[\'\"]}" CFG_PUBLIC_URL="${CFG_PUBLIC_URL%/}" if [[ "${INGRESS}" == "internal" && -n "${RUN_URL}" && "${CFG_PUBLIC_URL}" != "${RUN_URL%/}" ]]; then echo " NOTE — ${GATEWAY_YAML} has public_url: ${CFG_PUBLIC_URL:-}" echo " but this service's URL is ${RUN_URL}." echo " Set listen.public_url to ${RUN_URL} (or your LB hostname) and re-run." fi if [[ -n "${RUN_URL}" ]]; then # gcloud run deploy already fails the script if the revision can't boot (it # waits for the Ready condition), so what's left to verify is that the # gateway is serving. The OAuth discovery document below returns 200 only # after config load, OIDC discovery, upstream construction, and Postgres # migration all succeed, so it doubles as an end-to-end boot check (the # readiness probe proper is GET /readyz). With internal ingress the URL is # reachable only from inside the VPC (or a PSC-connected corp network), so # verification is left to the operator rather than attempted from here. log "Verify the gateway is serving (from inside the VPC, or a PSC-connected corp network):" echo " curl -s ${RUN_URL}/.well-known/oauth-authorization-server" echo " If it isn't responding yet, check logs:" echo " gcloud run services logs read ${SERVICE_NAME} --region=${REGION} --project=${PROJECT_ID}" log "Finish the OAuth bootstrap:" echo " 1. Register this redirect URI on the Google OAuth client: ${RUN_URL}/oauth/callback" echo " 2. Set listen.public_url in ${GATEWAY_YAML} to ${RUN_URL}, then re-run: INGRESS=${INGRESS} ./setup.sh" echo " (republishes ${CONFIG_SECRET} and redeploys so the IdP redirect_uri matches)." echo " With INGRESS=internal-and-cloud-load-balancing, use your internal ALB hostname" echo " instead of the run.app URL in both steps." fi fi # ---- summary ---------------------------------------------------------------- cat < Done. Service account ${SA_EMAIL} roles: aiplatform.user, secretmanager.secretAccessor Image ${IMAGE} Instance ${DB_INSTANCE} Connection name ${PROJECT_ID}:${REGION}:${DB_INSTANCE} Private IP ${PRIVATE_IP} Database / user ${DB_NAME} / ${DB_USER} Secrets ${SECRET_NAME}, ${JWT_SECRET_NAME}, ${CONFIG_SECRET} Cloud Run service ${SERVICE_NAME} -> ${RUN_URL:-(not deployed yet)} (ingress: ${INGRESS}) Next steps (see https://code.claude.com/docs/en/claude-apps-gateway-on-gcp): - Create the one operator-provided secret (from the Google Cloud Console OAuth client): printf '%s' "" | gcloud secrets create ${OIDC_SECRET_NAME} \\ --data-file=- --project="${PROJECT_ID}" setup.sh grants ${SA_EMAIL} secretAccessor on it on the next re-run. - Fill in the REPLACE_ME values in ${GATEWAY_YAML}, then re-run: setup.sh publishes ${CONFIG_SECRET} and deploys ${SERVICE_NAME} once both secrets exist. - After the first deploy: set listen.public_url to the Cloud Run URL above (or your internal ALB hostname) and register /oauth/callback on the Google OAuth client, then re-run to redeploy. - The gateway runs its own schema migrations at boot, so ${DB_USER} needs CREATE TABLE. EOF