File size: 30,481 Bytes
7f0ec95 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 | #!/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=<your-project> (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:-<unset>}"
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 <<EOF
==> 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' "<client-secret>" | 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 <url>/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
|