spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / examples / gateway / gcp / setup.sh
558 lines · 29.8 KBRaw
1#!/usr/bin/env bash
2#
3# setup.sh — GCP setup for Claude Gateway (walkthrough §1–7b).
4#
5# Provisions, in doc order: APIs (§1), service account + IAM (§2), the gateway
6# container image in Artifact Registry (§3), a Cloud SQL (PostgreSQL) backend
7# with PRIVATE IP only (§4), the JWT + postgres-url secrets (§5), the
8# gateway.yaml config secret (§6), and a Cloud Run deploy with Direct VPC
9# egress (§7b).
10#
11# Private IP is required because public IP is disallowed by the org-policy constraint
12# `constraints/sql.restrictPublicIp`. A Cloud SQL private IP is an address inside a VPC,
13# so §4 here also provisions the prerequisite VPC + Private Services Access — the
14# one-time, irreducible networking required for private IP.
15#
16# Section markers (§N) below map to the walkthrough:
17# https://code.claude.com/docs/en/claude-apps-gateway-on-gcp
18#
19# Covers here: APIs (§1) -> service account + IAM (§2) -> build & push image (§3)
20# -> VPC + Private Services Access -> Cloud SQL (private IP only) -> database
21# + user (§4) -> jwt + postgres-url secrets (§5) -> gateway-config
22# secret from gateway.yaml (§6) -> Cloud Run deploy (§7b).
23# Not covered: GKE track (§7a) — Cloud Run is the lower-friction path here.
24#
25# Idempotent: existing resources are detected and skipped, so it is safe to re-run.
26# Override any default below via environment variable, e.g. `REGION=us-east5 ./setup.sh`.
27
28set -euo pipefail
29
30# ---- configuration (env-overridable) ----------------------------------------
31PROJECT_ID="${PROJECT_ID:-$(gcloud config get-value project 2>/dev/null)}"
32REGION="${REGION:-${CLOUDSDK_COMPUTE_REGION:-us-east5}}" # guide §1 uses us-east5 (Agent Platform model region)
33
34SA_NAME="${SA_NAME:-claude-gateway}" # §2 service account
35SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
36
37# §3 image
38AR_REPO="${AR_REPO:-claude-gateway}" # Artifact Registry repository
39IMAGE_NAME="${IMAGE_NAME:-gateway}"
40RELEASES_URL="${RELEASES_URL:-https://downloads.claude.ai/claude-code-releases}" # public Claude Code release endpoint
41VERSION="${VERSION:-}" # Claude Code release to deploy; empty = latest release (resolved below)
42VERSION_FILE="${VERSION_FILE:-./.claude-version}" # pins the resolved release across re-runs; delete it (or set VERSION) to upgrade
43DOCKERFILE="${DOCKERFILE:-./Dockerfile}"
44CLAUDE_BINARY="${CLAUDE_BINARY:-./claude}" # linux-x64 Claude Code binary; downloaded from RELEASES_URL if missing
45CLAUDE_SHA256="${CLAUDE_SHA256:-}" # optional: out-of-band sha256 pin for the downloaded binary, checked in addition to the release manifest
46
47VPC_NETWORK="${VPC_NETWORK:-cc-gateway-vpc}"
48SUBNET="${SUBNET:-cc-gateway-subnet}"
49SUBNET_RANGE="${SUBNET_RANGE:-10.0.0.0/24}"
50
51PSA_RANGE_NAME="${PSA_RANGE_NAME:-google-managed-services-${VPC_NETWORK}}"
52PSA_PREFIX_LENGTH="${PSA_PREFIX_LENGTH:-16}" # /16 is GCP's recommendation; reserved, not consumed
53
54DB_INSTANCE="${DB_INSTANCE:-claude-gateway-db}"
55DB_VERSION="${DB_VERSION:-POSTGRES_16}" # PG14+ supported; 16 is the recommended default (§4)
56DB_TIER="${DB_TIER:-db-g1-small}"
57DB_NAME="${DB_NAME:-claude_gateway}"
58DB_USER="${DB_USER:-gateway}"
59
60SECRET_NAME="${SECRET_NAME:-gateway-postgres-url}" # §5 store.postgres_url
61JWT_SECRET_NAME="${JWT_SECRET_NAME:-gateway-jwt-secret}" # §5 session.jwt_secret
62
63GATEWAY_YAML="${GATEWAY_YAML:-./gateway.yaml}" # §6 config file
64CONFIG_SECRET="${CONFIG_SECRET:-gateway-config}" # §6 mounted at /etc/claude/gateway.yaml
65
66# §7 Cloud Run deploy
67SERVICE_NAME="${SERVICE_NAME:-claude-gateway}"
68OIDC_SECRET_NAME="${OIDC_SECRET_NAME:-gateway-oidc-client-secret}" # operator-created (Google OAuth client)
69DEPLOY="${DEPLOY:-1}" # set DEPLOY=0 to provision only, no Cloud Run deploy
70INGRESS="${INGRESS:-internal}" # internal (default; no public URL) | internal-and-cloud-load-balancing (only if you front it with your own internal ALB)
71MAX_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
72
73# ---- helpers ----------------------------------------------------------------
74log() { printf '\n==> %s\n' "$*"; }
75skip() { printf ' (exists) %s\n' "$*"; }
76curl_https() { curl --proto '=https' --proto-redir '=https' --tlsv1.2 "$@"; } # refuse plaintext/protocol-downgrade
77sha_of() { openssl dgst -sha256 "$1" | awk '{print $NF}'; } # openssl avoids shasum/sha256sum portability gaps
78
79if [[ -z "${PROJECT_ID}" ]]; then
80 echo "ERROR: PROJECT_ID is not set and no gcloud default project is configured." >&2
81 echo " Set it with: export PROJECT_ID=<your-project> (or 'gcloud config set project ...')" >&2
82 exit 1
83fi
84# VERSION tags the image and selects the public Claude Code release to download.
85# The first resolved value is pinned to ${VERSION_FILE} so the documented
86# re-runs (fill gateway.yaml -> re-run; set public_url -> re-run) don't silently
87# build and deploy a newer release mid-bootstrap.
88if [[ -z "${VERSION}" && -f "${VERSION_FILE}" ]]; then
89 VERSION="$(< "${VERSION_FILE}")"
90 log "Using release pinned in ${VERSION_FILE}: ${VERSION} (delete the file or set VERSION to change it)"
91elif [[ -z "${VERSION}" ]]; then
92 # /latest is the channel the official installer (claude.ai/install.sh) uses.
93 VERSION="$(curl_https -fsSL "${RELEASES_URL}/latest" | tr -d '[:space:]' || true)"
94 if [[ -z "${VERSION}" ]]; then
95 echo "ERROR: could not resolve the latest release from ${RELEASES_URL}/latest." >&2
96 echo " Set VERSION to a Claude Code release version, e.g. export VERSION=2.1.195" >&2
97 exit 1
98 fi
99 log "VERSION not set — using latest Claude Code release: ${VERSION}"
100fi
101# Reject non-version content (e.g. an HTML error page served with HTTP 200)
102# before it reaches the image tag and download URLs.
103if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
104 echo "ERROR: '${VERSION}' is not a release version (from VERSION, ${VERSION_FILE}, or ${RELEASES_URL}/latest)." >&2
105 exit 1
106fi
107printf '%s' "${VERSION}" > "${VERSION_FILE}"
108IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${AR_REPO}/${IMAGE_NAME}:${VERSION}"
109# Claude Code only connects to a gateway whose hostname resolves to private
110# addresses (a client-side /login check), so public ingress can never serve
111# clients — mirror the terraform module's validation and refuse it up front.
112if [[ "${INGRESS}" != "internal" && "${INGRESS}" != "internal-and-cloud-load-balancing" ]]; then
113 echo "ERROR: INGRESS must be 'internal' or 'internal-and-cloud-load-balancing' — Claude Code's" >&2
114 echo " /login only accepts gateway hosts on private addresses, so public ingress cannot serve clients." >&2
115 exit 1
116fi
117
118log "Project: ${PROJECT_ID} Region: ${REGION} VPC: ${VPC_NETWORK}"
119
120# ---- 1 Project & API setup ------------------------------------------------
121# walkthrough §1 list (aiplatform, artifactregistry, sqladmin, secretmanager, iamcredentials)
122# plus iam/compute/servicenetworking required for the SA + private-IP networking below.
123# container.googleapis.com is for the GKE track (§7a) — harmless if you stay on Cloud Run.
124# We pass --project on every call rather than mutating your gcloud config.
125log "Enabling required APIs (§1)"
126gcloud services enable \
127 aiplatform.googleapis.com \
128 artifactregistry.googleapis.com \
129 sqladmin.googleapis.com \
130 secretmanager.googleapis.com \
131 iamcredentials.googleapis.com \
132 iam.googleapis.com \
133 compute.googleapis.com \
134 container.googleapis.com \
135 servicenetworking.googleapis.com \
136 run.googleapis.com \
137 --project="${PROJECT_ID}"
138
139# ---- 2 Service account & IAM ----------------------------------------------
140log "Creating service account ${SA_EMAIL} and granting project roles (§2)"
141if gcloud iam service-accounts describe "${SA_EMAIL}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
142 skip "service account ${SA_EMAIL}"
143else
144 gcloud iam service-accounts create "${SA_NAME}" \
145 --display-name="Claude Gateway" --project="${PROJECT_ID}"
146fi
147
148# add-iam-policy-binding is idempotent (re-adding an existing binding is a no-op).
149# --condition=None avoids the interactive condition prompt in non-interactive runs.
150#
151# Only aiplatform.user is granted: the gateway reaches Cloud SQL over the VPC at
152# its PRIVATE IP with a password user (§4/§7b — direct TCP, not the Cloud SQL
153# Auth Proxy / connector), so it never calls cloudsql.instances.connect and no
154# roles/cloudsql.client grant is needed. Direct private-IP is used because the
155# gateway's store is a plain postgres_url — no proxy sidecar/socket plumbing,
156# one less moving part, and the connection string is portable across Cloud Run
157# and GKE.
158gcloud projects add-iam-policy-binding "${PROJECT_ID}" \
159 --member="serviceAccount:${SA_EMAIL}" \
160 --role="roles/aiplatform.user" --condition=None >/dev/null # Agent Platform inference (§2)
161
162# ---- 3 Build & push image to Artifact Registry ----------------------------
163log "Ensuring Artifact Registry repo and image (§3)"
164if gcloud artifacts repositories describe "${AR_REPO}" \
165 --location="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
166 skip "Artifact Registry repo ${AR_REPO}"
167else
168 gcloud artifacts repositories create "${AR_REPO}" \
169 --repository-format=docker --location="${REGION}" --project="${PROJECT_ID}"
170fi
171
172# Image is the expensive, already-done step: skip the build+push entirely if the
173# tag already exists in the registry.
174if gcloud artifacts docker images describe "${IMAGE}" >/dev/null 2>&1; then
175 skip "image ${IMAGE}"
176else
177 # The public Claude Code release includes the gateway subcommand, so the
178 # binary comes straight from the release endpoint, verified against the
179 # release manifest's sha256. A pre-existing ${CLAUDE_BINARY} (stale version,
180 # interrupted download, hand-placed file) is verified the same way and
181 # re-downloaded on mismatch, so an unverified binary can never reach the image.
182 manifest="$(curl_https -fsSL "${RELEASES_URL}/${VERSION}/manifest.json" | tr -d '[:space:]' || true)"
183 sha_re='"linux-x64"[^}]*"checksum":"([a-f0-9]{64})"' # structure-based: survives pretty-printed, minified, and one-line-per-platform manifests
184 if [[ ! "${manifest}" =~ ${sha_re} ]]; then
185 echo "ERROR: could not read the linux-x64 sha256 from ${RELEASES_URL}/${VERSION}/manifest.json — refusing to build." >&2
186 exit 1
187 fi
188 expected_sha="${BASH_REMATCH[1]}"
189 if [[ -f "${CLAUDE_BINARY}" && "$(sha_of "${CLAUDE_BINARY}")" == "${expected_sha}" ]]; then
190 skip "binary ${CLAUDE_BINARY} (sha256 matches release ${VERSION})"
191 else
192 if [[ -f "${CLAUDE_BINARY}" ]]; then
193 log "Existing ${CLAUDE_BINARY} does not match release ${VERSION} — re-downloading"
194 else
195 log "Downloading Claude Code ${VERSION} (linux-x64) from ${RELEASES_URL}"
196 fi
197 # Until verification passes, ANY exit (curl failure, set -e, signal, the
198 # error exit below) removes the file, so a partial download can't be
199 # silently picked up by a later run.
200 trap 'rm -f "${CLAUDE_BINARY}"' EXIT INT TERM
201 curl_https -fL -o "${CLAUDE_BINARY}" "${RELEASES_URL}/${VERSION}/linux-x64/claude"
202 actual_sha="$(sha_of "${CLAUDE_BINARY}")"
203 if [[ "${actual_sha}" != "${expected_sha}" ]]; then
204 echo "ERROR: sha256 of ${CLAUDE_BINARY} is ${actual_sha} but the release manifest says ${expected_sha} — refusing to build." >&2
205 exit 1
206 fi
207 trap - EXIT INT TERM
208 log "Verified binary sha256 ${actual_sha}"
209 fi
210 # Optional out-of-band pin, checked even for a pre-existing binary: the
211 # manifest shares an origin with the binary, so it can't defend against a
212 # compromised endpoint — CLAUDE_SHA256 can.
213 if [[ -n "${CLAUDE_SHA256}" && "$(sha_of "${CLAUDE_BINARY}")" != "${CLAUDE_SHA256}" ]]; then
214 echo "ERROR: sha256 of ${CLAUDE_BINARY} does not match CLAUDE_SHA256 (${CLAUDE_SHA256}) — refusing to build." >&2
215 exit 1
216 fi
217 chmod +x "${CLAUDE_BINARY}"
218 log "Building and pushing ${IMAGE}"
219 gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet
220 # Cloud Run requires linux/amd64. --platform forces it (e.g. when building on an
221 # Apple Silicon Mac), and --provenance=false keeps buildx from wrapping the result
222 # in an OCI image index that Cloud Run rejects ("manifest ... must support amd64/linux").
223 docker build --platform=linux/amd64 --provenance=false \
224 -f "${DOCKERFILE}" --build-arg CLAUDE_BINARY="${CLAUDE_BINARY}" -t "${IMAGE}" .
225 docker push "${IMAGE}"
226fi
227
228# ---- 4 VPC + Private Services Access (private-IP prerequisite) -------------
229log "Creating VPC network and subnet"
230if gcloud compute networks describe "${VPC_NETWORK}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
231 skip "network ${VPC_NETWORK}"
232else
233 gcloud compute networks create "${VPC_NETWORK}" \
234 --subnet-mode=custom --project="${PROJECT_ID}"
235fi
236
237if gcloud compute networks subnets describe "${SUBNET}" \
238 --region="${REGION}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
239 skip "subnet ${SUBNET}"
240else
241 gcloud compute networks subnets create "${SUBNET}" \
242 --network="${VPC_NETWORK}" --region="${REGION}" \
243 --range="${SUBNET_RANGE}" --project="${PROJECT_ID}"
244fi
245
246log "Configuring Private Services Access (allocated range + VPC peering)"
247if gcloud compute addresses describe "${PSA_RANGE_NAME}" \
248 --global --project="${PROJECT_ID}" >/dev/null 2>&1; then
249 skip "allocated range ${PSA_RANGE_NAME}"
250else
251 gcloud compute addresses create "${PSA_RANGE_NAME}" \
252 --global --purpose=VPC_PEERING --prefix-length="${PSA_PREFIX_LENGTH}" \
253 --network="${VPC_NETWORK}" --project="${PROJECT_ID}"
254fi
255
256if gcloud services vpc-peerings list --network="${VPC_NETWORK}" --project="${PROJECT_ID}" \
257 --format='value(peering)' 2>/dev/null | grep -q servicenetworking; then
258 skip "servicenetworking VPC peering"
259else
260 gcloud services vpc-peerings connect \
261 --service=servicenetworking.googleapis.com \
262 --ranges="${PSA_RANGE_NAME}" \
263 --network="${VPC_NETWORK}" --project="${PROJECT_ID}"
264fi
265
266# ---- 4 Cloud SQL instance (private IP only) -------------------------------
267log "Creating Cloud SQL instance ${DB_INSTANCE} (private IP only)"
268if gcloud sql instances describe "${DB_INSTANCE}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
269 skip "instance ${DB_INSTANCE}"
270else
271 gcloud sql instances create "${DB_INSTANCE}" \
272 --database-version="${DB_VERSION}" \
273 --tier="${DB_TIER}" \
274 --region="${REGION}" \
275 --network="projects/${PROJECT_ID}/global/networks/${VPC_NETWORK}" \
276 --no-assign-ip \
277 --project="${PROJECT_ID}"
278fi
279
280log "Creating database ${DB_NAME}"
281if gcloud sql databases describe "${DB_NAME}" \
282 --instance="${DB_INSTANCE}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
283 skip "database ${DB_NAME}"
284else
285 gcloud sql databases create "${DB_NAME}" \
286 --instance="${DB_INSTANCE}" --project="${PROJECT_ID}"
287fi
288
289# hex (not base64) keeps the password URL-safe for the connection string below.
290log "Creating database user ${DB_USER}"
291DB_PASSWORD=""
292if gcloud sql users list --instance="${DB_INSTANCE}" --project="${PROJECT_ID}" \
293 --format='value(name)' 2>/dev/null | grep -qx "${DB_USER}"; then
294 if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
295 skip "user ${DB_USER} (password unchanged; secret not rewritten)"
296 else
297 # Self-heal: a previous run died after creating the user but before writing
298 # the connection-string secret, losing the only copy of the password. The
299 # secret is the password's only consumer, so resetting it is safe and keeps
300 # re-runs able to recover from any partial state.
301 log "User ${DB_USER} exists but secret ${SECRET_NAME} is missing — resetting password"
302 DB_PASSWORD="$(openssl rand -hex 24)"
303 gcloud sql users set-password "${DB_USER}" \
304 --instance="${DB_INSTANCE}" --password="${DB_PASSWORD}" \
305 --project="${PROJECT_ID}"
306 fi
307else
308 DB_PASSWORD="$(openssl rand -hex 24)"
309 gcloud sql users create "${DB_USER}" \
310 --instance="${DB_INSTANCE}" --password="${DB_PASSWORD}" \
311 --project="${PROJECT_ID}"
312fi
313
314# ---- 5 Connection string -> Secret Manager + secretAccessor ---------------
315PRIVATE_IP="$(gcloud sql instances describe "${DB_INSTANCE}" --project="${PROJECT_ID}" \
316 --format='value(ipAddresses[0].ipAddress)')"
317
318if [[ -n "${DB_PASSWORD}" ]]; then
319 # direct private-IP form, ?sslmode=require (guide §4)
320 CONN="postgres://${DB_USER}:${DB_PASSWORD}@${PRIVATE_IP}:5432/${DB_NAME}?sslmode=require"
321 log "Storing connection string in Secret Manager secret ${SECRET_NAME}"
322 if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
323 printf '%s' "${CONN}" | gcloud secrets versions add "${SECRET_NAME}" \
324 --data-file=- --project="${PROJECT_ID}"
325 else
326 printf '%s' "${CONN}" | gcloud secrets create "${SECRET_NAME}" \
327 --replication-policy=automatic --data-file=- --project="${PROJECT_ID}"
328 fi
329else
330 log "Skipping secret write (user already existed, password not available this run)"
331fi
332
333if gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
334 log "Granting ${SA_EMAIL} secretAccessor on ${SECRET_NAME}"
335 gcloud secrets add-iam-policy-binding "${SECRET_NAME}" \
336 --member="serviceAccount:${SA_EMAIL}" \
337 --role="roles/secretmanager.secretAccessor" \
338 --condition=None --project="${PROJECT_ID}" >/dev/null
339fi
340
341# JWT signing secret — generated once (re-runs do NOT rotate it).
342log "Ensuring JWT signing secret ${JWT_SECRET_NAME} (§5)"
343if gcloud secrets describe "${JWT_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
344 skip "secret ${JWT_SECRET_NAME}"
345else
346 openssl rand -base64 32 | tr -d '\n' | gcloud secrets create "${JWT_SECRET_NAME}" \
347 --replication-policy=automatic --data-file=- --project="${PROJECT_ID}"
348fi
349gcloud secrets add-iam-policy-binding "${JWT_SECRET_NAME}" \
350 --member="serviceAccount:${SA_EMAIL}" \
351 --role="roles/secretmanager.secretAccessor" \
352 --condition=None --project="${PROJECT_ID}" >/dev/null
353
354# OIDC client secret — operator-created (the script can't generate it). Grant
355# accessor here once it exists so the deploy step doesn't fail on permission.
356if gcloud secrets describe "${OIDC_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
357 log "Granting ${SA_EMAIL} secretAccessor on ${OIDC_SECRET_NAME}"
358 gcloud secrets add-iam-policy-binding "${OIDC_SECRET_NAME}" \
359 --member="serviceAccount:${SA_EMAIL}" \
360 --role="roles/secretmanager.secretAccessor" \
361 --condition=None --project="${PROJECT_ID}" >/dev/null
362fi
363
364# ---- 6 gateway.yaml -> Secret Manager (gateway-config) --------------------
365# Published only when fully filled in: refuse to push a config that still has
366# REPLACE_ME placeholders (checked on non-comment lines so commented examples
367# and this file's header don't trip the guard).
368log "Publishing ${GATEWAY_YAML} as Secret Manager secret ${CONFIG_SECRET} (§6)"
369if [[ ! -f "${GATEWAY_YAML}" ]]; then
370 echo " (skip) ${GATEWAY_YAML} not found — run 'cp gateway.yaml.example gateway.yaml', fill it in, then re-run (§6)."
371elif grep -vE '^[[:space:]]*#' "${GATEWAY_YAML}" | grep -q 'REPLACE_ME'; then
372 echo " (skip) ${GATEWAY_YAML} still has REPLACE_ME placeholders to fill:"
373 grep -nE 'REPLACE_ME' "${GATEWAY_YAML}" | grep -vE '^[0-9]+:[[:space:]]*#' | sed 's/^/ /'
374 echo " Fill them in, then re-run to publish ${CONFIG_SECRET}."
375else
376 if gcloud secrets describe "${CONFIG_SECRET}" --project="${PROJECT_ID}" >/dev/null 2>&1; then
377 gcloud secrets versions add "${CONFIG_SECRET}" \
378 --data-file="${GATEWAY_YAML}" --project="${PROJECT_ID}"
379 else
380 gcloud secrets create "${CONFIG_SECRET}" --replication-policy=automatic \
381 --data-file="${GATEWAY_YAML}" --project="${PROJECT_ID}"
382 fi
383 gcloud secrets add-iam-policy-binding "${CONFIG_SECRET}" \
384 --member="serviceAccount:${SA_EMAIL}" \
385 --role="roles/secretmanager.secretAccessor" \
386 --condition=None --project="${PROJECT_ID}" >/dev/null
387fi
388
389# ---- 7 Cloud Run deploy (Direct VPC egress) -------------------------------
390# Direct VPC egress (--network/--subnet/--vpc-egress) puts the service on the
391# VPC so it reaches the Cloud SQL PRIVATE IP directly — matching the private-IP
392# connection string in the postgres-url secret. private-ranges-only keeps public
393# egress (Agent Platform, accounts.google.com) off the VPC, so no Cloud NAT is needed.
394# We deliberately do NOT use --add-cloudsql-instances (that's the Auth Proxy /
395# socket path, which would need a different connection string).
396#
397# Secrets: gateway.yaml is mounted as a FILE at /etc/claude (alone in its dir).
398# The JWT / OIDC / Postgres secrets are injected as ENV VARS — Cloud Run cannot
399# mount multiple secrets into one directory, and gateway.yaml references them via
400# ${ENV_VAR}. (See the env-var names in gateway.yaml: GATEWAY_JWT_SECRET etc.)
401#
402# Self-gating: deploy only once its inputs exist (config secret published + the
403# operator-provided OIDC client secret). On a first run these are missing and it
404# cleanly skips.
405RUN_URL=""
406missing=""
407gcloud secrets describe "${CONFIG_SECRET}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${CONFIG_SECRET}"
408gcloud secrets describe "${OIDC_SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${OIDC_SECRET_NAME}"
409# Also gate on the postgres-url secret (referenced by --set-secrets below): if it
410# is somehow absent, skip with a clear message rather than failing the deploy with
411# a raw Cloud Run missing-secret error.
412gcloud secrets describe "${SECRET_NAME}" --project="${PROJECT_ID}" >/dev/null 2>&1 || missing="${missing} ${SECRET_NAME}"
413
414if [[ "${DEPLOY}" != "1" ]]; then
415 log "Skipping Cloud Run deploy (DEPLOY=${DEPLOY}) (§7)"
416elif [[ -n "${missing// }" ]]; then
417 log "Skipping Cloud Run deploy — missing secret(s):${missing} (§7)"
418 echo " Fill ${GATEWAY_YAML} and re-run to publish ${CONFIG_SECRET}; create ${OIDC_SECRET_NAME}"
419 echo " from the Google OAuth client. Then re-run to deploy."
420else
421 SECRET_MOUNTS="/etc/claude/gateway.yaml=${CONFIG_SECRET}:latest" # file mount (alone in /etc/claude)
422 SECRET_MOUNTS="${SECRET_MOUNTS},GATEWAY_JWT_SECRET=${JWT_SECRET_NAME}:latest" # env var
423 SECRET_MOUNTS="${SECRET_MOUNTS},OIDC_CLIENT_SECRET=${OIDC_SECRET_NAME}:latest" # env var
424 SECRET_MOUNTS="${SECRET_MOUNTS},GATEWAY_POSTGRES_URL=${SECRET_NAME}:latest" # env var
425
426 log "Deploying Cloud Run service ${SERVICE_NAME} (§7b, Direct VPC egress)"
427 # Deploy private (--no-allow-unauthenticated avoids the interactive prompt and
428 # keeps allUsers OUT of the deploy, so a Domain-Restricted-Sharing org doesn't
429 # fail the deploy on the IAM step). Public access is attempted separately below.
430 #
431 # --ingress is passed EXPLICITLY because it is sticky across redeploys (omitting
432 # it keeps the previous value). The default, internal, keeps the *.run.app URL
433 # off the public internet — reachable only from this VPC, or from corp networks
434 # with the PSC endpoint + private run.app DNS plumbing (see terraform/README.md
435 # "Private access"). Public ingress cannot serve clients (see the INGRESS
436 # guard at the top of this script), so the two-pass OAuth bootstrap has to be
437 # completed from inside the VPC (or a PSC-connected corp network). Use
438 # internal-and-cloud-load-balancing instead if you front the service with
439 # your own internal ALB.
440 #
441 # --timeout=3600 raises Cloud Run's default 300s request timeout, which would
442 # otherwise cut off long streaming /v1/messages responses mid-stream.
443 #
444 # --max-instances bounds the Postgres connection footprint: each instance
445 # opens a pool of up to 5 connections (store.max_connections default) and
446 # db-g1-small caps at ~50 max_connections, so the default ceiling of 100
447 # instances would crash-loop new instances under load. Keep
448 # max-instances × 5 below the DB tier's max_connections; raise the DB tier
449 # (or set store.max_connections lower) before raising this.
450 gcloud run deploy "${SERVICE_NAME}" \
451 --image="${IMAGE}" \
452 --region="${REGION}" \
453 --service-account="${SA_EMAIL}" \
454 --min-instances=1 \
455 --max-instances="${MAX_INSTANCES}" \
456 --port=8080 \
457 --timeout=3600 \
458 --ingress="${INGRESS}" \
459 --network="${VPC_NETWORK}" \
460 --subnet="${SUBNET}" \
461 --vpc-egress=private-ranges-only \
462 --set-secrets="${SECRET_MOUNTS}" \
463 --no-allow-unauthenticated \
464 --project="${PROJECT_ID}"
465
466 # The gateway runs its OWN OIDC, so the Cloud Run IAM layer must allow
467 # unauthenticated. Attempt it separately and tolerate failure: Domain Restricted
468 # Sharing (iam.allowedPolicyMemberDomains) blocks allUsers in hardened orgs.
469 log "Granting public invoker (allUsers) — required for the gateway's OIDC login"
470 if gcloud run services add-iam-policy-binding "${SERVICE_NAME}" \
471 --region="${REGION}" --member=allUsers --role=roles/run.invoker \
472 --project="${PROJECT_ID}" >/dev/null 2>&1; then
473 echo " public invoker granted."
474 else
475 echo " WARN: allUsers rejected (likely Domain Restricted Sharing). The service is"
476 echo " deployed but the invoker IAM check is still enabled, so requests 403"
477 echo " before reaching the container. Preferred fix (where available):"
478 echo " gcloud run services update ${SERVICE_NAME} --no-invoker-iam-check \\"
479 echo " --region=${REGION} --project=${PROJECT_ID}"
480 echo " Alternatively: request a DRS exception for ${SERVICE_NAME}, or use the GKE"
481 echo " track, which exposes the gateway at the network layer with no allUsers"
482 echo " binding. An LB is NOT a fix — it does not bypass the invoker IAM check."
483 fi
484
485 RUN_URL="$(gcloud run services describe "${SERVICE_NAME}" --region="${REGION}" \
486 --project="${PROJECT_ID}" --format='value(status.url)')"
487 log "Cloud Run URL: ${RUN_URL}"
488
489 # public_url is now required (config validation refuses a non-loopback bind
490 # without it), so the template ships a placeholder for the first pass. Once we
491 # know the real URL, warn on any mismatch so the operator doesn't leave the
492 # placeholder — or a stale hostname — in place. Normalize quotes / inline
493 # comments / a trailing slash so schema-equivalent spellings compare equal.
494 # Only checked with internal ingress, where public_url should be the run.app
495 # URL; behind an internal ALB it is the ALB hostname, which this script
496 # cannot know.
497 CFG_PUBLIC_URL="$(grep -E '^[[:space:]]*public_url:' "${GATEWAY_YAML}" 2>/dev/null \
498 | head -1 \
499 | sed -E 's/^[[:space:]]*public_url:[[:space:]]*//; s/[[:space:]]+#.*$//; s/[[:space:]]*$//' \
500 || true)"
501 CFG_PUBLIC_URL="${CFG_PUBLIC_URL#[\'\"]}"; CFG_PUBLIC_URL="${CFG_PUBLIC_URL%[\'\"]}"
502 CFG_PUBLIC_URL="${CFG_PUBLIC_URL%/}"
503 if [[ "${INGRESS}" == "internal" && -n "${RUN_URL}" && "${CFG_PUBLIC_URL}" != "${RUN_URL%/}" ]]; then
504 echo " NOTE — ${GATEWAY_YAML} has public_url: ${CFG_PUBLIC_URL:-<unset>}"
505 echo " but this service's URL is ${RUN_URL}."
506 echo " Set listen.public_url to ${RUN_URL} (or your LB hostname) and re-run."
507 fi
508
509 if [[ -n "${RUN_URL}" ]]; then
510 # gcloud run deploy already fails the script if the revision can't boot (it
511 # waits for the Ready condition), so what's left to verify is that the
512 # gateway is serving. The OAuth discovery document below returns 200 only
513 # after config load, OIDC discovery, upstream construction, and Postgres
514 # migration all succeed, so it doubles as an end-to-end boot check (the
515 # readiness probe proper is GET /readyz). With internal ingress the URL is
516 # reachable only from inside the VPC (or a PSC-connected corp network), so
517 # verification is left to the operator rather than attempted from here.
518 log "Verify the gateway is serving (from inside the VPC, or a PSC-connected corp network):"
519 echo " curl -s ${RUN_URL}/.well-known/oauth-authorization-server"
520 echo " If it isn't responding yet, check logs:"
521 echo " gcloud run services logs read ${SERVICE_NAME} --region=${REGION} --project=${PROJECT_ID}"
522
523 log "Finish the OAuth bootstrap:"
524 echo " 1. Register this redirect URI on the Google OAuth client: ${RUN_URL}/oauth/callback"
525 echo " 2. Set listen.public_url in ${GATEWAY_YAML} to ${RUN_URL}, then re-run: INGRESS=${INGRESS} ./setup.sh"
526 echo " (republishes ${CONFIG_SECRET} and redeploys so the IdP redirect_uri matches)."
527 echo " With INGRESS=internal-and-cloud-load-balancing, use your internal ALB hostname"
528 echo " instead of the run.app URL in both steps."
529 fi
530fi
531
532# ---- summary ----------------------------------------------------------------
533cat <<EOF
534
535==> Done.
536
537 Service account ${SA_EMAIL}
538 roles: aiplatform.user, secretmanager.secretAccessor
539 Image ${IMAGE}
540 Instance ${DB_INSTANCE}
541 Connection name ${PROJECT_ID}:${REGION}:${DB_INSTANCE}
542 Private IP ${PRIVATE_IP}
543 Database / user ${DB_NAME} / ${DB_USER}
544 Secrets ${SECRET_NAME}, ${JWT_SECRET_NAME}, ${CONFIG_SECRET}
545 Cloud Run service ${SERVICE_NAME} -> ${RUN_URL:-(not deployed yet)} (ingress: ${INGRESS})
546
547Next steps (see https://code.claude.com/docs/en/claude-apps-gateway-on-gcp):
548 - Create the one operator-provided secret (from the Google Cloud Console OAuth client):
549 printf '%s' "<client-secret>" | gcloud secrets create ${OIDC_SECRET_NAME} \\
550 --data-file=- --project="${PROJECT_ID}"
551 setup.sh grants ${SA_EMAIL} secretAccessor on it on the next re-run.
552 - Fill in the REPLACE_ME values in ${GATEWAY_YAML}, then re-run: setup.sh publishes
553 ${CONFIG_SECRET} and deploys ${SERVICE_NAME} once both secrets exist.
554 - After the first deploy: set listen.public_url to the Cloud Run URL above (or your
555 internal ALB hostname) and register <url>/oauth/callback on the Google OAuth client,
556 then re-run to redeploy.
557 - The gateway runs its own schema migrations at boot, so ${DB_USER} needs CREATE TABLE.
558EOF