@exabugs/dynamodb-client 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.21.0] - 2026-10-24
11
+
12
+ ### Added
13
+
14
+ - **Terraform モジュール `terraform/ops-notify`・`terraform/alerts-cleanup`・`terraform/system-change-recorder`**(ADR 0006 の段階 6 = (b)): asanowa・astro に複製されていた運用の Lambda の Terraform(通知、alerts の定時削除、システム変更履歴の記録)を 1 つにした。`node_modules` のパスで `source` を指定して使う(`terraform/media` と同じ流儀)。**Lambda の成果物(エントリ)はアプリが作り、`source_file` と `handler` で渡す**(ライブラリの dist は使わない。`exports` への追加もない)。リソースのアドレスと AWS 上の名前は両アプリの旧モジュールと同じなので、module ブロックの名前を変えずに `source` だけを差し替えれば、`moved` なしで置き換えが起きない(astro dev の実機の plan で確認: 0 to add / 0 to destroy、in-place の 4 件だけ)。文言は asanowa を既定にした。ops-notify は asanowa の us-east-1 のアラームの転送(`forward_us_east_1_alarms`、既定 false)も持つので、**呼び出し側は `providers = { aws.us_east_1 = aws.us_east_1 }` を常に渡す**。provider は `hashicorp/aws >= 5.30, < 7.0`。使い方は各ディレクトリの README
15
+ - DynamoDB の直接アクセスの許可リストに、`terraform/ops-notify` の通知 Lambda のロール(別テーブル `notices` への `UpdateItem`。`src/notify/store.ts` と同じ理由の permanent)を加えた
16
+
17
+ ### Fixed
18
+
19
+ - `npm pack` が `terraform/` の中の Terraform の作業ファイル(`.terraform/`〔プロバイダのバイナリ〕、`.terraform.lock.hcl`、state、plan)を拾わないようにした(`terraform/.npmignore`。`files` の `terraform` の中で、`terraform init` などをしたときに入っていた)
20
+
10
21
  ## [1.20.0] - 2026-10-24
11
22
 
12
23
  ### Added
@@ -1,5 +1,5 @@
1
- // @exabugs/dynamodb-client v1.20.0
2
- // Built: 2026-10-24T11:55:31.940Z
1
+ // @exabugs/dynamodb-client v1.21.0
2
+ // Built: 2026-10-24T14:04:54.676Z
3
3
  "use strict";
4
4
  var __create = Object.create;
5
5
  var __defProp = Object.defineProperty;
@@ -38980,7 +38980,7 @@ async function handler(event) {
38980
38980
  return createCorsResponse(HTTP_STATUS.OK);
38981
38981
  }
38982
38982
  if (event.requestContext.http.method === "GET" && event.requestContext.http.path === "/version") {
38983
- const version = "1.20.0";
38983
+ const version = "1.21.0";
38984
38984
  return createSuccessResponse({ version, timestamp: (/* @__PURE__ */ new Date()).toISOString() }, requestId);
38985
38985
  }
38986
38986
  if (event.requestContext.http.method !== "POST") {
@@ -1,5 +1,5 @@
1
- // @exabugs/dynamodb-client v1.20.0
2
- // Built: 2026-10-24T11:55:31.940Z
1
+ // @exabugs/dynamodb-client v1.21.0
2
+ // Built: 2026-10-24T14:04:54.676Z
3
3
  "use strict";
4
4
  var __create = Object.create;
5
5
  var __defProp = Object.defineProperty;
@@ -1,5 +1,5 @@
1
- // @exabugs/dynamodb-client v1.20.0
2
- // Built: 2026-10-24T11:55:31.940Z
1
+ // @exabugs/dynamodb-client v1.21.0
2
+ // Built: 2026-10-24T14:04:54.676Z
3
3
  "use strict";
4
4
  var __create = Object.create;
5
5
  var __defProp = Object.defineProperty;
@@ -1,5 +1,5 @@
1
- // @exabugs/dynamodb-client v1.20.0
2
- // Built: 2026-10-24T11:55:31.940Z
1
+ // @exabugs/dynamodb-client v1.21.0
2
+ // Built: 2026-10-24T14:04:54.676Z
3
3
  "use strict";
4
4
  var __create = Object.create;
5
5
  var __defProp = Object.defineProperty;
@@ -138,7 +138,7 @@ ESLint は、エディタでも違反をその場で示す。CLI は ESLint の
138
138
 
139
139
  ## 許可リストの種別と約束
140
140
 
141
- - **恒久(permanent)**: 構造上必要な例外。このライブラリでは、Records の本体、Records が使う共有部品、別テーブル `notices`(`src/notify/store.ts`)。`notices` は例外ではなく原則の**対象外**(構造が違う別テーブルで、通知の重複抑制・流量制限の内部用。ビジネスのデータではない)。検査が IAM をテーブルごとに区別できないため permanent として載せる(ADR 0005)
141
+ - **恒久(permanent)**: 構造上必要な例外。このライブラリでは、Records の本体、Records が使う共有部品、別テーブル `notices`(`src/notify/store.ts` と、通知 Lambda のロール `terraform/ops-notify` の `UpdateItem`)。`notices` は例外ではなく原則の**対象外**(構造が違う別テーブルで、通知の重複抑制・流量制限の内部用。ビジネスのデータではない)。検査が IAM をテーブルごとに区別できないため permanent として載せる(ADR 0005)
142
142
  - **負債(debt)**: 期限つき。消す作業が決まっている
143
143
 
144
144
  運用の約束:
package/docs/NOTIFY.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 運用通知(`@exabugs/dynamodb-client/notify`)
2
2
 
3
- ログ(`warn` 以上)・CloudWatch アラーム・直接呼び出しを、管理者へ通知するための**部品**。配線(購読フィルタ・Lambda・Terraform・秘密の置き場)は、各アプリが行う。既存の挙動は変えない(サブパス `./notify` の追加のみ)。
3
+ ログ(`warn` 以上)・CloudWatch アラーム・直接呼び出しを、管理者へ通知するための**部品**。配線のうち、Lambda のエントリと秘密の置き場は各アプリが行う。購読フィルタ・Lambda・テーブルの Terraform は `terraform/ops-notify`(1.21.0)。既存の挙動は変えない(サブパス `./notify` の追加のみ)。
4
4
 
5
5
  ```text
6
6
  ログ(購読フィルタ) ─┐
@@ -177,7 +177,7 @@ export const handler = createNotifyHandler({
177
177
  - 環境変数: 既存の `NOTICE_TABLE` / `RECORDS_IAM_URL` に加えて、`ADMIN_BASE_URL`(任意。無ければ URL を載せない)
178
178
  - IAM: 通知ストアへは `dynamodb:UpdateItem` だけ(既存の権限のまま。`GetItem` は要らない)。Records の IAM URL の呼び出し(`lambda:InvokeFunctionUrl`)は、管理者の一覧の取得で既に持っている
179
179
  - 同時実行数: 同じレコードへの同時の書き込みはシャドーを孤立させうるので、**記録を有効にするときだけ**、通知 Lambda の予約同時実行数を 1 にする(今の通知 Lambda は未設定。入れると、記録なしでも遅れ方が変わる)。直列化は努力目標で、強制はされない(打ち切った HTTP は止まらない・削除の定時処理と重なる・管理画面から書ける)。孤立したシャドーは `src/scripts/repair-shadows.ts` で修復できる
180
- - ライブラリは Terraform を提供しない(`alerts` の定時削除・同時実行数・環境変数は各アプリの infra で)
180
+ - Terraform は `terraform/ops-notify`・`terraform/alerts-cleanup`(1.21.0。[README](../terraform/ops-notify/README.md))。Lambda の成果物(エントリ)と、秘密の置き場は各アプリ
181
181
 
182
182
  ### 削除(定時 Lambda)
183
183
 
@@ -183,4 +183,4 @@ ADR 0006 の段階 2 の手順: astro で先に置き換えて dev で確認し
183
183
  - Slack の文言は asanowa に揃えた。astro の「記録を省いた Lambda」は「ほかに記録しきれなかった Lambda」になる
184
184
  - Slack の送信は `postSlackText` を通る: webhook の URL が `https://hooks.slack.com/` で始まらないと送らない(`slack.urlPrefix`)。失敗の warn の理由は従来と同じ `HTTP 500` やエラー名(時間切れは `AbortError` ではなく `TimeoutError`)
185
185
  - asanowa の `changeId` の読み取りは `findOne`(404 を null にする)から `find({ id }, { sort: { id: 'asc' }, limit: 1 })`(Records の ID の経路。PK/SK の Query 1 回) に変わる。Records の ITEM_NOT_FOUND の warn が出なくなる
186
- - 記録 Lambda の Terraform(`source_file`)は、アプリのバンドルのまま(Terraform のモジュール化は段階 6)
186
+ - 記録 Lambda の Terraform は `terraform/system-change-recorder`(1.21.0。段階 6。[README](../terraform/system-change-recorder/README.md))。成果物はアプリのバンドルのまま(`source_file` で渡す)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exabugs/dynamodb-client",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "DynamoDB Single-Table Client SDK with MongoDB-like API, Shadow Records, and Lambda implementation for serverless applications",
5
5
  "author": "exabugs",
6
6
  "license": "MIT",
@@ -219,6 +219,15 @@ make logs-records ENV=dev
219
219
  aws logs tail /aws/lambda/my-project-dev-records --follow
220
220
  ```
221
221
 
222
+ ## 運用のモジュール(1.21.0)
223
+
224
+ Records のクライアントとして動く運用の Lambda を、別のモジュールとして持つ(ADR 0006 の (b))。Lambda の成果物(エントリ)はアプリが作り、`source_file` で渡す。
225
+
226
+ - [ops-notify](ops-notify/README.md): 運用通知(ログ・アラーム・生存確認 → Slack とメール。alerts の記録)
227
+ - [alerts-cleanup](alerts-cleanup/README.md): alerts の定時削除
228
+ - [system-change-recorder](system-change-recorder/README.md): システム変更履歴の記録
229
+ - [media](media/README.md): メディア(アップロード・処理・配信)
230
+
222
231
  ## 関連ドキュメント
223
232
 
224
233
  - [パッケージREADME](../README.md) - クライアントライブラリの使用方法
@@ -0,0 +1,65 @@
1
+ # alerts の定時削除 Lambda Terraformモジュール
2
+
3
+ ADR 0006 の (b)(1.21.0)。asanowa・astro の `infra/modules/alerts-cleanup` を 1 つにしたもの。使い方の詳細は [docs/NOTIFY.md](../../docs/NOTIFY.md) の「削除(定時 Lambda)」。
4
+
5
+ ## 作るもの
6
+
7
+ - Lambda `<project>-<env>-alerts-cleanup`(Node.js 22、arm64、120 秒、256 MB。構造化ログ JSON)と、そのロググループ・実行ロール
8
+ - EventBridge の定時ルール(既定 毎日 03:00 JST)とターゲット(再試行 0)、呼び出しの許可
9
+ - 権限: Records の IAM 版 URL の呼び出しだけ。DynamoDB には直接触れない
10
+ - 環境変数: `PROJECT_NAME`(`createAlertsCleanupHandler` の `app` の既定)、`RECORDS_IAM_URL`
11
+
12
+ ## 前提
13
+
14
+ - 参照は `node_modules` のパス(版は npm の版で固定される。git の `ref` ではない)。例: アプリの infra が `infra/` なら `source = "../node_modules/@exabugs/dynamodb-client/terraform/alerts-cleanup"`、`infra/apps/<app>/` なら `"../../../node_modules/..."`
15
+ - **Lambda の成果物はアプリが作り、`source_file`(バンドル済みの 1 ファイル)と `handler` で渡す**。エントリはアプリの数行(`createAlertsCleanupHandler`。アプリ名や、権限チェック層を通すアプリの包み方を渡す)なので、ライブラリの dist は使わない
16
+ - zip は `${path.root}/.terraform/archives/alerts-cleanup.zip` に作る(`path.module` を使わない。pnpm の版つきのパスが state に入ると、版を上げるたびに差分が出るため)
17
+ - pnpm では、版を上げた後に `.terraform/modules` を消してから `terraform init` する([../README.md](../README.md) の注意)
18
+ - モジュール全体の `depends_on` は使わない(依存先に変更があると、中のデータソースが apply 時の読み取りになり、既存のリソースが作り直しになる)。依存は、出力の値(`log_group_name` など)で渡す
19
+
20
+ ## 使い方
21
+
22
+ ```hcl
23
+ module "alerts_cleanup" {
24
+ source = "../node_modules/@exabugs/dynamodb-client/terraform/alerts-cleanup"
25
+
26
+ project_name = var.project_name
27
+ environment = var.environment
28
+ records_iam_function_url = module.records.iam_function_url
29
+ records_iam_alias_arn = module.records.iam_alias_arn
30
+ log_retention_days = var.log_retention_days
31
+
32
+ source_file = "${path.root}/../apps/server/dist/alerts-cleanup.cjs"
33
+ handler = "alerts-cleanup.handler"
34
+ }
35
+ ```
36
+
37
+ ## 変数
38
+
39
+ | 変数 | 既定 | 説明 |
40
+ | ---------------------------------------------------- | -------------------- | ------------------------------------------------------- |
41
+ | `project_name` / `environment` | 必須 | 名前の接頭辞 `<project>-<env>`。環境変数 `PROJECT_NAME` |
42
+ | `records_iam_function_url` / `records_iam_alias_arn` | 必須 | Records の IAM 版 Function URL と、そのエイリアスの ARN |
43
+ | `source_file` / `handler` | 必須 | アプリのバンドル(1 ファイル)と、ハンドラ |
44
+ | `schedule_expression` | `cron(0 18 * * ? *)` | 実行間隔(毎日 03:00 JST) |
45
+ | `log_retention_days` | 7 | ロググループの保持日数 |
46
+
47
+ ## 出力
48
+
49
+ `function_name`、`function_arn`、`log_group_name`(ops-notify に購読させる)
50
+
51
+ ## アプリに残るもの
52
+
53
+ - 上の module ブロック(`source`・`source_file`・`handler` と設定の値)
54
+ - Lambda のエントリ(`createAlertsCleanupHandler` を呼ぶ数行)とバンドルの作成(esbuild など)
55
+ - 秘密(webhook の URL など)の SSM パラメータの作成
56
+
57
+ ## アプリのモジュールからの移行(置き換えを起こさない)
58
+
59
+ リソースのアドレス・名前は、asanowa / astro の旧モジュール(`infra/modules/alerts-cleanup`)と同じ。**module ブロックの名前(`module "alerts_cleanup"`)を変えずに `source` だけを差し替え**、`source_file`・`handler` を足す。アドレスが変わらないので `moved` は要らない(ラッパーのモジュールで包むと、アドレスが変わり `moved` が要る)。
60
+
61
+ 1. `terraform init`(モジュールの取り直し)→ `terraform plan`
62
+ 2. **0 to add / 0 to destroy、`must be replaced` が無い**ことを確かめる。出てよいのは in-place の更新だけ(文言の差。astro ではルールの description が英語の文言に、Lambda に `PROJECT_NAME` が加わる)
63
+ 3. 確認のうえ apply。旧モジュールのディレクトリを消す
64
+
65
+ 詳細と実機の確認は [ADR 0006](../../docs/adr/0006-library-owns-table-operations.md) の段階 6。
@@ -0,0 +1,111 @@
1
+ # 運用通知の記録(alerts)の定時削除(ADR 0003・ADR 0006 の (b)。docs/NOTIFY.md の「削除(定時 Lambda)」)
2
+ # 最後の発生から 28 日を過ぎた alerts を、定時に消す(ネイティブ TTL は効かない)。
3
+ # Lambda の成果物はアプリが作り、source_file で渡す(実装は `createAlertsCleanupHandler`)。
4
+ #
5
+ # 失敗は構造化ログ(error)に出す。このロググループは、通知(../ops-notify)に購読させる。
6
+ # リソースのアドレス・名前は、asanowa / astro の旧モジュール(infra/modules/alerts-cleanup)と同じ。
7
+
8
+ locals {
9
+ prefix = "${var.project_name}-${var.environment}"
10
+ }
11
+
12
+ data "archive_file" "cleanup" {
13
+ type = "zip"
14
+ source_file = var.source_file
15
+ output_path = "${path.root}/.terraform/archives/alerts-cleanup.zip"
16
+ }
17
+
18
+ resource "aws_cloudwatch_log_group" "cleanup" {
19
+ name = "/aws/lambda/${local.prefix}-alerts-cleanup"
20
+ retention_in_days = var.log_retention_days
21
+ }
22
+
23
+ resource "aws_iam_role" "cleanup" {
24
+ name = "${local.prefix}-alerts-cleanup-role"
25
+
26
+ assume_role_policy = jsonencode({
27
+ Version = "2012-10-17"
28
+ Statement = [{
29
+ Action = "sts:AssumeRole"
30
+ Effect = "Allow"
31
+ Principal = { Service = "lambda.amazonaws.com" }
32
+ }]
33
+ })
34
+ }
35
+
36
+ resource "aws_iam_role_policy_attachment" "cleanup_logs" {
37
+ role = aws_iam_role.cleanup.name
38
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
39
+ }
40
+
41
+ # Records Lambda の IAM 認証版 URL(エイリアス)を呼ぶ権限だけ(最小権限)。DynamoDB には直接触れない
42
+ resource "aws_iam_role_policy" "cleanup_invoke_records" {
43
+ name = "${local.prefix}-alerts-cleanup-invoke-records"
44
+ role = aws_iam_role.cleanup.id
45
+
46
+ policy = jsonencode({
47
+ Version = "2012-10-17"
48
+ Statement = [{
49
+ Effect = "Allow"
50
+ Action = ["lambda:InvokeFunctionUrl"]
51
+ Resource = [var.records_iam_alias_arn]
52
+ }]
53
+ })
54
+ }
55
+
56
+ resource "aws_lambda_function" "cleanup" {
57
+ function_name = "${local.prefix}-alerts-cleanup"
58
+ role = aws_iam_role.cleanup.arn
59
+ filename = data.archive_file.cleanup.output_path
60
+ source_code_hash = data.archive_file.cleanup.output_base64sha256
61
+ handler = var.handler
62
+ runtime = "nodejs22.x"
63
+ architectures = ["arm64"]
64
+ timeout = 120
65
+ memory_size = 256
66
+
67
+ environment {
68
+ # PROJECT_NAME は createAlertsCleanupHandler の app の既定(app を渡すアプリは読まない)
69
+ variables = {
70
+ PROJECT_NAME = var.project_name
71
+ RECORDS_IAM_URL = var.records_iam_function_url
72
+ }
73
+ }
74
+
75
+ logging_config {
76
+ log_format = "JSON"
77
+ log_group = aws_cloudwatch_log_group.cleanup.name
78
+ }
79
+
80
+ depends_on = [aws_iam_role_policy_attachment.cleanup_logs]
81
+ }
82
+
83
+ # 再試行は 0(翌日の定時実行で残りを消す。失敗はログ → 通知で知る)
84
+ resource "aws_lambda_function_event_invoke_config" "cleanup" {
85
+ function_name = aws_lambda_function.cleanup.function_name
86
+ maximum_retry_attempts = 0
87
+ }
88
+
89
+ resource "aws_cloudwatch_event_rule" "cleanup" {
90
+ name = "${local.prefix}-alerts-cleanup"
91
+ description = "Delete ops alerts records older than 28 days (daily 03:00 JST)"
92
+ schedule_expression = var.schedule_expression
93
+ }
94
+
95
+ resource "aws_cloudwatch_event_target" "cleanup" {
96
+ rule = aws_cloudwatch_event_rule.cleanup.name
97
+ arn = aws_lambda_function.cleanup.arn
98
+
99
+ retry_policy {
100
+ maximum_retry_attempts = 0
101
+ maximum_event_age_in_seconds = 3600
102
+ }
103
+ }
104
+
105
+ resource "aws_lambda_permission" "cleanup_events" {
106
+ statement_id = "AllowEventBridgeInvoke"
107
+ action = "lambda:InvokeFunction"
108
+ function_name = aws_lambda_function.cleanup.function_name
109
+ principal = "events.amazonaws.com"
110
+ source_arn = aws_cloudwatch_event_rule.cleanup.arn
111
+ }
@@ -0,0 +1,14 @@
1
+ output "function_name" {
2
+ description = "alerts の定時削除 Lambda の関数名"
3
+ value = aws_lambda_function.cleanup.function_name
4
+ }
5
+
6
+ output "log_group_name" {
7
+ description = "alerts の定時削除 Lambda のロググループ名(通知 Lambda が購読する)"
8
+ value = aws_cloudwatch_log_group.cleanup.name
9
+ }
10
+
11
+ output "function_arn" {
12
+ description = "alerts の定時削除 Lambda の ARN"
13
+ value = aws_lambda_function.cleanup.arn
14
+ }
@@ -0,0 +1,42 @@
1
+ variable "project_name" {
2
+ description = "プロジェクト名"
3
+ type = string
4
+ }
5
+
6
+ variable "environment" {
7
+ description = "環境識別子(dev, prd)"
8
+ type = string
9
+ }
10
+
11
+ variable "records_iam_function_url" {
12
+ description = "Records Lambda の AWS_IAM 認証版 Function URL(alerts を読み、消す)"
13
+ type = string
14
+ }
15
+
16
+ variable "records_iam_alias_arn" {
17
+ description = "Records Lambda の IAM 認証用エイリアスの ARN(lambda:InvokeFunctionUrl の Resource)"
18
+ type = string
19
+ }
20
+
21
+ variable "schedule_expression" {
22
+ description = "実行間隔(既定: 毎日 03:00 JST = 18:00 UTC)"
23
+ type = string
24
+ default = "cron(0 18 * * ? *)"
25
+ }
26
+
27
+ variable "log_retention_days" {
28
+ description = "CloudWatch Logsの保持期間(日数)"
29
+ type = number
30
+ default = 7
31
+ }
32
+
33
+ variable "source_file" {
34
+ description = "Lambda の成果物(バンドル済みの 1 ファイル。例: apps/server/dist/alerts-cleanup.cjs)。zip にして使う"
35
+ type = string
36
+ }
37
+
38
+ variable "handler" {
39
+ description = "Lambda のハンドラ(例: alerts-cleanup.handler)"
40
+ type = string
41
+ }
42
+
@@ -0,0 +1,16 @@
1
+ # provider の版: astro(5.x)と asanowa(6.x)の両方で使う。
2
+ # 下限は Lambda の logging_config(5.x の途中で追加)。上限は data.aws_region の name(6.x で非推奨)が消える 7.x の前まで
3
+ terraform {
4
+ required_version = ">= 1.5"
5
+
6
+ required_providers {
7
+ aws = {
8
+ source = "hashicorp/aws"
9
+ version = ">= 5.30, < 7.0"
10
+ }
11
+ archive = {
12
+ source = "hashicorp/archive"
13
+ version = ">= 2.0"
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,109 @@
1
+ # 運用通知 Terraformモジュール
2
+
3
+ ADR 0006 の (b)(1.21.0)。asanowa・astro の `infra/modules/ops-notify` を 1 つにしたもの。使い方の詳細は [docs/NOTIFY.md](../../docs/NOTIFY.md)。
4
+
5
+ ## 作るもの
6
+
7
+ - 通知 Lambda `<project>-<env>-notify`(Node.js 22、arm64、60 秒、256 MB。構造化ログ JSON)と、そのロググループ・実行ロール
8
+ - 重複抑制・流量制限の記録のテーブル `<project>-<env>-notices`(pk、TTL `ttl`。Records の本体テーブルではない内部用)
9
+ - 失敗先の SNS トピック `<project>-<env>-notify-failure`(運用者のメールの購読つき)。非同期の再試行 0
10
+ - 入口 1: `watched_log_group_names` のロググループの購読(広めの語句。通知するかはハンドラが判定する)
11
+ - 入口 2: CloudWatch アラームの状態変化(名前が `<project>-<env>-` で始まるもの)
12
+ - 入口 3: 生存確認(既定 週 1 回)
13
+ - us-east-1 のアラーム(CloudFront など)の転送(`forward_us_east_1_alarms = true` のときだけ): us-east-1 の EventBridge ルールと、ホームのリージョンの default バスへ送るロール。転送されたイベントは入口 2 のルールが拾う
14
+ - 権限: notices の `UpdateItem`、Records の IAM 版 URL の呼び出し、webhook のパラメータの読み取り、失敗先の SNS、SES(`mail_from` があるときだけ。送信元アドレスとドメインに絞る)
15
+
16
+ ## 前提
17
+
18
+ - 参照は `node_modules` のパス(版は npm の版で固定される。git の `ref` ではない)。例: アプリの infra が `infra/` なら `source = "../node_modules/@exabugs/dynamodb-client/terraform/ops-notify"`、`infra/apps/<app>/` なら `"../../../node_modules/..."`
19
+ - **Lambda の成果物はアプリが作り、`source_file`(バンドル済みの 1 ファイル)と `handler` で渡す**。エントリはアプリの数行(`createNotifyHandler` などの notify の部品。アプリ名や、権限チェック層を通すアプリの包み方を渡す)なので、ライブラリの dist は使わない
20
+ - zip は `${path.root}/.terraform/archives/notify.zip` に作る(`path.module` を使わない。pnpm の版つきのパスが state に入ると、版を上げるたびに差分が出るため)
21
+ - pnpm では、版を上げた後に `.terraform/modules` を消してから `terraform init` する([../README.md](../README.md) の注意)
22
+ - モジュール全体の `depends_on` は使わない(依存先に変更があると、中のデータソースが apply 時の読み取りになり、既存のリソースが作り直しになる)。依存は、出力の値(`log_group_name` など)で渡す
23
+
24
+ ## 使い方
25
+
26
+ ```hcl
27
+ module "ops_notify" {
28
+ source = "../node_modules/@exabugs/dynamodb-client/terraform/ops-notify"
29
+
30
+ # 必須(forward_us_east_1_alarms が false でも)。モジュールが aws.us_east_1 の別名を宣言しているため
31
+ providers = {
32
+ aws.us_east_1 = aws.us_east_1
33
+ }
34
+
35
+ project_name = var.project_name
36
+ environment = var.environment
37
+ records_iam_function_url = module.records.iam_function_url
38
+ records_iam_alias_arn = module.records.iam_alias_arn
39
+ log_retention_days = var.log_retention_days
40
+
41
+ source_file = "${path.root}/../apps/server/dist/notify.cjs"
42
+ handler = "notify.handler"
43
+
44
+ # 通知 Lambda 自身のロググループは入れない(無限ループ)
45
+ watched_log_group_names = [
46
+ module.records.log_group_name,
47
+ module.alerts_cleanup.log_group_name,
48
+ module.system_change_recorder.log_group_name,
49
+ ]
50
+ slack_webhook_param_name = "/${var.project_name}/${var.environment}/ops/slack-webhook"
51
+ mail_from = var.ops_mail_from
52
+ ses_identity_domain = var.ops_ses_identity_domain
53
+ operator_email = var.ops_operator_email
54
+
55
+ # alerts(通知を管理画面のレコードとして残す)。空なら記録しない
56
+ admin_base_url = var.ops_admin_base_url
57
+ skip_record_log_groups = [module.records.log_group_name]
58
+ reserved_concurrent_executions = var.ops_admin_base_url == "" ? null : 1
59
+ }
60
+ ```
61
+
62
+ ## provider
63
+
64
+ `aws`(ホームのリージョン)に加えて、**別名 `aws.us_east_1` を `providers` で必ず渡す**(`configuration_aliases`。転送を使わないときも要る。リージョンは us-east-1)。呼び出し側に無ければ、次を足す。
65
+
66
+ ```hcl
67
+ provider "aws" {
68
+ alias = "us_east_1"
69
+ region = "us-east-1"
70
+ }
71
+ ```
72
+
73
+ ## 変数
74
+
75
+ | 変数 | 既定 | 説明 |
76
+ | ---------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------- |
77
+ | `project_name` / `environment` | 必須 | 名前の接頭辞 `<project>-<env>`。環境変数 `PROJECT_NAME` / `ENV_NAME` |
78
+ | `records_iam_function_url` / `records_iam_alias_arn` | 必須 | Records の IAM 版 Function URL(管理者の一覧を読み、alerts を書く)と、そのエイリアスの ARN |
79
+ | `source_file` / `handler` | 必須 | アプリのバンドル(1 ファイル)と、ハンドラ |
80
+ | `watched_log_group_names` | 必須 | 購読するロググループ。通知 Lambda 自身のものは入れない |
81
+ | `slack_webhook_param_name` | 必須 | Slack の webhook の SSM パラメータ名(SecureString。値は Terraform で扱わない) |
82
+ | `operator_email` | 必須 | 通知 Lambda 自身の失敗の届け先(SNS の確認メールが届く) |
83
+ | `mail_from` / `ses_region` / `ses_identity_domain` | `""` / `us-east-1` / `""` | メールの送信元と SES。`mail_from` が空ならメールは送らない |
84
+ | `heartbeat_schedule` | `rate(7 days)` | 生存確認の間隔 |
85
+ | `log_retention_days` | 7 | ロググループの保持日数 |
86
+ | `admin_base_url` | `""` | 管理画面のベース URL。空でなければ alerts に記録し、通知に詳細の URL を載せる。空なら環境変数に入れない |
87
+ | `skip_record_log_groups` | `[]` | alerts に記録しないロググループ。**Records のロググループを必ず入れる**(ループ防止)。空なら環境変数に入れない |
88
+ | `forward_us_east_1_alarms` | `false` | us-east-1 のアラームの状態変化を、ホームのリージョンの default バスへ転送する |
89
+ | `reserved_concurrent_executions` | `null` | 予約同時実行数。alerts の記録を有効にするときだけ 1 |
90
+
91
+ ## 出力
92
+
93
+ `function_name`、`function_arn`、`failure_topic_arn`、`notice_table_name`
94
+
95
+ ## アプリに残るもの
96
+
97
+ - 上の module ブロック(`source`・`providers`・`source_file`・`handler` と設定の値)
98
+ - Lambda のエントリ(`createNotifyHandler` などの notify の部品 を呼ぶ数行)とバンドルの作成(esbuild など)
99
+ - 秘密(webhook の URL など)の SSM パラメータの作成
100
+
101
+ ## アプリのモジュールからの移行(置き換えを起こさない)
102
+
103
+ リソースのアドレス・名前は、asanowa / astro の旧モジュール(`infra/modules/ops-notify`)と同じ。**module ブロックの名前(`module "ops_notify"`)を変えずに `source` だけを差し替え**、`source_file`・`handler` を足す。アドレスが変わらないので `moved` は要らない(ラッパーのモジュールで包むと、アドレスが変わり `moved` が要る)。
104
+
105
+ 1. `terraform init`(モジュールの取り直し)→ `terraform plan`
106
+ 2. **0 to add / 0 to destroy、`must be replaced` が無い**ことを確かめる。出てよいのは in-place の更新だけ(asanowa では zip の名前〔`ops-notify.zip` → `notify.zip`〕の `filename` が変わる。中身・`source_code_hash` は同じ。astro では環境変数 `PROJECT_NAME` が加わる)
107
+ 3. 確認のうえ apply。旧モジュールのディレクトリを消す
108
+
109
+ 詳細と実機の確認は [ADR 0006](../../docs/adr/0006-library-owns-table-operations.md) の段階 6。
@@ -0,0 +1,295 @@
1
+ # 運用通知(ADR 0003・ADR 0006 の (b))
2
+ #
3
+ # ログ(warn 以上)・CloudWatch アラームの状態変化・週 1 回の生存確認 → 通知 Lambda
4
+ # → 重複抑制・流量制限 → Slack とメール(管理者全員)。
5
+ # Lambda の成果物はアプリが作り、source_file で渡す(実装は notify の部品。docs/NOTIFY.md)。
6
+ # リソースのアドレス・名前は、asanowa / astro の旧モジュール(infra/modules/ops-notify)と同じ。
7
+ #
8
+ # 通知 Lambda 自身のログは購読しない(無限ループ)。失敗は Lambda の失敗先(SNS)→ 運用者のメールへ届ける。
9
+
10
+ data "aws_caller_identity" "current" {}
11
+ data "aws_region" "current" {}
12
+
13
+ locals {
14
+ prefix = "${var.project_name}-${var.environment}"
15
+ }
16
+
17
+ # 重複抑制・流量制限の記録(パーティションキー pk、TTL 属性 ttl)
18
+ resource "aws_dynamodb_table" "notices" {
19
+ name = "${local.prefix}-notices"
20
+ billing_mode = "PAY_PER_REQUEST"
21
+ hash_key = "pk"
22
+
23
+ attribute {
24
+ name = "pk"
25
+ type = "S"
26
+ }
27
+
28
+ ttl {
29
+ attribute_name = "ttl"
30
+ enabled = true
31
+ }
32
+
33
+ server_side_encryption {
34
+ enabled = true
35
+ }
36
+ }
37
+
38
+ # 通知 Lambda が失敗したとき(Slack もメールも届けられないとき)の届け先
39
+ resource "aws_sns_topic" "failure" {
40
+ name = "${local.prefix}-notify-failure"
41
+ }
42
+
43
+ resource "aws_sns_topic_policy" "failure" {
44
+ arn = aws_sns_topic.failure.arn
45
+
46
+ policy = jsonencode({
47
+ Version = "2012-10-17"
48
+ Statement = [
49
+ {
50
+ Sid = "AllowLambdaDestination"
51
+ Effect = "Allow"
52
+ Principal = { Service = "lambda.amazonaws.com" }
53
+ Action = "sns:Publish"
54
+ Resource = aws_sns_topic.failure.arn
55
+ Condition = {
56
+ StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id }
57
+ }
58
+ },
59
+ {
60
+ Sid = "DenyInsecureTransport"
61
+ Effect = "Deny"
62
+ Principal = "*"
63
+ Action = "sns:Publish"
64
+ Resource = aws_sns_topic.failure.arn
65
+ Condition = {
66
+ Bool = { "aws:SecureTransport" = "false" }
67
+ }
68
+ }
69
+ ]
70
+ })
71
+ }
72
+
73
+ resource "aws_sns_topic_subscription" "operator" {
74
+ topic_arn = aws_sns_topic.failure.arn
75
+ protocol = "email"
76
+ endpoint = var.operator_email
77
+ }
78
+
79
+ data "archive_file" "notify" {
80
+ type = "zip"
81
+ source_file = var.source_file
82
+ output_path = "${path.root}/.terraform/archives/notify.zip"
83
+ }
84
+
85
+ resource "aws_cloudwatch_log_group" "notify" {
86
+ name = "/aws/lambda/${local.prefix}-notify"
87
+ retention_in_days = var.log_retention_days
88
+ }
89
+
90
+ resource "aws_iam_role" "notify" {
91
+ name = "${local.prefix}-notify-role"
92
+
93
+ assume_role_policy = jsonencode({
94
+ Version = "2012-10-17"
95
+ Statement = [{
96
+ Action = "sts:AssumeRole"
97
+ Effect = "Allow"
98
+ Principal = { Service = "lambda.amazonaws.com" }
99
+ }]
100
+ })
101
+ }
102
+
103
+ resource "aws_iam_role_policy_attachment" "notify_logs" {
104
+ role = aws_iam_role.notify.name
105
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
106
+ }
107
+
108
+ # 最小権限: 記録のテーブル(更新だけ。alerts の集計の行も UpdateItem だけで足りる)、Records Lambda(管理者の一覧を読む)、Webhook のパラメータ、
109
+ # SES(送信元アドレスとドメインに絞る)、失敗先の SNS。alerts の書き込みも Records の IAM URL(既存の権限)
110
+ resource "aws_iam_role_policy" "notify" {
111
+ name = "${local.prefix}-notify"
112
+ role = aws_iam_role.notify.id
113
+
114
+ policy = jsonencode({
115
+ Version = "2012-10-17"
116
+ Statement = concat(
117
+ [
118
+ {
119
+ Effect = "Allow"
120
+ Action = ["dynamodb:UpdateItem"]
121
+ Resource = [aws_dynamodb_table.notices.arn]
122
+ },
123
+ {
124
+ Effect = "Allow"
125
+ Action = ["lambda:InvokeFunctionUrl"]
126
+ Resource = [var.records_iam_alias_arn]
127
+ },
128
+ {
129
+ Effect = "Allow"
130
+ Action = ["ssm:GetParameter"]
131
+ Resource = ["arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${var.slack_webhook_param_name}"]
132
+ },
133
+ {
134
+ Effect = "Allow"
135
+ Action = ["sns:Publish"]
136
+ Resource = [aws_sns_topic.failure.arn]
137
+ }
138
+ ],
139
+ var.mail_from == "" ? [] : [
140
+ {
141
+ Effect = "Allow"
142
+ Action = ["ses:SendEmail"]
143
+ Resource = ["arn:aws:ses:${var.ses_region}:${data.aws_caller_identity.current.account_id}:identity/${var.ses_identity_domain}"]
144
+ Condition = {
145
+ StringEquals = { "ses:FromAddress" = var.mail_from }
146
+ }
147
+ }
148
+ ]
149
+ )
150
+ })
151
+ }
152
+
153
+ resource "aws_lambda_function" "notify" {
154
+ function_name = "${local.prefix}-notify"
155
+ role = aws_iam_role.notify.arn
156
+ filename = data.archive_file.notify.output_path
157
+ source_code_hash = data.archive_file.notify.output_base64sha256
158
+ handler = var.handler
159
+ runtime = "nodejs22.x"
160
+ architectures = ["arm64"]
161
+ timeout = 60
162
+ memory_size = 256
163
+
164
+ # 既定 null(予約しない。従来どおり)。alerts の記録を有効にするときだけ 1(variables.tf)
165
+ reserved_concurrent_executions = var.reserved_concurrent_executions
166
+
167
+ environment {
168
+ # PROJECT_NAME・ENV_NAME・RECORDS_IAM_URL・NOTICE_TABLE は、通知のエントリが必須にすることがある(asanowa)ので常に入れる。
169
+ # alerts(ADMIN_BASE_URL・SKIP_RECORD_LOG_GROUPS)は、空なら環境変数に入れない(記録しない。asanowa の形)
170
+ variables = merge(
171
+ {
172
+ PROJECT_NAME = var.project_name
173
+ ENV_NAME = var.environment
174
+ RECORDS_IAM_URL = var.records_iam_function_url
175
+ NOTICE_TABLE = aws_dynamodb_table.notices.name
176
+ SLACK_WEBHOOK_PARAM = var.slack_webhook_param_name
177
+ MAIL_FROM = var.mail_from
178
+ SES_REGION = var.ses_region
179
+ },
180
+ var.admin_base_url == "" ? {} : { ADMIN_BASE_URL = var.admin_base_url },
181
+ length(var.skip_record_log_groups) == 0 ? {} : { SKIP_RECORD_LOG_GROUPS = join(",", var.skip_record_log_groups) }
182
+ )
183
+ }
184
+
185
+ logging_config {
186
+ log_format = "JSON"
187
+ log_group = aws_cloudwatch_log_group.notify.name
188
+ }
189
+
190
+ depends_on = [aws_iam_role_policy_attachment.notify_logs]
191
+ }
192
+
193
+ # 再試行は 0(重複を避ける)。失敗は SNS(運用者のメール)へ
194
+ resource "aws_lambda_function_event_invoke_config" "notify" {
195
+ function_name = aws_lambda_function.notify.function_name
196
+ maximum_retry_attempts = 0
197
+
198
+ destination_config {
199
+ on_failure {
200
+ destination = aws_sns_topic.failure.arn
201
+ }
202
+ }
203
+
204
+ depends_on = [aws_iam_role_policy.notify, aws_sns_topic_policy.failure]
205
+ }
206
+
207
+ # --- 入口 1: ログの購読(広めの語句。通知するかはハンドラが厳密に判定する)---
208
+
209
+ resource "aws_lambda_permission" "logs" {
210
+ for_each = toset(var.watched_log_group_names)
211
+
212
+ statement_id = "AllowLogs-${replace(each.value, "/", "_")}"
213
+ action = "lambda:InvokeFunction"
214
+ function_name = aws_lambda_function.notify.function_name
215
+ principal = "logs.amazonaws.com"
216
+ source_arn = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:${each.value}:*"
217
+ }
218
+
219
+ resource "aws_cloudwatch_log_subscription_filter" "notify" {
220
+ for_each = toset(var.watched_log_group_names)
221
+
222
+ name = "${local.prefix}-notify"
223
+ log_group_name = each.value
224
+ filter_pattern = "?warn ?error ?fatal ?WARN ?ERROR ?FATAL ?\"Task timed out\" ?\"Runtime exited\" ?Unhandled ?Uncaught ?\"Invoke Error\" ?\"Status: timeout\" ?\"Status: error\" ?timeout"
225
+ destination_arn = aws_lambda_function.notify.arn
226
+
227
+ depends_on = [aws_lambda_permission.logs]
228
+ }
229
+
230
+ # --- 入口 2: CloudWatch アラームの状態変化(このプロジェクト・環境のアラームだけ)---
231
+
232
+ resource "aws_cloudwatch_event_rule" "alarms" {
233
+ name = "${local.prefix}-notify-alarms"
234
+ description = "CloudWatch アラームの状態変化を、運用通知へ"
235
+
236
+ event_pattern = jsonencode({
237
+ source = ["aws.cloudwatch"]
238
+ "detail-type" = ["CloudWatch Alarm State Change"]
239
+ detail = {
240
+ alarmName = [{ prefix = "${local.prefix}-" }]
241
+ }
242
+ })
243
+ }
244
+
245
+ resource "aws_cloudwatch_event_target" "alarms" {
246
+ rule = aws_cloudwatch_event_rule.alarms.name
247
+ arn = aws_lambda_function.notify.arn
248
+
249
+ retry_policy {
250
+ maximum_retry_attempts = 0
251
+ maximum_event_age_in_seconds = 3600
252
+ }
253
+ }
254
+
255
+ resource "aws_lambda_permission" "alarms" {
256
+ statement_id = "AllowEventBridgeAlarms"
257
+ action = "lambda:InvokeFunction"
258
+ function_name = aws_lambda_function.notify.function_name
259
+ principal = "events.amazonaws.com"
260
+ source_arn = aws_cloudwatch_event_rule.alarms.arn
261
+ }
262
+
263
+ # --- 入口 3: 生存確認(通知の経路が壊れて無音になることを防ぐ)---
264
+
265
+ resource "aws_cloudwatch_event_rule" "heartbeat" {
266
+ name = "${local.prefix}-notify-heartbeat"
267
+ description = "通知の経路が生きていることの確認(Slack に投稿)"
268
+ schedule_expression = var.heartbeat_schedule
269
+ }
270
+
271
+ resource "aws_cloudwatch_event_target" "heartbeat" {
272
+ rule = aws_cloudwatch_event_rule.heartbeat.name
273
+ arn = aws_lambda_function.notify.arn
274
+
275
+ input = jsonencode({
276
+ notice = {
277
+ level = "ok"
278
+ event = "HEARTBEAT"
279
+ message = "通知の経路は正常です(週 1 回の生存確認)"
280
+ }
281
+ })
282
+
283
+ retry_policy {
284
+ maximum_retry_attempts = 0
285
+ maximum_event_age_in_seconds = 3600
286
+ }
287
+ }
288
+
289
+ resource "aws_lambda_permission" "heartbeat" {
290
+ statement_id = "AllowEventBridgeHeartbeat"
291
+ action = "lambda:InvokeFunction"
292
+ function_name = aws_lambda_function.notify.function_name
293
+ principal = "events.amazonaws.com"
294
+ source_arn = aws_cloudwatch_event_rule.heartbeat.arn
295
+ }
@@ -0,0 +1,19 @@
1
+ output "function_name" {
2
+ description = "通知 Lambda の関数名"
3
+ value = aws_lambda_function.notify.function_name
4
+ }
5
+
6
+ output "failure_topic_arn" {
7
+ description = "通知 Lambda の失敗先の SNS トピックの ARN(運用者のメールに届く)"
8
+ value = aws_sns_topic.failure.arn
9
+ }
10
+
11
+ output "notice_table_name" {
12
+ description = "重複抑制・流量制限の記録のテーブル名"
13
+ value = aws_dynamodb_table.notices.name
14
+ }
15
+
16
+ output "function_arn" {
17
+ description = "通知 Lambda の ARN"
18
+ value = aws_lambda_function.notify.arn
19
+ }
@@ -0,0 +1,68 @@
1
+ # us-east-1 のアラームの状態変化を、ホームのリージョン(例: Tokyo)の default バスへ転送する(asanowa の ADR-0046 から移した)
2
+ #
3
+ # CloudFront の指標は us-east-1 にしか出ないので、そのアラームも us-east-1 に作る。EventBridge は
4
+ # アラームと同じリージョンのバスにしか届かないため、us-east-1 のルールで Tokyo の default バスへ転送する。
5
+ # Tokyo の既存のルール(alarms)が、転送されたイベント(source・detail-type・detail は保たれる)を拾う。
6
+ #
7
+ # forward_us_east_1_alarms が true のときだけ作る(既定 false)。provider の aws.us_east_1 は、呼び出し側が常に渡す
8
+ # (versions.tf の configuration_aliases。false でも要る)。リソースのアドレス・名前は asanowa の旧モジュールと同じ。
9
+
10
+ locals {
11
+ forward_count = var.forward_us_east_1_alarms ? 1 : 0
12
+ }
13
+
14
+ # 転送先は、Tokyo の default バス
15
+ resource "aws_iam_role" "forward_alarms" {
16
+ count = local.forward_count
17
+
18
+ name = "${local.prefix}-notify-forward-alarms"
19
+
20
+ assume_role_policy = jsonencode({
21
+ Version = "2012-10-17"
22
+ Statement = [{
23
+ Action = "sts:AssumeRole"
24
+ Effect = "Allow"
25
+ Principal = { Service = "events.amazonaws.com" }
26
+ }]
27
+ })
28
+ }
29
+
30
+ resource "aws_iam_role_policy" "forward_alarms" {
31
+ count = local.forward_count
32
+
33
+ name = "${local.prefix}-notify-forward-alarms"
34
+ role = aws_iam_role.forward_alarms[0].id
35
+
36
+ policy = jsonencode({
37
+ Version = "2012-10-17"
38
+ Statement = [{
39
+ Effect = "Allow"
40
+ Action = ["events:PutEvents"]
41
+ Resource = [local.home_default_bus_arn]
42
+ }]
43
+ })
44
+ }
45
+
46
+ locals {
47
+ home_default_bus_arn = "arn:aws:events:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:event-bus/default"
48
+ }
49
+
50
+ resource "aws_cloudwatch_event_rule" "alarms_us_east_1" {
51
+ provider = aws.us_east_1
52
+ count = local.forward_count
53
+
54
+ name = "${local.prefix}-notify-alarms-forward"
55
+ description = "us-east-1 の CloudWatch アラームの状態変化を、Tokyo の運用通知へ転送"
56
+
57
+ # Tokyo のルール(alarms)と同じパターン
58
+ event_pattern = aws_cloudwatch_event_rule.alarms.event_pattern
59
+ }
60
+
61
+ resource "aws_cloudwatch_event_target" "alarms_us_east_1" {
62
+ provider = aws.us_east_1
63
+ count = local.forward_count
64
+
65
+ rule = aws_cloudwatch_event_rule.alarms_us_east_1[0].name
66
+ arn = local.home_default_bus_arn
67
+ role_arn = aws_iam_role.forward_alarms[0].arn
68
+ }
@@ -0,0 +1,105 @@
1
+ variable "project_name" {
2
+ description = "プロジェクト名"
3
+ type = string
4
+ }
5
+
6
+ variable "environment" {
7
+ description = "環境識別子(dev, prd)"
8
+ type = string
9
+ }
10
+
11
+ variable "records_iam_function_url" {
12
+ description = "Records Lambda の AWS_IAM 認証版 Function URL(管理者の一覧を読む)"
13
+ type = string
14
+ }
15
+
16
+ variable "records_iam_alias_arn" {
17
+ description = "Records Lambda の IAM 認証用エイリアスの ARN(lambda:InvokeFunctionUrl の Resource)"
18
+ type = string
19
+ }
20
+
21
+ variable "watched_log_group_names" {
22
+ description = "warn 以上のログを通知する Lambda のロググループ名。通知 Lambda 自身のロググループは入れない(自分のログで無限ループになる)"
23
+ type = list(string)
24
+ }
25
+
26
+ variable "slack_webhook_param_name" {
27
+ description = "Slack の Webhook の URL を置いた SSM パラメータ(SecureString)の名前。値は Terraform で扱わない(状態に残るため)。アプリの手順で置く"
28
+ type = string
29
+ }
30
+
31
+ variable "mail_from" {
32
+ description = "メールの送信元(SES の検証済みドメインのアドレス)。空ならメールは送らない"
33
+ type = string
34
+ default = ""
35
+ }
36
+
37
+ variable "ses_region" {
38
+ description = "SES のリージョン(本番アクセス・ドメイン検証が済んだもの)"
39
+ type = string
40
+ default = "us-east-1"
41
+ }
42
+
43
+ variable "ses_identity_domain" {
44
+ description = "SES の検証済みドメイン(送信権限の対象)"
45
+ type = string
46
+ default = ""
47
+ }
48
+
49
+ variable "operator_email" {
50
+ description = "通知 Lambda 自身が失敗したときの届け先(運用者。admins に依存しない最後の砦)。SNS の確認メールが届く"
51
+ type = string
52
+ }
53
+
54
+ variable "heartbeat_schedule" {
55
+ description = "通知の経路が生きていることの確認(Slack に投稿)の間隔"
56
+ type = string
57
+ default = "rate(7 days)"
58
+ }
59
+
60
+ variable "log_retention_days" {
61
+ description = "CloudWatch Logsの保持期間(日数)"
62
+ type = number
63
+ default = 7
64
+ }
65
+
66
+ # --- alerts(通知を管理画面のレコードとして残す。ADR 0003)---
67
+
68
+ variable "admin_base_url" {
69
+ description = "管理画面のベース URL。空でなければ alerts に記録し、通知に詳細の URL(<admin_base_url>/alerts/<id>/show)を載せる。空なら記録しない(従来どおり)"
70
+ type = string
71
+ default = ""
72
+
73
+ validation {
74
+ condition = var.admin_base_url == "" || can(regex("^https?://[^/]+", var.admin_base_url))
75
+ error_message = "admin_base_url は空か、http(s):// で始まる URL にしてください。"
76
+ }
77
+ }
78
+
79
+ variable "skip_record_log_groups" {
80
+ description = "alerts に記録しないロググループ(完全一致、または末尾 * の前方一致)。Records Lambda のロググループを必ず入れる(記録の失敗 → Records のエラーログ → 通知 → 記録、のループを防ぐ)。通知は送る。空なら環境変数に入れない"
81
+ type = list(string)
82
+ default = []
83
+ }
84
+
85
+ variable "reserved_concurrent_executions" {
86
+ description = "通知 Lambda の予約同時実行数。既定 null(予約しない。従来どおり)。**1 にするのは alerts の記録を有効にする(admin_base_url を設定する)ときだけ**(同じ alerts レコードへの同時の書き込みを避ける。入れると、記録なしでも通知の遅れ方が変わる。直列化は努力目標で、強制はされない)"
87
+ type = number
88
+ default = null
89
+ }
90
+
91
+ variable "source_file" {
92
+ description = "Lambda の成果物(バンドル済みの 1 ファイル。例: apps/server/dist/notify.cjs)。zip にして使う"
93
+ type = string
94
+ }
95
+
96
+ variable "handler" {
97
+ description = "Lambda のハンドラ(例: notify.handler)"
98
+ type = string
99
+ }
100
+
101
+ variable "forward_us_east_1_alarms" {
102
+ description = "us-east-1 の CloudWatch アラーム(CloudFront など。指標が us-east-1 にしか出ないもの)の状態変化を、ホームのリージョンの default バスへ転送するか。転送先のルール(alarms)が拾う。true なら providers の aws.us_east_1 が us-east-1 であること"
103
+ type = bool
104
+ default = false
105
+ }
@@ -0,0 +1,18 @@
1
+ # provider の版: astro(5.x)と asanowa(6.x)の両方で使う。
2
+ # 下限は Lambda の logging_config(5.x の途中で追加)。上限は data.aws_region の name(6.x で非推奨)が消える 7.x の前まで
3
+ terraform {
4
+ required_version = ">= 1.5"
5
+
6
+ required_providers {
7
+ aws = {
8
+ source = "hashicorp/aws"
9
+ version = ">= 5.30, < 7.0"
10
+ # us-east-1 のアラームの転送(us-east-1.tf)。呼び出し側は providers = { aws.us_east_1 = aws.us_east_1 } を常に渡す
11
+ configuration_aliases = [aws.us_east_1]
12
+ }
13
+ archive = {
14
+ source = "hashicorp/archive"
15
+ version = ">= 2.0"
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,71 @@
1
+ # システム変更履歴の記録 Lambda Terraformモジュール
2
+
3
+ ADR 0006 の (b)(1.21.0)。asanowa・astro の `infra/modules/system-change-recorder` を 1 つにしたもの。使い方の詳細は [docs/SYSTEM_CHANGES.md](../../docs/SYSTEM_CHANGES.md)。
4
+
5
+ ## 作るもの
6
+
7
+ - Lambda `<project>-<env>-system-change-recorder`(Node.js 22、arm64、15 秒、256 MB。構造化ログ JSON)と、そのロググループ・実行ロール
8
+ - 権限: Records の IAM 版 URL の呼び出し(`lambda:InvokeFunctionUrl`)、Slack の webhook のパラメータの読み取り(`slack_webhook_param_name` があるときだけ)。DynamoDB には直接触れない
9
+ - 非同期の呼び出しの再試行 0(同じ入力の再試行は、同じ変更が 2 件になる)
10
+ - 作らないもの: Function URL・EventBridge・`aws_lambda_permission`(呼び出しは人・CI の AWS の認証。`dynamodb-system-change` の CLI)
11
+
12
+ ## 前提
13
+
14
+ - 参照は `node_modules` のパス(版は npm の版で固定される。git の `ref` ではない)。例: アプリの infra が `infra/` なら `source = "../node_modules/@exabugs/dynamodb-client/terraform/system-change-recorder"`、`infra/apps/<app>/` なら `"../../../node_modules/..."`
15
+ - **Lambda の成果物はアプリが作り、`source_file`(バンドル済みの 1 ファイル)と `handler` で渡す**。エントリはアプリの数行(`createSystemChangeHandler`。アプリ名や、権限チェック層を通すアプリの包み方を渡す)なので、ライブラリの dist は使わない
16
+ - zip は `${path.root}/.terraform/archives/system-change-recorder.zip` に作る(`path.module` を使わない。pnpm の版つきのパスが state に入ると、版を上げるたびに差分が出るため)
17
+ - pnpm では、版を上げた後に `.terraform/modules` を消してから `terraform init` する([../README.md](../README.md) の注意)
18
+ - モジュール全体の `depends_on` は使わない(依存先に変更があると、中のデータソースが apply 時の読み取りになり、既存のリソースが作り直しになる)。依存は、出力の値(`log_group_name` など)で渡す
19
+
20
+ ## 使い方
21
+
22
+ ```hcl
23
+ module "system_change_recorder" {
24
+ source = "../node_modules/@exabugs/dynamodb-client/terraform/system-change-recorder"
25
+
26
+ project_name = var.project_name
27
+ environment = var.environment
28
+ records_iam_function_url = module.records.iam_function_url
29
+ records_iam_alias_arn = module.records.iam_alias_arn
30
+ log_retention_days = var.log_retention_days
31
+
32
+ # アプリのバンドル(エントリは createSystemChangeHandler の数行)
33
+ source_file = "${path.root}/../apps/server/dist/system-change-recorder.cjs"
34
+ handler = "system-change-recorder.handler"
35
+
36
+ slack_webhook_param_name = "/${var.project_name}/${var.environment}/ops/slack-webhook"
37
+ admin_base_url = var.ops_admin_base_url
38
+ }
39
+ ```
40
+
41
+ ## 変数
42
+
43
+ | 変数 | 既定 | 説明 |
44
+ | ---------------------------------------------------- | ------- | ----------------------------------------------------------------------------- |
45
+ | `project_name` / `environment` | 必須 | 名前の接頭辞 `<project>-<env>`。環境変数 `PROJECT_NAME` / `ENV_NAME` |
46
+ | `records_iam_function_url` / `records_iam_alias_arn` | 必須 | Records の IAM 版 Function URL と、そのエイリアスの ARN(呼び出し権限の対象) |
47
+ | `source_file` / `handler` | 必須 | アプリのバンドル(1 ファイル)と、ハンドラ |
48
+ | `log_retention_days` | 7 | ロググループの保持日数 |
49
+ | `slack_webhook_param_name` | `""` | Slack の webhook の SSM パラメータ名(SecureString)。空なら Slack に送らない |
50
+ | `admin_base_url` | `""` | 管理画面の URL(Slack の通知に詳細のリンクを付ける) |
51
+ | `notify_started` | `false` | 「開始」も Slack に送るか |
52
+
53
+ ## 出力
54
+
55
+ `function_name`(CLI の `--function` の既定と同じ名前)、`function_arn`、`log_group_name`(ops-notify に購読させる)
56
+
57
+ ## アプリに残るもの
58
+
59
+ - 上の module ブロック(`source`・`source_file`・`handler` と設定の値)
60
+ - Lambda のエントリ(`createSystemChangeHandler` を呼ぶ数行)とバンドルの作成(esbuild など)
61
+ - 秘密(webhook の URL など)の SSM パラメータの作成
62
+
63
+ ## アプリのモジュールからの移行(置き換えを起こさない)
64
+
65
+ リソースのアドレス・名前は、asanowa / astro の旧モジュール(`infra/modules/system-change-recorder`)と同じ。**module ブロックの名前(`module "system_change_recorder"`)を変えずに `source` だけを差し替え**、`source_file`・`handler` を足す。アドレスが変わらないので `moved` は要らない(ラッパーのモジュールで包むと、アドレスが変わり `moved` が要る)。
66
+
67
+ 1. `terraform init`(モジュールの取り直し)→ `terraform plan`
68
+ 2. **0 to add / 0 to destroy、`must be replaced` が無い**ことを確かめる。出てよいのは in-place の更新だけ(文言の差。asanowa / astro では Lambda の description の ADR の番号が消える)
69
+ 3. 確認のうえ apply。旧モジュールのディレクトリを消す
70
+
71
+ 詳細と実機の確認は [ADR 0006](../../docs/adr/0006-library-owns-table-operations.md) の段階 6。
@@ -0,0 +1,122 @@
1
+ # システム変更履歴(systemChanges)の記録 Lambda(ADR 0006 の (b)。docs/SYSTEM_CHANGES.md)
2
+ #
3
+ # 入力(直接呼び出し)を検証し、Records の IAM URL で systemChanges に書く(削除しない)。
4
+ # Lambda の成果物(バンドル済みの 1 ファイル)はアプリが作り、source_file で渡す(エントリでアプリの設定・
5
+ # 権限チェック層を渡すため。実装は `createSystemChangeHandler`)。
6
+ #
7
+ # 呼び出しは、人・CI の AWS の認証(lambda:InvokeFunction)で行う。Function URL・EventBridge・
8
+ # リソースベースのポリシー(aws_lambda_permission)は作らない。
9
+ # 失敗は構造化ログ(warn / error)に出す。このロググループは、通知(../ops-notify)に購読させる。
10
+ #
11
+ # リソースのアドレス・名前は、asanowa / astro の旧モジュール(infra/modules/system-change-recorder)と同じ
12
+ # (module ブロックの名前を変えずに source だけを差し替えれば、moved なしで置き換えが起きない)。
13
+
14
+ locals {
15
+ prefix = "${var.project_name}-${var.environment}"
16
+ }
17
+
18
+ data "aws_caller_identity" "current" {}
19
+ data "aws_region" "current" {}
20
+
21
+ data "archive_file" "recorder" {
22
+ type = "zip"
23
+ source_file = var.source_file
24
+ output_path = "${path.root}/.terraform/archives/system-change-recorder.zip"
25
+ }
26
+
27
+ resource "aws_cloudwatch_log_group" "recorder" {
28
+ name = "/aws/lambda/${local.prefix}-system-change-recorder"
29
+ retention_in_days = var.log_retention_days
30
+ }
31
+
32
+ resource "aws_iam_role" "recorder" {
33
+ name = "${local.prefix}-system-change-recorder-role"
34
+
35
+ assume_role_policy = jsonencode({
36
+ Version = "2012-10-17"
37
+ Statement = [{
38
+ Action = "sts:AssumeRole"
39
+ Effect = "Allow"
40
+ Principal = { Service = "lambda.amazonaws.com" }
41
+ }]
42
+ })
43
+ }
44
+
45
+ resource "aws_iam_role_policy_attachment" "recorder_logs" {
46
+ role = aws_iam_role.recorder.name
47
+ policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
48
+ }
49
+
50
+ # Records Lambda の IAM 認証版 URL(エイリアス)を呼ぶ権限だけ(最小権限)。DynamoDB には直接触れない
51
+ resource "aws_iam_role_policy" "recorder_invoke_records" {
52
+ name = "${local.prefix}-system-change-recorder-invoke-records"
53
+ role = aws_iam_role.recorder.id
54
+
55
+ policy = jsonencode({
56
+ Version = "2012-10-17"
57
+ Statement = [{
58
+ Effect = "Allow"
59
+ Action = ["lambda:InvokeFunctionUrl"]
60
+ Resource = [var.records_iam_alias_arn]
61
+ }]
62
+ })
63
+ }
64
+
65
+ # Slack の webhook(SecureString)を読む権限だけ。通知 Lambda と同じ(暗号化は、AWS 管理の鍵で、追加の KMS 権限は不要。PoC で確認)
66
+ resource "aws_iam_role_policy" "recorder_read_slack_webhook" {
67
+ count = var.slack_webhook_param_name == "" ? 0 : 1
68
+ name = "${local.prefix}-system-change-recorder-read-slack-webhook"
69
+ role = aws_iam_role.recorder.id
70
+
71
+ policy = jsonencode({
72
+ Version = "2012-10-17"
73
+ Statement = [{
74
+ Effect = "Allow"
75
+ Action = ["ssm:GetParameter"]
76
+ Resource = ["arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:parameter${var.slack_webhook_param_name}"]
77
+ }]
78
+ })
79
+ }
80
+
81
+ resource "aws_lambda_function" "recorder" {
82
+ function_name = "${local.prefix}-system-change-recorder"
83
+ description = "Record a system change (release, OTA, infra, Lambda) to the systemChanges records"
84
+ role = aws_iam_role.recorder.arn
85
+ filename = data.archive_file.recorder.output_path
86
+ source_code_hash = data.archive_file.recorder.output_base64sha256
87
+ handler = var.handler
88
+ runtime = "nodejs22.x"
89
+ architectures = ["arm64"]
90
+ # HTTP の時間切れ(RECORD_HTTP_TIMEOUT_MS = 5 秒)より長く
91
+ timeout = 15
92
+ memory_size = 256
93
+
94
+ environment {
95
+ variables = merge(
96
+ {
97
+ PROJECT_NAME = var.project_name
98
+ ENV_NAME = var.environment
99
+ RECORDS_IAM_URL = var.records_iam_function_url
100
+ },
101
+ var.slack_webhook_param_name == "" ? {} : {
102
+ SLACK_WEBHOOK_PARAM = var.slack_webhook_param_name
103
+ NOTIFY_STARTED = tostring(var.notify_started)
104
+ },
105
+ var.admin_base_url == "" ? {} : { ADMIN_BASE_URL = var.admin_base_url },
106
+ )
107
+ }
108
+
109
+ logging_config {
110
+ log_format = "JSON"
111
+ log_group = aws_cloudwatch_log_group.recorder.name
112
+ }
113
+
114
+ depends_on = [aws_iam_role_policy_attachment.recorder_logs]
115
+ }
116
+
117
+ # 非同期の呼び出し(--invocation-type Event)の再試行は 0
118
+ # (同じ入力の再試行は、同じ変更が 2 件になる。失敗はログ → 通知で知る)
119
+ resource "aws_lambda_function_event_invoke_config" "recorder" {
120
+ function_name = aws_lambda_function.recorder.function_name
121
+ maximum_retry_attempts = 0
122
+ }
@@ -0,0 +1,14 @@
1
+ output "function_name" {
2
+ description = "システム変更履歴の記録 Lambda の関数名(aws lambda invoke で呼ぶ)"
3
+ value = aws_lambda_function.recorder.function_name
4
+ }
5
+
6
+ output "function_arn" {
7
+ description = "システム変更履歴の記録 Lambda の ARN"
8
+ value = aws_lambda_function.recorder.arn
9
+ }
10
+
11
+ output "log_group_name" {
12
+ description = "システム変更履歴の記録 Lambda のロググループ名(通知 Lambda が購読する)"
13
+ value = aws_cloudwatch_log_group.recorder.name
14
+ }
@@ -0,0 +1,53 @@
1
+ variable "project_name" {
2
+ description = "プロジェクト名"
3
+ type = string
4
+ }
5
+
6
+ variable "environment" {
7
+ description = "環境識別子(dev, prd)"
8
+ type = string
9
+ }
10
+
11
+ variable "records_iam_function_url" {
12
+ description = "Records Lambda の AWS_IAM 認証版 Function URL(systemChanges に書く)"
13
+ type = string
14
+ }
15
+
16
+ variable "records_iam_alias_arn" {
17
+ description = "Records Lambda の IAM 認証用エイリアスの ARN(lambda:InvokeFunctionUrl の Resource)"
18
+ type = string
19
+ }
20
+
21
+ variable "log_retention_days" {
22
+ description = "CloudWatch Logsの保持期間(日数)"
23
+ type = number
24
+ default = 7
25
+ }
26
+
27
+ variable "slack_webhook_param_name" {
28
+ description = "Slack の webhook の SSM パラメータ名(SecureString。通知 Lambda と同じもの)。空なら、Slack に送らない"
29
+ type = string
30
+ default = ""
31
+ }
32
+
33
+ variable "admin_base_url" {
34
+ description = "管理画面の URL(Slack の通知に、システム変更履歴の詳細へのリンクを付ける)。空ならリンクを付けない"
35
+ type = string
36
+ default = ""
37
+ }
38
+
39
+ variable "notify_started" {
40
+ description = "「開始」も Slack に送るか。既定は false(1 回の適用で 2 通になるため。結果だけを送る)"
41
+ type = bool
42
+ default = false
43
+ }
44
+
45
+ variable "source_file" {
46
+ description = "Lambda の成果物(バンドル済みの 1 ファイル。例: apps/server/dist/system-change-recorder.cjs)。zip にして使う"
47
+ type = string
48
+ }
49
+
50
+ variable "handler" {
51
+ description = "Lambda のハンドラ(例: system-change-recorder.handler)"
52
+ type = string
53
+ }
@@ -0,0 +1,16 @@
1
+ # provider の版: astro(5.x)と asanowa(6.x)の両方で使う。
2
+ # 下限は Lambda の logging_config(5.x の途中で追加)。上限は data.aws_region の name(6.x で非推奨)が消える 7.x の前まで
3
+ terraform {
4
+ required_version = ">= 1.5"
5
+
6
+ required_providers {
7
+ aws = {
8
+ source = "hashicorp/aws"
9
+ version = ">= 5.30, < 7.0"
10
+ }
11
+ archive = {
12
+ source = "hashicorp/archive"
13
+ version = ">= 2.0"
14
+ }
15
+ }
16
+ }