Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions web/scripts/bootstrap-preview-infra.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
#
# Bootstrap shared CloudFront resources for preview environments.
#
# Each `sst.aws.Nextjs` preview stage normally creates its own CachePolicy and
# KeyValueStore — those carry low per-account quotas (20 cache policies / 5 KV
# stores). This script creates ONE of each and writes their IDs to SSM so that
# every preview stage can reference them via `web/sst.config.ts`.
#
# Idempotent: re-running checks SSM first and only creates resources if missing.
#
# Usage:
# web/scripts/bootstrap-preview-infra.sh

set -euo pipefail

if [ -z "${AWS_PROFILE:-}" ] \
&& [ -z "${AWS_ACCESS_KEY_ID:-}" ] \
&& [ -z "${AWS_WEB_IDENTITY_TOKEN_FILE:-}" ] \
&& [ -z "${AWS_ROLE_ARN:-}" ]; then
export AWS_PROFILE="ar_preview"
fi
export AWS_REGION="${AWS_REGION:-us-east-1}"
export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-$AWS_REGION}"

for bin in aws; do
command -v "$bin" >/dev/null || { echo "missing dependency: $bin" >&2; exit 1; }
done

CACHE_POLICY_NAME="relay-web-preview-shared"
KV_STORE_NAME="relay-web-preview-shared"
SSM_CACHE_POLICY_PARAM="/relay-web/preview/cache-policy-id"
SSM_KV_STORE_PARAM="/relay-web/preview/kv-store-arn"

echo "==> AWS_PROFILE=${AWS_PROFILE:-<unset>} AWS_REGION=$AWS_REGION"
aws sts get-caller-identity --query Account --output text

read_ssm() {
local name="$1"
local err
err="$(mktemp)"
if aws ssm get-parameter --name "$name" --query 'Parameter.Value' --output text 2>"$err"; then
rm -f "$err"
return 0
fi
if grep -q 'ParameterNotFound' "$err"; then
rm -f "$err"
return 0
fi
cat "$err" >&2
rm -f "$err"
return 1
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

write_ssm() {
aws ssm put-parameter --name "$1" --value "$2" --type String --overwrite >/dev/null
}

# 1. Cache policy. Config matches SST's internal default for sst.aws.Nextjs
# (cookies=none, query strings=all, headers=x-open-next-cache-key + x-forwarded-host,
# brotli/gzip enabled, default ttl 0, max 1 year).
existing_policy_id="$(read_ssm "$SSM_CACHE_POLICY_PARAM")"
if [ -n "$existing_policy_id" ] && aws cloudfront get-cache-policy --id "$existing_policy_id" >/dev/null 2>&1; then
Comment on lines +62 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse existing cache policy when SSM parameter is missing

This idempotency check only trusts the SSM value, so if the first run creates the policy but fails before put-parameter (or if the SSM param is later deleted), a rerun will execute create-cache-policy with the same fixed name and fail. AWS CloudFront requires cache policy names to be unique (CachePolicyAlreadyExists), so the script cannot recover automatically from partial failures even though it is documented as safe to rerun.

Useful? React with 👍 / 👎.

echo "==> Cache policy already exists: $existing_policy_id"
cache_policy_id="$existing_policy_id"
else
# SSM param missing or stale — look up by name before creating to stay idempotent
cache_policy_id="$(aws cloudfront list-cache-policies --type custom \
--query "CachePolicyList.Items[?CachePolicy.CachePolicyConfig.Name=='$CACHE_POLICY_NAME'].CachePolicy.Id | [0]" \
--output text 2>/dev/null || true)"
if [ -n "$cache_policy_id" ] && [ "$cache_policy_id" != "None" ]; then
echo "==> Cache policy found by name, updating SSM: $cache_policy_id"
write_ssm "$SSM_CACHE_POLICY_PARAM" "$cache_policy_id"
else
echo "==> Creating shared cache policy $CACHE_POLICY_NAME"
cache_policy_config=$(cat <<'JSON'
{
"Name": "relay-web-preview-shared",
"Comment": "Shared SST server response cache policy for relay-web preview stages",
"DefaultTTL": 0,
"MinTTL": 0,
"MaxTTL": 31536000,
"ParametersInCacheKeyAndForwardedToOrigin": {
"EnableAcceptEncodingGzip": true,
"EnableAcceptEncodingBrotli": true,
"HeadersConfig": {
"HeaderBehavior": "whitelist",
"Headers": {
"Quantity": 2,
"Items": ["x-open-next-cache-key", "x-forwarded-host"]
}
},
"CookiesConfig": { "CookieBehavior": "none" },
"QueryStringsConfig": { "QueryStringBehavior": "all" }
}
}
JSON
)
cache_policy_id="$(aws cloudfront create-cache-policy \
--cache-policy-config "$cache_policy_config" \
--query 'CachePolicy.Id' --output text)"
echo " created cache policy: $cache_policy_id"
write_ssm "$SSM_CACHE_POLICY_PARAM" "$cache_policy_id"
fi
fi

# 2. KV store. SST namespaces keys by md5(app + stage + componentName) so sharing
# the store across stages is safe — entries don't collide.
existing_kv_arn="$(read_ssm "$SSM_KV_STORE_PARAM")"
if [ -n "$existing_kv_arn" ] && aws cloudfront describe-key-value-store --name "$existing_kv_arn" >/dev/null 2>&1; then
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The KV-store existence check passes an ARN into --name, so the check never succeeds. This bypasses the intended SSM fast-path and can cause unnecessary fallback calls/failures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/scripts/bootstrap-preview-infra.sh, line 110:

<comment>The KV-store existence check passes an ARN into `--name`, so the check never succeeds. This bypasses the intended SSM fast-path and can cause unnecessary fallback calls/failures.</comment>

<file context>
@@ -72,27 +96,37 @@ else
 #    the store across stages is safe — entries don't collide.
 existing_kv_arn="$(read_ssm "$SSM_KV_STORE_PARAM")"
-if [ -n "$existing_kv_arn" ] && aws cloudfront describe-key-value-store --kvs-arn "$existing_kv_arn" >/dev/null 2>&1; then
+if [ -n "$existing_kv_arn" ] && aws cloudfront describe-key-value-store --name "$existing_kv_arn" >/dev/null 2>&1; then
   echo "==> KV store already exists: $existing_kv_arn"
   kv_store_arn="$existing_kv_arn"
</file context>
Fix with Cubic

echo "==> KV store already exists: $existing_kv_arn"
kv_store_arn="$existing_kv_arn"
else
# SSM param missing or stale — look up by name before creating to stay idempotent
kv_store_arn="$(aws cloudfront list-key-value-stores \
--query "KeyValueStoreList.Items[?Name=='$KV_STORE_NAME'].ARN | [0]" \
--output text 2>/dev/null || true)"
if [ -n "$kv_store_arn" ] && [ "$kv_store_arn" != "None" ]; then
echo "==> KV store found by name, updating SSM: $kv_store_arn"
write_ssm "$SSM_KV_STORE_PARAM" "$kv_store_arn"
else
echo "==> Creating shared KV store $KV_STORE_NAME"
kv_store_arn="$(aws cloudfront create-key-value-store \
--name "$KV_STORE_NAME" \
--comment "Shared KV store for relay-web preview stages" \
--query 'KeyValueStore.ARN' --output text)"
echo " created KV store: $kv_store_arn"
write_ssm "$SSM_KV_STORE_PARAM" "$kv_store_arn"
fi
fi

echo
echo "==> SSM parameters:"
echo " $SSM_CACHE_POLICY_PARAM = $cache_policy_id"
echo " $SSM_KV_STORE_PARAM = $kv_store_arn"
echo
echo "Preview deploys will now reuse these resources. Re-run after disaster"
echo "recovery or if AWS resources are manually deleted."
16 changes: 16 additions & 0 deletions web/sst.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ export default $config({
const NEXT_PUBLIC_POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST ?? 'https://i.agentrelay.com';
const NEXT_PUBLIC_POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY ?? '';

// Non-prod stages reuse a shared CloudFront cache policy + KV store so the
// per-account quotas (20 cache policies / 5 KV stores) don't cap how many
// preview deploys can exist concurrently. The IDs are written to SSM by
// web/scripts/bootstrap-preview-infra.sh; SST namespaces KV keys by stage
// so a shared store is safe.
const previewCachePolicyId = isProd
? undefined
: aws.ssm.getParameterOutput({ name: '/relay-web/preview/cache-policy-id' }).value;
const previewKvStoreArn = isProd
? undefined
: aws.ssm.getParameterOutput({ name: '/relay-web/preview/kv-store-arn' }).value;

new sst.aws.Nextjs('Web', {
path: '.',
openNextVersion: '3.9.16',
Expand All @@ -21,6 +33,10 @@ export default $config({
},
// Production deploys land on orgin.agentrelay.net; SEO canonicals are set in Next metadata.
domain: { name: domain, dns: sst.cloudflare.dns({ proxy: true }) },
...(previewCachePolicyId ? { cachePolicy: previewCachePolicyId } : {}),
...(previewKvStoreArn
? { edge: { viewerRequest: { kvStore: previewKvStoreArn, injection: '' } } }
: {}),
});
},
});