# 用 Terraform 照著 production 重建一份主控台手動建立的 AWS 帳戶 — 唯讀規格、設定差異比對與 GitHub OIDC

> 以唯讀方式擷取 production 的設定，把由 AWS 主控台建立的 AWS 資料層寫成 Terraform 程式碼。接著用設定差異比對驗證，並透過 GitHub OIDC 執行部署。

- Source: https://oharu121.com/zh-tw/blog/terraform-rebuild-console-aws-account-prod-spec-parity-oidc/
- Published: 2026-10-11T10:38:54+09:00
- Tags: Terraform, AWS, GitHub Actions

---
## 引言

我有一個專案，其 production 環境的資料層，所有資源都是在 AWS 主控台手動建立的。其中包括 S3 bucket、Glue 資料表、一個 OpenSearch Serverless collection、兩個 Bedrock knowledge base、一個 Lambda，還有 AgentCore 的 gateway 與 code interpreter。這些資源都沒有留下任何定義，所以很難把其設定複製到其他環境。

後來新增了一個測試用帳戶，用來在部署到 production 環境前測試新加入的功能。這個測試環境裡面已經部署了主應用程式的 stack，資料層則是空的。我利用它把資料層寫成 Terraform 模組，模組的依據是**以唯讀方式從 production 擷取的設定**。接著在測試帳戶套用這個模組，再比對兩個帳戶，直到**沒有任何無法解釋的差異**為止。

現在，repo 是資料層唯一的定義來源，測試帳戶可以直接從 repo 重新建立。每個 pull request 都會由 GitHub Actions 透過 OIDC 執行 `terraform plan`，合併到 `main` 之後，變更就會套用到測試帳戶。

## 為什麼選 Terraform 而不是 CDK？

**決定的關鍵是匯入既有資源的方式**。專案的 repo 是用 Python 寫的，而 CDK 也能用 Python 撰寫，語言可以保持一致。但在匯入與影響範圍這兩點上，Terraform 都比較有優勢：

1. **匯入**。兩種工具都能把 Bedrock knowledge base 納入管理，差別在於由誰來寫定義。`terraform plan -generate-config-out` 會從實際運行中的資源寫出 HCL，如此一來，下次 `plan` 的時候就可以看出設定上發生了哪些變化。`cdk import` 則需要自己手寫出一個和實際資源完全相符的 construct，而且 `cdk import` 不會檢查這些屬性是否正確、完整。

*Figure — ImportPaths: 兩條路徑最後都會把 knowledge base 納入管理。Terraform 從實際運行中的資源寫出定義，下一次 plan 就會檢查；CDK 則要手寫 construct，而且沒有任何步驟會檢查它。*

> **話說**
>
> **HCL 與 `plan` 的比對方式**
>
> HCL（HashiCorp Configuration Language）是 Terraform 的設定語言，用來描述資源應有的樣子。Terraform 的流程是 `plan` → `apply`：`plan` 會讀取實際資源的現況，和 HCL 比對，列出要建立、變更或刪除的項目；`apply` 照著這份計畫執行，再把結果寫進 state。產生的 HCL 只要和實際資源有任何不同，下一次 `plan` 就會列出來。

2. **影響範圍**。納入既有資源的 CloudFormation stack 會把這些資源綁在一起，只要一次更新失敗，整個 stack 就會回滾。Terraform 則以資源為單位套用，成功的部分會保留下來，並記錄在 state 中。

*Figure — BlastRadius: 一次更新要變更 A、B、C，而 B 失敗了。CloudFormation 會撤銷整次更新；Terraform 則把 A 記錄在 state 中，只有依賴 B 的部分不會套用。*

除了語言之外，CDK 還有一個優勢：state。CloudFormation 把每個 stack 的狀態保存在服務內部，鎖定、歷史紀錄與回滾都由 AWS 處理。CDK 確實也會建立一個 S3 bucket，但那是從一開始執行 `cdk bootstrap` 建立 `CDKToolkit` stack 時就自動建立的，也不需要我們去管理。

Terraform 的 state 則是 S3 bucket 裡的一個檔案，但是這個 bucket 需要我們手動 bootstrap。問題在於，這個 bucket 本身也是由 Terraform 建立的，在它存在之前，state 沒有地方可以放。所以第一次 `apply` 先把 state 存在本機，等 bucket 建好之後，再用 `terraform init -migrate-state` 把本機的 state 搬進這個 bucket。

bootstrap 只需要做一次，但是之後需要我們自己來管理維護。state 檔案可能以明文保存資源的屬性，所以這個 bucket 需要和任何存放敏感資料的 bucket 一樣的存取控制與版本控制；state 和實際資源不一致時，也要用 `terraform state` 指令修正。

*Figure — StateOwnership: CDK 的狀態存放在 CloudFormation 裡，由 AWS 負責管理；Terraform 的 state 是需要手動 bootstrap 的 bucket 裡的一個檔案，之後也要一直自己管理。*

最後，測試帳戶只匯入了兩個資源，都是我先前手動加上的 security group 規則，所以匯入在這裡幾乎沒有發揮作用。更重要的是 OpenSearch index：讓 Terraform 管理它的每一種方式都有代價（後面的段落會說明），換成 CDK 也不會比較好。

測試帳戶的資料層原本是空的，所以 Terraform 幾乎都是直接新建資源。production 則不同：所有資源都已經在主控台建好，而且裡面有實際的資料，不能刪掉重建，只能一個一個匯入 Terraform。到時候，匯入既有資源的能力才會真正派上用場。

## 把 production 當作唯讀規格，用測試帳戶驗證

**Terraform 完全沒有去碰 production**。我用一支只呼叫 `List`、`Get` 和 `Describe` 等 AWS API 的指令碼，讀取一次 production 裡的 Glue 欄位定義、vector index 的 mapping，以及 AgentCore gateway 公開的工具 schema，並把讀到的內容存進模組旁邊的 `spec/` 目錄。接著資料層的 Terraform 模組會讀取這些檔案，所以它的設定都能對應到 production 實際存在的資源。

*Figure — ProdAsSpec: production 永遠只讀取。模組套用到測試帳戶，資料由另一支指令碼複製，快照差異比對則讀取兩個帳戶來比較。資料泳道與快照差異比對會在後面說明。*

Terraform 模組本身**不含任何帳戶 ID**。模組需要的外部資源都不會去寫死 ID，而是 Terraform 自己查找出來的：每個 AWS 帳戶裡的 landing zone 都會給 VPC 和 subnet 相同的 `Name` 標籤，讓 Terraform 可以用標籤來查找；應用程式 stack 的 security group 與 task role 則用名稱查找。因為這些名稱在每個帳戶裡都一樣，**之後要讓 production 也使用這個模組時，模組本身不必修改，只需要新增一個 tfvars 檔案**。

*Figure — ModuleReuse: 一個模組，兩個帳戶。模組以 Name 標籤查找網路，以名稱查找應用程式 stack 的資源，所以不同的只有 tfvars 檔案。*

另外，S3 bucket 的名稱在所有 AWS 帳戶之間必須是獨一無二的。production 有兩個存放文件的 bucket，名稱裡沒有帳戶 ID，所以測試帳戶無法建立同名的 bucket，比較好的做法是在 bucket 名稱後面加上帳戶 ID（例如 `kb-docs` → `kb-docs-222222222222`），或是加上環境名稱（例如 `kb-docs` → `kb-docs-stg`）。

搞定了 AWS 的資源之後，我們還需要複製 production 中儲存的資料，例如 S3 bucket 裡的檔案（包括 Athena 等資料），以及應用程式在 DynamoDB 裡的數據。我選擇不把這些資料放進 Terraform 的 state，而是用 seed 指令碼分兩個步驟複製：`export` 先把 production 的資料讀進本機快取，`import` 再把快取寫進測試帳戶裡相對應的位置。

knowledge base ID、gateway URL、code interpreter ID 和 bucket 名稱等值都必須更換，如果原封不動地複製，測試帳戶的應用程式就會去呼叫 production 的 knowledge base 和 gateway。所以 `import` 在寫入時，會把每一個 production 的值換成測試帳戶裡對應的值：bucket 名稱依照命名規則就能推得，knowledge base ID 這類由 AWS 產生的值，則需要等 Terraform 建好資源之後才能取得。

```python title="scripts/seed_test_env.py"
# 一次替換完成，並優先替換較長的字串。測試 bucket 的名稱以 production bucket
# 的名稱開頭（"docs" -> "docs-222222222222"），所以如果依序呼叫 str.replace，
# 會把已經改寫過的值再改寫一次。
pattern = re.compile("|".join(re.escape(k) for k in sorted(mapping, key=len, reverse=True)))
text = pattern.sub(lambda m: mapping[m.group(0)], json.dumps(item))
if leftover.search(text):
    raise SystemExit(f"prod value survived in {label}")
```

## 哪些由 Terraform 管理，哪些只查找

凡是由其他團隊建立的東西，Terraform 都是用 data source（只讀取、不管理的查詢）查找；在這基礎之上額外需要的東西，才會寫成 resource，由 Terraform 建立並管理。VPC 與 subnet 屬於 landing zone；ECS 服務、它的 security group 與 task role，則屬於應用程式團隊管理的 CloudFormation 範本。**Terraform 會讀取這些資源，但一個也不會建立**。

*Figure — OwnershipBoundary: Terraform 查找 landing zone 的網路與應用程式 stack，並管理資料層，以及過去手動加上的規則。*

比較 production 與測試帳戶裡新建的應用程式 stack 之後，找出了**我之前在 production 手動做過的變更**。這些變更都在由範本管理的資源上：

1. 我在應用程式的 security group 上加了一條允許 3000 連接埠的 inbound rule。這是因為範本原本的規則是「允許來自 load balancer 的 security group 的網路流量」，只允許直接從 load balancer 送來的封包。但實際上 public subnet 與 protected subnet 之間的流量，會先經過 landing zone 的 Gateway Load Balancer（GWLB）endpoint，送進 landing zone 提供者的 inspection VPC 檢查，因此抵達時就不再是來自那個 security group，範本的規則因此對不上。所以我之前手動加了這條規則：「允許來自 load balancer 所在 subnet 的 IP 範圍（CIDR）」，這樣不管封包走哪條路都能放行。

*Figure — InspectionPath: 經過 landing zone 的 inspection VPC 之後，封包就不再算是來自 load balancer 的 security group，所以只有 CIDR 規則還對得上。*

2. 一條允許 443 連接埠、來源是 security group 自己的 inbound rule，意思是「同一個 security group 裡的成員之間可以互相連線」。ECS task、Lambda 和 `bedrock-runtime` 的 VPC endpoint 都掛在這個 security group 上，所以有了這條規則，ECS task 和 Lambda 才能連到 endpoint。
3. task role 上的兩條 inline policy，一條允許存取 Athena 與 Glue，另一條允許讀取原始 PDF。

現在這三項都交給 Terraform 來管理，作為附加在範本物件上的獨立資源。如果應用程式的 stack 更新移除了其中一項，下一次 `apply` 就會自動把它加回來。endpoint 也要等 443 規則就位之後才建立：這個 endpoint 啟用了 private DNS，endpoint 一建立，VPC 內所有 `bedrock-runtime` 呼叫都會改走這條路徑，應用程式自己的呼叫也一樣。如果規則還沒加上，這些呼叫就會全部被擋下，所以我用了 `depends_on` 來確保順序：

```hcl title="terraform/modules/data-layer/network.tf"
resource "aws_vpc_endpoint" "bedrock_runtime" {
  vpc_id              = data.aws_vpc.lz.id
  service_name        = "com.amazonaws.${local.region}.bedrock-runtime"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = local.protected_subnet_ids
  security_group_ids  = [local.service_sg_id]
  private_dns_enabled = true

  depends_on = [aws_vpc_security_group_ingress_rule.self_https]
}
```

## 用 `terraform_data` 建立 OpenSearch Serverless index

vector index 由指令碼透過原生 API 建立，而且只在它還不存在時才建立，之後 Terraform 不會去更新或刪除它。Terraform 並不是完全沒辦法管理 index，但每一種方式都有代價：

1. Terraform 的 AWS provider（`hashicorp/aws`）沒有 index 的 resource。它的原始碼 `internal/service/opensearchserverless/` 裡有 collection、policy 和 VPC endpoint，但沒有 `index.go`。
2. `opensearch` provider 可以管理 index。但當 Bedrock 在 mapping 中加入動態欄位時，它的 plan 會要求替換 index（[#175](https://github.com/opensearch-project/terraform-provider-opensearch/issues/175)，在 v2.6.0 仍未修正），而替換 index 會刪掉裡面所有的向量。加上 `lifecycle { ignore_changes = [mappings] }` 和 `prevent_destroy` 可以避開這個風險，但避不開設定上的問題：這個 provider 的 `url` 要填 collection 的 endpoint，而 endpoint 要等 collection 建好之後才會存在。所以在全新的帳戶裡，第一次 apply 必須分成兩次（先用 `-target` 只建立 collection），而 CI 的自動 apply 只會執行一次。這個限制是 provider 的 issue 中回報的，這裡沒有實際重現。
3. CloudFormation 裡有 `AWS::OpenSearchServerless::Index`，所以 `aws_cloudcontrolapi_resource` 看起來可行。但 Cloud Control 雖然能用 `ListResources` 列出 production 的 index，用 `GetResource` 讀取任何一個 index 的設定時都會失敗。

```bash
aws cloudcontrol get-resource --type-name AWS::OpenSearchServerless::Index --identifier "docs-index|https://<collection-id>.ap-northeast-1.aoss.amazonaws.com"
# aws: [ERROR]: An error occurred (GeneralServiceException) when calling the GetResource operation: AWS::OpenSearchServerless::Index Handler returned status FAILED: Error occurred during operation 'GetIndex'.
```

Terraform 每次 `plan` 都會先讀取（refresh）資源的最新狀態，所以只要 `GetResource` 失敗，每一次 `plan` 都會跟著失敗。原生的 `create-index` 與 `get-index` 呼叫都能正常運作，所以我改用一支小指令碼，只做「不存在就建立」，其他什麼都不做：

```bash title="terraform/modules/data-layer/scripts/ensure_index.sh"
for i in $(seq 1 12); do
  if aws opensearchserverless get-index --id "$COLLECTION_ID" --index-name "$INDEX_NAME" >/dev/null 2>&1; then
    echo "index ${INDEX_NAME}: exists"; exit 0
  fi
  # 剛建立的 data access policy 可能需要一段時間才會就緒，所以要重試。
  if aws opensearchserverless create-index --id "$COLLECTION_ID" --index-name "$INDEX_NAME" \
      --index-schema "file://${SCHEMA_FILE}" >/dev/null 2>&1; then
    echo "index ${INDEX_NAME}: created"; exit 0
  fi
  sleep 15
done
exit 1
```

這支指令碼也讓專案少了一個 provider：不需要第三方 provider、不需要為它再設定一次 SigV4 認證，也不必追蹤它的版本。代價是 index 不會出現在任何 plan 裡：mapping 偏移了，Terraform 也完全看不到，只有本文後面的快照差異比對才抓得到，mapping 的變更也必須手動套用。

我建立了一個 `terraform_data` 資源來執行這支指令碼，並以 collection ID、index 名稱和 mapping 檔案的雜湊作為觸發條件。我還在 index 與 knowledge base 之間放了一個 60 秒的 `time_sleep`：index 剛建立時還在傳播，這時馬上建立 knowledge base 的話，可能會因為找不到 index 而失敗（「no such index」）。

我在 mapping 檔案裡只寫了 knowledge base 指定的三個欄位（vector、文字與 metadata 欄位），**其餘欄位由 Bedrock 在同步資料時動態加入**。

## 證明兩個帳戶一致：比對正規化後的設定，並確認檢索結果相同

我寫了一支快照指令碼，**它會以名稱查找 production 和測試帳戶裡的資源並比較其設定，只要有任何差異沒有列在預期差異清單（`EXPECTED_DIFFS`）裡，就會失敗**。從 knowledge base 到 gateway，每個資源都靠名稱找到並比較。

比對之前，指令碼會先把帳戶特有的值換成 placeholder，例如 `<ACCT>`、`<bucket:rag>`、`<kb:docs>`、`<subnet:LZPrivateSubnet1a>`。這樣一來，帳戶 ID 這類本來就不同的值，就不會被列成差異。而且我們需要先換成 placeholder 之後再把 JSON 裡的清單排序，這樣 production 和測試帳戶回傳的 JSON，兩邊的順序才會一致。

```python title="scripts/infra_snapshot.py"
EXPECTED_DIFFS = {
    "lambda.code_sha256": "test runs the repository handler; prod runs older console code",
    "lambda.handler": "same reason",
    "kb_data_sources.*.name": "prod names were generated by the console quick-create",
    "*.tags": "test carries default_tags (ManagedBy, Repository)",
}
```

第一次執行列出了三個差異，都是 Terraform 模組設定錯的地方：

1. S3 Bucket Keys 在 production 啟用，在測試帳戶卻停用。
2. 主控台在 production 會自動加到每個 knowledge base role 上的 marketplace statement，模組漏掉了。
3. gateway role 的 statement 範圍設定和 production 不同。

我修好這三個地方之後，**除了預期差異清單裡的項目，其他差異都消失了**。index 的 mapping 也一致，包括 Bedrock 在同步資料時加入的欄位。

但是即使設定一致，也不代表行為會一致，所以我分別在兩個帳戶執行了同樣的檢索測試：對兩個 knowledge base 各送出五個查詢，取前五筆結果。**這十個查詢全都以相同順序回傳了相同的文件**，而測試帳戶中的每個來源連結都指向測試帳戶的 bucket。我也用 Athena 查詢了五張資料表的列數，兩邊的結果完全一致。

## GitHub OIDC：PR 時執行 plan，併入 main 時執行 apply

我設定了一個 GitHub Actions workflow：每個修改到 `terraform/` 的 PR 都會執行 plan，並且在該 PR 底下張貼執行結果，等到 PR 合併到 main 之後再執行 apply 到測試帳戶。AWS 的憑證來自 GitHub 的 OIDC token，所以 repo 裡沒有存放任何金鑰。

### 兩個 role，兩種 subject

GitHub 發出的 OIDC token 裡有一個 subject（`sub` 欄位），記錄這個 token 來自哪個 repo、由哪個事件觸發，AWS 的 trust policy 就是比對這個值來決定誰能 assume 哪個 role。所以我準備了兩個 role，各自只信任一種 subject：

1. **plan role**：plan job 會執行 PR 裡的程式碼，所以 plan role 只信任 `pull_request` subject，而且是唯讀的。
2. **apply role**：apply job 在 GitHub 的 environment `test` 中執行，所以 apply role 只信任 `environment:test` subject，擁有管理員權限。

*Figure — OidcRoles: plan role 信任 PR，而且讀不到資料。apply role 只信任 test environment，而這個 environment 只從 main 部署。*

我把兩個 job 放在不同的 concurrency group。GitHub 每個 group 只保留一個等待中的 run，所以**如果共用同一個 group，較新 PR 的 plan 會取代正在等待的 apply**。Terraform 在 plan 或 apply 開始前，會先在 S3 的 state 檔旁邊建立一個 `.tflock` 鎖定檔，因此兩個 job 不會同時操作 state。

### 修正 `sub` 的前綴

第一個 PR 在 Terraform 啟動之前就失敗了：

```text
Retry AssumeRole: attempt 1 of 12 failed: Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity.
```

trust policy 預期的是 `repo:<owner>/<repo>:pull_request`。**但這個 repo 送出的是 `repo:<owner>@<owner-id>/<repo>@<repo-id>:pull_request`**，裡面嵌入了擁有者與 repo 的數字 ID。trust policy 是逐字比對 subject 的，因此被 AWS 拒絕了。GitHub 實際使用的前綴，可以透過這個 API 確認：

```bash
gh api repos/<owner>/<repo>/actions/oidc/customization/sub
# {"use_default":true,"use_immutable_subject":true,"sub_claim_prefix":"repo:<owner>@<owner-id>/<repo>@<repo-id>"}
```

> **話說**
>
> **為什麼 GitHub 要把 subject 改成不可變格式？**
>
> 如果原本的 repo 刪除了、或 organization 改名之後，有人重新註冊了同一個名稱，GitHub 發給新 repo 的 token 就會帶著和舊 repo 完全相同的 subject，新的擁有者等於繼承了原本的信任，能夠存取那些仍然信任舊 repo 的雲端資源。新格式在名稱後面加上不可變的 ID（例如 `repo:octo-org@123/octo-repo@456`），讓 subject 永遠綁定原本的 repo，因此更加安全。

我把兩份 trust policy 都改成比對這個前綴：

```hcl title="terraform/bootstrap/test/ci.tf" del={2} ins={3}
locals {
  github_subject = "repo:<owner>/<repo>"
  github_subject = "repo:<owner>@<owner-id>/<repo>@<repo-id>"
}
# plan:  "${local.github_subject}:pull_request"
# apply: "${local.github_subject}:environment:test"
```

### 在 ReadOnlyAccess 上補上 Bedrock 讀取權限，並拒絕讀取資料

我修好 trust policy 之後，plan 往前推進了一些，但接著又出現了以下錯誤：

```text
Error: listing tags for Bedrock Agents Knowledge Base (arn:aws:bedrock:ap-northeast-1:<account>:knowledge-base/<kb-id>)
AccessDeniedException: User: arn:aws:sts::<account>:assumed-role/github-terraform-plan/GitHubActions is not authorized to perform: bedrock:ListTagsForResource
```

`ReadOnlyAccess` 不包含 `bedrock:ListTagsForResource`，所以除了原本就有的 `bedrock-agentcore` 與 `aoss` 讀取權限，我又替 plan role 加上了 `bedrock:Get*` 和 `bedrock:List*`。接著我用 `gh run rerun <run-id> --failed` 重新執行了 plan，這樣不需要新的 commit 也能以更新後的 role 重跑。

另一方面，`ReadOnlyAccess` 能讀的東西也遠超過 plan 所需要的範圍，包括 S3 object、DynamoDB item 和 CloudWatch log event。測試帳戶裡有一份 production 資料的副本與應用程式的聊天紀錄，而 plan role 會執行 PR 的程式碼，也就是說，能開 PR 的人都能透過 plan 讀到這些資料。所以我**加了一條規則拒絕了 state bucket 以外的所有資料讀取**：

```hcl title="terraform/bootstrap/test/ci.tf"
statement {
  sid    = "DenyDataReads"
  effect = "Deny"
  actions = [
    "s3:GetObject", "s3:GetObjectVersion",
    "dynamodb:GetItem", "dynamodb:BatchGetItem", "dynamodb:Query", "dynamodb:Scan",
    "logs:GetLogEvents", "logs:FilterLogEvents", "logs:StartQuery",
    "athena:GetQueryResults", "bedrock:Retrieve", "bedrock:RetrieveAndGenerate",
  ]
  not_resources = ["${aws_s3_bucket.state.arn}/*"]
}
```

> **小心喔**
>
> **`simulate-principal-policy` 全部回報拒絕**
>
> 剛開始我用 `simulate-principal-policy` 來測試新增加的 Deny：給它一個 role，再列出要檢查的 action，它會逐一回報是否允許。但它會把 landing zone 的 service control policy 一起算進去，一律回傳 `AllowedByOrganizations: false`。所以我改用 `simulate-custom-policy`，只傳入 role 自己的 policy，再加上一份允許全部的 policy，排除 service control policy 的影響，單獨測試這條 Deny。

### 用 environment 的 deployment branch policy 把 apply 限定在 main

**AWS 的 trust policy 無法分辨 environment job 來自哪個分支**，environment job 的 subject（`sub`）只會列出 environment 名稱，例如 `…:environment:test`，但不會寫出分支。如果 GitHub 這一側沒有分支規則，從任何分支觸發的 `workflow_dispatch`，或是在 job 中加上 `environment: test` 的 pull request，都能 assume 管理員 role。

如果 workflow 執行時設定的 environment 尚未存在，該 environment 就會自動建立，而且預設沒有分支保護。所以我在 workflow 第一次執行之前，先建立了 `test` environment，並限制該 environment 只能從 `main` 部署：

```bash
gh api -X PUT repos/<owner>/<repo>/environments/test --input - <<'JSON'
{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}
JSON
gh api -X POST repos/<owner>/<repo>/environments/test/deployment-branch-policies -f name=main -f type=branch
```

> **讚啦**
>
> PR 合併之後，從 `main` 執行的 apply 成功 assume 了 role，並回報 `Apply complete! Resources: 0 added, 0 changed, 0 destroyed.`。Lambda 的套件是在 runner 上重新建置的，如果它的雜湊和本機建置的不同，Terraform 就會更新 Lambda；結果是 0 changed，也就證實了兩邊建置出的套件完全一致。

## AgentCore 的 network interface 會占住 security group 長達 8 小時

最後，我試著把測試帳戶從零重建，但 `terraform destroy` 在 45 分鐘的刪除逾時之後，停在 code interpreter 的 security group 上：

```text
Error: deleting Security Group (sg-<id>): operation error EC2: DeleteSecurityGroup, https response error StatusCode: 400, api error DependencyViolation: resource sg-<id> has a dependent object
```

code interpreter 本身已經刪除了，但還有兩個類型為 `agentic_ai` 的 network interface 附加在 security group 上。這兩個 network interface 是由 AgentCore 的服務帳戶（由 AWS 管理的帳戶）請求建立的，不屬於這個測試帳戶。AWS 的說明文件寫明，這些 network interface 在刪除後**可能會保留長達 8 小時**，而且不允許 detach。

但其實這並不影響重建：其他資源都已經刪除，只剩下該 security group 還留在 state 中，所以我直接執行 `terraform apply`，**它沿用了這個 security group，重建了帳戶的其餘部分**。我重新執行 seed 指令碼之後，快照差異比對、檢索測試與 Athena 查詢到的列數都和先前一樣。

## 總結

把 production 的設定匯出到 `spec/` 目錄，讓 Terraform 模組有了可以對照檢查的依據；我選擇 Terraform 而不是 CDK，是因為它匯入既有資源時會自己寫出設定。vector index 則交給一支呼叫原生 API 的指令碼負責；`opensearch` provider 在全新的帳戶裡得等 collection 建好才能設定，CI 的一次 apply 沒辦法設定。

CI 這一側遇到三個問題，都是手動建立的 role 永遠不會遇到的：不可變格式的 subject、`ReadOnlyAccess` 缺少的 Bedrock 讀取權限與它不該有的資料讀取，以及 apply role 所信任的 environment 需要的 deployment branch policy。

## 參考連結

- [Terraform：import 區塊與產生設定](https://developer.hashicorp.com/terraform/language/import)
- [Terraform S3 backend，包含 use_lockfile](https://developer.hashicorp.com/terraform/language/backend/s3)
- [CloudFormation：支援 resource import 的 resource type](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/resource-import-supported-resources.html)
- [AWS CDK v2：cdk import](https://docs.aws.amazon.com/cdk/v2/guide/ref-cli-cmd-import.html)
- [GitHub changelog：GitHub Actions OIDC token 改用不可變格式的 subject](https://github.blog/changelog/2026-04-23-immutable-subject-claims-for-github-actions-oidc-tokens/)
- [GitHub Docs：OpenID Connect 參考](https://docs.github.com/actions/reference/openid-connect-reference)
- [Amazon Bedrock AgentCore：刪除 code interpreter，以及 network interface 最長 8 小時的保留](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/code-interpreter-delete.html)
- [terraform-provider-opensearch #175：動態屬性造成 index 替換](https://github.com/opensearch-project/terraform-provider-opensearch/issues/175)
