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