Skip to content
Merged
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
43 changes: 43 additions & 0 deletions .claude/rules/infra/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<project>-`) を
付ける。同名にすると 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 <FQDN> --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` で確認する。
137 changes: 120 additions & 17 deletions .claude/rules/infra/smalruby-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -83,16 +97,105 @@ 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

実装場所: `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 成功の証拠。
8 changes: 8 additions & 0 deletions infra/smalruby-api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading
Loading