
這篇記錄在 Cloud Run 上跑 FastAPI 服務時, 我怎麼定義 runtime contract、設計 dev 環境的 auth bypass、以及自動部署 pipeline 的做法

跟之前寫的 [WIF branch boundaries](/github-actions-gcp-wif-branch-boundaries/) 和 [dev/prod isolation](/gcp-single-project-dev-prod-isolation/) 不同, 這篇聚焦在 app 層跟 infra 層之間的「合約」— 哪些東西由 Terraform 注入, 哪些由 app code 消費, 中間的邊界怎麼畫

## Runtime Contract: App 不碰 GCP SDK

一開始我覺得 Cloud Run 上的 app 應該自己去 Secret Manager 拿 secret。但後來發現這個做法有幾個問題:

1. App code 要 import `google.cloud.secretmanager`, 增加 domain/application 層對 GCP 的耦合
2. 每次啟動或每次 request 都要做 API call, 增加延遲
3. 測試時要 mock GCP SDK
4. IAM 權限要從 "runtime SA 能用 secret" 變成 "runtime SA 能 read secret + 能 call Secret Manager API"

Cloud Run 原生就支持把 Secret Manager 的值注入成環境變數。Terraform 設定好之後, app 只要 `os.getenv()` 就好:

```hcl
dynamic "env" {
  for_each = {
    DATABASE_URL    = "database-url-${var.env}"
    CLERK_SECRET_KEY = "clerk-secret-key"
    ADMIN_API_KEY   = "admin-api-key"
  }
  content {
    name = env.key
    value_source {
      secret_key_ref {
        secret  = env.value
        version = "latest"
      }
    }
  }
}
```

App code 只看到環境變數, 不知道值從哪裡來。所以我在 config 裡寫了兩個 contract constant:

```python
GCP_SECRET_DELIVERY_MODE = "environment-injection-only"
GCP_IDENTITY_MODE = "service-account-adc-only"
```

再用 test 確保整個 `src/` 底下沒有人 import Secret Manager client:

```python
FORBIDDEN_RUNTIME_SECRET_PATTERNS = (
    "google.cloud.secretmanager",
    "SecretManagerServiceClient",
)

def test_source_tree_does_not_use_runtime_secret_manager_reads():
    for path in SOURCE_ROOT.rglob("*.py"):
        content = path.read_text()
        for pattern in FORBIDDEN_RUNTIME_SECRET_PATTERNS:
            assert pattern not in content
```

Domain 層和 application 層也不允許出現任何 GCP identity API:

```python
FORBIDDEN_MODULE_IDENTITY_PATTERNS = (
    "google.cloud",
    "google.auth",
    "secretmanager",
    "GOOGLE_APPLICATION_CREDENTIALS",
)

def test_module_domain_and_application_layers_do_not_depend_on_gcp_identity_apis():
    module_root = SOURCE_ROOT / "modules"
    for layer in ("application", "domain"):
        for path in module_root.glob(f"*/{layer}/**/*.py"):
            content = path.read_text()
            for pattern in FORBIDDEN_MODULE_IDENTITY_PATTERNS:
                assert pattern not in content
```

這樣即使有人不小心在 domain 層加了 GCP 相關的 import, CI 就會擋住

### 環境變數的分類

Config 裡把所有環境變數分成明確的類別:

| 類別 | 變數 | 誰注入 |
|------|------|--------|
| Required secrets | `DATABASE_URL`, `CLERK_SECRET_KEY`, `ADMIN_API_KEY` | Secret Manager → env var |
| Optional secrets | `RESEND_API_KEY` | Secret Manager → env var |
| Required plain | `ENV`, `BASE_URL` | Terraform env block |
| Optional plain | `RESEND_FROM_EMAIL` | Terraform env block |
| Code defaults | `SHOPIFY_API_VERSION`, `SHOPIFY_SCOPES` | App code 有預設值 |
| Dev-only plain | `ALLOW_DEV_AUTH_BYPASS` | Terraform env block (conditional) |
| Dev-only secrets | `DEV_BYPASS_SECRET` | Secret Manager → env var (conditional) |

每個分類都有對應的 tuple constant, test 會驗證這些 constant 的值沒有被意外修改

## Secret-Gated Auth Bypass

在 dev 環境測試 API 時, 每次都要拿到一個真的 Clerk JWT 很麻煩。所以需要 dev bypass — 送一個假的 token 就能模擬登入。但如果 bypass 沒有保護, 任何人都可以假冒任何使用者

### 三種環境, 三種行為

| 環境 | Bypass 行為 | Token 格式 |
|------|------------|-----------|
| LOCAL | 預設開啟, 不需要 secret | `dev:<user_id>` |
| DEV | 需要 flag + secret | `dev:<secret>:<user_id>` |
| PROD | 不可能開啟 (啟動時 crash) | — |

LOCAL 最寬鬆 — 本地開發不需要任何額外設定, `dev:user_1` 就能用。DEV 多一層保護: token 裡要帶 secret, 用 `hmac.compare_digest` 做 constant-time 比較

### 防護層

整個設計有五層防護:

1. **PROD crash** — config.py 在啟動時檢查, PROD 環境設了 `ALLOW_DEV_AUTH_BYPASS` 直接 raise, app 不會啟動
2. **Secret required** — DEV 環境開啟 bypass 但沒設 `DEV_BYPASS_SECRET` 也會 crash
3. **Token validation** — ClerkAuthClient 用 `hmac.compare_digest` 驗 secret, 不是用 `==`
4. **Terraform guard** — `ALLOW_DEV_AUTH_BYPASS` 和 `DEV_BYPASS_SECRET` 的注入都是 conditional, 只在 `var.allow_dev_auth_bypass == true` 時才有
5. **Platform guard** — Secret Manager 裡的 `dev-bypass-secret` resource 也是 conditional, TF variable 沒開就不存在

```python
def _parse_dev_token(self, token: str) -> str:
    payload = token[4:]
    if self._env == Environment.LOCAL:
        if not payload:
            raise AuthenticationError("dev bypass: empty user id")
        return payload
    parts = payload.split(":", 1)
    if len(parts) != 2 or not parts[0] or not parts[1]:
        raise AuthenticationError("dev bypass: expected dev:<secret>:<user_id>")
    secret, user_id = parts
    if not hmac.compare_digest(secret, self._dev_bypass_secret):
        raise AuthenticationError("dev bypass: invalid secret")
    return user_id
```

### 為什麼用 hmac.compare_digest

字串比較 (`==`) 在第一個不同的字元就會 return, 攻擊者可以透過 response time 猜出 secret 的每一個字元 (timing attack)。`hmac.compare_digest` 不管內容是否相同, 比較時間都一樣

### Single Source of Truth

控制 bypass 開關的 flag 放在 GitHub repo variable (`vars.ALLOW_DEV_AUTH_BYPASS`), 同時被 infra workflow 和 deploy workflow 讀取:

```yaml
env:
  TF_VAR_allow_dev_auth_bypass: ${{ vars.ALLOW_DEV_AUTH_BYPASS || 'false' }}
```

Infra workflow 跑 platform Terraform 時, 這個值決定要不要建 Secret Manager resource。Deploy workflow 跑 service Terraform 時, 決定要不要注入環境變數。一個 variable 控制兩條路, 不會出現 "platform 有建 secret 但 service 沒注入" 的不一致

## Auto-Deploy Pipeline

Push to `dev` branch 觸發 deploy-dev, push to `main` 觸發 deploy-prod。四個 stage:

```
test → build → migrate → deploy (+ health check)
```

### Test

跟 CI workflow (PR 時跑的) 完全一樣的 lint + type check + test:

```yaml
- name: Lint and test
  run: |
    uv run --directory api ruff check src tests
    uv run --directory api ruff format --check src tests
    uv run --directory api pyright
    uv run --directory api pytest tests -q
```

在 deploy pipeline 裡再跑一次的原因: PR merge 到 `dev` 或 `main` 之後, 可能有多個 PR 合在一起, 合併後的狀態不一定通過測試。Deploy pipeline 的 test 是最後防線

### Build

Build Docker image, push 到 Artifact Registry, 取得 immutable digest:

```yaml
- name: Build and push
  run: |
    TAG="${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/pebble/pebble-api:sha-${GITHUB_SHA::7}"
    docker build -f api/Dockerfile -t "$TAG" api/
    docker push "$TAG"

- name: Resolve immutable digest
  run: |
    IMAGE=$(docker inspect --format='{{index .RepoDigests 0}}' "${BUILD_TAG}")
    echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
```

用 `sha-${GITHUB_SHA::7}` 當 tag 方便人看, 但後面 deploy 時用的是 immutable digest (`sha256:...`), 不是 tag。Tag 可以被覆蓋, digest 不行

### Migrate

用 Cloud SQL Proxy 連到 Cloud SQL, 跑 `alembic upgrade head`:

```yaml
- name: Run database migration
  run: |
    CONN=$(gcloud sql instances describe "${GCP_SQL_INSTANCE}" \
      --project="${GCP_PROJECT_ID}" --format='value(connectionName)')
    cloud-sql-proxy "${CONN}" --port 5432 &
    sleep 2
    SECRET_URL=$(gcloud secrets versions access latest \
      --secret="database-url-${ENV}" --project="${GCP_PROJECT_ID}")
    CI_URL=$(echo "${SECRET_URL}" \
      | sed 's|@/pebble?host=/cloudsql/.*|@127.0.0.1:5432/pebble|')
    DATABASE_URL="${CI_URL}" uv run --directory api alembic upgrade head
```

這裡有一個細節: runtime 的 `DATABASE_URL` 用 Unix socket path (`?host=/cloudsql/...`) 連到 Cloud SQL Auth Proxy sidecar, 但 CI 裡是透過 TCP (`127.0.0.1:5432`) 連到本地啟動的 Cloud SQL Proxy。所以要用 `sed` 把 URL 改掉

Migration 在 deploy 之前跑, 確保 schema 是最新的。如果 migration 失敗, deploy 不會執行

### Deploy

用 Terraform 更新 Cloud Run service:

```yaml
- name: Terraform plan
  run: |
    terraform plan -lock-timeout=5m \
      -var-file=terraform.tfvars \
      -var="api_image=${API_IMAGE}" \
      -out=tfplan

- name: Terraform apply
  run: terraform apply -lock-timeout=5m tfplan
```

Image digest 透過 `-var` 傳入 Terraform, Terraform 更新 Cloud Run revision。用 saved plan file (`-out=tfplan`), apply 時不會有意外

### Health Check

Deploy 之後等 Cloud Run 新 revision 起來, 打 `/health` 確認:

```yaml
- name: Health check
  run: |
    API_URL=$(terraform output -raw api_url)
    for i in $(seq 1 30); do
      if curl -sf "${API_URL}/health"; then
        echo "Health check passed"
        exit 0
      fi
      sleep 5
    done
    echo "::error::Health check failed after 150 seconds"
    exit 1
```

Prod 環境多一個 custom domain 的 health check, 但用 warning 而不是 error — 因為 DNS 和 certificate propagation 可能需要更多時間, 不應該因此把整個 deploy 標成失敗

### Dev 和 Prod 的差異

| 項目 | Dev | Prod |
|------|-----|------|
| Trigger | push to `dev` | push to `main` |
| `cancel-in-progress` | `true` | `false` |
| Branch validation | 只允許 `dev` | 只允許 `main` |
| Health check | Cloud Run URL only | Cloud Run URL + custom domain |
| Secret suffix | `database-url-dev` | `database-url-prod` |
| GitHub environment | `dev` | `prod` |

`cancel-in-progress: true` 在 dev 上是合理的 — 連續 push 時只需要跑最新的。Prod 用 `false`, 因為不想在 deploy 到一半的時候被 cancel

## 踩到的坑

### Platform TF 和 Service TF 的順序

Secret Manager resource 是 platform Terraform 建的, env var 注入是 service Terraform 設的。加 bypass secret 時, 我先跑了 service TF 的 deploy — 結果 Cloud Run 說 "Permission denied on secret", 因為 secret 還不存在

正確順序: **platform TF (建 secret + IAM) → 存 secret 值 → service TF (deploy)**

### Secret 建了但沒有 version

Platform TF `google_secret_manager_secret` 只建了 secret resource, 但不會存值。Secret Manager 需要至少一個 version 才能被 Cloud Run 讀取。所以 platform apply 之後, 還要手動存一個值:

```fish
echo -n "the-actual-secret" | gcloud secrets versions add dev-bypass-secret --data-file=-
```

這個步驟沒有被自動化, 因為 secret 的值不應該出現在任何 repo 裡

### Workflow 檔案不在 PR 裡

改了 `.github/workflows/deploy-dev.yml` 加 `TF_VAR_allow_dev_auth_bypass`, 但這個改動沒有包含在當時的 PR 裡。結果 merge 之後跑 infra workflow, TF variable 沒有值, deploy 失敗

教訓: workflow 檔案的改動跟它要服務的 code 改動要放在同一個 PR

## 經驗總結

1. **App code 不碰 GCP SDK** — 用 env injection, 不做 runtime secret fetch。domain/application 層跟雲平台完全解耦
2. **Runtime contract 用 test 守** — constant + pattern scan, CI 擋住任何違反
3. **Dev bypass 要有 secret gate** — 不能只靠 flag, 要加 constant-time 比較的 secret
4. **Pipeline 裡再跑一次測試** — PR CI 通過不代表 merge 後也通過
5. **Platform TF 先於 Service TF** — secret 和 IAM 要先存在, deploy 才能成功
6. **Workflow 改動要跟 code 一起送** — 不然 merge 後 workflow 拿不到新的設定

## References

- [Cloud Run — Using secrets](https://cloud.google.com/run/docs/configuring/services/secrets)
- [Cloud Run — Environment variables](https://cloud.google.com/run/docs/configuring/services/environment-variables)
- [Secret Manager — Quickstart](https://cloud.google.com/secret-manager/docs/quickstart)
- [Python hmac — Timing attack resistant comparison](https://docs.python.org/3/library/hmac.html#hmac.compare_digest)
- [GitHub Actions — Concurrency](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/control-the-concurrency-of-workflows-and-jobs)
- [Terraform — Saved plan files](https://developer.hashicorp.com/terraform/cli/commands/plan#out-filename)
- [Cloud SQL — Connect from Cloud Run](https://cloud.google.com/sql/docs/postgres/connect-run)
- [Artifact Registry — Container images](https://cloud.google.com/artifact-registry/docs/docker)
