From e0aa443905754c6ce3b6edb22c422290b0f49692 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Wed, 29 Apr 2026 17:09:55 +0900 Subject: [PATCH 1/2] refactor(infra/smalruby-api): rename Lambdas + import existing domain for prod cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prod カットオーバー (api.smalruby.app を旧 SAM スタックから CDK へ) に対応する 変更。ダウンタイムを最小化するため、既存の API Gateway カスタムドメインを 新規作成せずに import する。 Lambda 関数名変更: - smalruby-cors-proxy → smalruby-api-cors-proxy - smalruby-mesh-zone-get → smalruby-api-mesh-zone - smalruby-scratch-api-projects → smalruby-api-scratch-projects - smalruby-scratch-api-translate → smalruby-api-scratch-translate 旧 SAM スタックが使っていた `smalruby-cors-proxy` 等と衝突していたため、 すべて `smalruby-api-` プレフィックスで統一。stg は既存の関数を CFN が delete + create で置き換える (stg のみ短時間ダウンする)。 カスタムドメイン import: - `IMPORT_EXISTING_CUSTOM_DOMAIN=true` のとき、apigatewayv2.DomainName. fromDomainNameAttributes() で既存ドメインを参照 - ACM 証明書、Route53 レコードは作成せず再利用 - prod 用の .env.prod でこのフラグと既存の regional domain attributes を設定 Integration test の Origin を localhost:8601 → smalruby.app に変更: - prod CORS は localhost を許可しない (.env.prod で意図的に除外) - smalruby.app は両環境で常に許可されるため、両方で pass する prod 検証 (2026-04-29): - 全 4 endpoint 200/404/CORS 正常動作 - mesh-domain は旧 SAM secret_key 引き継ぎでドメイン継続性 OK - integration test 18 件すべて pass - ダウンタイム実測: 約 5 分 (CDK deploy + HTTP API mapping 反映) 旧 SAM スタック smalruby-infra-prod は CloudFormation から削除済み。 smalruby/smalruby-infra リポジトリの該当ディレクトリ deprecate は後続作業。 Refs: #573 --- .claude/rules/infra/smalruby-api.md | 53 +++++++++----- infra/smalruby-api/.env.example | 8 +++ .../tests/cors-proxy.integration.test.ts | 4 +- .../tests/mesh-zone-get.integration.test.ts | 4 +- .../scratch-api-projects.integration.test.ts | 4 +- .../scratch-api-translate.integration.test.ts | 4 +- infra/smalruby-api/lib/smalruby-api-stack.ts | 70 +++++++++++++------ 7 files changed, 102 insertions(+), 45 deletions(-) diff --git a/.claude/rules/infra/smalruby-api.md b/.claude/rules/infra/smalruby-api.md index 0dd670528b9..654c23eb77b 100644 --- a/.claude/rules/infra/smalruby-api.md +++ b/.claude/rules/infra/smalruby-api.md @@ -9,23 +9,37 @@ CDK project for Smalruby's API Gateway endpoints (HTTP API v2 + Lambda). | Path | Method | Lambda 関数名 (prod) | 説明 | |------|--------|----------------------|------| -| `/cors-proxy` | GET | `smalruby-cors-proxy` | 任意 URL のフェッチ + Google Drive URL 変換 + バイナリ Base64 化 | -| `/mesh-domain` | GET | `smalruby-mesh-zone-get` | source IP から Mesh ドメイン (CRC32) を生成 | -| `/scratch-api-proxy/projects/{projectId}` | GET | `smalruby-scratch-api-projects` | Scratch API のプロジェクト情報取得プロキシ (status pass-through) | -| `/scratch-api-proxy/translate` | GET | `smalruby-scratch-api-translate` | Scratch translate サービスプロキシ | +| `/cors-proxy` | GET | `smalruby-api-cors-proxy` | 任意 URL のフェッチ + Google Drive URL 変換 + バイナリ Base64 化 | +| `/mesh-domain` | GET | `smalruby-api-mesh-zone` | source IP から Mesh ドメイン (CRC32) を生成 | +| `/scratch-api-proxy/projects/{projectId}` | GET | `smalruby-api-scratch-projects` | Scratch API のプロジェクト情報取得プロキシ (status pass-through) | +| `/scratch-api-proxy/translate` | GET | `smalruby-api-scratch-translate` | Scratch translate サービスプロキシ | -stg では Lambda 関数名に `-stg` サフィックスが付く。 +stg では Lambda 関数名に `-stg` サフィックスが付く (`smalruby-api-cors-proxy-stg` 等)。 OPTIONS (preflight) は HTTP API v2 の built-in CORS で自動処理 — 旧 `cors-for-smalruby` Lambda は不要。 +**Lambda 関数名の `smalruby-api-` プレフィックス**: 旧 SAM スタックの `smalruby-cors-proxy` などと衝突しないよう、CDK 側はすべて `smalruby-api-` で始まる名前に統一している。 + ## Custom Domains | Stage | Domain | |-------|--------| -| stg | `stg.api.smalruby.app` | -| prod | `api.smalruby.app` | - -prod ドメインは旧 SAM スタック (`smalruby-infra-prod`) が現在保持しているため、 -prod カットオーバーは旧スタックのドメイン解放と協調が必要 (後続作業)。 +| stg | `stg.api.smalruby.app` (CDK が ACM 証明書 + Route53 レコードも管理) | +| prod | `api.smalruby.app` (既存ドメインを **import** して再利用、ACM/Route53 は別管理) | + +prod カットオーバー (2026-04-29 完了) では、旧 SAM スタック `smalruby-infra-prod` +が保持していた `api.smalruby.app` カスタムドメインを CDK スタックから +`apigatewayv2.DomainName.fromDomainNameAttributes()` で **import** する方式を採用。 +これにより: + +- 既存 ACM 証明書 (`b813732a-...`) と Route53 A レコードを再利用、 + 証明書再発行や DNS 切り替えの待ち時間が発生しない +- ダウンタイムは「base path mapping を SAM から CDK へ切り替える数分」のみ +- import 設定は `.env.prod` の以下 3 環境変数で制御: + ``` + IMPORT_EXISTING_CUSTOM_DOMAIN=true + IMPORTED_REGIONAL_DOMAIN_NAME=d-8g2cqu3hqg.execute-api.ap-northeast-1.amazonaws.com + IMPORTED_REGIONAL_HOSTED_ZONE_ID=Z1YSHQZHG15GKL + ``` ## Commands @@ -83,14 +97,19 @@ rm .env && ln -s .env.prod .env # → prod 4. **`mesh-zone-get` の secret key を環境変数化** — 旧実装はハードコード 5. **stg 環境を新設** — 旧実装は prod のみ -## Cutover (prod) 手順 — 後続作業 +## Cutover (prod) 手順 — 完了済み (2026-04-29) + +完了済みの手順を記録として残す: + +1. ✅ stg で動作確認 + frontend を `stg.api.smalruby.app` で結合テスト +2. ✅ `.env.prod` 作成 (`MESH_ZONE_SECRET_KEY` を旧実装と同値、import 設定 3 環境変数) +3. ✅ SAM スタックのドメインマッピング解除: `aws apigateway delete-base-path-mapping --domain-name api.smalruby.app --base-path '(none)'` +4. ✅ CDK deploy: `cdk deploy --context stage=prod` で新スタックに mapping 追加 +5. ✅ 動作確認: 全 4 endpoint + CORS + integration tests (18 件) prod で pass +6. ✅ SAM スタック削除: `aws cloudformation delete-stack --stack-name smalruby-infra-prod` +7. ⏳ smalruby/smalruby-infra リポジトリの該当ファイルを deprecate (後続作業) -1. stg で動作確認 + frontend を `stg.api.smalruby.app` で結合テスト -2. `MESH_ZONE_SECRET_KEY` を旧実装と同値で `.env.prod` に設定 (mesh ドメインが既存ユーザーで変わらないようにする) -3. SAM スタック (`smalruby-infra-prod`) のドメインマッピング解除 (`api.smalruby.app`) -4. `cdk deploy --context stage=prod` で新スタックに `api.smalruby.app` を紐付け -5. 動作確認後、SAM スタック (`smalruby-infra-prod`) を CloudFormation から削除 -6. smalruby/smalruby-infra リポジトリの該当ファイルを deprecate +ダウンタイム実測: 約 5 分 (CDK deploy + HTTP API mapping 反映待ち) ## Source diff --git a/infra/smalruby-api/.env.example b/infra/smalruby-api/.env.example index 3b279318587..1ab61e6ea18 100644 --- a/infra/smalruby-api/.env.example +++ b/infra/smalruby-api/.env.example @@ -30,3 +30,11 @@ ROUTE53_PARENT_ZONE_NAME=api.smalruby.app # Mesh-zone-get secret key (used to derive Mesh group identity from source IP) # IMPORTANT: keep this stable across stages once set; changing rotates all derived domains MESH_ZONE_SECRET_KEY=replace-me-with-a-long-random-string + +# Import an existing API Gateway custom domain (used for prod cutover from +# the legacy SAM stack). When true, CDK does not create a new ACM certificate +# or Route53 record — just adds an ApiMapping to the existing custom domain. +# The Route53 A record must already point at IMPORTED_REGIONAL_DOMAIN_NAME. +# IMPORT_EXISTING_CUSTOM_DOMAIN=false +# IMPORTED_REGIONAL_DOMAIN_NAME=d-xxxxx.execute-api.ap-northeast-1.amazonaws.com +# IMPORTED_REGIONAL_HOSTED_ZONE_ID=ZXXXXXXXXXXX diff --git a/infra/smalruby-api/lambda/tests/cors-proxy.integration.test.ts b/infra/smalruby-api/lambda/tests/cors-proxy.integration.test.ts index 66fb89b2044..632961f9cb6 100644 --- a/infra/smalruby-api/lambda/tests/cors-proxy.integration.test.ts +++ b/infra/smalruby-api/lambda/tests/cors-proxy.integration.test.ts @@ -72,11 +72,11 @@ describe('GET /cors-proxy', () => { const res = await fetch(`${ENDPOINT}/cors-proxy`, { method: 'OPTIONS', headers: { - Origin: 'http://localhost:8601', + Origin: 'https://smalruby.app', 'Access-Control-Request-Method': 'GET', }, }); expect(res.status).toBe(204); - expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:8601'); + expect(res.headers.get('access-control-allow-origin')).toBe('https://smalruby.app'); }); }); diff --git a/infra/smalruby-api/lambda/tests/mesh-zone-get.integration.test.ts b/infra/smalruby-api/lambda/tests/mesh-zone-get.integration.test.ts index d4498062922..9f31a10a233 100644 --- a/infra/smalruby-api/lambda/tests/mesh-zone-get.integration.test.ts +++ b/infra/smalruby-api/lambda/tests/mesh-zone-get.integration.test.ts @@ -37,11 +37,11 @@ describe('GET /mesh-domain', () => { const res = await fetch(`${ENDPOINT}/mesh-domain`, { method: 'OPTIONS', headers: { - Origin: 'http://localhost:8601', + Origin: 'https://smalruby.app', 'Access-Control-Request-Method': 'GET', }, }); expect(res.status).toBe(204); - expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:8601'); + expect(res.headers.get('access-control-allow-origin')).toBe('https://smalruby.app'); }); }); diff --git a/infra/smalruby-api/lambda/tests/scratch-api-projects.integration.test.ts b/infra/smalruby-api/lambda/tests/scratch-api-projects.integration.test.ts index a07431c3e21..9ba41d12a4d 100644 --- a/infra/smalruby-api/lambda/tests/scratch-api-projects.integration.test.ts +++ b/infra/smalruby-api/lambda/tests/scratch-api-projects.integration.test.ts @@ -77,12 +77,12 @@ describe('GET /scratch-api-proxy/projects/{projectId}', () => { const res = await fetch(`${ENDPOINT}/scratch-api-proxy/projects/1209008277`, { method: 'OPTIONS', headers: { - Origin: 'http://localhost:8601', + Origin: 'https://smalruby.app', 'Access-Control-Request-Method': 'GET', }, }); expect(res.status).toBe(204); - expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:8601'); + expect(res.headers.get('access-control-allow-origin')).toBe('https://smalruby.app'); expect(res.headers.get('access-control-allow-methods')?.toUpperCase()).toContain('GET'); }); diff --git a/infra/smalruby-api/lambda/tests/scratch-api-translate.integration.test.ts b/infra/smalruby-api/lambda/tests/scratch-api-translate.integration.test.ts index e0ca0fdbcbe..5f94b2269aa 100644 --- a/infra/smalruby-api/lambda/tests/scratch-api-translate.integration.test.ts +++ b/infra/smalruby-api/lambda/tests/scratch-api-translate.integration.test.ts @@ -54,11 +54,11 @@ describe('GET /scratch-api-proxy/translate', () => { const res = await fetch(`${ENDPOINT}/scratch-api-proxy/translate`, { method: 'OPTIONS', headers: { - Origin: 'http://localhost:8601', + Origin: 'https://smalruby.app', 'Access-Control-Request-Method': 'GET', }, }); expect(res.status).toBe(204); - expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:8601'); + expect(res.headers.get('access-control-allow-origin')).toBe('https://smalruby.app'); }); }); diff --git a/infra/smalruby-api/lib/smalruby-api-stack.ts b/infra/smalruby-api/lib/smalruby-api-stack.ts index d801e4adaa6..3ce6c997598 100644 --- a/infra/smalruby-api/lib/smalruby-api-stack.ts +++ b/infra/smalruby-api/lib/smalruby-api-stack.ts @@ -78,7 +78,7 @@ export class SmalrubyApiStack extends cdk.Stack { const corsProxyFn = makeLambda( 'CorsProxy', - `smalruby-cors-proxy${stageSuffix}`, + `smalruby-api-cors-proxy${stageSuffix}`, 'cors-proxy.ts', {}, 512, @@ -87,20 +87,20 @@ export class SmalrubyApiStack extends cdk.Stack { const meshZoneGetFn = makeLambda( 'MeshZoneGet', - `smalruby-mesh-zone-get${stageSuffix}`, + `smalruby-api-mesh-zone${stageSuffix}`, 'mesh-zone-get.ts', { MESH_ZONE_SECRET_KEY: meshZoneSecretKey }, ); const scratchProjectsFn = makeLambda( 'ScratchApiProjects', - `smalruby-scratch-api-projects${stageSuffix}`, + `smalruby-api-scratch-projects${stageSuffix}`, 'scratch-api-projects.ts', ); const scratchTranslateFn = makeLambda( 'ScratchApiTranslate', - `smalruby-scratch-api-translate${stageSuffix}`, + `smalruby-api-scratch-translate${stageSuffix}`, 'scratch-api-translate.ts', ); @@ -114,23 +114,51 @@ export class SmalrubyApiStack extends cdk.Stack { ? undefined : process.env.SMALRUBY_API_CUSTOM_DOMAIN || defaultCustomDomain; - let domainName: apigatewayv2.DomainName | undefined; + // Optional import of an existing API Gateway custom domain. + // Used during the prod cutover from the legacy SAM stack: the + // `api.smalruby.app` custom domain (and its ACM certificate / Route53 + // alias) already exists, so importing it lets us reuse those resources + // and minimise downtime — only the base path mapping needs to swap. + const importExistingDomain = process.env.IMPORT_EXISTING_CUSTOM_DOMAIN === 'true'; + const importedRegionalDomainName = process.env.IMPORTED_REGIONAL_DOMAIN_NAME; + const importedRegionalHostedZoneId = process.env.IMPORTED_REGIONAL_HOSTED_ZONE_ID; + + let domainName: apigatewayv2.IDomainName | undefined; let zone: route53.IHostedZone | undefined; + let manageRoute53Record = false; if (customDomain) { - zone = route53.HostedZone.fromLookup(this, 'HostedZone', { - domainName: parentZoneName, - }); - - const certificate = new acm.Certificate(this, 'ApiCertificate', { - domainName: customDomain, - validation: acm.CertificateValidation.fromDns(zone), - }); - - domainName = new apigatewayv2.DomainName(this, 'ApiDomainName', { - domainName: customDomain, - certificate, - }); + if (importExistingDomain) { + if (!importedRegionalDomainName || !importedRegionalHostedZoneId) { + throw new Error( + 'IMPORT_EXISTING_CUSTOM_DOMAIN=true requires IMPORTED_REGIONAL_DOMAIN_NAME ' + + 'and IMPORTED_REGIONAL_HOSTED_ZONE_ID to be set.', + ); + } + // Import the existing custom domain; we do not create a new ACM + // cert or Route53 record. The Route53 A record already points + // at this regional endpoint, so traffic continues to flow. + domainName = apigatewayv2.DomainName.fromDomainNameAttributes(this, 'ApiDomainName', { + name: customDomain, + regionalDomainName: importedRegionalDomainName, + regionalHostedZoneId: importedRegionalHostedZoneId, + }); + } else { + zone = route53.HostedZone.fromLookup(this, 'HostedZone', { + domainName: parentZoneName, + }); + + const certificate = new acm.Certificate(this, 'ApiCertificate', { + domainName: customDomain, + validation: acm.CertificateValidation.fromDns(zone), + }); + + domainName = new apigatewayv2.DomainName(this, 'ApiDomainName', { + domainName: customDomain, + certificate, + }); + manageRoute53Record = true; + } } // --- HTTP API --- @@ -184,8 +212,10 @@ export class SmalrubyApiStack extends cdk.Stack { cdk.Tags.of(this.api).add('ResourceType', 'APIGatewayHTTPAPI'); - // Route53 Alias record - if (customDomain && zone && domainName) { + // Route53 Alias record (only when CDK manages the custom domain itself). + // When importing an existing custom domain (prod cutover), the Route53 + // record already exists and is owned by another (or no) stack. + if (manageRoute53Record && customDomain && zone && domainName) { const subdomain = customDomain === parentZoneName ? '' : customDomain.replace(`.${parentZoneName}`, ''); From 385193d9ed3dcbcb53443b8167c5be5adba493d8 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Wed, 29 Apr 2026 17:33:09 +0900 Subject: [PATCH 2/2] docs(infra): record cutover gotchas and lessons learned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prod カットオーバー (2026-04-29) で得た学びを今後のために記録: .claude/rules/infra/smalruby-api.md: - Lambda 関数名衝突 → smalruby-api- プレフィックスで回避 - カスタムドメイン import (fromDomainNameAttributes) でダウンタイム削減 - base path mapping は domain と切り離されている - integration test Origin は両環境で許可される値を使う - mesh-zone-get の secret key は引き継ぎ必須 (互換性破壊リスク) - import 時は manageRoute53Record で ARecord 作成をスキップ - CFN Delete 後は polling で消滅確認 - 検証は curl + Playwright で end-to-end .claude/rules/infra/development.md: - 汎用「旧スタック → CDK 移行チェックリスト」を追加 - 関数名衝突回避、import パターン、secret 引き継ぎ、カットオーバー手順 --- .claude/rules/infra/development.md | 43 +++++++++++++++ .claude/rules/infra/smalruby-api.md | 84 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/.claude/rules/infra/development.md b/.claude/rules/infra/development.md index 5602dda3e7d..893e351d274 100644 --- a/.claude/rules/infra/development.md +++ b/.claude/rules/infra/development.md @@ -97,3 +97,46 @@ dig graphql.api.smalruby.app A +short ``` If a custom domain is missing, redeploy the affected stage with the correct `.env` symlink. + +## CDK 化 / 旧スタック → CDK 移行のチェックリスト (汎用) + +`smalruby-infra` (旧 SAM) → `infra/smalruby-api/` (CDK) のような移行を行う際の +共通チェックポイント。詳細な実例は `.claude/rules/infra/smalruby-api.md` の +「ハマりポイント / 学び」を参照。 + +### 事前 + +1. **Lambda 関数名の衝突回避**: 旧スタックと新スタックを並走させる前提で、 + CDK 側の関数名にプロジェクト固有のプレフィックス (`smalruby--`) を + 付ける。同名にすると CFN が `already exists` で deploy 失敗する。 +2. **既存カスタムドメインは作り直さず import**: `apigatewayv2.DomainName.fromDomainNameAttributes()` + などで参照。ACM 証明書や Route53 レコードを再利用してダウンタイムを + 数分に圧縮する。 +3. **既存 secret/config 値の引き継ぎ**: ハードコードされていた値があれば + prod 環境変数で **同じ値を再使用** する。CRC32 キーのような互換性 + クリティカルな値は変えると既存ユーザーが壊れる。 + +### カットオーバー手順 + +1. stg を CDK で先に立てて `npm run test:integration` を pass させる +2. 旧スタックの **base path mapping だけ** 解除 (custom domain は残す): + `aws apigateway delete-base-path-mapping --domain-name --base-path '(none)'` +3. 即座に `cdk deploy --context stage=prod` +4. curl + Playwright で全エンドポイント検証 (CORS preflight、status passthrough、 + バイナリレスポンス等) +5. 旧 CFN スタック削除 (`aws cloudformation delete-stack --stack-name ...`) + +### Integration test の Origin + +両環境 (stg / prod) で実行する integration test の CORS preflight は、 +両方で許可される Origin (`https://smalruby.app` など) を使う。`localhost` は +prod の CORS 許可リストに入れていないので、テストで期待値にすると prod 実行で +fail する。 + +### 検証ハマり + +- `cdk deploy` 直後の API Gateway は数十秒〜数分のルーティング反映待ちがある。 + curl が一時的に 403 (`Forbidden`) を返したら polling で待つ。 +- ブラウザの `response.headers.get('access-control-allow-origin')` は Fetch + spec の制約で `null` に見える。fetch 自体が成功している事実が CORS preflight + 成功の証拠。生のヘッダー値を見たいときは `curl -i -X OPTIONS` で確認する。 diff --git a/.claude/rules/infra/smalruby-api.md b/.claude/rules/infra/smalruby-api.md index 654c23eb77b..1d95f765cba 100644 --- a/.claude/rules/infra/smalruby-api.md +++ b/.claude/rules/infra/smalruby-api.md @@ -115,3 +115,87 @@ rm .env && ln -s .env.prod .env # → prod 実装場所: `lambda/*.ts`, `lib/smalruby-api-stack.ts`, `bin/smalruby-api.ts` ユニットテスト: `lambda/tests/*.test.ts` + +## ハマりポイント / 学び (2026-04-29 prod カットオーバー) + +### 1. 既存 SAM スタックと CDK スタックを並走させる前提で名前を組む + +CDK 側の Lambda 関数名を旧 SAM 側と同じにすると、prod カットオーバー時に CFN +レベルで `Resource of type 'AWS::Lambda::Function' with identifier 'smalruby-cors-proxy' already exists` +で deploy が失敗する。SAM スタックは別 CFN スタックなので、まだ存在する間は +そこにある Lambda 名と衝突する。**最初から CDK 側で固有プレフィックス +(`smalruby-api-`) を付けておくのが正解**。 + +別解として「SAM を先に削除してから CDK deploy」もあるが、ドメインマッピング +切り替えが先か関数移行が先かで `api.smalruby.app` のダウンタイムが伸びる。 +固有プレフィックスにしておけば衝突なしで並走できる。 + +### 2. 既存 API Gateway カスタムドメインは「import」で再利用する + +`api.smalruby.app` のような既に運用中のカスタムドメインは、CDK で新規作成 +しようとすると競合エラーで失敗する。CDK の `apigatewayv2.DomainName.fromDomainNameAttributes()` +で既存ドメインを参照し、新スタックは `ApiMapping` だけ作るパターンにする。 + +メリット: +- 既存 ACM 証明書 (DNS validation 不要) を再利用 → cdk deploy が速い +- 既存 Route53 A レコードを再利用 → DNS 切り替え不要 +- ダウンタイム = base path mapping swap の数分のみ + +`IMPORT_EXISTING_CUSTOM_DOMAIN=true` フラグで切り替え可能 (本プロジェクトの実装)。 + +### 3. base path mapping は domain と切り離されている + +`api.smalruby.app` は API Gateway の **Custom Domain** リソース、その +`base path mapping` は別リソース。SAM スタックを CFN delete する前に +mapping だけ `aws apigateway delete-base-path-mapping --base-path '(none)'` +で外し、その瞬間に `cdk deploy` で新 mapping を作成すれば +`api.smalruby.app` 自体は残ったまま、ルーティングだけが SAM → CDK へ移る。 + +### 4. integration test の Origin は両環境で許可される値を使う + +CORS preflight テストで `Origin: http://localhost:8601` を期待値にしたら、 +prod では `.env.prod` の `CORS_ALLOWED_ORIGINS` に localhost が含まれない +(意図的) ため `access-control-allow-origin` が一致せず fail。 + +教訓: integration test を **両環境で動かす前提** なら、Origin は +`https://smalruby.app` のように両方で許可される値にする。localhost を +個別に試したいときは別テストで条件分岐するか、stg 専用にスキップ条件を +入れる。 + +### 5. mesh-zone-get の secret key は引き継ぎ必須 + +旧 SAM 実装の `MeshZoneGet` は secret_key がハードコード +(`uXM1VAA6MO39yJ+djz4kbpVGy3Rg1V3Z`)。CDK 化に合わせて環境変数化したが、 +**新しい値を使うとすべての既存ユーザーの mesh group identity (CRC32) が +変わってしまう**。プライベートな mesh ネットワークで他ユーザーと通信できなく +なる致命的な互換性破壊。 + +prod カットオーバー時は `.env.prod` で **必ず旧 SAM のハードコード値を引き継ぐ**。 +stg は新規だったので別の random 値を割り当てた。 + +### 6. CDK で `IDomainName` を import すると `manageRoute53Record` は false に + +`apigatewayv2.DomainName.fromDomainNameAttributes()` で import したドメイン +オブジェクトは CDK 管理外。`new route53.ARecord(...)` で alias を作ると、 +既存の Route53 record と衝突する (`Resource conflict`)。 + +import 時は ARecord 作成をスキップする条件分岐を入れる +(`manageRoute53Record` フラグ)。これで prod 時は CDK が DNS 触らない。 + +### 7. CFN の "Delete initiated" 後の検証は数十秒待つ + +`aws cloudformation delete-stack` は非同期。`describe-stacks` が +`does not exist` を返すまで polling しないと、削除完了を誤認する。 +本ケースでは `until` ループで 15 秒間隔ポーリングを使った。 + +### 8. 検証は curl と Playwright の両方で + +CDK deploy 直後はカスタムドメインの routing 反映に数十秒〜数分のタイムラグが +あり、初回 curl が 403 (`Forbidden` from API Gateway) を返すことがある。 +焦らずポーリングする。 + +prod では Playwright で smalruby.app から実際の fetch を実行して、 +`api.smalruby.app` への CORS/route/data flow を end-to-end で確認するのが +確実。`response.headers.get('access-control-allow-origin')` は Fetch spec +の制約で JS から `null` に見えるが、fetch が成功している事実が CORS +preflight 成功の証拠。