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 / aws / setup.sh
964 lines · 52.5 KBRaw
1#!/usr/bin/env bash
2#
3# setup.sh — AWS setup for Claude apps gateway (walkthrough §1–7, ECS track).
4#
5# Provisions, in this order: the three security groups (§1), the task +
6# execution IAM roles (§2), the gateway container image in Amazon ECR (§6),
7# an RDS for PostgreSQL instance in the private subnets with no public
8# address (§3), the JWT + postgres-url secrets (§5), and an ECS Fargate
9# service behind an internal Application Load Balancer (§7).
10#
11# gateway.yaml (§4 of the walkthrough) is BAKED INTO THE IMAGE on this track —
12# the task definition injects only the secrets it references, as env vars — so
13# the config step here lives inside the image build (§6): the build is gated on
14# a fully filled-in gateway.yaml and the image tag carries a hash of it, so a
15# config edit triggers a rebuild on the next run.
16#
17# Section markers (§N) below map to the walkthrough:
18# https://code.claude.com/docs/en/claude-apps-gateway-on-aws
19#
20# Covers here: security groups (§1) -> IAM roles + Bedrock model-access note (§2)
21# -> build & push image, config baked in (§6 + §4) -> DB subnet group
22# + RDS instance (§3) -> jwt + postgres-url secrets (§5) -> ECS
23# cluster/task definition/service + internal ALB (§7, ECS Fargate tab).
24# Not covered: EKS track (§7's EKS tab) — ECS Fargate is the lower-friction path here.
25# Bedrock model access (§2) — console-only; the script reminds you.
26# Route 53 alias — see the next steps it prints. Client MDM
27# push (§8) is covered by the walkthrough, not this script.
28#
29# Idempotent: existing resources are detected and skipped, so it is safe to re-run.
30# Reuse is by NAME, so a pre-existing resource may not match what this script
31# would have created: reuse that would change the exposure model is fatal (an
32# ALB that is not internal/in ${VPC_ID}); upsert-able settings are converged on
33# every run; other posture drift (extra security group ingress, a public or
34# unencrypted RDS instance, wrong-VPC target group) is checked and warned
35# about, never silently adopted.
36# Override any default below via environment variable, e.g. `AWS_REGION=us-west-2 ./setup.sh`.
37
38set -euo pipefail
39
40# ---- configuration (env-overridable) ----------------------------------------
41AWS_REGION="${AWS_REGION:-$(aws configure get region 2>/dev/null || true)}" # guide uses us-east-1 (a region where Bedrock serves the Claude models you need)
42ACCOUNT_ID="${ACCOUNT_ID:-$(aws sts get-caller-identity --query Account --output text 2>/dev/null || true)}"
43
44VPC_ID="${VPC_ID:-}" # REQUIRED — the VPC from the prerequisites
45PRIVATE_SUBNETS="${PRIVATE_SUBNETS:-}" # REQUIRED — two+ private subnet IDs in different AZs, space-separated
46CORP_CIDR="${CORP_CIDR:-}" # REQUIRED — your corporate network CIDR (ALB :443 ingress source)
47 # Must not overlap PRIVATE_SUBNETS: hosts there are trusted_proxies (gateway.yaml) and could spoof client IPs via X-Forwarded-For.
48
49# §1 security groups
50ALB_SG_NAME="${ALB_SG_NAME:-claude-gateway-alb}"
51GW_SG_NAME="${GW_SG_NAME:-claude-gateway-svc}"
52DB_SG_NAME="${DB_SG_NAME:-claude-gateway-db}"
53
54# §2 IAM roles (task role = the gateway's runtime AWS identity; execution role
55# = the ECS agent's identity for pulling the image and injecting secrets)
56TASK_ROLE="${TASK_ROLE:-claude-gateway-task}"
57EXEC_ROLE="${EXEC_ROLE:-claude-gateway-execution}"
58
59# §6 image
60ECR_REPO="${ECR_REPO:-claude-gateway}" # ECR repository name
61VERSION="${VERSION:-}" # REQUIRED — the gateway release tag you build and push (e.g. the linux-x64 binary's version)
62DOCKERFILE="${DOCKERFILE:-./Dockerfile}"
63CLAUDE_BINARY="${CLAUDE_BINARY:-./claude}" # prebuilt linux-x64 Claude Code release binary (includes the gateway subcommand)
64DIST_URL="${DIST_URL:-}" # optional: download URL, used only if $CLAUDE_BINARY is missing
65DIST_SHA256="${DIST_SHA256:-}" # REQUIRED with DIST_URL: expected sha256 of the binary (verified fail-closed)
66DIST_SHA256="${DIST_SHA256,,}" # normalize to lowercase — openssl emits lowercase hex; some tools (PowerShell Get-FileHash) publish uppercase
67# Obtain DIST_SHA256 out-of-band — never from the server that serves DIST_URL.
68# For binaries from the standard Claude Code release channel, verify the
69# release's GPG-signed manifest.json and copy the platform checksum from it:
70# https://code.claude.com/docs/en/setup#binary-integrity-and-code-signing
71# For any other distribution channel, use the checksum published alongside the
72# download link on that channel.
73GATEWAY_YAML="${GATEWAY_YAML:-./gateway.yaml}" # §4 config file — BAKED into the image
74RDS_CA_BUNDLE="${RDS_CA_BUNDLE:-./rds-global-bundle.pem}" # RDS CA trust anchor — BAKED into the image (downloaded below if missing)
75# Official AWS RDS truststore. AWS rotates this bundle (new regional CAs get
76# appended), so no checksum is pinned — a pinned hash would break on every
77# rotation. The script downloads it only when absent (an existing file is never
78# re-downloaded); to pick up a rotation, delete the file — and since the image
79# tag hashes only gateway.yaml, also bump VERSION or set IMAGE_TAG so the
80# next run rebuilds rather than reusing the existing tag. Operators who want
81# to pin may pre-place a reviewed copy at ${RDS_CA_BUNDLE}.
82RDS_CA_BUNDLE_URL="${RDS_CA_BUNDLE_URL:-https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem}"
83REGISTRY="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com"
84
85# §3 RDS
86DB_SUBNET_GROUP="${DB_SUBNET_GROUP:-claude-gateway-db}"
87DB_PARAM_GROUP="${DB_PARAM_GROUP:-claude-gateway-db}" # carries rds.force_ssl=1 (server-side TLS enforcement)
88DB_INSTANCE="${DB_INSTANCE:-claude-gateway-db}"
89DB_CLASS="${DB_CLASS:-db.t4g.micro}"
90DB_STORAGE_GB="${DB_STORAGE_GB:-20}"
91DB_NAME="${DB_NAME:-claude_gateway}"
92DB_USER="${DB_USER:-gateway}"
93# PG14+ supported; 16 is the recommended default (matches terraform/'s).
94# Always pinned: the instance's engine version and the parameter group's
95# family must name the same major, so both derive from this one value.
96DB_ENGINE_VERSION="${DB_ENGINE_VERSION:-16}"
97
98SECRET_NAME="${SECRET_NAME:-gateway-postgres-url}" # §5 store.postgres_url
99JWT_SECRET_NAME="${JWT_SECRET_NAME:-gateway-jwt-secret}" # §5 session.jwt_secret
100OIDC_SECRET_NAME="${OIDC_SECRET_NAME:-gateway-oidc-client-secret}" # operator-created (Okta OIDC web app)
101# NOTE: the execution role's secrets-read policy (§2) is built from these
102# three names, one per-secret ARN prefix each — a rename is picked up on the
103# next run (put-role-policy is an upsert).
104
105# §7 ECS + internal ALB deploy
106CLUSTER="${CLUSTER:-claude-gateway}"
107SERVICE="${SERVICE:-claude-gateway}"
108TASK_FAMILY="${TASK_FAMILY:-claude-gateway}"
109LOG_GROUP="${LOG_GROUP:-/ecs/claude-gateway}"
110LOG_RETENTION_DAYS="${LOG_RETENTION_DAYS:-90}" # CloudWatch retention — the group carries the gateway's audit events, so align with your audit retention policy
111ALB_NAME="${ALB_NAME:-claude-gateway}"
112TG_NAME="${TG_NAME:-claude-gateway}"
113# Explicit modern TLS policy — omitting it falls back to the legacy
114# ELBSecurityPolicy-2016-08 default, which still accepts TLS 1.0/1.1.
115ALB_SSL_POLICY="${ALB_SSL_POLICY:-ELBSecurityPolicy-TLS13-1-2-2021-06}"
116ACM_CERT_ARN="${ACM_CERT_ARN:-}" # REQUIRED for deploy — ACM cert for your internal gateway hostname
117TASK_CPU="${TASK_CPU:-1024}"
118TASK_MEMORY="${TASK_MEMORY:-2048}"
119DESIRED_COUNT="${DESIRED_COUNT:-1}" # each task opens a Postgres pool of up to 5 connections (store.max_connections default); keep DESIRED_COUNT × 5 below the DB class's max_connections (~80 on db.t4g.micro)
120DEPLOY="${DEPLOY:-1}" # set DEPLOY=0 to provision only, no ECS/ALB deploy
121
122# ---- helpers ----------------------------------------------------------------
123log() { printf '\n==> %s\n' "$*"; }
124skip() { printf ' (exists) %s\n' "$*"; }
125curl_https() { curl --proto '=https' --proto-redir '=https' --tlsv1.2 "$@"; } # refuse plaintext/protocol-downgrade
126sha_of() { openssl dgst -sha256 "$1" | awk '{print $NF}'; } # openssl avoids shasum/sha256sum portability gaps
127
128# authorize-security-group-ingress is NOT idempotent (re-adding a rule errors),
129# so tolerate exactly the duplicate-rule error and fail on anything else.
130authorize_ingress() {
131 local out
132 if out="$(aws ec2 authorize-security-group-ingress "$@" 2>&1)"; then
133 return 0
134 elif grep -q 'InvalidPermission.Duplicate' <<<"${out}"; then
135 skip "ingress rule already present"
136 else
137 printf '%s\n' "${out}" >&2
138 return 1
139 fi
140}
141
142# Security-group lookup by name within the VPC; prints the GroupId or "None".
143sg_id() {
144 aws ec2 describe-security-groups \
145 --filters "Name=group-name,Values=$1" "Name=vpc-id,Values=${VPC_ID}" \
146 --query 'SecurityGroups[0].GroupId' --output text 2>/dev/null || echo None
147}
148
149# Name-based reuse can adopt a pre-existing group carrying ingress this script
150# never added. Audit after the intended rule is ensured: each group's traffic
151# path is exactly one rule (tcp <port> from <cidr-or-source-group>), so anything
152# else is flagged on stderr. Non-fatal — an extra rule may be a deliberate
153# operator addition — but every one widens the path, so it must be visible.
154warn_unexpected_ingress() { # <group-id> <group-name> <port> <expected cidr or source group-id>
155 local perms
156 if ! perms="$(aws ec2 describe-security-groups --group-ids "$1" \
157 --query 'SecurityGroups[0].IpPermissions' --output json 2>/dev/null)"; then
158 echo " WARN — could not audit ingress rules on $2 ($1)." >&2
159 return 0
160 fi
161 # `|| echo` keeps a parse hiccup non-fatal — this audit must never abort a run.
162 _SG_ID="$1" _SG_NAME="$2" _SG_PORT="$3" _SG_EXPECTED="$4" python3 -c "
163import json, os, sys
164perms = json.load(sys.stdin) or []
165port, expected = int(os.environ[\"_SG_PORT\"]), os.environ[\"_SG_EXPECTED\"]
166extras = []
167for p in perms:
168 proto, lo, hi = p.get(\"IpProtocol\"), p.get(\"FromPort\"), p.get(\"ToPort\")
169 scope_ok = proto == \"tcp\" and lo == port and hi == port
170 sources = (
171 [r.get(\"CidrIp\", \"?\") for r in p.get(\"IpRanges\", [])]
172 + [r.get(\"CidrIpv6\", \"?\") for r in p.get(\"Ipv6Ranges\", [])]
173 + [r.get(\"GroupId\", \"?\") for r in p.get(\"UserIdGroupPairs\", [])]
174 + [r.get(\"PrefixListId\", \"?\") for r in p.get(\"PrefixListIds\", [])]
175 )
176 extras += [(proto, lo, hi, s) for s in sources if not (scope_ok and s == expected)]
177if extras:
178 name, gid = os.environ[\"_SG_NAME\"], os.environ[\"_SG_ID\"]
179 print(f\" WARN — security group {name} ({gid}) has ingress beyond the intended rule\", file=sys.stderr)
180 print(f\" (tcp {port} from {expected}) — review it; remove anything you did not add deliberately:\", file=sys.stderr)
181 for proto, lo, hi, src in extras:
182 scope = \"all traffic\" if proto == \"-1\" else (f\"{proto} {lo}\" if lo == hi else f\"{proto} {lo}-{hi}\")
183 print(f\" {scope} from {src}\", file=sys.stderr)
184" <<<"${perms}" || echo " WARN — could not audit ingress rules on $2 ($1)." >&2
185}
186
187secret_arn() {
188 aws secretsmanager describe-secret --secret-id "$1" \
189 --query ARN --output text 2>/dev/null || true
190}
191
192# Existence check that fails closed: 0 = exists, 1 = definitively absent
193# (ResourceNotFoundException), anything else ABORTS the run. Gating on a bare
194# exit status would let a transient failure (throttle, expired token, network
195# blip) masquerade as "secret missing" — and the missing-secret branches below
196# do destructive work (the §3 self-heal resets the DB password), so they must
197# run only on a definitive not-found.
198secret_exists() { # <secret-id>
199 local out
200 if out="$(aws secretsmanager describe-secret --secret-id "$1" 2>&1 >/dev/null)"; then
201 return 0
202 elif grep -q 'ResourceNotFoundException' <<<"${out}"; then
203 return 1
204 else
205 echo "ERROR: could not determine whether secret $1 exists (transient AWS error?):" >&2
206 printf '%s\n' "${out}" >&2
207 echo " Refusing to guess — re-run once the call succeeds." >&2
208 exit 1
209 fi
210}
211
212# Secret values must never appear on a process argv (argv is world-readable
213# via /proc and routinely recorded by EDR/auditd), so every aws call that
214# carries one takes it via --cli-input-json file://<0600 temp file> instead —
215# explicit flags on the same command line override/merge with the JSON, so
216# only the secret parameter needs to live in the file. secret_json writes
217# {"<Key>": "<value>"} to a fresh temp file and returns the path in the named
218# variable (printf -v, not command substitution — a subshell would lose the
219# SECRET_TMP_FILES bookkeeping below): the value crosses into python3 via the
220# environment (never argv) and json.dumps escapes it, so any characters
221# survive. Callers rm -f the file as soon as the aws call returns; the EXIT
222# trap sweeps whatever an aborted run leaves.
223SECRET_TMP_FILES=()
224cleanup_secret_tmp() { rm -f "${SECRET_TMP_FILES[@]+"${SECRET_TMP_FILES[@]}"}"; }
225trap cleanup_secret_tmp EXIT
226secret_json() { # secret_json <outvar> <JsonKey> <value> -> path in <outvar>
227 local file
228 file="$(mktemp)" # mktemp creates 0600
229 chmod 600 "${file}" # belt and braces if TMPDIR overrides umask semantics
230 SECRET_TMP_FILES+=("${file}")
231 _JSON_KEY="$2" _JSON_VALUE="$3" python3 -c \
232 'import json, os; print(json.dumps({os.environ["_JSON_KEY"]: os.environ["_JSON_VALUE"]}))' \
233 > "${file}"
234 printf -v "$1" '%s' "${file}"
235}
236
237for required in AWS_REGION ACCOUNT_ID VPC_ID PRIVATE_SUBNETS CORP_CIDR VERSION; do
238 if [[ -z "${!required}" ]]; then
239 echo "ERROR: ${required} is not set." >&2
240 case "${required}" in
241 AWS_REGION) echo " Set it to a region where Bedrock serves the Claude models you need, e.g. export AWS_REGION=us-east-1" >&2 ;;
242 ACCOUNT_ID) echo " Could not resolve it from STS — is the AWS CLI authenticated? (aws sts get-caller-identity)" >&2 ;;
243 VPC_ID) echo " Set it to the VPC from the prerequisites, e.g. export VPC_ID=vpc-..." >&2 ;;
244 PRIVATE_SUBNETS) echo " Set it to two+ private subnet IDs in different AZs, e.g. export PRIVATE_SUBNETS='subnet-a subnet-b'" >&2 ;;
245 CORP_CIDR) echo " Set it to your corporate network CIDR (the ALB's :443 ingress source), e.g. export CORP_CIDR=10.0.0.0/8" >&2 ;;
246 VERSION) echo " Set it to the gateway release version — it tags the image you build and push, e.g. export VERSION=<version>" >&2 ;;
247 esac
248 exit 1
249 fi
250done
251
252# The walkthrough (and this bundle) is scoped to commercial US regions: the
253# task role's Bedrock policy (§2) and the gateway's built-in model catalog
254# both use the us.anthropic.* geo-prefixed cross-region inference profiles,
255# which only exist in the commercial US regions — an explicit list, not a
256# `us-*` prefix match, because GovCloud (us-gov-*) and ISO (us-iso-*) regions
257# share the prefix but live in different AWS partitions where those profiles
258# and this bundle's arn:aws: ARNs are wrong. Anywhere else the deploy
259# provisions fine and then every model call fails. Other-region deploys must
260# pin region-appropriate inference profiles via a models: block in
261# gateway.yaml (see the config reference:
262# https://code.claude.com/docs/en/claude-apps-gateway-config) and adjust the
263# inference-profile ARN prefix in bedrock-invoke.iam.json below — set
264# ALLOW_NON_US_REGION=1 once that's done to proceed.
265case "${AWS_REGION}" in
266 us-east-1|us-east-2|us-west-1|us-west-2) ;;
267 *)
268 if [[ "${ALLOW_NON_US_REGION:-0}" != "1" ]]; then
269 echo "ERROR: AWS_REGION=${AWS_REGION} is not a commercial US region, but this bundle's IAM policy" >&2
270 echo " and model IDs use the US-geo (us.anthropic.*) cross-region inference profiles" >&2
271 echo " (GovCloud/ISO regions are different partitions — the profiles and arn:aws: ARNs" >&2
272 echo " here do not exist there)." >&2
273 echo " Either deploy to us-east-1/us-east-2/us-west-1/us-west-2, or pin region-appropriate" >&2
274 echo " inference profiles in a models: block in gateway.yaml" >&2
275 echo " (https://code.claude.com/docs/en/claude-apps-gateway-config), adjust the" >&2
276 echo " inference-profile ARN in the bedrock-invoke policy, and re-run with" >&2
277 echo " ALLOW_NON_US_REGION=1." >&2
278 exit 1
279 fi
280 ;;
281esac
282
283if ! command -v python3 >/dev/null 2>&1; then
284 echo "ERROR: python3 is required (it JSON-escapes secret values for --cli-input-json; the AWS CLI itself ships on Python)." >&2
285 exit 1
286fi
287
288# shellcheck disable=SC2086 # PRIVATE_SUBNETS is intentionally word-split everywhere below
289set -- ${PRIVATE_SUBNETS}
290if (( $# < 2 )); then
291 echo "ERROR: PRIVATE_SUBNETS must list at least two subnets in different AZs (the internal ALB requires two)." >&2
292 exit 1
293fi
294# Normalize whatever whitespace (spaces, tabs, newlines) separates the list —
295# `set --` above word-split on IFS, so join those same words with commas
296# rather than only converting single spaces.
297SUBNETS_CSV="$(printf '%s,' "$@")"; SUBNETS_CSV="${SUBNETS_CSV%,}"
298
299log "Account: ${ACCOUNT_ID} Region: ${AWS_REGION} VPC: ${VPC_ID}"
300
301# ---- 1 Security groups -----------------------------------------------------
302# Three groups chain the traffic path (walkthrough §1): corp network -> ALB :443,
303# ALB -> gateway :8080, gateway -> Postgres :5432. Nothing else is reachable.
304log "Creating security groups (§1)"
305ALB_SG="$(sg_id "${ALB_SG_NAME}")"
306if [[ "${ALB_SG}" != "None" ]]; then
307 skip "security group ${ALB_SG_NAME} (${ALB_SG})"
308else
309 ALB_SG="$(aws ec2 create-security-group --group-name "${ALB_SG_NAME}" \
310 --description "Claude gateway ALB" --vpc-id "${VPC_ID}" \
311 --query GroupId --output text)"
312fi
313
314GW_SG="$(sg_id "${GW_SG_NAME}")"
315if [[ "${GW_SG}" != "None" ]]; then
316 skip "security group ${GW_SG_NAME} (${GW_SG})"
317else
318 GW_SG="$(aws ec2 create-security-group --group-name "${GW_SG_NAME}" \
319 --description "Claude gateway service" --vpc-id "${VPC_ID}" \
320 --query GroupId --output text)"
321fi
322
323DB_SG="$(sg_id "${DB_SG_NAME}")"
324if [[ "${DB_SG}" != "None" ]]; then
325 skip "security group ${DB_SG_NAME} (${DB_SG})"
326else
327 DB_SG="$(aws ec2 create-security-group --group-name "${DB_SG_NAME}" \
328 --description "Claude gateway Postgres" --vpc-id "${VPC_ID}" \
329 --query GroupId --output text)"
330fi
331
332authorize_ingress --group-id "${ALB_SG}" --protocol tcp --port 443 --cidr "${CORP_CIDR}"
333authorize_ingress --group-id "${GW_SG}" --protocol tcp --port 8080 --source-group "${ALB_SG}"
334authorize_ingress --group-id "${DB_SG}" --protocol tcp --port 5432 --source-group "${GW_SG}"
335
336# Flag any ingress beyond the three rules above (pre-existing groups may carry more).
337warn_unexpected_ingress "${ALB_SG}" "${ALB_SG_NAME}" 443 "${CORP_CIDR}"
338warn_unexpected_ingress "${GW_SG}" "${GW_SG_NAME}" 8080 "${ALB_SG}"
339warn_unexpected_ingress "${DB_SG}" "${DB_SG_NAME}" 5432 "${GW_SG}"
340
341# ---- 2 IAM roles ------------------------------------------------------------
342# Task role: the gateway's runtime identity — its ONLY permission is invoking
343# Claude models on Bedrock (the upstream's `auth: {}` resolves to this role via
344# the AWS default credential chain). The policy must cover both the cross-region
345# inference-profile ARNs and the underlying foundation-model ARNs.
346# Execution role: the ECS agent's identity — pulls the image from ECR and
347# injects the Secrets Manager values; the gateway never uses it.
348log "Creating IAM roles ${TASK_ROLE} + ${EXEC_ROLE} (§2)"
349cat > ecs-trust.iam.json <<'EOF'
350{
351 "Version": "2012-10-17",
352 "Statement": [{
353 "Effect": "Allow",
354 "Principal": { "Service": "ecs-tasks.amazonaws.com" },
355 "Action": "sts:AssumeRole"
356 }]
357}
358EOF
359cat > bedrock-invoke.iam.json <<EOF
360{
361 "Version": "2012-10-17",
362 "Statement": [{
363 "Effect": "Allow",
364 "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
365 "Resource": [
366 "arn:aws:bedrock:${AWS_REGION}:${ACCOUNT_ID}:inference-profile/us.anthropic.*",
367 "arn:aws:bedrock:*::foundation-model/anthropic.*"
368 ]
369 }]
370}
371EOF
372# One ARN per secret (never a bare gateway-* wildcard, which would also match
373# unrelated secrets in a shared account). The trailing -?????? matches exactly
374# the random 6-character suffix Secrets Manager appends to every secret's ARN
375# (AWS's documented pattern; a trailing -* would be a plain prefix glob and
376# also match longer names like ${SECRET_NAME}-prod) — the exact ARNs aren't
377# knowable here because the role is created before the secrets are.
378cat > secrets-read.iam.json <<EOF
379{
380 "Version": "2012-10-17",
381 "Statement": [{
382 "Effect": "Allow",
383 "Action": "secretsmanager:GetSecretValue",
384 "Resource": [
385 "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:${JWT_SECRET_NAME}-??????",
386 "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:${OIDC_SECRET_NAME}-??????",
387 "arn:aws:secretsmanager:${AWS_REGION}:${ACCOUNT_ID}:secret:${SECRET_NAME}-??????"
388 ]
389 }]
390}
391EOF
392
393if aws iam get-role --role-name "${TASK_ROLE}" >/dev/null 2>&1; then
394 skip "role ${TASK_ROLE}"
395else
396 aws iam create-role --role-name "${TASK_ROLE}" \
397 --assume-role-policy-document file://ecs-trust.iam.json >/dev/null
398fi
399# put-role-policy is an upsert — safe to re-run (it also picks up region changes).
400aws iam put-role-policy --role-name "${TASK_ROLE}" \
401 --policy-name bedrock-invoke --policy-document file://bedrock-invoke.iam.json
402
403if aws iam get-role --role-name "${EXEC_ROLE}" >/dev/null 2>&1; then
404 skip "role ${EXEC_ROLE}"
405else
406 aws iam create-role --role-name "${EXEC_ROLE}" \
407 --assume-role-policy-document file://ecs-trust.iam.json >/dev/null
408fi
409# attach-role-policy is idempotent (re-attaching is a no-op).
410aws iam attach-role-policy --role-name "${EXEC_ROLE}" \
411 --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
412aws iam put-role-policy --role-name "${EXEC_ROLE}" \
413 --policy-name read-gateway-secrets --policy-document file://secrets-read.iam.json
414
415echo " NOTE: Bedrock model access is console-only — enable it for the Claude models"
416echo " you need (Bedrock console -> Model access), and submit the one-time use"
417echo " case form for the account. Cross-region inference profiles"
418echo " (us.anthropic.*) need access in EACH region the profile spans."
419
420# ---- 6 Build & push image to Amazon ECR (config baked in — §6 + §4) ---------
421log "Ensuring ECR repository and image (§6)"
422if aws ecr describe-repositories --repository-names "${ECR_REPO}" >/dev/null 2>&1; then
423 skip "ECR repository ${ECR_REPO}"
424 # Integrity-critical settings: converge on re-runs so a pre-existing MUTABLE repo can't slip through.
425 aws ecr put-image-tag-mutability --repository-name "${ECR_REPO}" \
426 --image-tag-mutability IMMUTABLE >/dev/null
427 aws ecr put-image-scanning-configuration --repository-name "${ECR_REPO}" \
428 --image-scanning-configuration scanOnPush=true >/dev/null
429else
430 # IMMUTABLE tags + scan-on-push: the ECS service pulls whatever this repo
431 # serves under the deployed tag, so a pushed tag must never be silently
432 # re-pointed. For production, also restrict push rights on this repo to your
433 # CI / image-promotion pipeline rather than operator credentials — this
434 # walkthrough pushes directly for simplicity.
435 aws ecr create-repository --repository-name "${ECR_REPO}" \
436 --image-tag-mutability IMMUTABLE \
437 --image-scanning-configuration scanOnPush=true >/dev/null
438fi
439
440# The config is baked into the image, so the build is gated the way the GCP
441# example gates its config-secret publish: gateway.yaml must exist and be fully
442# filled in (REPLACE_ME checked on non-comment lines so commented examples and
443# the file's header don't trip the guard). The tag carries a hash of the config
444# so an edit produces a NEW tag (required by tag immutability) and a re-run
445# rebuilds automatically.
446IMAGE=""
447if [[ ! -f "${GATEWAY_YAML}" ]]; then
448 echo " (skip) ${GATEWAY_YAML} not found — run 'cp gateway.yaml.example gateway.yaml', fill it in, then re-run (§4)."
449elif grep -vE '^[[:space:]]*#' "${GATEWAY_YAML}" | grep -q 'REPLACE_ME'; then
450 echo " (skip) ${GATEWAY_YAML} still has REPLACE_ME placeholders to fill:"
451 grep -nE 'REPLACE_ME' "${GATEWAY_YAML}" | grep -vE '^[0-9]+:[[:space:]]*#' | sed 's/^/ /'
452 echo " Fill them in, then re-run to build the image (the config is baked in)."
453else
454 CONFIG_SHA="$(sha_of "${GATEWAY_YAML}" | cut -c1-8)"
455 IMAGE_TAG="${IMAGE_TAG:-${VERSION}-cfg${CONFIG_SHA}}"
456 IMAGE="${REGISTRY}/${ECR_REPO}:${IMAGE_TAG}"
457
458 # Image is the expensive, already-done step: skip the build+push entirely if
459 # the tag already exists in the registry.
460 if aws ecr describe-images --repository-name "${ECR_REPO}" \
461 --image-ids "imageTag=${IMAGE_TAG}" >/dev/null 2>&1; then
462 skip "image ${IMAGE}"
463 else
464 # When the expected checksum is known, verify a PRE-EXISTING binary too:
465 # the [[ ! -f ]] guard below otherwise trusts whatever is on disk, so a
466 # stale binary from an earlier VERSION (or a tampered one) would be baked
467 # into the image silently. On mismatch, set it aside (never delete — the
468 # mismatch may be a typo'd DIST_SHA256, not a bad binary) and fall through
469 # to the fail-closed download path. Without DIST_SHA256 the operator-
470 # provided-binary flow is unchanged — no checksum was declared, so none is
471 # checked.
472 QUARANTINED_SHA=""
473 if [[ -n "${DIST_SHA256}" && -f "${CLAUDE_BINARY}" ]]; then
474 existing_sha="$(sha_of "${CLAUDE_BINARY}")"
475 if [[ "${existing_sha}" != "${DIST_SHA256}" ]]; then
476 log "Existing ${CLAUDE_BINARY} sha256 ${existing_sha} does not match DIST_SHA256 — setting it aside as ${CLAUDE_BINARY}.bad"
477 mv -f "${CLAUDE_BINARY}" "${CLAUDE_BINARY}.bad"
478 QUARANTINED_SHA="${existing_sha}"
479 fi
480 fi
481 if [[ ! -f "${CLAUDE_BINARY}" ]]; then
482 if [[ -n "${DIST_URL}" ]]; then
483 # Fail closed: never download an executable we can't verify.
484 if [[ -z "${DIST_SHA256}" ]]; then
485 echo "ERROR: DIST_SHA256 must be set when DIST_URL is used — refusing to download an unverified binary." >&2
486 echo " Set DIST_SHA256 to the expected sha256 of the binary at DIST_URL, obtained out-of-band:" >&2
487 echo " for standard-release binaries, from the release's GPG-signed manifest.json (verify the" >&2
488 echo " manifest signature first — see code.claude.com/docs/en/setup#binary-integrity-and-code-signing);" >&2
489 echo " otherwise from the channel that published the download link, never from the download server." >&2
490 exit 1
491 fi
492 log "Downloading gateway binary from ${DIST_URL}"
493 # Download to a temp path and only mv into place after the checksum
494 # verifies, so an interrupted download can't leave a partial CLAUDE_BINARY
495 # that the [[ ! -f ]] guard above would skip — and silently push — on re-run.
496 # Refuse plaintext/protocol-downgrade; only follow HTTPS redirects.
497 dl_tmp="${CLAUDE_BINARY}.download"
498 rm -f "${dl_tmp}"
499 curl_https -fL -o "${dl_tmp}" "${DIST_URL}"
500 actual_sha="$(sha_of "${dl_tmp}")"
501 if [[ "${actual_sha}" != "${DIST_SHA256}" ]]; then
502 echo "ERROR: checksum mismatch for ${dl_tmp} (expected ${DIST_SHA256}, got ${actual_sha}) — refusing to build." >&2
503 rm -f "${dl_tmp}"
504 exit 1
505 fi
506 log "Verified binary sha256 ${actual_sha}"
507 chmod +x "${dl_tmp}"
508 mv -f "${dl_tmp}" "${CLAUDE_BINARY}"
509 else
510 echo "ERROR: build binary not found at ${CLAUDE_BINARY} and DIST_URL is not set." >&2
511 if [[ -n "${QUARANTINED_SHA}" ]]; then
512 echo " The binary that WAS there had sha256 ${QUARANTINED_SHA}, which does not match" >&2
513 echo " DIST_SHA256=${DIST_SHA256} — it was preserved as ${CLAUDE_BINARY}.bad." >&2
514 echo " If DIST_SHA256 was a typo, fix it and move the file back:" >&2
515 echo " mv '${CLAUDE_BINARY}.bad' '${CLAUDE_BINARY}'" >&2
516 echo " Otherwise treat that file as untrusted and obtain a verified binary." >&2
517 fi
518 echo " Provide the prebuilt linux-x64 Claude Code release binary at that path" >&2
519 echo " or set DIST_URL to its download URL (see the walkthrough, §6)." >&2
520 exit 1
521 fi
522 fi
523 # The RDS CA bundle is baked into the image as the trust anchor for the
524 # connection string's sslmode=verify-full (§3/§5). Fail closed: no bundle,
525 # no build. AWS rotates the bundle, so no checksum is pinned (see the
526 # RDS_CA_BUNDLE_URL comment up top); the sanity check below catches an
527 # error page or truncated download.
528 if [[ ! -f "${RDS_CA_BUNDLE}" ]]; then
529 log "Downloading RDS CA bundle from ${RDS_CA_BUNDLE_URL}"
530 curl_https -fL -o "${RDS_CA_BUNDLE}" "${RDS_CA_BUNDLE_URL}"
531 fi
532 if ! grep -q 'BEGIN CERTIFICATE' "${RDS_CA_BUNDLE}" \
533 || (( "$(wc -c < "${RDS_CA_BUNDLE}")" < 10000 )); then
534 echo "ERROR: ${RDS_CA_BUNDLE} does not look like the RDS CA bundle (missing PEM blocks or implausibly small) — refusing to build." >&2
535 echo " Delete it and re-run to re-download, or place the bundle from ${RDS_CA_BUNDLE_URL} there yourself." >&2
536 exit 1
537 fi
538 log "Building and pushing ${IMAGE}"
539 aws ecr get-login-password --region "${AWS_REGION}" \
540 | docker login --username AWS --password-stdin "${REGISTRY}"
541 # The task definition below runs linux/amd64 (cpuArchitecture X86_64);
542 # --platform forces it (e.g. when building on an Apple Silicon Mac), and
543 # --provenance=false keeps buildx from wrapping the result in an OCI image
544 # index that some pullers reject. For Fargate on ARM64 (Graviton), build
545 # linux/arm64 with the linux-arm64 binary and set cpuArchitecture to ARM64.
546 docker build --platform=linux/amd64 --provenance=false \
547 -f "${DOCKERFILE}" \
548 --build-arg CLAUDE_BINARY="${CLAUDE_BINARY}" \
549 --build-arg GATEWAY_CONFIG="${GATEWAY_YAML}" \
550 --build-arg RDS_CA_BUNDLE="${RDS_CA_BUNDLE}" \
551 -t "${IMAGE}" .
552 docker push "${IMAGE}"
553 fi
554fi
555
556# ---- 3 RDS for PostgreSQL (private subnets, no public address) --------------
557log "Creating DB subnet group ${DB_SUBNET_GROUP} (§3)"
558if aws rds describe-db-subnet-groups --db-subnet-group-name "${DB_SUBNET_GROUP}" >/dev/null 2>&1; then
559 skip "DB subnet group ${DB_SUBNET_GROUP}"
560else
561 # shellcheck disable=SC2086 # subnet IDs are separate arguments by design
562 aws rds create-db-subnet-group --db-subnet-group-name "${DB_SUBNET_GROUP}" \
563 --db-subnet-group-description "Claude gateway" --subnet-ids ${PRIVATE_SUBNETS} >/dev/null
564fi
565
566# Parameter group with rds.force_ssl=1: the server side of TLS enforcement —
567# the client side is sslmode=verify-full in the connection string (§5). The
568# family must match the engine major version, so it derives from the same
569# DB_ENGINE_VERSION that create-db-instance pins below.
570log "Ensuring DB parameter group ${DB_PARAM_GROUP} (rds.force_ssl=1)"
571PG_FAMILY="postgres${DB_ENGINE_VERSION%%.*}"
572if aws rds describe-db-parameter-groups --db-parameter-group-name "${DB_PARAM_GROUP}" >/dev/null 2>&1; then
573 skip "DB parameter group ${DB_PARAM_GROUP}"
574else
575 aws rds create-db-parameter-group --db-parameter-group-name "${DB_PARAM_GROUP}" \
576 --db-parameter-group-family "${PG_FAMILY}" \
577 --description "Claude gateway - require TLS on every connection" >/dev/null
578fi
579# modify-db-parameter-group is an upsert — applied every run so a pre-existing
580# group converges too. rds.force_ssl is dynamic; no reboot needed.
581aws rds modify-db-parameter-group --db-parameter-group-name "${DB_PARAM_GROUP}" \
582 --parameters "ParameterName=rds.force_ssl,ParameterValue=1,ApplyMethod=immediate" >/dev/null
583
584# hex (not base64) keeps the password URL-safe for the connection string below.
585# The password reaches every aws call via --cli-input-json (never argv — see
586# the secret_json helper); explicit flags merge with (and would override) the
587# JSON, so only the password lives in the temp file.
588log "Creating RDS instance ${DB_INSTANCE} (private subnets, --no-publicly-accessible)"
589DB_PASSWORD=""
590DB_POSTURE="$(aws rds describe-db-instances --db-instance-identifier "${DB_INSTANCE}" \
591 --query 'DBInstances[0].[PubliclyAccessible,StorageEncrypted]' --output text 2>/dev/null || true)"
592if [[ -n "${DB_POSTURE}" ]]; then
593 # Name-based reuse: a pre-existing instance may not carry the posture this
594 # script would have created it with. Non-fatal (the operator may be migrating
595 # an existing DB on purpose), but drift from the guide's baseline must be seen.
596 read -r DB_PUBLIC DB_ENCRYPTED <<<"${DB_POSTURE}"
597 if [[ "${DB_PUBLIC}" == "True" ]]; then
598 echo " WARN — RDS instance ${DB_INSTANCE} is PubliclyAccessible; this script would have" >&2
599 echo " created it with --no-publicly-accessible. Fix: aws rds modify-db-instance" >&2
600 echo " --db-instance-identifier ${DB_INSTANCE} --no-publicly-accessible --apply-immediately" >&2
601 fi
602 if [[ "${DB_ENCRYPTED}" == "False" ]]; then
603 echo " WARN — RDS instance ${DB_INSTANCE} has StorageEncrypted=false; this script would" >&2
604 echo " have created it with --storage-encrypted (encryption cannot be enabled in" >&2
605 echo " place — restore an encrypted snapshot copy to migrate)." >&2
606 fi
607 if secret_exists "${SECRET_NAME}"; then
608 skip "instance ${DB_INSTANCE} (password unchanged; secret not rewritten)"
609 else
610 # Self-heal: a previous run died after creating the instance but before
611 # writing the connection-string secret, losing the only copy of the
612 # password. The secret is the password's only consumer, so resetting it is
613 # safe and keeps re-runs able to recover from any partial state.
614 # secret_exists (not a bare exit-status check) gates this: only a
615 # definitive ResourceNotFoundException may trigger a password reset.
616 # ORDERING INVARIANT: the secret write (§5 below) is the heal's commit
617 # point — everything that can fail must happen BEFORE it, so a crash at
618 # any point leaves the secret still missing and the next run simply
619 # repeats the heal. Writing the secret first would invert that: a crash
620 # between secret write and modify-db-instance would leave an existing
621 # secret whose password the DB never received, and every later run would
622 # skip the heal while the gateway can't connect.
623 # NOTE: the parameter group is attached on create only — an instance that
624 # predates it keeps its current group (attach via modify-db-instance
625 # --db-parameter-group-name yourself if you want force_ssl retrofitted).
626 log "Instance ${DB_INSTANCE} exists but secret ${SECRET_NAME} is missing — resetting password"
627 DB_PASSWORD="$(openssl rand -hex 24)"
628 pw_json=""; secret_json pw_json MasterUserPassword "${DB_PASSWORD}"
629 aws rds modify-db-instance --db-instance-identifier "${DB_INSTANCE}" \
630 --cli-input-json "file://${pw_json}" --apply-immediately >/dev/null
631 rm -f "${pw_json}"
632 fi
633else
634 DB_PASSWORD="$(openssl rand -hex 24)"
635 pw_json=""; secret_json pw_json MasterUserPassword "${DB_PASSWORD}"
636 aws rds create-db-instance --db-instance-identifier "${DB_INSTANCE}" \
637 --engine postgres --engine-version "${DB_ENGINE_VERSION}" \
638 --db-instance-class "${DB_CLASS}" \
639 --allocated-storage "${DB_STORAGE_GB}" --db-name "${DB_NAME}" \
640 --master-username "${DB_USER}" --cli-input-json "file://${pw_json}" \
641 --db-subnet-group-name "${DB_SUBNET_GROUP}" \
642 --db-parameter-group-name "${DB_PARAM_GROUP}" \
643 --vpc-security-group-ids "${DB_SG}" \
644 --no-publicly-accessible \
645 --storage-encrypted >/dev/null
646 rm -f "${pw_json}"
647fi
648
649log "Waiting for ${DB_INSTANCE} to become available (first creation takes ~10 min)"
650aws rds wait db-instance-available --db-instance-identifier "${DB_INSTANCE}"
651DB_HOST="$(aws rds describe-db-instances --db-instance-identifier "${DB_INSTANCE}" \
652 --query 'DBInstances[0].Endpoint.Address' --output text)"
653
654# ---- 5 Connection string + JWT secret -> Secrets Manager --------------------
655# No per-secret IAM grants are needed: the execution role's read-gateway-secrets
656# policy (§2) names each of the three secrets by its ARN prefix.
657# Secret values go to aws via --cli-input-json temp files, never argv.
658if [[ -n "${DB_PASSWORD}" ]]; then
659 # RDS private endpoint (guide §3); the gateway connects directly over the
660 # VPC — the DB security group only admits ${GW_SG_NAME}.
661 # sslmode=verify-full: the gateway's driver honors sslmode from the URL and
662 # verifies the RDS certificate chain AND hostname against the CA bundle the
663 # image trusts via NODE_EXTRA_CA_CERTS (see the Dockerfile). Do NOT add a
664 # libpq-style `sslrootcert=` query param — the driver doesn't read it and
665 # forwards it to Postgres as a startup parameter, which the server rejects.
666 CONN="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:5432/${DB_NAME}?sslmode=verify-full"
667 log "Storing connection string in Secrets Manager secret ${SECRET_NAME} (§5)"
668 conn_json=""; secret_json conn_json SecretString "${CONN}"
669 if secret_exists "${SECRET_NAME}"; then
670 aws secretsmanager put-secret-value --secret-id "${SECRET_NAME}" \
671 --cli-input-json "file://${conn_json}" >/dev/null
672 else
673 aws secretsmanager create-secret --name "${SECRET_NAME}" \
674 --cli-input-json "file://${conn_json}" >/dev/null
675 fi
676 rm -f "${conn_json}"
677else
678 log "Skipping postgres-url secret write (instance already existed, password not available this run)"
679fi
680
681# JWT signing secret — generated once (re-runs do NOT rotate it).
682log "Ensuring JWT signing secret ${JWT_SECRET_NAME} (§5)"
683if secret_exists "${JWT_SECRET_NAME}"; then
684 skip "secret ${JWT_SECRET_NAME}"
685else
686 jwt_json=""; secret_json jwt_json SecretString "$(openssl rand -base64 32)"
687 aws secretsmanager create-secret --name "${JWT_SECRET_NAME}" \
688 --cli-input-json "file://${jwt_json}" >/dev/null
689 rm -f "${jwt_json}"
690fi
691
692# OIDC client secret — operator-created (the script can't generate it; it comes
693# from the Okta OIDC web application). Checked here so the deploy step below can
694# gate on it with a clear message instead of a raw ECS secret-injection failure.
695OIDC_ARN="$(secret_arn "${OIDC_SECRET_NAME}")"
696
697# ---- 7 ECS Fargate service + internal ALB ----------------------------------
698# Self-gating: deploy only once its inputs exist (image pushed — i.e.
699# gateway.yaml was filled in — plus the operator-provided OIDC client secret
700# and the ACM certificate for the internal hostname). On a first run these are
701# usually missing and it cleanly skips.
702ALB_DNS=""
703missing=""
704[[ -n "${IMAGE}" ]] || missing="${missing} image(fill ${GATEWAY_YAML})"
705[[ -n "${OIDC_ARN}" ]] || missing="${missing} ${OIDC_SECRET_NAME}"
706[[ -n "${ACM_CERT_ARN}" ]] || missing="${missing} ACM_CERT_ARN"
707SECRET_ARN="$(secret_arn "${SECRET_NAME}")"
708JWT_ARN="$(secret_arn "${JWT_SECRET_NAME}")"
709[[ -n "${SECRET_ARN}" ]] || missing="${missing} ${SECRET_NAME}"
710[[ -n "${JWT_ARN}" ]] || missing="${missing} ${JWT_SECRET_NAME}"
711
712if [[ "${DEPLOY}" != "1" ]]; then
713 log "Skipping ECS/ALB deploy (DEPLOY=${DEPLOY}) (§7)"
714elif [[ -n "${missing// }" ]]; then
715 log "Skipping ECS/ALB deploy — missing input(s):${missing} (§7)"
716 echo " Fill ${GATEWAY_YAML} and re-run to build the image; create ${OIDC_SECRET_NAME}"
717 echo " from the Okta client secret; set ACM_CERT_ARN to the certificate for your"
718 echo " internal gateway hostname. Then re-run to deploy."
719else
720 log "Creating ECS cluster ${CLUSTER} and log group ${LOG_GROUP} (§7)"
721 if [[ "$(aws ecs describe-clusters --clusters "${CLUSTER}" \
722 --query 'clusters[0].status' --output text 2>/dev/null)" == "ACTIVE" ]]; then
723 skip "cluster ${CLUSTER}"
724 else
725 aws ecs create-cluster --cluster-name "${CLUSTER}" >/dev/null
726 fi
727 # The gateway's stderr carries both its audit events and operational logs.
728 if aws logs describe-log-groups --log-group-name-prefix "${LOG_GROUP}" \
729 --query 'logGroups[?logGroupName==`'"${LOG_GROUP}"'`]' --output text 2>/dev/null | grep -q .; then
730 skip "log group ${LOG_GROUP}"
731 else
732 aws logs create-log-group --log-group-name "${LOG_GROUP}"
733 fi
734 # Retention is a separate API (create-log-group has no retention flag) and an
735 # upsert — applied every run so pre-existing groups converge too. Without it
736 # the group keeps logs forever and cost grows unbounded.
737 aws logs put-retention-policy --log-group-name "${LOG_GROUP}" \
738 --retention-in-days "${LOG_RETENTION_DAYS}"
739
740 # Task definition: the task role carries the Bedrock permission; the
741 # execution role injects the secrets. Registering is an append (a new
742 # revision) — the service below always points at the latest.
743 log "Registering task definition ${TASK_FAMILY}"
744 taskdef_tmp="$(mktemp)"
745 cat > "${taskdef_tmp}" <<EOF
746{
747 "family": "${TASK_FAMILY}",
748 "networkMode": "awsvpc",
749 "requiresCompatibilities": ["FARGATE"],
750 "cpu": "${TASK_CPU}",
751 "memory": "${TASK_MEMORY}",
752 "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
753 "executionRoleArn": "arn:aws:iam::${ACCOUNT_ID}:role/${EXEC_ROLE}",
754 "taskRoleArn": "arn:aws:iam::${ACCOUNT_ID}:role/${TASK_ROLE}",
755 "containerDefinitions": [
756 {
757 "name": "gateway",
758 "image": "${IMAGE}",
759 "portMappings": [{ "containerPort": 8080 }],
760 "secrets": [
761 { "name": "GATEWAY_JWT_SECRET", "valueFrom": "${JWT_ARN}" },
762 { "name": "OIDC_CLIENT_SECRET", "valueFrom": "${OIDC_ARN}" },
763 { "name": "GATEWAY_POSTGRES_URL", "valueFrom": "${SECRET_ARN}" }
764 ],
765 "logConfiguration": {
766 "logDriver": "awslogs",
767 "options": {
768 "awslogs-group": "${LOG_GROUP}",
769 "awslogs-region": "${AWS_REGION}",
770 "awslogs-stream-prefix": "gateway"
771 }
772 }
773 }
774 ]
775}
776EOF
777 aws ecs register-task-definition --cli-input-json "file://${taskdef_tmp}" >/dev/null
778 rm -f "${taskdef_tmp}"
779
780 # Internal ALB. --ip-address-type ipv4: an internal dual-stack ALB publishes
781 # public-range AAAA records, which the CLI's /login private-network check
782 # rejects.
783 log "Creating internal ALB ${ALB_NAME} + target group + HTTPS listener"
784 read -r ALB_ARN ALB_SCHEME ALB_VPC ALB_IP_TYPE <<<"$(aws elbv2 describe-load-balancers --names "${ALB_NAME}" \
785 --query 'LoadBalancers[0].[LoadBalancerArn,Scheme,VpcId,IpAddressType]' --output text 2>/dev/null || true)"
786 if [[ -n "${ALB_ARN}" && "${ALB_ARN}" != "None" ]]; then
787 # Reuse is by name, and scheme/VPC are immutable on an ALB — so posture is
788 # asserted, fail-closed: attaching the gateway to an internet-facing or
789 # wrong-VPC load balancer would change the exposure model, not just drift.
790 if [[ "${ALB_SCHEME}" != "internal" || "${ALB_VPC}" != "${VPC_ID}" ]]; then
791 echo "ERROR: load balancer ${ALB_NAME} exists but is not the internal ALB this script expects:" >&2
792 echo " scheme=${ALB_SCHEME} (need internal), vpc=${ALB_VPC} (need ${VPC_ID})." >&2
793 echo " Refusing to deploy the gateway behind it. Delete that load balancer, or set" >&2
794 echo " ALB_NAME to an unused name, then re-run." >&2
795 exit 1
796 fi
797 skip "load balancer ${ALB_NAME} (internal, ${ALB_VPC})"
798 # ip-address-type IS mutable (unlike scheme/VPC) — converge a reused
799 # dualstack ALB back to ipv4, matching the Terraform sibling: dual-stack
800 # publishes public-range AAAA records that /login rejects (see above).
801 if [[ "${ALB_IP_TYPE}" != "ipv4" ]]; then
802 aws elbv2 set-ip-address-type --load-balancer-arn "${ALB_ARN}" \
803 --ip-address-type ipv4 >/dev/null
804 fi
805 else
806 # shellcheck disable=SC2086
807 ALB_ARN="$(aws elbv2 create-load-balancer --name "${ALB_NAME}" \
808 --scheme internal --type application --ip-address-type ipv4 \
809 --subnets ${PRIVATE_SUBNETS} --security-groups "${ALB_SG}" \
810 --query 'LoadBalancers[0].LoadBalancerArn' --output text)"
811 fi
812
813 # The ALB closes a connection after 60 seconds with no data by default, which
814 # cuts off streams during quiet periods (long prompt processing before the
815 # first token, extended thinking). Attribute setting is idempotent.
816 aws elbv2 modify-load-balancer-attributes --load-balancer-arn "${ALB_ARN}" \
817 --attributes Key=idle_timeout.timeout_seconds,Value=3600 >/dev/null
818
819 read -r TG_ARN TG_VPC <<<"$(aws elbv2 describe-target-groups --names "${TG_NAME}" \
820 --query 'TargetGroups[0].[TargetGroupArn,VpcId]' --output text 2>/dev/null || true)"
821 if [[ -n "${TG_ARN}" && "${TG_ARN}" != "None" ]]; then
822 skip "target group ${TG_NAME}"
823 # VPC is immutable on a target group; a wrong-VPC one can't reach the tasks.
824 if [[ "${TG_VPC}" != "${VPC_ID}" ]]; then
825 echo " WARN — target group ${TG_NAME} is in ${TG_VPC}, not ${VPC_ID}; the service's tasks" >&2
826 echo " will not become healthy behind it. Delete it or set TG_NAME to an unused" >&2
827 echo " name, then re-run." >&2
828 fi
829 else
830 # /readyz verifies the store is reachable, so a task that can't reach
831 # Postgres never enters rotation (the gateway also serves liveness-only
832 # /healthz — see the deploy guide's outage-behavior tradeoff).
833 TG_ARN="$(aws elbv2 create-target-group --name "${TG_NAME}" \
834 --protocol HTTP --port 8080 --vpc-id "${VPC_ID}" --target-type ip \
835 --health-check-path /readyz \
836 --query 'TargetGroups[0].TargetGroupArn' --output text)"
837 fi
838
839 # Select the HTTPS:443 listener specifically — a reused ALB may carry other
840 # listeners (say HTTP:80); those stay untouched, and the 443 listener is
841 # still created when it's the one that's missing.
842 # shellcheck disable=SC2016 # backticks are JMESPath literals, not expansion
843 LISTENER_ARN="$(aws elbv2 describe-listeners --load-balancer-arn "${ALB_ARN}" \
844 --query 'Listeners[?Port==`443`]|[0].ListenerArn' --output text 2>/dev/null || true)"
845 if [[ -n "${LISTENER_ARN}" && "${LISTENER_ARN}" != "None" ]]; then
846 skip "HTTPS:443 listener on ${ALB_NAME}"
847 # Converge everything this script owns on pre-existing listeners
848 # (modify-listener is an upsert): the TLS policy (so re-runs pick up an
849 # ALB_SSL_POLICY change, and listeners created before this script pinned
850 # one lose the legacy default), the certificate (so a changed ACM_CERT_ARN
851 # — e.g. a renewal under a new ARN — is not silently ignored), and the
852 # default action (so the listener always forwards to this target group).
853 aws elbv2 modify-listener --listener-arn "${LISTENER_ARN}" \
854 --ssl-policy "${ALB_SSL_POLICY}" \
855 --certificates "CertificateArn=${ACM_CERT_ARN}" \
856 --default-actions "Type=forward,TargetGroupArn=${TG_ARN}" >/dev/null
857 else
858 aws elbv2 create-listener --load-balancer-arn "${ALB_ARN}" \
859 --protocol HTTPS --port 443 \
860 --ssl-policy "${ALB_SSL_POLICY}" \
861 --certificates "CertificateArn=${ACM_CERT_ARN}" \
862 --default-actions "Type=forward,TargetGroupArn=${TG_ARN}" >/dev/null
863 fi
864
865 # Service: created once, then rolled forward — a re-run points it at the
866 # latest task-definition revision (which carries the current image tag, and
867 # therefore the current gateway.yaml) and forces a new deployment.
868 log "Creating/updating ECS service ${SERVICE} (Fargate, private subnets, no public IP)"
869 svc_status="$(aws ecs describe-services --cluster "${CLUSTER}" --services "${SERVICE}" \
870 --query 'services[0].status' --output text 2>/dev/null || true)"
871 if [[ "${svc_status}" == "ACTIVE" ]]; then
872 aws ecs update-service --cluster "${CLUSTER}" --service "${SERVICE}" \
873 --task-definition "${TASK_FAMILY}" --desired-count "${DESIRED_COUNT}" \
874 --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
875 --health-check-grace-period-seconds 60 \
876 --force-new-deployment >/dev/null
877 echo " service updated to the latest task-definition revision."
878 else
879 # All egress (Bedrock, the IdP, Secrets Manager, ECR, CloudWatch Logs) goes
880 # through the NAT gateway — assignPublicIp stays DISABLED.
881 # The deployment circuit breaker stops a rollout whose tasks keep failing
882 # (bad image, unbootable config) and rolls back to the last steady state
883 # instead of relaunching failing tasks forever. The health-check grace
884 # period gives a cold task (image pull + store connect + first /readyz)
885 # time before ECS counts it unhealthy — without it the circuit breaker can
886 # declare the very first rollout failed (matches terraform/'s
887 # health_check_grace_period_seconds).
888 aws ecs create-service --cluster "${CLUSTER}" --service-name "${SERVICE}" \
889 --task-definition "${TASK_FAMILY}" --desired-count "${DESIRED_COUNT}" \
890 --launch-type FARGATE \
891 --deployment-configuration "deploymentCircuitBreaker={enable=true,rollback=true}" \
892 --health-check-grace-period-seconds 60 \
893 --network-configuration "awsvpcConfiguration={subnets=[${SUBNETS_CSV}],securityGroups=[${GW_SG}],assignPublicIp=DISABLED}" \
894 --load-balancers "targetGroupArn=${TG_ARN},containerName=gateway,containerPort=8080" >/dev/null
895 fi
896
897 ALB_DNS="$(aws elbv2 describe-load-balancers --load-balancer-arns "${ALB_ARN}" \
898 --query 'LoadBalancers[0].DNSName' --output text)"
899 log "Internal ALB DNS: ${ALB_DNS}"
900
901 # Post-deploy smoke check: the ALB is internal (unreachable from this
902 # machine), but target health is visible through the API — poll until the
903 # /readyz health check passes. Non-fatal; a cold task needs a minute or two
904 # (image pull + store connect).
905 log "Smoke check: polling target health on ${TG_NAME} (health check: GET /readyz)"
906 tg_state="unknown"
907 for _ in $(seq 1 24); do
908 tg_state="$(aws elbv2 describe-target-health --target-group-arn "${TG_ARN}" \
909 --query 'TargetHealthDescriptions[0].TargetHealth.State' --output text 2>/dev/null || true)"
910 [[ "${tg_state}" == "healthy" ]] && break
911 sleep 10
912 done
913 if [[ "${tg_state}" == "healthy" ]]; then
914 echo " OK — a gateway task is healthy behind the ALB (store reachable)."
915 else
916 echo " WARN — last target state: ${tg_state:-none}; the task may still be starting."
917 echo " Check the service events and the gateway's logs:"
918 echo " aws ecs describe-services --cluster ${CLUSTER} --services ${SERVICE} --query 'services[0].events[:5]'"
919 echo " aws logs tail ${LOG_GROUP} --since 10m"
920 fi
921
922 # public_url is baked into the image, so verify the operator's chosen
923 # hostname is in place (the redirect URI and discovery doc derive from it).
924 CFG_PUBLIC_URL="$(grep -E '^[[:space:]]*public_url:' "${GATEWAY_YAML}" 2>/dev/null \
925 | head -1 \
926 | sed -E 's/^[[:space:]]*public_url:[[:space:]]*//; s/[[:space:]]+#.*$//; s/[[:space:]]*$//' \
927 || true)"
928 CFG_PUBLIC_URL="${CFG_PUBLIC_URL#[\'\"]}"; CFG_PUBLIC_URL="${CFG_PUBLIC_URL%[\'\"]}"
929 CFG_PUBLIC_URL="${CFG_PUBLIC_URL%/}"
930 echo " 1. In your Route 53 private hosted zone, alias the host of"
931 echo " ${CFG_PUBLIC_URL:-<public_url>} to the ALB: ${ALB_DNS}"
932 echo " (the ALB's own *.elb.amazonaws.com name can't carry your ACM certificate)."
933 echo " 2. Register this redirect URI on the Okta OIDC web app: ${CFG_PUBLIC_URL:-<public_url>}/oauth/callback"
934 echo " 3. Verify from inside your corporate network:"
935 echo " curl -s ${CFG_PUBLIC_URL:-<public_url>}/.well-known/oauth-authorization-server"
936fi
937
938# ---- summary ----------------------------------------------------------------
939cat <<EOF
940
941==> Done.
942
943 Security groups ${ALB_SG_NAME}=${ALB_SG} ${GW_SG_NAME}=${GW_SG} ${DB_SG_NAME}=${DB_SG}
944 IAM roles ${TASK_ROLE} (bedrock-invoke), ${EXEC_ROLE} (pull + secrets)
945 Image ${IMAGE:-(not built yet — fill ${GATEWAY_YAML})}
946 RDS instance ${DB_INSTANCE} -> ${DB_HOST}
947 Database / user ${DB_NAME} / ${DB_USER}
948 Secrets ${SECRET_NAME}, ${JWT_SECRET_NAME}, ${OIDC_SECRET_NAME}$( [[ -n "${OIDC_ARN}" ]] || printf ' (MISSING — create it)' )
949 ECS service ${CLUSTER}/${SERVICE} behind ${ALB_DNS:-(not deployed yet)}
950
951Next steps (see https://code.claude.com/docs/en/claude-apps-gateway-on-aws):
952 - Create the one operator-provided secret (from the Okta OIDC web app). Put the
953 client secret in a 0600 file first — passing it as a literal argument would
954 leave it readable in the process table and in audit/EDR logs:
955 aws secretsmanager create-secret --name ${OIDC_SECRET_NAME} \\
956 --secret-string file:///path/to/okta-client-secret.txt
957 - Fill in the REPLACE_ME values in ${GATEWAY_YAML}, then re-run: setup.sh builds the
958 image (config baked in) and deploys once the secret and ACM_CERT_ARN exist.
959 - Enable Bedrock model access in the console for the Claude models you need (per
960 region the us.anthropic.* profiles span) and submit the one-time use case form.
961 - Alias your internal hostname (gateway.yaml public_url) to the ALB in a Route 53
962 private hosted zone, and register <public_url>/oauth/callback on the Okta app.
963 - The gateway runs its own schema migrations at boot, so ${DB_USER} needs CREATE TABLE.
964EOF